import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { ANONYMOUS_PRINCIPAL, createAuthenticatedPrincipal, type EkklesiaPrincipal, type ModuleDatabase, type ModuleRuntimeServices, parseEkklesiaUserId, } from "@ekklesia/contracts"; import { type AuthorizationEvaluator, type AuthorizationStore, createAuthorizationEvaluator, } from "@ekklesia/core/authorization"; import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; import { composePlatformPermissionRegistry, createModuleUiPublicationRegistry, type DiscoveredModule, discoverModules, EMPTY_MODULE_REALTIME_REGISTRY, } from "@ekklesia/core/modules"; import type { Hono } from "hono"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createApiApp } from "../src/app.js"; import { createDisplayHostAssets, type DisplayHostAssets } from "../src/display-host-assets.js"; import { DISPLAY_HOST_SCRIPT_PATH, ModuleDisplayConfigurationError, moduleDisplayDescriptorUrl, moduleDisplayPageUrl, } from "../src/module-display-routes.js"; import { moduleApiBaseUrl } from "../src/module-http-routes.js"; import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js"; import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js"; import { moduleDisplayUiArtifactUrl } from "../src/module-ui-routes.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js"; import { createInMemoryModuleLifecycleStore, moduleLifecycleSnapshotFor, } from "./support/in-memory-module-lifecycle.js"; import { moduleStorageOptionFor } from "./support/in-memory-module-storage.js"; const viewerId = parseEkklesiaUserId("11111111-1111-4111-8111-111111111111"); const trustedOrigin = "https://dashboard.example.test"; const displayPermission = "announcements.display.view"; const displayHostAssets: DisplayHostAssets = createDisplayHostAssets( `
`, "globalThis.__ekklesiaDisplayHostLoaded = true;\n", ); const temporaryDirectories: string[] = []; afterEach(async () => { await Promise.all( temporaryDirectories .splice(0) .map((directory) => rm(directory, { force: true, recursive: true })), ); }); interface ModuleFixture { readonly display?: boolean; readonly id?: string; readonly ui?: boolean; readonly version?: string; } async function createModule(modulesDirectory: string, fixture: ModuleFixture = {}): Promise { const id = fixture.id ?? "announcements"; const version = fixture.version ?? "1.4.2"; const moduleDirectory = path.join(modulesDirectory, id); await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true }); await writeFile( path.join(moduleDirectory, "package.json"), JSON.stringify({ ekklesia: { layer: "module" }, name: `@test/module-${id}`, type: "module", version, }), ); await writeFile( path.join(moduleDirectory, "ekklesia.module.json"), JSON.stringify({ compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, entrypoints: { server: "./dist/server/index.js", ...(fixture.ui ? { ui: "./dist/ui/index.js" } : {}), ...(fixture.display ? { display: { artifact: "./dist/display/index.js", permission: `${id}.display.view`, }, } : {}), }, id, manifestVersion: 6, name: `${id} module`, permissions: fixture.display ? [ { baseline: "restricted", description: `View the ${id} display.`, id: `${id}.display.view`, }, ] : [], version, }), ); await writeFile( path.join(moduleDirectory, "dist/server/index.js"), `export default { id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)} };\n`, ); if (fixture.ui) { await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true }); await writeFile( path.join(moduleDirectory, "dist/ui/index.js"), `export default { id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)}, mount() {} };\n`, ); } if (fixture.display) { await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true }); await writeFile( path.join(moduleDirectory, "dist/display/index.js"), `export default { id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)}, mount(container, context) { container.textContent = context.moduleId + ":slideshow"; } };\n`, ); } } interface InstallOptions extends ModuleFixture { readonly assets?: DisplayHostAssets; readonly evaluator?: AuthorizationEvaluator; readonly granted?: readonly string[]; readonly principal?: EkklesiaPrincipal; /** The API builds publication from the eligible/running set. `false` models a module that was * discovered but disabled or dependency-blocked before runtime startup. */ readonly published?: boolean; readonly withAssets?: boolean; } interface Installation { readonly app: Hono; readonly modules: readonly DiscoveredModule[]; } function fakeServices(): ModuleRuntimeServices { return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase }); } function fakeAuthorizationStore(): AuthorizationStore { return { createGrant: vi.fn(), grantPlatformOwner: vi.fn(), hasActiveGrant: vi.fn().mockResolvedValue(false), isPlatformOwner: vi.fn().mockResolvedValue(false), listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }), revokeGrant: vi.fn(), revokePlatformOwner: vi.fn(), } as unknown as AuthorizationStore; } async function install(options: InstallOptions): Promise { const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-api-display-")); temporaryDirectories.push(modulesDirectory); await createModule(modulesDirectory, options); const modules = await discoverModules({ moduleSdkVersion: "0.1.0", modulesDirectory, platformVersion: "0.1.0", }); const granted = new Set(options.granted ?? []); const evaluator = options.evaluator ?? createAuthorizationEvaluator({ grants: { hasActiveGrant: async (query) => granted.has(query.permission) }, platformOwners: { isPlatformOwner: async () => false }, }); const runtime: EkklesiaIdentityRuntime = { handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })), resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ANONYMOUS_PRINCIPAL), }; const publishedModules = options.published === false ? [] : modules; const moduleUi = createModuleUiPublicationRegistry(publishedModules); const app = createApiApp({ authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store: fakeAuthorizationStore(), }, dashboard: createInMemoryDashboardStore(), ...(moduleUi.listDisplays().length > 0 && options.withAssets !== false ? { displayHostAssets: options.assets ?? displayHostAssets } : {}), moduleConfiguration: moduleConfigurationOptionFor(modules), moduleLifecycle: { snapshot: moduleLifecycleSnapshotFor(modules), store: createInMemoryModuleLifecycleStore(), }, moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY, moduleRoutes: Object.freeze([]), moduleServices: fakeServices(), moduleStorage: moduleStorageOptionFor(modules), moduleUi, runtime, trustedOrigins: [trustedOrigin], }); return { app, modules }; } function signedIn(): EkklesiaPrincipal { return createAuthenticatedPrincipal(viewerId); } describe("module display routes — authorization and descriptor", () => { it("gives an unpaired viewer the inert shell but never the descriptor", async () => { const { app } = await install({ display: true }); const [page, descriptor] = await Promise.all([ app.request(moduleDisplayPageUrl("announcements")), app.request(moduleDisplayDescriptorUrl("announcements")), ]); // 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.headers.get("cache-control")).toBe("no-store"); expect(await descriptor.json()).toEqual({ code: "AUTHORIZATION_DENIED" }); }); it("requires the declared display permission, not merely an authenticated principal", async () => { const { app } = await install({ display: true, principal: signedIn() }); // Being signed in is not being permitted, and the descriptor is where that is decided. expect((await app.request(moduleDisplayDescriptorUrl("announcements"))).status).toBe(403); }); it("serves the stable shell and current versioned descriptor to a permitted principal", async () => { const { app } = await install({ display: true, granted: [displayPermission], principal: signedIn(), version: "1.4.2", }); const page = await app.request(moduleDisplayPageUrl("announcements")); const descriptor = await app.request(moduleDisplayDescriptorUrl("announcements")); expect(page.status).toBe(200); expect(page.headers.get("content-type")).toBe("text/html; charset=utf-8"); expect(page.headers.get("cache-control")).toBe("no-store"); expect(page.headers.get("x-content-type-options")).toBe("nosniff"); expect(await page.text()).toBe(displayHostAssets.html); expect(await descriptor.json()).toEqual({ viewer: { kind: "authenticated", userId: viewerId }, module: { api: { baseUrl: moduleApiBaseUrl("announcements") }, display: { url: moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }), }, id: "announcements", permissions: [displayPermission], realtime: { baseUrl: moduleRealtimeBaseUrl("announcements") }, version: "1.4.2", }, }); expect(descriptor.headers.get("cache-control")).toBe("no-store"); expect(descriptor.headers.get("content-type")).toContain("application/json"); expect(descriptor.headers.get("vary")).toContain("Origin"); expect(descriptor.headers.get("vary")).toContain("Cookie"); }); it("sanitizes a permission-hint evaluation failure after route authorization succeeds", async () => { const authorize = vi .fn() .mockResolvedValueOnce({ allowed: true }) .mockRejectedValueOnce(new Error("postgresql://secret@127.0.0.1 /srv/modules/announcements")); const evaluator: AuthorizationEvaluator = { authorize }; const { app } = await install({ display: true, evaluator, principal: signedIn() }); const response = await app.request(moduleDisplayDescriptorUrl("announcements")); const body = await response.text(); expect(authorize).toHaveBeenCalledTimes(2); expect(response.status).toBe(503); expect(JSON.parse(body)).toEqual({ code: "AUTHORIZATION_EVALUATION_FAILED" }); expect(response.headers.get("cache-control")).toBe("no-store"); expect(response.headers.get("vary")).toContain("Origin"); expect(response.headers.get("vary")).toContain("Cookie"); for (const secret of ["postgresql", "127.0.0.1", "/srv/", "announcements"]) { expect(body).not.toContain(secret); } }); it("keeps the outer page stable when the internal artifact version changes", () => { expect(moduleDisplayPageUrl("announcements")).toBe("/display/announcements"); expect(moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.0.0" })).not.toBe( moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "2.0.0" }), ); }); }); describe("module display routes — role and lifecycle separation", () => { it("keeps a display-only module out of the primary UI inventory", async () => { const { app } = await install({ display: true, granted: [displayPermission], principal: signedIn(), }); expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(200); const inventory = await app.request(MODULE_UI_INVENTORY_PATH); expect(inventory.status).toBe(200); expect(await inventory.json()).toEqual({ modules: [] }); }); it("does not give a dashboard-only module a display route", async () => { const { app } = await install({ principal: signedIn(), ui: true }); expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(404); const inventory = await app.request(MODULE_UI_INVENTORY_PATH); expect(inventory.status).toBe(200); expect((await inventory.json()) as { modules: unknown[] }).toMatchObject({ modules: [{ id: "announcements" }], }); }); it("publishes both roles independently when a module declares both", async () => { const { app } = await install({ display: true, granted: [displayPermission], principal: signedIn(), ui: true, }); expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(200); expect((await app.request(MODULE_UI_INVENTORY_PATH)).status).toBe(200); expect( ((await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as { modules: unknown[] }) .modules, ).toHaveLength(1); }); it("has no display page, descriptor, or artifact when the module is outside the eligible set", async () => { const { app } = await install({ display: true, granted: [displayPermission], principal: signedIn(), published: false, }); expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(404); expect((await app.request(moduleDisplayDescriptorUrl("announcements"))).status).toBe(404); expect( ( await app.request( moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }), ) ).status, ).toBe(404); expect((await app.request(DISPLAY_HOST_SCRIPT_PATH)).status).toBe(404); }); it("registers only the exact stable page and descriptor paths", async () => { const { app } = await install({ display: true, granted: [displayPermission], principal: signedIn(), }); for (const route of [ "/display", "/display/announcements/", "/display/announcements/settings", "/api/v1/module-displays", "/api/v1/module-displays/announcements/", "/api/v1/module-displays/announcements/settings", ]) { expect((await app.request(route)).status, route).toBe(404); } }); it("fails composition when a display exists but its host assets were not supplied", async () => { await expect(install({ display: true, withAssets: false })).rejects.toBeInstanceOf( ModuleDisplayConfigurationError, ); }); }); describe("module display host asset publication", () => { it("serves only the fixed script with JavaScript and revalidation headers", async () => { const { app } = await install({ display: true }); const response = await app.request(DISPLAY_HOST_SCRIPT_PATH); expect(response.status).toBe(200); expect(await response.text()).toBe(displayHostAssets.script); expect(response.headers.get("content-type")).toBe("text/javascript; charset=utf-8"); expect(response.headers.get("cache-control")).toBe("no-cache"); expect(response.headers.get("x-content-type-options")).toBe("nosniff"); const notModified = await app.request(DISPLAY_HOST_SCRIPT_PATH, { headers: { "if-none-match": displayHostAssets.scriptEtag }, }); expect(notModified.status).toBe(304); expect(await notModified.text()).toBe(""); expect(notModified.headers.get("etag")).toBe(displayHostAssets.scriptEtag); expect(notModified.headers.get("cache-control")).toBe("no-cache"); for (const header of [ `W/${displayHostAssets.scriptEtag}`, "*", `"stale", ${displayHostAssets.scriptEtag}`, ]) { expect( ( await app.request(DISPLAY_HOST_SCRIPT_PATH, { headers: { "if-none-match": header }, }) ).status, header, ).toBe(304); } expect( ( await app.request(DISPLAY_HOST_SCRIPT_PATH, { headers: { "if-none-match": '"stale"' }, }) ).status, ).toBe(200); }); it("exposes no source map, neighboring file, arbitrary role, or request-selected path", async () => { const { app } = await install({ display: true }); for (const candidate of [ `${DISPLAY_HOST_SCRIPT_PATH}.map`, "/assets/ekklesia-display/index.html", "/assets/ekklesia-display/secret.txt", "/assets/ekklesia-display/../secret.txt", "/api/v1/module-ui/announcements/1.4.2/management", ]) { expect((await app.request(candidate)).status, candidate).toBe(404); } }); it("uses identical non-interpolated HTML for every eligible module", async () => { const first = await install({ display: true, granted: [displayPermission], principal: signedIn(), }); const second = await install({ display: true, granted: ["signage.display.view"], id: "signage", principal: signedIn(), }); const firstHtml = await (await first.app.request(moduleDisplayPageUrl("announcements"))).text(); const secondHtml = await (await second.app.request(moduleDisplayPageUrl("signage"))).text(); expect(firstHtml).toBe(secondHtml); expect(firstHtml).not.toContain("announcements"); expect(secondHtml).not.toContain("signage"); }); it("snapshots injected assets and rejects a validator that does not describe its script", async () => { const source = createDisplayHostAssets("display host", "export const host = 1;\n"); const mutable = { ...source }; const { app } = await install({ assets: mutable, display: true }); mutable.html = "mutated secret"; mutable.script = "throw new Error('mutated secret');"; expect(await (await app.request(DISPLAY_HOST_SCRIPT_PATH)).text()).toBe(source.script); await expect( install({ assets: { ...source, scriptEtag: '"stale-validator"' }, display: true, }), ).rejects.toBeInstanceOf(ModuleDisplayConfigurationError); }); it("does not publish the generic host script when no eligible display uses it", async () => { const { app } = await install({ ui: true, withAssets: false }); expect((await app.request(DISPLAY_HOST_SCRIPT_PATH)).status).toBe(404); }); });