Merge pull request 'feat: give display devices their own paired identity' (#35) from phase-7b/device-pairing into main
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/35
This commit was merged in pull request #35.
This commit is contained in:
@@ -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;
|
The permission must be declared by that module and have an `authenticated` or `restricted` baseline. Primary UI and display UI are independently optional, must
|
||||||
public/device identity remains deferred. Primary UI and display UI are independently optional, must
|
|
||||||
use paths distinct from every other entrypoint, and use the same
|
use paths distinct from every other entrypoint, and use the same
|
||||||
`defineModuleUi({ id, version, mount })` ABI and five-member `ModuleUiHostContext` with **no mode
|
`defineModuleUi({ id, version, mount })` ABI and five-member `ModuleUiHostContext` with **no mode
|
||||||
flag**. The canonical builder reads fixed sources (`src/ui/index.tsx` and
|
flag**. The canonical builder reads fixed sources (`src/ui/index.tsx` and
|
||||||
`src/display/index.tsx`) and atomically emits every declared role.
|
`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
|
Ekklesia publishes the display bundle at
|
||||||
`GET /api/v1/module-ui/<module-id>/<version>/display`, serves a stable permission-gated page 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
|
`/display/<module-id>`, and exposes that page's permission-gated descriptor at
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
PermissionRegistry,
|
PermissionRegistry,
|
||||||
} from "@ekklesia/core/authorization";
|
} from "@ekklesia/core/authorization";
|
||||||
import type { DashboardStore } from "@ekklesia/core/dashboard";
|
import type { DashboardStore } from "@ekklesia/core/dashboard";
|
||||||
|
import type { DeviceStore } from "@ekklesia/core/devices";
|
||||||
import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
|
import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
|
||||||
import type {
|
import type {
|
||||||
DiscoveredModule,
|
DiscoveredModule,
|
||||||
@@ -27,6 +28,7 @@ import { Hono } from "hono";
|
|||||||
import { cors } from "hono/cors";
|
import { cors } from "hono/cors";
|
||||||
import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
|
import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
|
||||||
import { createDashboardRoutes } from "./dashboard-routes.js";
|
import { createDashboardRoutes } from "./dashboard-routes.js";
|
||||||
|
import { createDeviceRoutes } from "./device-routes.js";
|
||||||
import type { DisplayHostAssets } from "./display-host-assets.js";
|
import type { DisplayHostAssets } from "./display-host-assets.js";
|
||||||
import { createGrantsRoutes } from "./grants-routes.js";
|
import { createGrantsRoutes } from "./grants-routes.js";
|
||||||
import { createModuleConfigurationRoutes } from "./module-configuration-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
|
* 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`. */
|
* fall through to an implicit one. See `./module-ui-routes.js`. */
|
||||||
readonly moduleUi: ModuleUiPublicationRegistry;
|
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
|
/** 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
|
* eligible publication registry contains no display entrypoint; route composition rejects an
|
||||||
* inconsistent display-bearing registry synchronously. */
|
* inconsistent display-bearing registry synchronously. */
|
||||||
@@ -369,6 +382,23 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
|||||||
...(options.displayHostAssets ? { assets: options.displayHostAssets } : {}),
|
...(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
|
// 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,
|
// 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`.
|
// and this adds Ekklesia's own presentation of those same modules. See `./dashboard-routes.js`.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type DashboardDrizzleDatabase,
|
type DashboardDrizzleDatabase,
|
||||||
} from "@ekklesia/core/dashboard";
|
} from "@ekklesia/core/dashboard";
|
||||||
import { createValidatedPostgresPool, type PostgresPoolOptions } from "@ekklesia/core/database";
|
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 { createIdentityDrizzleView, type IdentityDrizzleDatabase } from "@ekklesia/core/identity";
|
||||||
import {
|
import {
|
||||||
createModuleConfigurationView,
|
createModuleConfigurationView,
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
|
|
||||||
export interface ApiDatabaseHandle {
|
export interface ApiDatabaseHandle {
|
||||||
readonly authorization: AuthorizationDrizzleDatabase;
|
readonly authorization: AuthorizationDrizzleDatabase;
|
||||||
|
readonly devices: DeviceDrizzleDatabase;
|
||||||
readonly dashboard: DashboardDrizzleDatabase;
|
readonly dashboard: DashboardDrizzleDatabase;
|
||||||
readonly identity: IdentityDrizzleDatabase;
|
readonly identity: IdentityDrizzleDatabase;
|
||||||
readonly module: ModuleDatabase;
|
readonly module: ModuleDatabase;
|
||||||
@@ -50,6 +52,7 @@ export function createApiDatabase(
|
|||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
authorization: createAuthorizationDrizzleView(pool),
|
authorization: createAuthorizationDrizzleView(pool),
|
||||||
close,
|
close,
|
||||||
|
devices: createDeviceDrizzleView(pool),
|
||||||
dashboard: createDashboardDrizzleView(pool),
|
dashboard: createDashboardDrizzleView(pool),
|
||||||
identity: createIdentityDrizzleView(pool),
|
identity: createIdentityDrizzleView(pool),
|
||||||
module: createModuleDatabaseView(pool),
|
module: createModuleDatabaseView(pool),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -39,8 +39,22 @@ export interface HostModuleDisplayDescriptor {
|
|||||||
readonly permissions: readonly string[];
|
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 {
|
export interface HostModuleDisplayResource {
|
||||||
readonly module: HostModuleDisplayDescriptor;
|
readonly module: HostModuleDisplayDescriptor;
|
||||||
|
readonly viewer: HostDisplayViewer;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModuleDisplayRoutesOptions extends AuthorizationMiddlewareOptions {
|
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(
|
async function describeDisplay(
|
||||||
options: ModuleDisplayRoutesOptions,
|
options: ModuleDisplayRoutesOptions,
|
||||||
principal: EkklesiaPrincipal,
|
principal: EkklesiaPrincipal,
|
||||||
display: PublishableModuleDisplay,
|
display: PublishableModuleDisplay,
|
||||||
|
viewer: HostDisplayViewer,
|
||||||
): Promise<HostModuleDisplayResource> {
|
): Promise<HostModuleDisplayResource> {
|
||||||
return {
|
return {
|
||||||
|
viewer,
|
||||||
module: {
|
module: {
|
||||||
api: { baseUrl: moduleApiBaseUrl(display.moduleId) },
|
api: { baseUrl: moduleApiBaseUrl(display.moduleId) },
|
||||||
display: { url: moduleDisplayUiArtifactUrl(display) },
|
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.
|
// neither the permission nor the artifact; it can only hit this exact pre-composed route.
|
||||||
const requireDisplayPermission = requirePermission(options, display.displayPermission);
|
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, {
|
return new Response(options.assets.html, {
|
||||||
headers: {
|
headers: {
|
||||||
"Cache-Control": "no-store",
|
"Cache-Control": "no-store",
|
||||||
@@ -141,7 +183,12 @@ function mountDisplay(
|
|||||||
c.header("Cache-Control", "no-store");
|
c.header("Cache-Control", "no-store");
|
||||||
c.header("Vary", "Origin, Cookie");
|
c.header("Vary", "Origin, Cookie");
|
||||||
try {
|
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 {
|
} catch {
|
||||||
return displayFailure(c);
|
return displayFailure(c);
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-2
@@ -11,6 +11,7 @@ import {
|
|||||||
createAuthorizationStore,
|
createAuthorizationStore,
|
||||||
} from "@ekklesia/core/authorization";
|
} from "@ekklesia/core/authorization";
|
||||||
import { createDashboardStore } from "@ekklesia/core/dashboard";
|
import { createDashboardStore } from "@ekklesia/core/dashboard";
|
||||||
|
import { createDeviceStore } from "@ekklesia/core/devices";
|
||||||
import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity";
|
import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity";
|
||||||
import {
|
import {
|
||||||
createEnvironmentModuleSecretSource,
|
createEnvironmentModuleSecretSource,
|
||||||
@@ -32,7 +33,6 @@ import {
|
|||||||
import { type ServerType, serve } from "@hono/node-server";
|
import { type ServerType, serve } from "@hono/node-server";
|
||||||
import type { Hono } from "hono";
|
import type { Hono } from "hono";
|
||||||
import { createApiApp } from "./app.js";
|
import { createApiApp } from "./app.js";
|
||||||
import { loadDisplayHostAssets } from "./display-host-assets.js";
|
|
||||||
import {
|
import {
|
||||||
type ClientAddressConfigInput,
|
type ClientAddressConfigInput,
|
||||||
type ClientAddressResolver,
|
type ClientAddressResolver,
|
||||||
@@ -41,6 +41,8 @@ import {
|
|||||||
} from "./client-address.js";
|
} from "./client-address.js";
|
||||||
import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js";
|
import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js";
|
||||||
import { type ApiDatabaseHandle, createApiDatabase } from "./database.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";
|
import { prepareStartupModuleInventory } from "./platform-permissions.js";
|
||||||
|
|
||||||
const connectionTimeoutMs = 10_000;
|
const connectionTimeoutMs = 10_000;
|
||||||
@@ -419,6 +421,7 @@ export async function startApi(
|
|||||||
let services: ModuleRuntimeServices;
|
let services: ModuleRuntimeServices;
|
||||||
let runtime: ReturnType<typeof createIdentityRuntime>;
|
let runtime: ReturnType<typeof createIdentityRuntime>;
|
||||||
let authorizationStore: ReturnType<typeof createAuthorizationStore>;
|
let authorizationStore: ReturnType<typeof createAuthorizationStore>;
|
||||||
|
let deviceStore: ReturnType<typeof createDeviceStore>;
|
||||||
try {
|
try {
|
||||||
services = Object.freeze({ database: databaseHandle.module });
|
services = Object.freeze({ database: databaseHandle.module });
|
||||||
runtime = createIdentityRuntime({
|
runtime = createIdentityRuntime({
|
||||||
@@ -427,12 +430,16 @@ export async function startApi(
|
|||||||
...(options.rateLimitOverride ? { rateLimitOverride: options.rateLimitOverride } : {}),
|
...(options.rateLimitOverride ? { rateLimitOverride: options.rateLimitOverride } : {}),
|
||||||
});
|
});
|
||||||
authorizationStore = createAuthorizationStore(databaseHandle.authorization);
|
authorizationStore = createAuthorizationStore(databaseHandle.authorization);
|
||||||
|
deviceStore = createDeviceStore(databaseHandle.devices);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await safeCloseDatabase(databaseHandle);
|
await safeCloseDatabase(databaseHandle);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const evaluator = createAuthorizationEvaluator({
|
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,
|
grants: authorizationStore,
|
||||||
platformOwners: 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.
|
// A typed view over the same single pool as identity, authorization, and the module facade.
|
||||||
dashboard: createDashboardStore(databaseHandle.dashboard),
|
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,
|
trustedOrigins: config.identity.trustedOrigins,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -223,6 +223,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
|||||||
"core:0003_dashboard_navigation_tables",
|
"core:0003_dashboard_navigation_tables",
|
||||||
"core:0004_module_lifecycle_state",
|
"core:0004_module_lifecycle_state",
|
||||||
"core:0005_module_configuration",
|
"core:0005_module_configuration",
|
||||||
|
"core:0006_device_identity",
|
||||||
]);
|
]);
|
||||||
expect(aggregate.migrations[0]?.checksum).toBe(
|
expect(aggregate.migrations[0]?.checksum).toBe(
|
||||||
"f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f",
|
"f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f",
|
||||||
@@ -2517,10 +2518,10 @@ describe.sequential("live API — installed module permission end-to-end", () =>
|
|||||||
expect(moduleCheck).toMatchObject({ status: "pass" });
|
expect(moduleCheck).toMatchObject({ status: "pass" });
|
||||||
expect(moduleCheck?.details).toEqual(["calendar 0.1.0"]);
|
expect(moduleCheck?.details).toEqual(["calendar 0.1.0"]);
|
||||||
expect(permissionsCheck).toMatchObject({
|
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.dashboard.navigation.manage`, `core.modules.lifecycle.manage`,
|
||||||
// `core.modules.configuration.manage`) plus the fixture module's one.
|
// `core.modules.configuration.manage`, `core.devices.manage`) plus the fixture module's one.
|
||||||
details: ["6 permissions registered"],
|
details: ["7 permissions registered"],
|
||||||
status: "pass",
|
status: "pass",
|
||||||
});
|
});
|
||||||
// Doctor and permission composition read only the manifest — the fixture's register/start
|
// 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 { afterAll, describe, expect, it } from "vitest";
|
||||||
import { createApiRuntimeConfig } from "../src/config.js";
|
import { createApiRuntimeConfig } from "../src/config.js";
|
||||||
import { createApiDatabase } from "../src/database.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
|
* 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:0003_dashboard_navigation_tables",
|
||||||
"core:0004_module_lifecycle_state",
|
"core:0004_module_lifecycle_state",
|
||||||
"core:0005_module_configuration",
|
"core:0005_module_configuration",
|
||||||
|
"core:0006_device_identity",
|
||||||
]);
|
]);
|
||||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -251,6 +252,7 @@ async function applyMigrationsWithModule(url: string): Promise<void> {
|
|||||||
"core:0003_dashboard_navigation_tables",
|
"core:0003_dashboard_navigation_tables",
|
||||||
"core:0004_module_lifecycle_state",
|
"core:0004_module_lifecycle_state",
|
||||||
"core:0005_module_configuration",
|
"core:0005_module_configuration",
|
||||||
|
"core:0006_device_identity",
|
||||||
"db-facade:0000_initial",
|
"db-facade:0000_initial",
|
||||||
]);
|
]);
|
||||||
expect(aggregate.migrations[0]?.checksum).toBe(
|
expect(aggregate.migrations[0]?.checksum).toBe(
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ function signedIn(): EkklesiaPrincipal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("module display routes — authorization and descriptor", () => {
|
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 { app } = await install({ display: true });
|
||||||
|
|
||||||
const [page, descriptor] = await Promise.all([
|
const [page, descriptor] = await Promise.all([
|
||||||
@@ -228,18 +228,22 @@ describe("module display routes — authorization and descriptor", () => {
|
|||||||
app.request(moduleDisplayDescriptorUrl("announcements")),
|
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(descriptor.status).toBe(403);
|
||||||
expect(page.headers.get("cache-control")).toBe("no-store");
|
|
||||||
expect(descriptor.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" });
|
expect(await descriptor.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("requires the declared display permission, not merely an authenticated principal", async () => {
|
it("requires the declared display permission, not merely an authenticated principal", async () => {
|
||||||
const { app } = await install({ display: true, principal: signedIn() });
|
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);
|
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(page.headers.get("x-content-type-options")).toBe("nosniff");
|
||||||
expect(await page.text()).toBe(displayHostAssets.html);
|
expect(await page.text()).toBe(displayHostAssets.html);
|
||||||
expect(await descriptor.json()).toEqual({
|
expect(await descriptor.json()).toEqual({
|
||||||
|
viewer: { kind: "authenticated", userId: viewerId },
|
||||||
module: {
|
module: {
|
||||||
api: { baseUrl: moduleApiBaseUrl("announcements") },
|
api: { baseUrl: moduleApiBaseUrl("announcements") },
|
||||||
display: {
|
display: {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export default defineConfig({
|
|||||||
"./src/dashboard/schema.ts",
|
"./src/dashboard/schema.ts",
|
||||||
"./src/modules/lifecycle-schema.ts",
|
"./src/modules/lifecycle-schema.ts",
|
||||||
"./src/modules/configuration-schema.ts",
|
"./src/modules/configuration-schema.ts",
|
||||||
|
"./src/devices/schema.ts",
|
||||||
],
|
],
|
||||||
out: "./drizzle",
|
out: "./drizzle",
|
||||||
migrations: {
|
migrations: {
|
||||||
|
|||||||
@@ -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
@@ -37,6 +37,12 @@
|
|||||||
"destructive": {
|
"destructive": {
|
||||||
"mode": "none"
|
"mode": "none"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "0006_device_identity",
|
||||||
|
"destructive": {
|
||||||
|
"mode": "none"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,13 @@
|
|||||||
"when": 1787451496425,
|
"when": 1787451496425,
|
||||||
"tag": "0005_module_configuration",
|
"tag": "0005_module_configuration",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 6,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787543773057,
|
||||||
|
"tag": "0006_device_identity",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,10 @@
|
|||||||
"./database": {
|
"./database": {
|
||||||
"types": "./dist/database/index.d.ts",
|
"types": "./dist/database/index.d.ts",
|
||||||
"import": "./dist/database/index.js"
|
"import": "./dist/database/index.js"
|
||||||
|
},
|
||||||
|
"./devices": {
|
||||||
|
"types": "./dist/devices/index.d.ts",
|
||||||
|
"import": "./dist/devices/index.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -5,11 +5,16 @@ import {
|
|||||||
type AuthorizationScope,
|
type AuthorizationScope,
|
||||||
type EkklesiaPrincipal,
|
type EkklesiaPrincipal,
|
||||||
isAuthenticatedPrincipal,
|
isAuthenticatedPrincipal,
|
||||||
|
isDevicePrincipal,
|
||||||
permissionBaselineSchema,
|
permissionBaselineSchema,
|
||||||
safeParseAuthorizationScope,
|
safeParseAuthorizationScope,
|
||||||
safeParsePermissionId,
|
safeParsePermissionId,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
import type { AuthorizationGrantPort, AuthorizationPlatformOwnerPort } from "./ports.js";
|
import type {
|
||||||
|
AuthorizationDevicePort,
|
||||||
|
AuthorizationGrantPort,
|
||||||
|
AuthorizationPlatformOwnerPort,
|
||||||
|
} from "./ports.js";
|
||||||
|
|
||||||
export type AuthorizationEvaluationErrorCode = "AUTHORIZATION_EVALUATION_FAILED";
|
export type AuthorizationEvaluationErrorCode = "AUTHORIZATION_EVALUATION_FAILED";
|
||||||
|
|
||||||
@@ -54,6 +59,10 @@ export interface AuthorizationEvaluator {
|
|||||||
export interface CreateAuthorizationEvaluatorOptions {
|
export interface CreateAuthorizationEvaluatorOptions {
|
||||||
readonly grants: AuthorizationGrantPort;
|
readonly grants: AuthorizationGrantPort;
|
||||||
readonly platformOwners: AuthorizationPlatformOwnerPort;
|
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
|
/** Injectable clock so expiration evaluation is deterministic and testable. Defaults to the
|
||||||
* real wall clock; production composition must leave this unset. */
|
* real wall clock; production composition must leave this unset. */
|
||||||
readonly now?: () => Date;
|
readonly now?: () => Date;
|
||||||
@@ -100,17 +109,14 @@ export function createAuthorizationEvaluator(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (parsedBaseline.data === "authenticated") {
|
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)
|
return isAuthenticatedPrincipal(request.principal)
|
||||||
? AUTHORIZATION_ALLOWED
|
? AUTHORIZATION_ALLOWED
|
||||||
: AUTHORIZATION_DENIED;
|
: 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;
|
let scope: AuthorizationScope | undefined;
|
||||||
if (request.scope !== undefined) {
|
if (request.scope !== undefined) {
|
||||||
const parsedScope = safeParseAuthorizationScope(request.scope);
|
const parsedScope = safeParseAuthorizationScope(request.scope);
|
||||||
@@ -120,6 +126,34 @@ export function createAuthorizationEvaluator(
|
|||||||
scope = parsedScope.data;
|
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;
|
const userId = request.principal.userId;
|
||||||
let isOwner: boolean;
|
let isOwner: boolean;
|
||||||
let hasGrant: boolean;
|
let hasGrant: boolean;
|
||||||
|
|||||||
@@ -152,6 +152,23 @@ export const CORE_MODULES_CONFIGURATION_MANAGE: PermissionId = parsePermissionId
|
|||||||
"core.modules.configuration.manage",
|
"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
|
* 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
|
* 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.",
|
description: "Read and change an installed module's own configuration values.",
|
||||||
id: CORE_MODULES_CONFIGURATION_MANAGE,
|
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,
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import type { AuthorizationScope, EkklesiaUserId, PermissionId } from "@ekklesia/contracts";
|
import type {
|
||||||
|
AuthorizationScope,
|
||||||
|
EkklesiaDeviceId,
|
||||||
|
EkklesiaUserId,
|
||||||
|
PermissionId,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
|
||||||
export interface ActiveGrantQuery {
|
export interface ActiveGrantQuery {
|
||||||
readonly userId: EkklesiaUserId;
|
readonly userId: EkklesiaUserId;
|
||||||
@@ -26,3 +31,17 @@ export interface AuthorizationGrantPort {
|
|||||||
export interface AuthorizationPlatformOwnerPort {
|
export interface AuthorizationPlatformOwnerPort {
|
||||||
isPlatformOwner(userId: EkklesiaUserId): Promise<boolean>;
|
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>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./database.js";
|
||||||
|
export * from "./schema.js";
|
||||||
|
export * from "./store.js";
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||||
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
||||||
|
CORE_DEVICES_MANAGE,
|
||||||
CORE_MODULES_CONFIGURATION_MANAGE,
|
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||||
CORE_MODULES_UI_READ,
|
CORE_MODULES_UI_READ,
|
||||||
@@ -132,13 +133,14 @@ describe("CORE_PERMISSION_REGISTRY", () => {
|
|||||||
expect(CORE_MODULES_CONFIGURATION_MANAGE).not.toBe(CORE_MODULES_LIFECYCLE_MANAGE);
|
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([
|
expect(CORE_PERMISSION_REGISTRY.declarations.map((declaration) => declaration.id)).toEqual([
|
||||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||||
CORE_MODULES_UI_READ,
|
CORE_MODULES_UI_READ,
|
||||||
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
||||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||||
CORE_MODULES_CONFIGURATION_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.",
|
description: "Read and change an installed module's own configuration values.",
|
||||||
id: CORE_MODULES_CONFIGURATION_MANAGE,
|
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);
|
expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS)).toBe(true);
|
||||||
for (const declaration of CORE_PERMISSION_DECLARATIONS) {
|
for (const declaration of CORE_PERMISSION_DECLARATIONS) {
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
|||||||
"core:0003_dashboard_navigation_tables",
|
"core:0003_dashboard_navigation_tables",
|
||||||
"core:0004_module_lifecycle_state",
|
"core:0004_module_lifecycle_state",
|
||||||
"core:0005_module_configuration",
|
"core:0005_module_configuration",
|
||||||
|
"core:0006_device_identity",
|
||||||
]);
|
]);
|
||||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -228,6 +229,11 @@ describe.sequential("live Core authorization store", () => {
|
|||||||
"ekklesia_core_authorization_audit",
|
"ekklesia_core_authorization_audit",
|
||||||
"ekklesia_core_dashboard_group",
|
"ekklesia_core_dashboard_group",
|
||||||
"ekklesia_core_dashboard_placement",
|
"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_grant",
|
||||||
"ekklesia_core_migrations",
|
"ekklesia_core_migrations",
|
||||||
"ekklesia_core_module_configuration",
|
"ekklesia_core_module_configuration",
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
|||||||
"core:0003_dashboard_navigation_tables",
|
"core:0003_dashboard_navigation_tables",
|
||||||
"core:0004_module_lifecycle_state",
|
"core:0004_module_lifecycle_state",
|
||||||
"core:0005_module_configuration",
|
"core:0005_module_configuration",
|
||||||
|
"core:0006_device_identity",
|
||||||
]);
|
]);
|
||||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ Modules
|
|||||||
✓ Dependency graph valid
|
✓ Dependency graph valid
|
||||||
Dependency order is empty.
|
Dependency order is empty.
|
||||||
✓ Permission registry composed
|
✓ Permission registry composed
|
||||||
5 permissions registered
|
6 permissions registered
|
||||||
○ Module lifecycle skipped
|
○ Module lifecycle skipped
|
||||||
DATABASE_URL is not configured.
|
DATABASE_URL is not configured.
|
||||||
○ Module configuration skipped
|
○ Module configuration skipped
|
||||||
|
|||||||
@@ -3,8 +3,15 @@ import { mkdtemp, rm } from "node:fs/promises";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { parseModuleManifest } from "@ekklesia/contracts";
|
||||||
import { Client, type QueryResultRow } from "pg";
|
import { Client, type QueryResultRow } from "pg";
|
||||||
import { afterAll, describe, expect, it } from "vitest";
|
import { afterAll, describe, expect, it } from "vitest";
|
||||||
|
import { createValidatedPostgresPool } from "../src/database/index.js";
|
||||||
|
import {
|
||||||
|
applyModuleMigrations,
|
||||||
|
createPostgresMigrationDatabase,
|
||||||
|
discoverAllMigrations,
|
||||||
|
} from "../src/migrations/index.js";
|
||||||
import {
|
import {
|
||||||
createModuleConfigurationStore,
|
createModuleConfigurationStore,
|
||||||
createModuleConfigurationView,
|
createModuleConfigurationView,
|
||||||
@@ -12,13 +19,6 @@ import {
|
|||||||
ModuleConfigurationStoreError,
|
ModuleConfigurationStoreError,
|
||||||
resolveModuleConfigurationFor,
|
resolveModuleConfigurationFor,
|
||||||
} from "../src/modules/index.js";
|
} 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.
|
* The module configuration store against real PostgreSQL.
|
||||||
@@ -177,6 +177,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
|||||||
"core:0003_dashboard_navigation_tables",
|
"core:0003_dashboard_navigation_tables",
|
||||||
"core:0004_module_lifecycle_state",
|
"core:0004_module_lifecycle_state",
|
||||||
"core:0005_module_configuration",
|
"core:0005_module_configuration",
|
||||||
|
"core:0006_device_identity",
|
||||||
]);
|
]);
|
||||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -3,8 +3,16 @@ import { mkdtemp, rm } from "node:fs/promises";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { parseModuleManifest } from "@ekklesia/contracts";
|
||||||
import { Client, type QueryResultRow } from "pg";
|
import { Client, type QueryResultRow } from "pg";
|
||||||
import { afterAll, describe, expect, it } from "vitest";
|
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 {
|
import {
|
||||||
createModuleLifecycleStore,
|
createModuleLifecycleStore,
|
||||||
createModuleLifecycleView,
|
createModuleLifecycleView,
|
||||||
@@ -12,14 +20,6 @@ import {
|
|||||||
ModuleLifecycleStoreError,
|
ModuleLifecycleStoreError,
|
||||||
resolveModuleEligibility,
|
resolveModuleEligibility,
|
||||||
} from "../src/modules/index.js";
|
} 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.
|
* 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:0003_dashboard_navigation_tables",
|
||||||
"core:0004_module_lifecycle_state",
|
"core:0004_module_lifecycle_state",
|
||||||
"core:0005_module_configuration",
|
"core:0005_module_configuration",
|
||||||
|
"core:0006_device_identity",
|
||||||
]);
|
]);
|
||||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||||
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
||||||
|
CORE_DEVICES_MANAGE,
|
||||||
CORE_MODULES_CONFIGURATION_MANAGE,
|
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||||
CORE_MODULES_UI_READ,
|
CORE_MODULES_UI_READ,
|
||||||
@@ -74,7 +75,7 @@ describe("composePlatformPermissionRegistry", () => {
|
|||||||
expect(registry.has(CORE_MODULES_LIFECYCLE_MANAGE)).toBe(true);
|
expect(registry.has(CORE_MODULES_LIFECYCLE_MANAGE)).toBe(true);
|
||||||
expect(registry.has(CORE_MODULES_CONFIGURATION_MANAGE)).toBe(true);
|
expect(registry.has(CORE_MODULES_CONFIGURATION_MANAGE)).toBe(true);
|
||||||
// Core's own five declarations plus one from each module.
|
// 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", () => {
|
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_DASHBOARD_NAVIGATION_MANAGE,
|
||||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||||
CORE_MODULES_CONFIGURATION_MANAGE,
|
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||||
|
CORE_DEVICES_MANAGE,
|
||||||
"calendar.events.read",
|
"calendar.events.read",
|
||||||
"calendar.events.write",
|
"calendar.events.write",
|
||||||
"people.directory.read",
|
"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.
|
||||||
@@ -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
|
- [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
|
- [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
|
- [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
|
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.
|
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 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`,
|
the same `ModuleUiHostContext`; role selection comes from separate artifacts, never a `mode`,
|
||||||
`presentation.kind`, or host-identity branch. The canonical builder reads only
|
`presentation.kind`, or host-identity branch. The canonical builder reads only
|
||||||
|
|||||||
@@ -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
|
There are no signed **write** URLs: uploads go through a module route, where the module can validate
|
||||||
what it is accepting.
|
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
|
## Module HTTP routes
|
||||||
|
|
||||||
An installed module's routes (declared via `http.route(...)` in its `register` hook — see
|
An installed module's routes (declared via `http.route(...)` in its `register` hook — see
|
||||||
|
|||||||
@@ -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
|
`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.
|
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
|
## Platform-owner bootstrap
|
||||||
|
|
||||||
There is no "first user becomes owner" inference anywhere in this repository, and no HTTP
|
There is no "first user becomes owner" inference anywhere in this repository, and no HTTP
|
||||||
|
|||||||
@@ -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
|
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.
|
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
|
## Creating the first platform owner
|
||||||
|
|
||||||
A fresh installation has a login page and nothing to log in to. Public registration is disabled by
|
A fresh installation has a login page and nothing to log in to. Public registration is disabled by
|
||||||
|
|||||||
@@ -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,
|
It deliberately does not solve creating *additional* users or owners. That is user administration,
|
||||||
which does not exist yet.
|
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
|
### Rate limiting is per-process today
|
||||||
|
|
||||||
Better Auth's authentication rate limiter is **in-memory and per API process**. That is acceptable
|
Better Auth's authentication rate limiter is **in-memory and per API process**. That is acceptable
|
||||||
|
|||||||
@@ -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
|
`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.
|
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
|
### Which kind of timing proof lives where
|
||||||
|
|
||||||
Pass 4 introduced several deadlines, and their tests deliberately differ in kind:
|
Pass 4 introduced several deadlines, and their tests deliberately differ in kind:
|
||||||
|
|||||||
@@ -22,6 +22,34 @@ export function safeParseEkklesiaUserId(input: unknown): z.ZodSafeParseResult<Ek
|
|||||||
return ekklesiaUserIdSchema.safeParse(input);
|
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 {
|
export interface EkklesiaAnonymousPrincipal {
|
||||||
readonly kind: "anonymous";
|
readonly kind: "anonymous";
|
||||||
}
|
}
|
||||||
@@ -31,7 +59,20 @@ export interface EkklesiaAuthenticatedPrincipal {
|
|||||||
readonly userId: EkklesiaUserId;
|
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({
|
export const ANONYMOUS_PRINCIPAL: EkklesiaAnonymousPrincipal = Object.freeze({
|
||||||
kind: "anonymous",
|
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(
|
export function isAnonymousPrincipal(
|
||||||
principal: EkklesiaPrincipal,
|
principal: EkklesiaPrincipal,
|
||||||
): principal is EkklesiaAnonymousPrincipal {
|
): principal is EkklesiaAnonymousPrincipal {
|
||||||
return principal.kind === "anonymous";
|
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(
|
export function isAuthenticatedPrincipal(
|
||||||
principal: EkklesiaPrincipal,
|
principal: EkklesiaPrincipal,
|
||||||
): principal is EkklesiaAuthenticatedPrincipal {
|
): principal is EkklesiaAuthenticatedPrincipal {
|
||||||
return principal.kind === "authenticated";
|
return principal.kind === "authenticated";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isDevicePrincipal(
|
||||||
|
principal: EkklesiaPrincipal,
|
||||||
|
): principal is EkklesiaDevicePrincipal {
|
||||||
|
return principal.kind === "device";
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,7 +29,26 @@ export interface ModuleUiAuthenticatedPrincipal {
|
|||||||
readonly userId: string;
|
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.
|
* Everything a host hands a module's UI, and the complete list of what module UI may assume exists.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
HostProtocolError,
|
HostProtocolError,
|
||||||
loadHostPrincipal,
|
loadHostPrincipal,
|
||||||
} from "../host-protocol.js";
|
} from "../host-protocol.js";
|
||||||
|
import { DevicePanel } from "./DevicePanel.js";
|
||||||
import { LoginForm } from "./LoginForm.js";
|
import { LoginForm } from "./LoginForm.js";
|
||||||
import { ModuleUiFrame } from "./ModuleUiFrame.js";
|
import { ModuleUiFrame } from "./ModuleUiFrame.js";
|
||||||
|
|
||||||
@@ -157,9 +158,13 @@ async function resolveBootstrapState(
|
|||||||
): Promise<ResolvedBootstrapState> {
|
): Promise<ResolvedBootstrapState> {
|
||||||
try {
|
try {
|
||||||
const bootstrap = await bootstrapHost(config, signal);
|
const bootstrap = await bootstrapHost(config, signal);
|
||||||
return bootstrap.principal.kind === "anonymous"
|
// Only a person signs into the Dashboard. The test is `=== "authenticated"` and never "not
|
||||||
? { status: "anonymous" }
|
// anonymous", so a principal class added later cannot quietly become a Dashboard login — which
|
||||||
: { bootstrap, status: "ready" };
|
// 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) {
|
} catch (error) {
|
||||||
if (error instanceof HostProtocolError && error.code === "HOST_UNAUTHENTICATED") {
|
if (error instanceof HostProtocolError && error.code === "HOST_UNAUTHENTICATED") {
|
||||||
return { status: "anonymous" };
|
return { status: "anonymous" };
|
||||||
@@ -167,9 +172,9 @@ async function resolveBootstrapState(
|
|||||||
if (error instanceof HostProtocolError && error.code === "HOST_ACCESS_DENIED") {
|
if (error instanceof HostProtocolError && error.code === "HOST_ACCESS_DENIED") {
|
||||||
try {
|
try {
|
||||||
const principal = await loadHostPrincipal(config, signal);
|
const principal = await loadHostPrincipal(config, signal);
|
||||||
return principal.kind === "anonymous"
|
return principal.kind === "authenticated"
|
||||||
? { status: "anonymous" }
|
? { principal, status: "access-denied" }
|
||||||
: { principal, status: "access-denied" };
|
: { status: "anonymous" };
|
||||||
} catch {
|
} catch {
|
||||||
return { status: "failed" };
|
return { status: "failed" };
|
||||||
}
|
}
|
||||||
@@ -454,6 +459,7 @@ export function HostApp(props: HostAppProps): ReactElement {
|
|||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<aside style={shellStyles.aside}>
|
<aside style={shellStyles.aside}>
|
||||||
|
<DevicePanel config={config} />
|
||||||
{entries.length === 0 && groups.length === 0 ? (
|
{entries.length === 0 && groups.length === 0 ? (
|
||||||
<p data-testid="host-empty" style={{ color: "#57534e", margin: 0 }}>
|
<p data-testid="host-empty" style={{ color: "#57534e", margin: 0 }}>
|
||||||
No installed module provides a user interface.
|
No installed module provides a user interface.
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import {
|
|||||||
HostProtocolError,
|
HostProtocolError,
|
||||||
} from "../host-protocol.js";
|
} from "../host-protocol.js";
|
||||||
import { DisplayUiFrame } from "./DisplayUiFrame.js";
|
import { DisplayUiFrame } from "./DisplayUiFrame.js";
|
||||||
|
import { PairingScreen } from "./PairingScreen.js";
|
||||||
|
|
||||||
type DisplayState =
|
type DisplayState =
|
||||||
| { readonly status: "loading"; readonly generation: number }
|
| { readonly status: "loading"; readonly generation: number }
|
||||||
| { readonly status: "ready"; readonly bootstrap: DisplayHostBootstrap & { status: "ready" } }
|
| { 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: "unavailable" }
|
||||||
| { readonly status: "failed" };
|
| { readonly status: "failed" };
|
||||||
|
|
||||||
@@ -93,23 +95,25 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
|
|||||||
if (abort.signal.aborted) {
|
if (abort.signal.aborted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState(
|
setState({ bootstrap, status: "ready" });
|
||||||
bootstrap.status === "signed-out"
|
|
||||||
? { status: "signed-out" }
|
|
||||||
: { bootstrap, status: "ready" },
|
|
||||||
);
|
|
||||||
})
|
})
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
if (abort.signal.aborted) {
|
if (abort.signal.aborted) {
|
||||||
return;
|
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);
|
console.error("[ekklesia] display host bootstrap failed", error);
|
||||||
setState(
|
setState(
|
||||||
error instanceof HostProtocolError &&
|
error instanceof HostProtocolError && error.code === "DISPLAY_NOT_AVAILABLE"
|
||||||
(error.code === "DISPLAY_ACCESS_DENIED" || error.code === "DISPLAY_NOT_AVAILABLE")
|
|
||||||
? { status: "unavailable" }
|
? { status: "unavailable" }
|
||||||
: error instanceof HostProtocolError && error.code === "HOST_UNAUTHENTICATED"
|
|
||||||
? { status: "signed-out" }
|
|
||||||
: { status: "failed" },
|
: { status: "failed" },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -123,10 +127,8 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
|
|||||||
{state.status === "loading" ? (
|
{state.status === "loading" ? (
|
||||||
<DisplayMessage testId="display-host-loading">Loading display…</DisplayMessage>
|
<DisplayMessage testId="display-host-loading">Loading display…</DisplayMessage>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
{state.status === "signed-out" ? (
|
{state.status === "pairing" ? (
|
||||||
<DisplayMessage onRetry={retry} testId="display-host-signed-out">
|
<PairingScreen config={config} moduleId={moduleId} onRetry={retry} />
|
||||||
Sign in to use this display.
|
|
||||||
</DisplayMessage>
|
|
||||||
) : undefined}
|
) : undefined}
|
||||||
{state.status === "unavailable" ? (
|
{state.status === "unavailable" ? (
|
||||||
<DisplayMessage onRetry={retry} testId="display-host-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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -231,13 +231,37 @@ function parseDescriptor(entry: unknown): HostModuleUiDescriptor {
|
|||||||
*/
|
*/
|
||||||
export interface HostModuleDisplayResponse {
|
export interface HostModuleDisplayResponse {
|
||||||
readonly module: HostModuleUiDescriptor;
|
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. */
|
/** Strictly validates the display descriptor response, including every nested URL container. */
|
||||||
export function parseHostModuleDisplay(payload: unknown): HostModuleDisplayResponse {
|
export function parseHostModuleDisplay(payload: unknown): HostModuleDisplayResponse {
|
||||||
if (!hasExactKeys(payload, ["module"])) {
|
if (!hasExactKeys(payload, ["module", "viewer"])) {
|
||||||
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
||||||
}
|
}
|
||||||
|
const viewer = parseDisplayViewer(payload.viewer);
|
||||||
const entry = payload.module;
|
const entry = payload.module;
|
||||||
if (
|
if (
|
||||||
!hasExactKeys(entry, ["api", "display", "id", "permissions", "realtime", "version"]) ||
|
!hasExactKeys(entry, ["api", "display", "id", "permissions", "realtime", "version"]) ||
|
||||||
@@ -278,9 +302,207 @@ export function parseHostModuleDisplay(payload: unknown): HostModuleDisplayRespo
|
|||||||
uiUrl,
|
uiUrl,
|
||||||
version,
|
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. */
|
/** Reads one exact `/display/<module-id>` pathname without accepting a trailing path or traversal. */
|
||||||
export function parseDisplayRoute(pathname: string): string {
|
export function parseDisplayRoute(pathname: string): string {
|
||||||
const match = /^\/display\/([^/]+)$/u.exec(pathname);
|
const match = /^\/display\/([^/]+)$/u.exec(pathname);
|
||||||
@@ -480,43 +702,46 @@ export async function loadModuleDisplay(
|
|||||||
config: EkklesiaHostConfig,
|
config: EkklesiaHostConfig,
|
||||||
moduleId: string,
|
moduleId: string,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<HostModuleUiDescriptor> {
|
): Promise<HostModuleDisplayResponse> {
|
||||||
const payload = await requestPlatformJson(config, moduleDisplayDescriptorPath(moduleId), signal, {
|
const payload = await requestPlatformJson(config, moduleDisplayDescriptorPath(moduleId), signal, {
|
||||||
forbidden: "DISPLAY_ACCESS_DENIED",
|
forbidden: "DISPLAY_ACCESS_DENIED",
|
||||||
notFound: "DISPLAY_NOT_AVAILABLE",
|
notFound: "DISPLAY_NOT_AVAILABLE",
|
||||||
});
|
});
|
||||||
const descriptor = parseHostModuleDisplay(payload).module;
|
const response = parseHostModuleDisplay(payload);
|
||||||
if (descriptor.id !== moduleId) {
|
if (response.module.id !== moduleId) {
|
||||||
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
||||||
}
|
}
|
||||||
return descriptor;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DisplayHostBootstrap =
|
export type DisplayHostBootstrap = {
|
||||||
| {
|
|
||||||
readonly status: "signed-out";
|
|
||||||
readonly principal: Extract<ModuleUiPrincipal, { readonly kind: "anonymous" }>;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
readonly status: "ready";
|
readonly status: "ready";
|
||||||
readonly descriptor: HostModuleUiDescriptor;
|
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(
|
export async function bootstrapDisplayHost(
|
||||||
config: EkklesiaHostConfig,
|
config: EkklesiaHostConfig,
|
||||||
moduleId: string,
|
moduleId: string,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<DisplayHostBootstrap> {
|
): Promise<DisplayHostBootstrap> {
|
||||||
const principal = await loadHostPrincipal(config, signal);
|
const response = await loadModuleDisplay(config, moduleId, signal);
|
||||||
if (principal.kind === "anonymous") {
|
|
||||||
return Object.freeze({ principal, status: "signed-out" });
|
|
||||||
}
|
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
descriptor: await loadModuleDisplay(config, moduleId, signal),
|
descriptor: response.module,
|
||||||
principal,
|
principal: response.viewer,
|
||||||
status: "ready",
|
status: "ready" as const,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,8 +35,16 @@ afterEach(async () => {
|
|||||||
consoleError.mockRestore();
|
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 {
|
return {
|
||||||
|
viewer,
|
||||||
module: {
|
module: {
|
||||||
api: { baseUrl: `/api/v1/modules/${id}` },
|
api: { baseUrl: `/api/v1/modules/${id}` },
|
||||||
display: { url: `/api/v1/module-ui/${id}/${version}/display` },
|
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")) {
|
if (url.endsWith("/api/v1/module-displays/announcements")) {
|
||||||
return json(descriptorPayload(), options.descriptorStatus ?? 200);
|
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")) {
|
if (url.endsWith("/api/v1/modules/announcements/slides")) {
|
||||||
return json([{ id: "slide-1" }]);
|
return json([{ id: "slide-1" }]);
|
||||||
}
|
}
|
||||||
@@ -394,26 +411,44 @@ describe("display host — full-viewport boundary", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("display host — authentication and controlled failures", () => {
|
describe("display host — authentication and controlled failures", () => {
|
||||||
it("mounts nothing and never requests a descriptor for an anonymous browser", async () => {
|
it("shows a pairing code instead of a human login when it has no identity", async () => {
|
||||||
const server = platform({ principal: { kind: "anonymous" } });
|
const server = platform({ descriptorStatus: 403 });
|
||||||
const importer = vi.fn(async () => artifact());
|
const importer = vi.fn(async () => artifact());
|
||||||
|
|
||||||
await render({ apiBaseUrl, fetch: server.fetch }, importer);
|
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(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 () => {
|
it("puts no request id, secret, or permission on a screen anyone can read", async () => {
|
||||||
for (const status of [403, 404]) {
|
const server = platform({ descriptorStatus: 403 });
|
||||||
const server = platform({ descriptorStatus: status });
|
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());
|
const importer = vi.fn(async () => artifact());
|
||||||
|
|
||||||
await render({ apiBaseUrl, fetch: server.fetch }, importer);
|
await render({ apiBaseUrl, fetch: server.fetch }, importer);
|
||||||
|
|
||||||
expect(query("display-host-unavailable"), String(status)).not.toBeNull();
|
// 404 is "there is no such display", which pairing cannot fix — so it stays a plain message.
|
||||||
expect(importer, String(status)).not.toHaveBeenCalled();
|
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 () => {
|
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> = {}): {
|
function displayPayload(overrides: Record<string, unknown> = {}): {
|
||||||
readonly module: Record<string, unknown>;
|
readonly module: Record<string, unknown>;
|
||||||
|
readonly viewer: Record<string, unknown>;
|
||||||
} {
|
} {
|
||||||
return {
|
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: {
|
module: {
|
||||||
api: { baseUrl: "/api/v1/modules/announcements" },
|
api: { baseUrl: "/api/v1/modules/announcements" },
|
||||||
display: { url: "/api/v1/module-ui/announcements/1.2.0/display" },
|
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",
|
uiUrl: "/api/v1/module-ui/announcements/1.2.0/display",
|
||||||
version: "1.2.0",
|
version: "1.2.0",
|
||||||
},
|
},
|
||||||
|
viewer: { kind: "authenticated", userId: "user-1" },
|
||||||
});
|
});
|
||||||
expect(Object.isFrozen(parsed)).toBe(true);
|
expect(Object.isFrozen(parsed)).toBe(true);
|
||||||
expect(Object.isFrozen(parsed.module)).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", () => {
|
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 calls: Array<{ init: RequestInit | undefined; url: string }> = [];
|
||||||
const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
const url = String(input);
|
const url = String(input);
|
||||||
calls.push({ init, url });
|
calls.push({ init, url });
|
||||||
return url.endsWith("/api/v1/principal")
|
return json(displayPayload());
|
||||||
? json({ kind: "authenticated", userId: "user-1" })
|
|
||||||
: json(displayPayload());
|
|
||||||
}) as unknown as typeof globalThis.fetch;
|
}) as unknown as typeof globalThis.fetch;
|
||||||
|
|
||||||
const bootstrap = await bootstrapDisplayHost({ apiBaseUrl, fetch }, "announcements");
|
const bootstrap = await bootstrapDisplayHost({ apiBaseUrl, fetch }, "announcements");
|
||||||
|
|
||||||
expect(bootstrap.status).toBe("ready");
|
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([
|
expect(calls.map((call) => call.url)).toEqual([
|
||||||
`${apiBaseUrl}/api/v1/principal`,
|
|
||||||
`${apiBaseUrl}/api/v1/module-displays/announcements`,
|
`${apiBaseUrl}/api/v1/module-displays/announcements`,
|
||||||
]);
|
]);
|
||||||
expect(calls.every((call) => call.init?.credentials === "include")).toBe(true);
|
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 () =>
|
const fetch = vi.fn(async () =>
|
||||||
json({ kind: "anonymous" }),
|
json({ ...displayPayload(), viewer }),
|
||||||
) as unknown as typeof globalThis.fetch;
|
) 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" });
|
it("refuses a descriptor that claims an anonymous or malformed viewer", async () => {
|
||||||
expect(fetch).toHaveBeenCalledTimes(1);
|
// 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 () => {
|
it("distinguishes denied, unavailable, unauthenticated, and infrastructure failures safely", async () => {
|
||||||
|
|||||||
@@ -443,6 +443,8 @@ describe("Ekklesia shell — browser authentication", () => {
|
|||||||
"/api/auth/sign-in/email",
|
"/api/auth/sign-in/email",
|
||||||
"/api/v1/principal",
|
"/api/v1/principal",
|
||||||
"/api/v1/dashboard/navigation",
|
"/api/v1/dashboard/navigation",
|
||||||
|
// The host-owned device panel loads with the rest of the shell for an administrator.
|
||||||
|
"/api/v1/devices",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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", () => {
|
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"');
|
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/v1/dashboard/navigation"');
|
||||||
expect(bundle).not.toContain('"/api/auth/sign-in/email"');
|
expect(bundle).not.toContain('"/api/auth/sign-in/email"');
|
||||||
expect(bundle).not.toContain('"/api/auth/sign-out"');
|
expect(bundle).not.toContain('"/api/auth/sign-out"');
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export default defineConfig({
|
|||||||
"core/tests/module-delivery.integration.ts",
|
"core/tests/module-delivery.integration.ts",
|
||||||
"apps/api/tests/api-http.integration.ts",
|
"apps/api/tests/api-http.integration.ts",
|
||||||
"apps/api/tests/initial-owner.integration.ts",
|
"apps/api/tests/initial-owner.integration.ts",
|
||||||
|
"apps/api/tests/device-pairing.integration.ts",
|
||||||
"apps/api/tests/module-database.integration.ts",
|
"apps/api/tests/module-database.integration.ts",
|
||||||
],
|
],
|
||||||
passWithNoTests: false,
|
passWithNoTests: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user