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 AuthorizationStore, createAuthorizationEvaluator, type PermissionRegistry, } from "@ekklesia/core/authorization"; import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; import { composePlatformPermissionRegistry, createModuleUiPublicationRegistry, type DiscoveredModule, discoverModules, } from "@ekklesia/core/modules"; import type { Hono } from "hono"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createApiApp } from "../src/app.js"; import { moduleApiBaseUrl } from "../src/module-http-routes.js"; import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js"; import { moduleUiArtifactUrl } from "../src/module-ui-routes.js"; const trustedOrigin = "https://dashboard.example.test"; const viewerId = parseEkklesiaUserId("11111111-1111-4111-8111-111111111111"); const temporaryDirectories: string[] = []; afterEach(async () => { await Promise.all( temporaryDirectories .splice(0) .map((directory) => rm(directory, { force: true, recursive: true })), ); }); interface ModuleFixture { readonly id: string; readonly version?: string; /** Omit for a backend-only module: the manifest then declares no `entrypoints.ui` at all. */ readonly withUi?: boolean; readonly permissions?: ReadonlyArray<{ readonly id: string; readonly baseline: "authenticated" | "public" | "restricted"; }>; } async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise { const version = fixture.version ?? "0.1.0"; const moduleDirectory = path.join(modulesDirectory, fixture.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-${fixture.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.withUi ? { ui: "./dist/ui/index.js" } : {}), }, id: fixture.id, manifestVersion: 4, name: `${fixture.id} module`, permissions: (fixture.permissions ?? []).map((permission) => ({ ...permission, description: `Test permission for ${fixture.id}.`, })), version, }), ); await writeFile( path.join(moduleDirectory, "dist/server/index.js"), `export default { id: ${JSON.stringify(fixture.id)}, version: ${JSON.stringify(version)} };`, ); if (fixture.withUi) { await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true }); await writeFile( path.join(moduleDirectory, "dist/ui/index.js"), `export default { id: ${JSON.stringify(fixture.id)}, version: ${JSON.stringify(version)}, mount() {} };\n`, ); } } interface InstallOptions { readonly fixtures: readonly ModuleFixture[]; /** Anonymous by default, so every scenario has to opt in to being signed in. */ readonly principal?: EkklesiaPrincipal; /** Permission ids the viewer holds an active grant for. Evaluated by Core's real evaluator, so * baselines, the owner bypass, and deny-by-default all behave exactly as in production. */ readonly grants?: readonly string[]; readonly platformOwner?: boolean; /** Makes the grant port fail, so the "a hint that could not be determined is never reported as * denied" path can be exercised. */ readonly grantsUnavailable?: boolean; } interface Installation { readonly app: Hono; readonly modules: readonly DiscoveredModule[]; readonly modulesDirectory: string; readonly permissionRegistry: PermissionRegistry; } /** A real installation: fixtures on disk, Core's real discovery, real permission composition, the * real publication registry, the real authorization evaluator, and the real `createApiApp`. Only * identity resolution and the two authorization ports are doubled, because neither needs PostgreSQL * to answer the questions these tests ask. */ async function install(options: InstallOptions): Promise { const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-api-ui-inventory-")); temporaryDirectories.push(modulesDirectory); for (const fixture of options.fixtures) { await createModule(modulesDirectory, fixture); } const modules = await discoverModules({ moduleSdkVersion: "0.1.0", modulesDirectory, platformVersion: "0.1.0", }); const granted = new Set(options.grants ?? []); const evaluator = createAuthorizationEvaluator({ grants: { hasActiveGrant: async (query) => { if (options.grantsUnavailable) { throw new Error("grant store unavailable"); } return granted.has(query.permission); }, }, platformOwners: { isPlatformOwner: async () => options.platformOwner === true }, }); const runtime: EkklesiaIdentityRuntime = { handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })), resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ANONYMOUS_PRINCIPAL), }; const store = { 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; const services: ModuleRuntimeServices = Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase, }); const permissionRegistry = composePlatformPermissionRegistry(modules); const app = createApiApp({ authorization: { evaluator, registry: permissionRegistry, store }, moduleRoutes: Object.freeze([]), moduleServices: services, moduleUi: createModuleUiPublicationRegistry(modules), runtime, trustedOrigins: [trustedOrigin], }); return { app, modules, modulesDirectory, permissionRegistry }; } function signedIn(): EkklesiaPrincipal { return createAuthenticatedPrincipal(viewerId); } async function inventoryOf(app: Hono): Promise<{ readonly body: string; readonly modules: ReadonlyArray>; readonly response: Response; }> { const response = await app.request(MODULE_UI_INVENTORY_PATH); const body = await response.clone().text(); const parsed = (await response.json()) as { modules?: ReadonlyArray> }; return { body, modules: parsed.modules ?? [], response }; } describe("module UI inventory — what it lists", () => { it("describes a publishable module with exactly the fields a host needs", async () => { const { app } = await install({ fixtures: [{ id: "example", permissions: [], withUi: true }], principal: signedIn(), }); const { modules, response } = await inventoryOf(app); expect(response.status).toBe(200); expect(modules).toEqual([ { api: { baseUrl: "/api/v1/modules/example" }, id: "example", permissions: [], ui: { url: "/api/v1/module-ui/example/0.1.0" }, version: "0.1.0", }, ]); }); it("builds both URLs with the server's own canonical helpers", async () => { const { app, modules: discovered } = await install({ fixtures: [{ id: "example", version: "2.4.1", withUi: true }], principal: signedIn(), }); const module = discovered[0] as DiscoveredModule; const { modules } = await inventoryOf(app); // Not "looks like the right shape" — byte-identical to what the helpers produce, so a second // hand-written template string anywhere would fail here. expect(modules[0]?.ui).toEqual({ url: moduleUiArtifactUrl({ moduleId: module.manifest.id, version: module.manifest.version }), }); expect(modules[0]?.api).toEqual({ baseUrl: moduleApiBaseUrl(module.manifest.id) }); }); it("omits a module that declares no UI entrypoint", async () => { const { app } = await install({ fixtures: [{ id: "calendar" }, { id: "example", withUi: true }], principal: signedIn(), }); const { modules } = await inventoryOf(app); expect(modules.map((module) => module.id)).toEqual(["example"]); }); it("is empty, not an error, when no installed module provides a UI", async () => { const { app } = await install({ fixtures: [{ id: "calendar" }], principal: signedIn() }); const { modules, response } = await inventoryOf(app); expect(response.status).toBe(200); expect(modules).toEqual([]); }); it("orders modules deterministically, matching the publication registry", async () => { const { app } = await install({ fixtures: [ { id: "signage", withUi: true }, { id: "announcements", withUi: true }, { id: "calendar", withUi: true }, ], principal: signedIn(), }); const first = await inventoryOf(app); const second = await inventoryOf(app); // Discovery order, which is sorted; the same on every request. expect(first.modules.map((module) => module.id)).toEqual([ "announcements", "calendar", "signage", ]); expect(second.modules.map((module) => module.id)).toEqual( first.modules.map((module) => module.id), ); }); it("leaks no filesystem path, package root, or other manifest internal", async () => { const { app, modules: discovered, modulesDirectory, } = await install({ fixtures: [{ id: "example", permissions: [], withUi: true }], principal: signedIn(), }); const artifactPath = (discovered[0] as DiscoveredModule).entrypoints.ui as string; const { body, modules } = await inventoryOf(app); expect(body).not.toContain(modulesDirectory); expect(body).not.toContain(artifactPath); expect(body).not.toContain(tmpdir()); expect(body).not.toContain("dist"); expect(body).not.toContain("@test/module-example"); expect(body).not.toContain("ekklesia.module.json"); // The exact key set, so a future field cannot be added without a deliberate decision here. expect(Object.keys(modules[0] ?? {}).sort()).toEqual([ "api", "id", "permissions", "ui", "version", ]); }); it("carries no navigation or presentation metadata", async () => { const { app } = await install({ fixtures: [{ id: "example", withUi: true }], principal: signedIn(), }); const { body } = await inventoryOf(app); // ADR 0021 keeps Dashboard grouping an administrator's configuration, never a module // declaration. The manifest's own `name` and `description` must not leak into it by accident. for (const field of ["name", "description", "icon", "category", "folder", "order", "group"]) { expect(body, field).not.toContain(`"${field}"`); } }); }); describe("module UI inventory — authentication", () => { it("refuses an anonymous caller", async () => { const { app } = await install({ fixtures: [{ id: "example", withUi: true }] }); const response = await app.request(MODULE_UI_INVENTORY_PATH); expect(response.status).toBe(403); expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" }); }); it("tells an anonymous caller nothing about what is installed", async () => { const { app } = await install({ fixtures: [ { id: "example", withUi: true }, { id: "calendar", withUi: true }, ], }); const body = await (await app.request(MODULE_UI_INVENTORY_PATH)).text(); expect(body).not.toContain("example"); expect(body).not.toContain("calendar"); }); it("admits any signed-in user without needing an explicit grant", async () => { // `core.modules.ui.read` is baseline `authenticated`, so being signed in is enough — a shell // that rendered nothing until an operator granted every user a permission would be unusable. const { app } = await install({ fixtures: [{ id: "example", withUi: true }], grants: [], principal: signedIn(), platformOwner: false, }); expect((await app.request(MODULE_UI_INVENTORY_PATH)).status).toBe(200); }); it("is never cached and varies on the caller's identity", async () => { const { app } = await install({ fixtures: [{ id: "example", withUi: true }], principal: signedIn(), }); const response = await app.request(MODULE_UI_INVENTORY_PATH, { headers: { origin: trustedOrigin }, }); expect(response.headers.get("cache-control")).toBe("no-store"); const vary = response.headers.get("vary")?.toLowerCase() ?? ""; expect(vary).toContain("origin"); expect(vary).toContain("cookie"); }); }); describe("module UI inventory — permission hints", () => { const readPermission = { baseline: "restricted" as const, id: "example.records.read" }; const writePermission = { baseline: "restricted" as const, id: "example.records.write" }; it("returns only the permissions the server actually granted this principal", async () => { const { app } = await install({ fixtures: [{ id: "example", permissions: [readPermission, writePermission], withUi: true }], grants: ["example.records.read"], principal: signedIn(), }); const { modules } = await inventoryOf(app); expect(modules[0]?.permissions).toEqual(["example.records.read"]); }); it("returns none of them for a signed-in user holding no grant", async () => { const { app } = await install({ fixtures: [{ id: "example", permissions: [readPermission, writePermission], withUi: true }], grants: [], principal: signedIn(), }); const { modules } = await inventoryOf(app); // Never a default of true, and never the module's full declared surface. expect(modules[0]?.permissions).toEqual([]); }); it("honours a baseline that needs no grant at all", async () => { const { app } = await install({ fixtures: [ { id: "example", permissions: [{ baseline: "authenticated", id: "example.records.list" }, readPermission], withUi: true, }, ], grants: [], principal: signedIn(), }); const { modules } = await inventoryOf(app); expect(modules[0]?.permissions).toEqual(["example.records.list"]); }); it("honours the platform-owner bypass, exactly like a real request would", async () => { const { app } = await install({ fixtures: [{ id: "example", permissions: [readPermission, writePermission], withUi: true }], grants: [], platformOwner: true, principal: signedIn(), }); const { modules } = await inventoryOf(app); expect(modules[0]?.permissions).toEqual(["example.records.read", "example.records.write"]); }); it("scopes hints to each module's own declared permissions", async () => { const { app } = await install({ fixtures: [ { id: "example", permissions: [readPermission], withUi: true }, { id: "calendar", permissions: [{ baseline: "restricted", id: "calendar.events.read" }], withUi: true, }, ], grants: ["example.records.read", "calendar.events.read"], principal: signedIn(), }); const { modules } = await inventoryOf(app); const byId = new Map(modules.map((module) => [module.id, module.permissions])); // Neither module learns anything about the other's authorization state, and neither is told // about Core's own permissions. expect(byId.get("example")).toEqual(["example.records.read"]); expect(byId.get("calendar")).toEqual(["calendar.events.read"]); expect(JSON.stringify(modules)).not.toContain("core.authorization.grants.manage"); expect(JSON.stringify(modules)).not.toContain("core.modules.ui.read"); }); it("reports an evaluation outage as unavailable rather than as 'nothing granted'", async () => { const { app } = await install({ fixtures: [{ id: "example", permissions: [readPermission], withUi: true }], grantsUnavailable: true, principal: signedIn(), }); const response = await app.request(MODULE_UI_INVENTORY_PATH); // A hint that silently read "denied" during an outage would blank the UI and look like a // permissions problem. expect(response.status).toBe(503); expect(await response.json()).toEqual({ code: "AUTHORIZATION_EVALUATION_FAILED" }); }); }); describe("module UI inventory — it is not a module management API", () => { it("routes no method other than GET", async () => { const { app } = await install({ fixtures: [{ id: "example", withUi: true }], principal: signedIn(), }); for (const method of ["POST", "PUT", "PATCH", "DELETE"]) { const response = await app.request(MODULE_UI_INVENTORY_PATH, { method }); expect(response.status, method).toBe(404); expect(await response.json(), method).toEqual({ code: "NOT_FOUND" }); } }); it("still refuses to serve an artifact under the collection path", async () => { const { app } = await install({ fixtures: [{ id: "example", withUi: true }], principal: signedIn(), }); for (const suffix of ["/example", "/example/", "/example/0.1.0/index.js"]) { const response = await app.request(`${MODULE_UI_INVENTORY_PATH}${suffix}`); expect(response.status, suffix).toBe(404); } }); });