ADR 0033 gave Ekklesia a display host and ADR 0034 gave it a login page. Together, the only way to light a television in a foyer was to sign a human being into it and leave that session running on a screen anyone can walk up to: a person's credentials on a wall, a person's session as the thing signage depends on, and the display going dark when they are revoked or their session simply expires. A screen is not a person. Ekklesia now recognizes three principal classes — anonymous, authenticated human, and managed device — and a device is a first-class principal with its own credential and its own explicit permissions. It has no email, no password, and no row in the identity tables, and it satisfies the `authenticated` baseline never, so every route that settles for "somebody is signed in" stays closed to it. Device permissions live in their own table because ekklesia_core_grant.user_id is a non-null foreign key into the user table and making it polymorphic would rewrite the platform's central authorization record to serve one narrow case. That is a schema decision, not a second decision point: the evaluator gained one branch, so a device is allowed or denied by the same function, with the same deny-by-default, as anyone else — with no owner path it could reach and no baseline that stands in for a grant. The six-digit code is not the credential. It is printed across a screen in a public hallway, so it is treated as public: it identifies a request to an administrator and is worth nothing alone. Collecting the credential also requires a 256-bit claim secret held only in the requesting browser's HttpOnly cookie, and approving requires the restricted core.devices.manage permission. Without that split, reading a code off a wall would hand over the screen's identity. The permission a pairing asks for is read from the module's validated display declaration, never from the request, so a browser cannot ask to be granted core.devices.manage or another module's permission. Approval grants exactly that one permission. Credentials are stored only as SHA-256; the raw value exists in the Set-Cookie that issues it and the device's own cookie jar, and is never logged, returned in JSON, or placed in a URL. The cookie is HttpOnly, so the module display code running in that same page cannot read it. Revocation is a server-side column checked on every principal resolution, and re-pairing mints a new identity so an old credential can never come back. State transitions are conditional updates, so one code cannot be approved twice or claimed into two credentials under a race. /api/v1/principal still reports a device as anonymous: it is what the Dashboard uses to decide whether a person is signed in. Displays read their viewer from the descriptor instead, which already performed the authorization — so displays are descriptor-first while the Dashboard stays principal-first, and neither infers an identity by elimination. Proven end to end against real PostgreSQL and real HTTP: an unpaired screen shows a code, an owner approves it, the device opens the display descriptor and a realtime stream, and revocation closes both while the device still holds its cookie. An already-open SSE stream is bounded by realtime's existing five-minute reconnect rather than closed instantly, and that bound is documented rather than called immediate. Co-Authored-By: Claude Opus 5 <[email protected]>
244 lines
10 KiB
TypeScript
244 lines
10 KiB
TypeScript
import { readdir, readFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { build } from "esbuild";
|
|
import { beforeAll, describe, expect, it } from "vitest";
|
|
|
|
/**
|
|
* The proof that this shell is a *generic* host rather than an application that happens to render
|
|
* some known modules.
|
|
*
|
|
* Two levels, because they fail differently. At source level, a static import of a module would be
|
|
* an architectural mistake a reviewer should catch. At bundle level, a dynamic import that a bundler
|
|
* managed to resolve statically would be an invisible one: the code would look correct, the tests
|
|
* would pass, and installing a new module would silently do nothing until the shell was rebuilt.
|
|
*
|
|
* `happy-dom` replaces the global `URL` with the DOM implementation, which `node:fs` rejects, so
|
|
* everything here works in plain filesystem paths.
|
|
*/
|
|
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
const sourceDirectory = path.join(packageDirectory, "src");
|
|
|
|
/** The complete set of packages this shell may reach for. `@ekklesia/module-ui-host` is the generic
|
|
* loader and `@ekklesia/module-ui-sdk` is the browser-side contract; no other `@ekklesia/*` package
|
|
* — and no module — may appear. */
|
|
const ALLOWED_EKKLESIA_IMPORTS = new Set(["@ekklesia/module-ui-host", "@ekklesia/module-ui-sdk"]);
|
|
|
|
async function sourceFiles(directory: string): Promise<string[]> {
|
|
const entries = await readdir(directory, { withFileTypes: true });
|
|
const files: string[] = [];
|
|
for (const entry of entries) {
|
|
const entryPath = path.join(directory, entry.name);
|
|
if (entry.isDirectory()) {
|
|
files.push(...(await sourceFiles(entryPath)));
|
|
} else if (/\.tsx?$/u.test(entry.name)) {
|
|
files.push(entryPath);
|
|
}
|
|
}
|
|
return files.sort();
|
|
}
|
|
|
|
function importSpecifiers(source: string): string[] {
|
|
const specifiers = new Set<string>();
|
|
const patterns = [
|
|
/\b(?:import|export)\s+(?:type\s+)?[^;]*?\bfrom\s*["']([^"'\n]+)["']/gu,
|
|
/\bimport\s*["']([^"'\n]+)["']/gu,
|
|
/\bimport\s*\(\s*["']([^"'\n]+)["']\s*\)/gu,
|
|
];
|
|
for (const pattern of patterns) {
|
|
for (const match of source.matchAll(pattern)) {
|
|
if (match[1]) {
|
|
specifiers.add(match[1]);
|
|
}
|
|
}
|
|
}
|
|
return [...specifiers];
|
|
}
|
|
|
|
let files: string[];
|
|
let sources: Map<string, string>;
|
|
|
|
beforeAll(async () => {
|
|
files = await sourceFiles(sourceDirectory);
|
|
sources = new Map(
|
|
await Promise.all(files.map(async (file) => [file, await readFile(file, "utf8")] as const)),
|
|
);
|
|
});
|
|
|
|
describe("the shell is coupled to no module", () => {
|
|
it("has source files to check", () => {
|
|
expect(files.length).toBeGreaterThan(3);
|
|
});
|
|
|
|
it("imports no module, template, or workspace package beyond the two browser contracts", () => {
|
|
for (const [file, source] of sources) {
|
|
for (const specifier of importSpecifiers(source)) {
|
|
const label = `${path.relative(packageDirectory, file)} -> ${specifier}`;
|
|
expect(specifier.includes("modules/"), label).toBe(false);
|
|
expect(specifier.includes("templates/"), label).toBe(false);
|
|
// Nothing may climb out of this package at all.
|
|
expect(specifier.startsWith("../../"), label).toBe(false);
|
|
if (specifier.startsWith("@ekklesia/")) {
|
|
expect(ALLOWED_EKKLESIA_IMPORTS.has(specifier), label).toBe(true);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
it("performs no dynamic import of its own: all module loading goes through the generic loader", () => {
|
|
for (const [file, source] of sources) {
|
|
// Comment lines are dropped so a doc comment may still *describe* the loader's own
|
|
// `(url) => import(url)`. The authoritative check is the bundle-level one below, which counts
|
|
// dynamic imports in the emitted output where no comment survives.
|
|
const code = source
|
|
.split("\n")
|
|
.filter((line) => !/^\s*(\*|\/\/)/u.test(line))
|
|
.join("\n");
|
|
const dynamicImports = [...code.matchAll(/\bimport\s*\(/gu)];
|
|
expect(dynamicImports, path.relative(packageDirectory, file)).toEqual([]);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("the shell builds no module knowledge into its bundle", () => {
|
|
let bundle: string;
|
|
|
|
beforeAll(async () => {
|
|
// The real production bundle configuration, produced in memory so this test never depends on a
|
|
// previous `pnpm build` having run.
|
|
const result = await build({
|
|
absWorkingDir: packageDirectory,
|
|
bundle: true,
|
|
define: {
|
|
__EKKLESIA_API_BASE_URL__: '""',
|
|
"process.env.NODE_ENV": '"production"',
|
|
},
|
|
entryPoints: [path.join(sourceDirectory, "browser", "main.tsx")],
|
|
format: "esm",
|
|
jsx: "automatic",
|
|
logLevel: "silent",
|
|
minify: false,
|
|
platform: "browser",
|
|
target: "es2022",
|
|
write: false,
|
|
});
|
|
bundle = result.outputFiles.map((file) => file.text).join("\n");
|
|
}, 60_000);
|
|
|
|
it("leaves exactly one dynamic import — the loader's — unresolved for run time", () => {
|
|
const dynamicImports = [...bundle.matchAll(/\bimport\s*\(/gu)];
|
|
|
|
// Exactly one: `@ekklesia/module-ui-host`'s `(url) => import(url)`. More would mean the shell
|
|
// acquired its own loader; fewer would mean the bundler resolved it away and installed modules
|
|
// could never be fetched at run time.
|
|
expect(dynamicImports).toHaveLength(1);
|
|
expect(bundle).toContain("(url) => import(url)");
|
|
});
|
|
|
|
it("contains no module identity and no module API prefix", () => {
|
|
// The shell learns every module id, artifact URL, and API root from the inventory at run time.
|
|
// Any of these appearing in the bundle would mean it had been baked in instead.
|
|
expect(bundle).not.toContain("/api/v1/modules");
|
|
expect(bundle).not.toContain("/api/v1/module-ui/");
|
|
expect(bundle).not.toContain("ekklesia.module.json");
|
|
expect(bundle).not.toContain("entrypoints");
|
|
});
|
|
|
|
it("spells only the platform paths it genuinely owns", () => {
|
|
// The platform's own endpoints — never a module's. Everything module-specific arrives at run
|
|
// time from the navigation the server resolved.
|
|
expect(bundle).toContain('"/api/v1/principal"');
|
|
expect(bundle).toContain('"/api/v1/dashboard/navigation"');
|
|
expect(bundle).toContain('"/api/auth/sign-in/email"');
|
|
expect(bundle).toContain('"/api/auth/sign-out"');
|
|
// The shell reads the navigation view, which already contains the inventory; it never calls
|
|
// the host-neutral inventory endpoint itself.
|
|
expect(bundle).not.toContain('"/api/v1/module-ui"');
|
|
});
|
|
|
|
it("contains only the browser HTTP auth adapter, never Better Auth or server identity internals", () => {
|
|
expect(bundle.toLowerCase()).not.toContain("better-auth");
|
|
expect(bundle.toLowerCase()).not.toContain("drizzle");
|
|
expect(bundle).not.toContain("EKKLESIA_IDENTITY_SECRET");
|
|
expect(bundle).not.toContain("bootstrapPlatformOwner");
|
|
expect(bundle).not.toContain("session_token");
|
|
});
|
|
|
|
it("bakes in no group, label, or placement", () => {
|
|
// Grouping is server-resolved configuration. A group id or label appearing in the bundle would
|
|
// mean the shell had an opinion about an arrangement it must only render.
|
|
// Deliberately distinctive terms: a generic word like "media" appears inside React's own
|
|
// bundled source and would make this assertion meaningless rather than strict.
|
|
for (const term of ["planning", "sunday-production", "livestream", "calendar", "ungrouped-"]) {
|
|
expect(bundle.toLowerCase(), term).not.toContain(term);
|
|
}
|
|
});
|
|
|
|
it("uses neither eval nor a blob URL to load module code", () => {
|
|
expect(bundle).not.toContain("new Function(");
|
|
expect(bundle).not.toMatch(/\beval\s*\(/u);
|
|
expect(bundle).not.toContain("createObjectURL");
|
|
});
|
|
});
|
|
|
|
describe("the display host is a separate generic browser bundle", () => {
|
|
let bundle: string;
|
|
|
|
beforeAll(async () => {
|
|
const result = await build({
|
|
absWorkingDir: packageDirectory,
|
|
bundle: true,
|
|
define: {
|
|
__EKKLESIA_API_BASE_URL__: '""',
|
|
"process.env.NODE_ENV": '"production"',
|
|
},
|
|
entryPoints: [path.join(sourceDirectory, "display", "main.tsx")],
|
|
format: "esm",
|
|
jsx: "automatic",
|
|
logLevel: "silent",
|
|
minify: false,
|
|
platform: "browser",
|
|
target: "es2022",
|
|
write: false,
|
|
});
|
|
bundle = result.outputFiles.map((file) => file.text).join("\n");
|
|
}, 60_000);
|
|
|
|
it("contains the generic loader's one dynamic import and no statically known module", () => {
|
|
expect([...bundle.matchAll(/\bimport\s*\(/gu)]).toHaveLength(1);
|
|
expect(bundle).toContain("(url) => import(url)");
|
|
expect(bundle).not.toContain("ekklesia.module.json");
|
|
expect(bundle).not.toContain("/api/v1/modules");
|
|
for (const moduleId of ["announcements", "calendar", "livestream", "signage"]) {
|
|
expect(bundle.toLowerCase()).not.toContain(moduleId);
|
|
}
|
|
});
|
|
|
|
it("knows only its host route resources, with no Dashboard application or transport coupling", () => {
|
|
expect(bundle).toContain('"/api/v1/module-displays"');
|
|
// Pairing is a display concern and lives here; the Dashboard bundle has its own management UI.
|
|
expect(bundle).toContain('"/api/v1/device-pairing/requests"');
|
|
expect(bundle).toContain('"/api/v1/device-pairing/claims"');
|
|
// And the display deliberately never asks the *human* principal endpoint: a paired device is
|
|
// anonymous to it, so consulting it would report every display in the building as signed out
|
|
// (ADR 0036). The descriptor reports the viewer instead.
|
|
expect(bundle).not.toContain('"/api/v1/principal"');
|
|
// A display never approves, lists, or revokes a device — that is the administrator's surface.
|
|
expect(bundle).not.toContain('"/api/v1/device-pairing/approvals"');
|
|
expect(bundle).not.toContain('"/api/v1/devices"');
|
|
expect(bundle).not.toContain('"/api/v1/dashboard/navigation"');
|
|
expect(bundle).not.toContain('"/api/auth/sign-in/email"');
|
|
expect(bundle).not.toContain('"/api/auth/sign-out"');
|
|
expect(bundle).not.toContain("Loading Ekklesia");
|
|
expect(bundle).not.toContain("Sign in to Ekklesia");
|
|
expect(bundle.toLowerCase()).not.toContain("socket.io");
|
|
expect(bundle.toLowerCase()).not.toContain("mqtt");
|
|
});
|
|
|
|
it("uses neither eval nor a blob URL to load display code", () => {
|
|
expect(bundle).not.toContain("new Function(");
|
|
expect(bundle).not.toMatch(/\beval\s*\(/u);
|
|
expect(bundle).not.toContain("createObjectURL");
|
|
});
|
|
});
|