import { randomBytes } from "node:crypto"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { createAuthorizationDrizzleDatabase, createInitialPlatformOwner, } from "@ekklesia/core/authorization"; import { createBootstrapIdentityCreator, createIdentityDrizzleDatabase, } from "@ekklesia/core/identity"; import { applyModuleMigrations, createPostgresMigrationDatabase, discoverAllMigrations, } from "@ekklesia/core/migrations"; import { Client, type QueryResultRow } from "pg"; import { afterAll, describe, expect, it } from "vitest"; import { createApiRuntimeConfig } from "../src/config.js"; import { type RunningApi, startApi } from "../src/server.js"; /** * Device pairing end to end, against real PostgreSQL and the real HTTP transport (ADR 0036). * * The whole point is that none of this is mocked. A screen with no identity asks to be paired, a * real administrator signs in through the real login transport and approves it, the host issues a * credential in a real `Set-Cookie`, and the resulting device opens a real display descriptor and a * real realtime stream — and is then revoked and loses all of it. Anything short of that would * prove that rows were written, not that a television in a foyer works. */ const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL"; const repositoryRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); const coreMigrationsDirectory = path.join(repositoryRoot, "core", "drizzle"); const trackedDatabases = new Set(); const trackedDirectories: string[] = []; let databaseCounter = 0; const identityBaseUrl = "https://identity.example.test"; const dashboardOrigin = "https://dashboard.example.test"; const untrustedOrigin = "https://evil.example.test"; const ownerPassword = "correct horse battery staple"; const displayPermission = "announcements.slideshow.view"; class IntegrationHarnessError extends Error { constructor(message: string) { super(message); this.name = "IntegrationHarnessError"; this.stack = `${this.name}: ${this.message}`; } } function baseDatabaseUrl(): string { const url = process.env[integrationDatabaseUrlEnvironment]; if (typeof url !== "string" || url.trim() === "") { throw new IntegrationHarnessError("The integration database URL is not configured."); } return url; } function databaseUrl(name: string): string { const url = new URL(baseDatabaseUrl()); url.pathname = `/${name}`; return url.toString(); } async function openAdministrativeClient(): Promise { const client = new Client({ connectionString: baseDatabaseUrl() }); await client.connect(); return client; } async function safeQuery( client: Client, text: string, values: readonly unknown[] = [], ): Promise { try { return (await client.query(text, [...values])).rows; } catch { throw new IntegrationHarnessError("An integration database statement failed."); } } async function query( url: string, text: string, values: readonly unknown[] = [], ): Promise { const client = new Client({ connectionString: url }); await client.connect(); try { return await safeQuery(client, text, values); } finally { await client.end().catch(() => undefined); } } /** A display-capable module: one display entrypoint, one restricted permission protecting it, and * one realtime channel gated by that same permission. */ async function createDisplayModuleDirectory(): Promise { const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-device-pairing-")); trackedDirectories.push(directory); const moduleDirectory = path.join(directory, "announcements"); await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true }); await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true }); await Promise.all([ writeFile( path.join(moduleDirectory, "package.json"), JSON.stringify({ ekklesia: { layer: "module" }, name: "@test/module-announcements", type: "module", version: "1.0.0", }), ), writeFile( path.join(moduleDirectory, "ekklesia.module.json"), JSON.stringify({ compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, entrypoints: { display: { artifact: "./dist/ui/display.js", permission: displayPermission }, server: "./dist/server/index.js", }, id: "announcements", manifestVersion: 6, name: "Announcements fixture", permissions: [ { baseline: "restricted", description: "View the announcement slideshow.", id: displayPermission, }, ], version: "1.0.0", }), ), writeFile( path.join(moduleDirectory, "dist/server/index.js"), `export default { id: "announcements", version: "1.0.0", register({ realtime }) { realtime.channel({ channel: "state", permission: ${JSON.stringify(displayPermission)} }); }, start({ realtime }) { realtime.publish("state", { slides: [] }); }, stop() {}, };\n`, ), writeFile(path.join(moduleDirectory, "dist/ui/display.js"), "export function mount() {}\n"), ]); return directory; } async function createMigratedDatabase(label: string): Promise { databaseCounter += 1; const name = `ekk_device_${label}_${databaseCounter}_${randomBytes(4).toString("hex")}`.toLowerCase(); const admin = await openAdministrativeClient(); try { await safeQuery(admin, `create database "${name}"`); } finally { await admin.end().catch(() => undefined); } trackedDatabases.add(name); const url = databaseUrl(name); const emptyModules = await mkdtemp(path.join(tmpdir(), "ekklesia-device-no-modules-")); try { const aggregate = await discoverAllMigrations({ coreMigrationsDirectory, moduleDiscovery: { moduleSdkVersion: "0.1.0", modulesDirectory: emptyModules, platformVersion: "0.1.0", }, }); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); } finally { await rm(emptyModules, { force: true, recursive: true }); } return url; } async function startPlatform(url: string, modulesDirectory: string): Promise { const config = createApiRuntimeConfig( { databaseUrl: url, host: "127.0.0.1", identity: { baseUrl: identityBaseUrl, // The production posture: no public signup anywhere in this suite. publicSignupEnabled: false, secret: "integration-test-secret-0123456789abcdefgh", trustedOrigins: [identityBaseUrl, dashboardOrigin], }, port: 0, }, { environment: "development" }, ); return startApi(config, { modulesDirectoryOverride: modulesDirectory, rateLimitOverride: { max: 1000, window: 1 }, }); } function apiOrigin(running: RunningApi): string { const address = running.server.address(); if (!address || typeof address !== "object") { throw new IntegrationHarnessError("The API integration server has no bound address."); } return `http://127.0.0.1:${address.port}`; } /** Matches a cookie by suffix, because Better Auth prefixes its session cookie with `__Secure-` * whenever the configured base URL is https — which this harness's is. */ function cookiePair(response: Response, name: string): string | undefined { for (const header of response.headers.getSetCookie()) { const pair = header.split(";")[0]?.trim() ?? ""; const cookieName = pair.slice(0, Math.max(0, pair.indexOf("="))); if (cookieName === name || cookieName.endsWith(name)) { return pair; } } return undefined; } function setCookieFor(response: Response, name: string): string | undefined { return response.headers.getSetCookie().find((header) => header.startsWith(`${name}=`)); } async function createOwner(url: string, origin: string): Promise { const email = `owner-${randomBytes(4).toString("hex")}@example.test`; const authorization = createAuthorizationDrizzleDatabase(url); const identity = createIdentityDrizzleDatabase(url, {}); try { await createInitialPlatformOwner( { authorizationDb: authorization.db, createIdentity: createBootstrapIdentityCreator(identity.db), identityDb: identity.db, }, { email, name: "Platform Owner", password: ownerPassword }, ); } finally { await Promise.all([authorization.close(), identity.close()]); } const signIn = await fetch(`${origin}/api/auth/sign-in/email`, { body: JSON.stringify({ email, password: ownerPassword }), headers: { "content-type": "application/json", origin: dashboardOrigin }, method: "POST", }); const cookie = cookiePair(signIn, "better-auth.session_token"); if (signIn.status !== 200 || cookie === undefined) { throw new IntegrationHarnessError("The platform owner could not sign in."); } return cookie; } interface Platform { readonly api: RunningApi; readonly origin: string; readonly url: string; readonly ownerCookie: string; readonly close: () => Promise; } async function platform(label: string): Promise { const url = await createMigratedDatabase(label); const modulesDirectory = await createDisplayModuleDirectory(); const api = await startPlatform(url, modulesDirectory); const origin = apiOrigin(api); const ownerCookie = await createOwner(url, origin); return { api, close: async () => { await api.shutdown(); }, origin, ownerCookie, url, }; } /** The device's own browser: no human session, only whatever cookies the host has set on it. */ class DeviceBrowser { #cookies = new Map(); constructor(private readonly origin: string) {} absorb(response: Response): void { for (const header of response.headers.getSetCookie()) { const pair = header.split(";")[0] ?? ""; const separator = pair.indexOf("="); if (separator === -1) { continue; } const name = pair.slice(0, separator).trim(); const value = pair.slice(separator + 1).trim(); if (value.length === 0 || /max-age=0/iu.test(header)) { this.#cookies.delete(name); } else { this.#cookies.set(name, value); } } } header(): string { return [...this.#cookies].map(([name, value]) => `${name}=${value}`).join("; "); } has(name: string): boolean { return this.#cookies.has(name); } overwrite(name: string, value: string): void { this.#cookies.set(name, value); } async request(path: string, init: RequestInit = {}): Promise { const cookie = this.header(); const response = await fetch(`${this.origin}${path}`, { ...init, headers: { origin: dashboardOrigin, ...(cookie.length > 0 ? { cookie } : {}), ...(init.headers as Record | undefined), }, }); this.absorb(response); return response; } post(path: string, body: unknown): Promise { return this.request(path, { body: JSON.stringify(body), headers: { "content-type": "application/json" }, method: "POST", }); } } function adminRequest( origin: string, cookie: string, path: string, body?: unknown, overrides: RequestInit = {}, ): Promise { return fetch(`${origin}${path}`, { ...(body === undefined ? {} : { body: JSON.stringify(body) }), headers: { cookie, origin: dashboardOrigin, ...(body === undefined ? {} : { "content-type": "application/json" }), ...(overrides.headers as Record | undefined), }, method: body === undefined ? "GET" : "POST", ...overrides, }); } /** Drives a screen from "nobody has paired me" to "I hold a device session". */ async function pairDevice( platform: Platform, device: DeviceBrowser, deviceName = "Foyer TV", ): Promise<{ readonly code: string }> { const requested = await device.post("/api/v1/device-pairing/requests", { moduleId: "announcements", }); if (requested.status !== 201) { throw new IntegrationHarnessError("The pairing request was refused."); } const { code } = (await requested.json()) as { code: string }; const approved = await adminRequest( platform.origin, platform.ownerCookie, "/api/v1/device-pairing/approvals", { code, deviceName }, ); if (approved.status !== 200) { throw new IntegrationHarnessError("The pairing approval was refused."); } const claimed = await device.post("/api/v1/device-pairing/claims", {}); if (((await claimed.json()) as { status: string }).status !== "paired") { throw new IntegrationHarnessError("The device was not issued a session."); } return { code }; } afterAll(async () => { const admin = await openAdministrativeClient().catch(() => undefined); if (admin) { try { for (const name of trackedDatabases) { await safeQuery(admin, `drop database if exists "${name}" with (force)`).catch( () => undefined, ); } } finally { await admin.end().catch(() => undefined); } } for (const directory of trackedDirectories.splice(0)) { await rm(directory, { force: true, recursive: true }).catch(() => undefined); } }); describe.sequential("display device pairing, end to end", () => { it("takes an unpaired screen all the way to a working display, and back off again", async () => { const running = await platform("e2e"); const device = new DeviceBrowser(running.origin); try { // 1. The screen loads. It has no identity, so the descriptor refuses it — but the page it // needs in order to show a code is served. expect((await device.request("/display/announcements")).status).toBe(200); expect((await device.request("/api/v1/module-displays/announcements")).status).toBe(403); // 2. It asks to be paired and receives a code plus an HttpOnly claim cookie. const requested = await device.post("/api/v1/device-pairing/requests", { moduleId: "announcements", }); expect(requested.status).toBe(201); const body = (await requested.json()) as Record; expect(body.code).toMatch(/^[0-9]{6}$/u); // The claim secret is never in the body — only in a cookie script cannot read. expect(JSON.stringify(body)).not.toContain("claim"); const claimCookie = setCookieFor(requested, "ekklesia_device_pairing"); expect(claimCookie).toMatch(/HttpOnly/iu); // 3. Nothing has happened yet: polling says pending, and the display is still refused. expect( ( (await (await device.post("/api/v1/device-pairing/claims", {})).json()) as { status: string; } ).status, ).toBe("pending"); // 4. The administrator looks the code up, sees what it wants, and approves it by name. const lookup = await adminRequest( running.origin, running.ownerCookie, "/api/v1/device-pairing/lookups", { code: body.code }, ); expect(lookup.status).toBe(200); expect(await lookup.json()).toMatchObject({ moduleId: "announcements", permissionId: displayPermission, }); expect( ( await adminRequest( running.origin, running.ownerCookie, "/api/v1/device-pairing/approvals", { code: body.code, deviceName: "Foyer TV" }, ) ).status, ).toBe(200); // 5. The screen collects its credential — through Set-Cookie, never through JSON. const claimed = await device.post("/api/v1/device-pairing/claims", {}); expect(await claimed.json()).toEqual({ status: "paired" }); const sessionCookie = setCookieFor(claimed, "ekklesia_device_session"); expect(sessionCookie).toMatch(/HttpOnly/iu); expect(sessionCookie).toMatch(/SameSite=Strict/iu); expect(sessionCookie).toMatch(/Path=\//u); expect(sessionCookie).not.toMatch(/Domain=/iu); // The spent pairing cookie is cleared rather than left on the device. expect(device.has("ekklesia_device_pairing")).toBe(false); expect(device.has("ekklesia_device_session")).toBe(true); // 6. The display now works, and reports the device as its viewer. const descriptor = await device.request("/api/v1/module-displays/announcements"); expect(descriptor.status).toBe(200); const descriptorBody = (await descriptor.json()) as { viewer: { kind: string; deviceId?: string }; module: { permissions: string[] }; }; expect(descriptorBody.viewer.kind).toBe("device"); expect(descriptorBody.viewer.deviceId).toMatch(/^[0-9a-f-]{36}$/u); // Exactly the one permission it was paired for. expect(descriptorBody.module.permissions).toEqual([displayPermission]); // 7. Realtime, through the ordinary permission-gated channel. const stream = await device.request("/api/v1/module-realtime/announcements/state", { headers: { accept: "text/event-stream" }, }); expect(stream.status).toBe(200); expect(stream.headers.get("content-type")).toContain("text/event-stream"); await stream.body?.cancel(); // 8. The device is still nobody as far as the Dashboard is concerned. expect(await (await device.request("/api/v1/principal")).json()).toEqual({ kind: "anonymous", }); expect((await device.request("/api/v1/dashboard/navigation")).status).toBe(403); expect((await device.request("/api/v1/devices")).status).toBe(403); // 9. The administrator revokes it. const devices = (await ( await adminRequest(running.origin, running.ownerCookie, "/api/v1/devices") ).json()) as { devices: Array<{ id: string; name: string; permissions: string[] }> }; expect(devices.devices).toHaveLength(1); expect(devices.devices[0]?.name).toBe("Foyer TV"); expect(devices.devices[0]?.permissions).toEqual([displayPermission]); const revoked = await adminRequest( running.origin, running.ownerCookie, `/api/v1/devices/${devices.devices[0]?.id}/revoke`, {}, ); expect(revoked.status).toBe(204); // 10. Everything it could do, it can no longer do — while still holding its cookie. expect(device.has("ekklesia_device_session")).toBe(true); expect((await device.request("/api/v1/module-displays/announcements")).status).toBe(403); expect( ( await device.request("/api/v1/module-realtime/announcements/state", { headers: { accept: "text/event-stream" }, }) ).status, ).toBe(403); // And the screen can start over: the page it needs for a new code is still served. expect((await device.request("/display/announcements")).status).toBe(200); } finally { await running.close(); } }); it("stores no usable credential, and puts none in any response body", async () => { const running = await platform("secrets"); const device = new DeviceBrowser(running.origin); try { await pairDevice(running, device); const credential = device.header().replace(/^.*ekklesia_device_session=/u, ""); expect(credential.length).toBeGreaterThan(20); const stored = await query<{ credential_hash: string; name: string }>( running.url, `select credential_hash, name from ekklesia_core_device`, ); expect(stored).toHaveLength(1); // The raw credential is nowhere in the database — only an irreversible digest of it. expect(stored[0]?.credential_hash).not.toContain(credential); expect(stored[0]?.credential_hash).toMatch(/^[0-9a-f]{64}$/u); const everything = JSON.stringify( await query(running.url, `select * from ekklesia_core_device_pairing_request`), ); expect(everything).not.toContain(credential); // The descriptor a module's UI can read carries no credential either. const descriptor = await device.request("/api/v1/module-displays/announcements"); expect(await descriptor.text()).not.toContain(credential); } finally { await running.close(); } }); describe("pairing cannot be talked into anything", () => { it("derives the permission from the display, never from the request", async () => { const running = await platform("derive"); const device = new DeviceBrowser(running.origin); try { // The browser tries to name its own permission and its own module. const requested = await device.post("/api/v1/device-pairing/requests", { moduleId: "announcements", permission: "core.devices.manage", permissionId: "core.devices.manage", }); expect(requested.status).toBe(201); const { code } = (await requested.json()) as { code: string }; const lookup = await adminRequest( running.origin, running.ownerCookie, "/api/v1/device-pairing/lookups", { code }, ); // The host recorded the display's own declared permission and ignored the rest. expect(await lookup.json()).toMatchObject({ permissionId: displayPermission }); const stored = await query<{ permission_id: string }>( running.url, `select permission_id from ekklesia_core_device_pairing_request`, ); expect(stored.map((row) => row.permission_id)).toEqual([displayPermission]); } finally { await running.close(); } }); it("refuses to start pairing for a module that has no display", async () => { const running = await platform("nodisplay"); const device = new DeviceBrowser(running.origin); try { for (const moduleId of ["calendar", "core", "../announcements", ""]) { const response = await device.post("/api/v1/device-pairing/requests", { moduleId }); expect(response.status, moduleId).toBeGreaterThanOrEqual(400); } expect( await query(running.url, `select id from ekklesia_core_device_pairing_request`), ).toHaveLength(0); } finally { await running.close(); } }); it("issues nothing to someone who knows only the code", async () => { const running = await platform("codeonly"); const device = new DeviceBrowser(running.origin); const onlooker = new DeviceBrowser(running.origin); try { const requested = await device.post("/api/v1/device-pairing/requests", { moduleId: "announcements", }); const { code } = (await requested.json()) as { code: string }; await adminRequest( running.origin, running.ownerCookie, "/api/v1/device-pairing/approvals", { code, deviceName: "Foyer TV", }, ); // Someone who read the code off the television — and even guessed the request exists — // holds no claim cookie, so there is nothing for them to collect. const stolen = await onlooker.post("/api/v1/device-pairing/claims", {}); expect(stolen.status).toBe(400); expect(onlooker.has("ekklesia_device_session")).toBe(false); // The real screen still gets its credential. const claimed = await device.post("/api/v1/device-pairing/claims", {}); expect(await claimed.json()).toEqual({ status: "paired" }); } finally { await running.close(); } }); }); describe("approval is an administrator's act", () => { it("refuses an anonymous or unauthorized caller, and enforces a trusted origin", async () => { const running = await platform("approve"); const device = new DeviceBrowser(running.origin); try { const requested = await device.post("/api/v1/device-pairing/requests", { moduleId: "announcements", }); const { code } = (await requested.json()) as { code: string }; // Anonymous. const anonymous = await fetch(`${running.origin}/api/v1/device-pairing/approvals`, { body: JSON.stringify({ code, deviceName: "Intruder TV" }), headers: { "content-type": "application/json", origin: dashboardOrigin }, method: "POST", }); expect(anonymous.status).toBe(403); // The paired-device cookie is not an administrator either — a device can never approve // another device, or itself. const selfApproval = await device.post("/api/v1/device-pairing/approvals", { code, deviceName: "Self TV", }); expect(selfApproval.status).toBe(403); // A real administrator from an untrusted origin: CSRF policy applies before anything else. const crossOrigin = await adminRequest( running.origin, running.ownerCookie, "/api/v1/device-pairing/approvals", { code, deviceName: "Foyer TV" }, { headers: { origin: untrustedOrigin } }, ); expect(crossOrigin.status).toBe(403); // Nothing was approved by any of that. expect( ( (await (await device.post("/api/v1/device-pairing/claims", {})).json()) as { status: string; } ).status, ).toBe("pending"); } finally { await running.close(); } }); it("cannot approve the same code twice, and cannot mint two credentials from one", async () => { const running = await platform("single"); const device = new DeviceBrowser(running.origin); try { const requested = await device.post("/api/v1/device-pairing/requests", { moduleId: "announcements", }); const { code } = (await requested.json()) as { code: string }; expect( ( await adminRequest( running.origin, running.ownerCookie, "/api/v1/device-pairing/approvals", { code, deviceName: "Foyer TV" }, ) ).status, ).toBe(200); // A second approval of the same code finds nothing pending. expect( ( await adminRequest( running.origin, running.ownerCookie, "/api/v1/device-pairing/approvals", { code, deviceName: "Second TV" }, ) ).status, ).toBe(404); // Two claims race for one approved request; exactly one credential may exist. const [first, second] = await Promise.all([ device.post("/api/v1/device-pairing/claims", {}), device.post("/api/v1/device-pairing/claims", {}), ]); const statuses = [ ((await first.json()) as { status: string }).status, ((await second.json()) as { status: string }).status, ]; expect(statuses.filter((status) => status === "paired")).toHaveLength(1); expect(await query(running.url, `select id from ekklesia_core_device`)).toHaveLength(1); } finally { await running.close(); } }); }); it("re-pairs a revoked screen into a fresh identity, leaving the old credential dead", async () => { const running = await platform("repair"); const device = new DeviceBrowser(running.origin); try { await pairDevice(running, device, "Foyer TV"); const firstCredential = device.header().replace(/^.*ekklesia_device_session=/u, ""); const listed = (await ( await adminRequest(running.origin, running.ownerCookie, "/api/v1/devices") ).json()) as { devices: Array<{ id: string }> }; await adminRequest( running.origin, running.ownerCookie, `/api/v1/devices/${listed.devices[0]?.id}/revoke`, {}, ); await pairDevice(running, device, "Foyer TV (replaced)"); const secondCredential = device.header().replace(/^.*ekklesia_device_session=/u, ""); expect(secondCredential).not.toBe(firstCredential); expect((await device.request("/api/v1/module-displays/announcements")).status).toBe(200); // Re-pairing creates a new identity rather than reviving the revoked one, so the old // credential can never come back — presenting it again is simply anonymous. const revokedBrowser = new DeviceBrowser(running.origin); revokedBrowser.overwrite("ekklesia_device_session", firstCredential); expect((await revokedBrowser.request("/api/v1/module-displays/announcements")).status).toBe( 403, ); const devices = (await ( await adminRequest(running.origin, running.ownerCookie, "/api/v1/devices") ).json()) as { devices: Array<{ revoked: boolean }> }; expect(devices.devices).toHaveLength(2); expect(devices.devices.filter((entry) => entry.revoked)).toHaveLength(1); } finally { await running.close(); } }); it("keeps a human administrator's own session unaffected by a device cookie", async () => { const running = await platform("mixed"); const device = new DeviceBrowser(running.origin); try { await pairDevice(running, device); const deviceCredential = device.header(); // A browser presenting both: the person still wins, and stays a person. const both = await fetch(`${running.origin}/api/v1/principal`, { headers: { cookie: `${running.ownerCookie}; ${deviceCredential}` }, }); expect(((await both.json()) as { kind: string }).kind).toBe("authenticated"); // And that administrator can still open the display for testing, as a human. const descriptor = await fetch(`${running.origin}/api/v1/module-displays/announcements`, { headers: { cookie: `${running.ownerCookie}; ${deviceCredential}` }, }); expect(descriptor.status).toBe(200); expect(((await descriptor.json()) as { viewer: { kind: string } }).viewer.kind).toBe( "authenticated", ); } finally { await running.close(); } }); });