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,
DISPLAY_SERVICE_WORKER_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 { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.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",
"globalThis.__ekklesiaDisplayWorkerLoaded = 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: 7,
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(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
...(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: {
contributions: [],
api: { baseUrl: moduleApiBaseUrl("announcements") },
display: {
url: moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }),
},
id: "announcements",
page: { url: moduleDisplayPageUrl("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: Array<{ display: { url: string } | null }>;
}
).modules,
).toEqual([
expect.objectContaining({ display: { url: moduleDisplayPageUrl("announcements") } }),
]);
});
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",
"export const worker = 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);
});
it("serves the offline service worker from the path whose directory is its scope", async () => {
const { app } = await install({ display: true });
const response = await app.request(DISPLAY_SERVICE_WORKER_PATH);
// `/display/sw.js`, so the worker's default scope is `/display/` — display pages and nothing
// else. The Dashboard is never controlled by it (ADR 0043).
expect(DISPLAY_SERVICE_WORKER_PATH).toBe("/display/sw.js");
expect(response.status).toBe(200);
expect(await response.text()).toBe(displayHostAssets.serviceWorker);
expect(response.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
// `no-cache` with a strong validator, exactly like the host script: a browser re-checks the
// worker on its own schedule, which is how a screen avoids being trapped on an obsolete one.
expect(response.headers.get("cache-control")).toBe("no-cache");
expect(response.headers.get("etag")).toBe(displayHostAssets.serviceWorkerEtag);
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
});
it("revalidates the service worker with its own validator, never the host script's", async () => {
const { app } = await install({ display: true });
const notModified = await app.request(DISPLAY_SERVICE_WORKER_PATH, {
headers: { "if-none-match": displayHostAssets.serviceWorkerEtag },
});
expect(notModified.status).toBe(304);
const wrongValidator = await app.request(DISPLAY_SERVICE_WORKER_PATH, {
headers: { "if-none-match": displayHostAssets.scriptEtag },
});
expect(wrongValidator.status).toBe(200);
});
it("does not publish the service worker when no eligible display uses it", async () => {
const { app } = await install({ ui: true, withAssets: false });
expect((await app.request(DISPLAY_SERVICE_WORKER_PATH)).status).toBe(404);
});
it("cannot be reached through a neighbouring or request-selected path", async () => {
const { app } = await install({ display: true });
for (const candidate of [
`${DISPLAY_SERVICE_WORKER_PATH}.map`,
"/display/sw.js/",
"/sw.js",
"/assets/ekklesia-display/sw.js",
]) {
expect((await app.request(candidate)).status, candidate).toBe(404);
}
});
});