The platform has had a complete story for one module using another's
*backend* since ADR 0006 — capabilities, versioned ranges, provider
registration. It has had none at all for one module using another's *browser*
code, and every workaround is wrong in a way that matters: a consumer cannot
import a peer module, it cannot re-implement a renderer for a domain it does
not own, and a second `entrypoints` role would put the platform in charge of
deciding when a slot inside someone else's screen should be filled.
Add a generic browser contribution. `manifestVersion: 7` declares one:
namespaced id, its own version, a target capability, a compatible range, and
one artifact. That is the complete list, and the omissions are the decision —
no payload schema, slot, ordering, or rendering hint exists anywhere, so Core
cannot learn what a slide is because the declaration cannot express it.
Discovery resolves a contribution artifact through the *identical* loop every
other compiled entrypoint uses, inheriting the path, containment, symlink,
extension, and file-kind checks rather than getting a weaker copy of them. The
registry publishes one only when another eligible module provides the target
capability within the declared range; otherwise it is absent rather than
mounted and broken. `apps/api` serves it as a sibling of the artifact and
display routes, with the same policy: three lookup keys, one exact match, one
already-validated path, and one indistinguishable 404 for unknown, undeclared,
stale, and incompatible alike.
A consumer discovers through the host, never through a URL. Its descriptor
carries identity, version, and capability; the URL stays server-side and is
resolved exactly as `api.request("records")` is. A module can only ever see
contributions aimed at a capability it itself provides, and cannot name its
way to anything else — a mount for a withheld id fails without fetching.
The consumer owns the domain contract entirely. Input is opaque JSON, checked
structurally so a Date, a function, a cycle, or `undefined` fails at the
boundary rather than vanishing somewhere far from the mistake, and checked for
nothing else. A contributed renderer receives its own id and that input: no
API client, realtime, principal, configuration, database, storage, or
credential. Loading one grants its module nothing.
The contribution ABI is versioned separately from the module UI ABI, because
the two have different shapes and lifecycles and coupling them would force
every module to rebuild whenever either moved. It supports asynchronous
readiness, update-without-remount (a screen that remounted per tick would
restart animations and reload media), deterministic cleanup in a `finally`,
and sanitized failure that never carries the consumer's domain data. Each
renderer mounts in its own isolated root with its own stylesheet.
The template now declares a contribution, so `pnpm build` exercises the whole
authoring path, and the shell suite drives the full relationship — one module
discovering, mounting, updating, and unmounting another's React renderer with
neither importing the other.
Co-Authored-By: Claude Opus 5 <[email protected]>
811 lines
30 KiB
TypeScript
811 lines
30 KiB
TypeScript
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: 7,
|
|
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();
|
|
}
|
|
});
|
|
});
|