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, } from "@ekklesia/core/authorization"; import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; import { composePlatformPermissionRegistry, createModuleUiPublicationRegistry, discoverModules, startDiscoveredModuleRuntime, } from "@ekklesia/core/modules"; import { loadModuleUi } from "@ekklesia/module-ui-host"; import { bootstrapHost, createModuleUiContext, type EkklesiaHostConfig, moduleUiSourceUrl, } from "@ekklesia/shell"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createApiApp } from "../src/app.js"; /** * The whole chain, with nothing between the halves faked: * * ```text * module fixture on disk -> discovery -> module runtime started -> publication registry * -> inventory endpoint -> the shell's own bootstrap -> server-produced artifact URL * -> the generic loader -> mount -> the module calls its own API relatively * -> the shell resolves it to /api/v1/modules//... -> server permission enforcement * ``` * * Every step runs real code. The module is discovered and *started* by Core's real runtime, its * routes are mounted by the real `createApiApp`, its artifact is published by the real ADR 0025 * route, its descriptor comes from the real inventory endpoint, and the browser half is * `@ekklesia/shell`'s own exported protocol — the same functions the React shell calls — rather * than a reimplementation of them here. * * Two things are deliberately *not* faked-around and are worth naming. The shell's `fetch` is the * Hono app's own `request`, so requests really traverse routing, CORS, authorization middleware, and * handlers without needing a socket. And Node cannot `import()` an `http:` URL, so the artifact is * fetched over that same in-process HTTP path and the **bytes it returned** are handed to the * generic loader through a `data:` URL — a genuine module URL, the same separation ADR 0025 used. * No test here reads the artifact off the filesystem and calls that a proof of publication. */ const viewerId = parseEkklesiaUserId("22222222-2222-4222-8222-222222222222"); const temporaryDirectories: string[] = []; afterEach(async () => { await Promise.all( temporaryDirectories .splice(0) .map((directory) => rm(directory, { force: true, recursive: true })), ); }); /** * A real, complete module: a manifest declaring a restricted permission and a UI entrypoint, a * server entrypoint that registers a permission-gated route, and a compiled UI artifact that reads * its host context and calls its own API relatively. * * The UI artifact is written as plain ESM rather than compiled from TSX here: ADR 0023's pipeline is * already proven end to end by `templates/module`'s own tests, and what this test needs is a byte * sequence whose journey through publication, inventory, and the loader can be asserted exactly. */ async function createFixtureModule(modulesDirectory: string): Promise { const moduleDirectory = path.join(modulesDirectory, "example"); await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true }); await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true }); await writeFile( path.join(moduleDirectory, "package.json"), JSON.stringify({ ekklesia: { layer: "module" }, name: "@test/module-example", type: "module", version: "0.1.0", }), ); 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", ui: "./dist/ui/index.js" }, id: "example", manifestVersion: 4, name: "Example module", permissions: [ { baseline: "restricted", description: "Read example records.", id: "example.records.read", }, ], version: "0.1.0", }), ); await writeFile( path.join(moduleDirectory, "dist/server/index.js"), `export default { id: "example", version: "0.1.0", register({ http }) { http.route({ method: "GET", path: "/records", permission: "example.records.read", maxBodyBytes: 0, async handler() { return Response.json([{ id: "a" }, { id: "b" }]); }, }); }, };\n`, ); // The module UI: host-neutral, asks for a relative path, and renders from the host context. It // hardcodes no origin, no prefix, and no permission decision of its own. await writeFile( path.join(moduleDirectory, "dist/ui/index.js"), `export default { id: "example", version: "0.1.0", async mount(container, context) { if (!context.permissions.has("example.records.read")) { container.textContent = context.moduleId + ":denied"; return () => { container.textContent = ""; }; } const response = await context.api.request("records"); const records = response.ok ? await response.json() : []; container.textContent = context.moduleId + ":" + response.status + ":" + records.length; return () => { container.textContent = ""; }; }, };\n`, ); } interface HarnessOptions { readonly principal?: EkklesiaPrincipal; readonly grants?: readonly string[]; } interface Harness { readonly config: EkklesiaHostConfig; readonly moduleApiCalls: readonly string[]; stop(): Promise; } /** * Starts a real API over a real discovered-and-started module, and returns the `EkklesiaHostConfig` * a browser would use — with `fetch` wired to the app itself. */ async function startHarness(options: HarnessOptions = {}): Promise { const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-host-e2e-")); temporaryDirectories.push(modulesDirectory); await createFixtureModule(modulesDirectory); const modules = await discoverModules({ moduleSdkVersion: "0.1.0", modulesDirectory, platformVersion: "0.1.0", }); const services: ModuleRuntimeServices = Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase, }); // The module's real runtime: its server entrypoint is imported, registered, and started, and the // route snapshot below is the one it actually published. const moduleRuntime = await startDiscoveredModuleRuntime(modules, services); const granted = new Set(options.grants ?? []); const 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 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 app = createApiApp({ authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store }, moduleRoutes: moduleRuntime.routes, moduleServices: services, moduleUi: createModuleUiPublicationRegistry(modules), runtime, trustedOrigins: ["https://dashboard.example.test"], }); const moduleApiCalls: string[] = []; const hostFetch = (async (input: Parameters[0], init?: RequestInit) => { const url = new URL(String(input)); if (url.pathname.startsWith("/api/v1/modules/")) { moduleApiCalls.push(`${init?.method ?? "GET"} ${url.pathname}${url.search}`); } return app.request(url.href, init); }) as unknown as typeof fetch; return { config: { apiBaseUrl: "https://ekklesia.example.test", fetch: hostFetch }, moduleApiCalls, stop: () => moduleRuntime.stop(), }; } /** Node cannot `import()` an `http:` URL, so the artifact is fetched over the real route and its * exact bytes become a `data:` URL. The loader still performs a genuine dynamic import of a real * module URL; only the transport differs, and the transport itself is asserted separately. */ async function publishedArtifactUrl(harness: Harness, sourceUrl: string): Promise { const response = await (harness.config.fetch as typeof fetch)(sourceUrl); expect(response.status).toBe(200); expect(response.headers.get("content-type")).toBe("text/javascript; charset=utf-8"); const bytes = Buffer.from(await response.arrayBuffer()); return `data:text/javascript;base64,${bytes.toString("base64")}`; } /** The loader hands the container straight to the module and never inspects it, so a plain sink is * enough and no DOM implementation is needed in this Node-environment suite. */ function containerSink(): { textContent: string } { return { textContent: "" }; } type LoaderContainer = Parameters[0]["container"]; describe("end to end: a discovered module's UI mounts in the Ekklesia host", () => { it("bootstraps, publishes, loads, mounts, and reaches the module's own API", async () => { const harness = await startHarness({ grants: ["example.records.read"], principal: createAuthenticatedPrincipal(viewerId), }); try { // 1. The shell's own bootstrap: principal, then inventory. const bootstrap = await bootstrapHost(harness.config); expect(bootstrap.principal).toEqual({ kind: "authenticated", userId: viewerId }); expect(bootstrap.modules).toHaveLength(1); const descriptor = bootstrap.modules[0]; if (!descriptor) { throw new Error("the inventory returned no module"); } // 2. The URLs came from the server, not from anything the browser composed. expect(descriptor).toEqual({ apiBaseUrl: "/api/v1/modules/example", id: "example", permissions: ["example.records.read"], uiUrl: "/api/v1/module-ui/example/0.1.0", version: "0.1.0", }); // 3. The artifact really is served by the real publication route at that URL. const sourceUrl = moduleUiSourceUrl(harness.config, descriptor); expect(sourceUrl).toBe("https://ekklesia.example.test/api/v1/module-ui/example/0.1.0"); const artifactUrl = await publishedArtifactUrl(harness, sourceUrl); // 4. The generic loader mounts it with the context the shell built. const container = containerSink(); const mounted = await loadModuleUi({ container: container as unknown as LoaderContainer, context: createModuleUiContext({ config: harness.config, descriptor, principal: bootstrap.principal, }), source: { moduleId: descriptor.id, url: artifactUrl, version: descriptor.version }, }); // 5. The module rendered, having reached its own backend through the host. expect(container.textContent).toBe("example:200:2"); // 6. It asked for "records"; the host resolved that onto Ekklesia's module mount prefix. expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]); // 7. And it tears down cleanly. await mounted.unmount(); expect(container.textContent).toBe(""); expect(mounted.mounted).toBe(false); } finally { await harness.stop(); } }); it("keeps server authorization authoritative when the UI ignores its own hint", async () => { // Signed in, but holding no grant for the module's restricted permission. const harness = await startHarness({ grants: [], principal: createAuthenticatedPrincipal(viewerId), }); try { const bootstrap = await bootstrapHost(harness.config); const descriptor = bootstrap.modules[0]; if (!descriptor) { throw new Error("the inventory returned no module"); } // The hint is honest about it... expect(descriptor.permissions).toEqual([]); const context = createModuleUiContext({ config: harness.config, descriptor, principal: bootstrap.principal, }); expect(context.permissions.has("example.records.read")).toBe(false); // ...and the hint is not what protects the route. Calling anyway — exactly what a buggy or // hostile UI would do — is still refused by the server. const response = await context.api.request("records"); expect(response.status).toBe(403); expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" }); expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]); } finally { await harness.stop(); } }); it("renders the module's own denied state from the hint, without calling the API", async () => { const harness = await startHarness({ grants: [], principal: createAuthenticatedPrincipal(viewerId), }); try { const bootstrap = await bootstrapHost(harness.config); const descriptor = bootstrap.modules[0]; if (!descriptor) { throw new Error("the inventory returned no module"); } const artifactUrl = await publishedArtifactUrl( harness, moduleUiSourceUrl(harness.config, descriptor), ); const container = containerSink(); await loadModuleUi({ container: container as unknown as LoaderContainer, context: createModuleUiContext({ config: harness.config, descriptor, principal: bootstrap.principal, }), source: { moduleId: descriptor.id, url: artifactUrl, version: descriptor.version }, }); // Hiding a control is a usability improvement: the module simply did not ask. expect(container.textContent).toBe("example:denied"); expect(harness.moduleApiCalls).toEqual([]); } finally { await harness.stop(); } }); it("gives an anonymous visitor no inventory and therefore nothing to mount", async () => { const harness = await startHarness(); try { const bootstrap = await bootstrapHost(harness.config); expect(bootstrap.principal).toEqual({ kind: "anonymous" }); expect(bootstrap.modules).toEqual([]); } finally { await harness.stop(); } }); it("refuses a module UI that tries to address another module's API through the host", async () => { const harness = await startHarness({ grants: ["example.records.read"], principal: createAuthenticatedPrincipal(viewerId), }); try { const bootstrap = await bootstrapHost(harness.config); const descriptor = bootstrap.modules[0]; if (!descriptor) { throw new Error("the inventory returned no module"); } const context = createModuleUiContext({ config: harness.config, descriptor, principal: bootstrap.principal, }); await expect(context.api.request("../../grants?userId=someone")).rejects.toMatchObject({ code: "MODULE_API_PATH_INVALID", }); // It never became a request at all. expect(harness.moduleApiCalls).toEqual([]); } finally { await harness.stop(); } }); });