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, CORE_MODULES_LIFECYCLE_MANAGE, createAuthorizationEvaluator, } from "@ekklesia/core/authorization"; import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; import { composePlatformPermissionRegistry, createModuleUiPublicationRegistry, type DiscoveredModule, discoverModules, type ModuleLifecycleStore, ModuleLifecycleStoreError, EMPTY_MODULE_REALTIME_REGISTRY, resolveModuleEligibility, } from "@ekklesia/core/modules"; import type { Hono } from "hono"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createApiApp } from "../src/app.js"; import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js"; import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js"; import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js"; import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { readyHealth } from "./support/ready-health.js"; import { createInMemoryModuleLifecycleStore } from "./support/in-memory-module-lifecycle.js"; import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js"; import { moduleStorageOptionFor } from "./support/in-memory-module-storage.js"; const trustedOrigin = "https://dashboard.example.test"; const untrustedOrigin = "https://evil.example.test"; const viewerId = parseEkklesiaUserId("44444444-4444-4444-8444-444444444444"); 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 dependencies?: Record; } async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise { const moduleDirectory = path.join(modulesDirectory, fixture.id); await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true }); await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true }); await Promise.all([ writeFile( path.join(moduleDirectory, "package.json"), JSON.stringify({ ekklesia: { layer: "module" }, name: `@test/module-${fixture.id}`, type: "module", version: "1.0.0", }), ), writeFile( path.join(moduleDirectory, "ekklesia.module.json"), JSON.stringify({ compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, ...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}), entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" }, id: fixture.id, manifestVersion: 7, name: `${fixture.id} module`, version: "1.0.0", }), ), writeFile( path.join(moduleDirectory, "dist/server/index.js"), `export default { id: ${JSON.stringify(fixture.id)}, version: "1.0.0" };`, ), writeFile( path.join(moduleDirectory, "dist/ui/index.js"), `export default { id: ${JSON.stringify(fixture.id)}, version: "1.0.0", mount() {} };\n`, ), ]); } const defaultFixtures: readonly ModuleFixture[] = [ { id: "storage" }, { id: "announcements", dependencies: { modules: { required: { storage: "^1.0.0" } } } }, ]; interface InstallOptions { readonly fixtures?: readonly ModuleFixture[]; readonly principal?: EkklesiaPrincipal; readonly platformOwner?: boolean; readonly bootDisabled?: readonly string[]; readonly store?: ModuleLifecycleStore; /** Permissions this principal actually holds through a grant, evaluated by Core's real evaluator * — never a bypass, so a delegated holder is exercised the same way a real one would be. */ readonly grants?: readonly string[]; } interface Installation { readonly app: Hono; readonly modules: readonly DiscoveredModule[]; readonly store: ModuleLifecycleStore; } /** * Composes the real application over a real discovered inventory, reproducing `startApi`'s own * composition: resolve eligibility from the stored configuration, then build the UI publication * registry and the boot snapshot from the **eligible** list rather than the discovered one. Doing it * the same way here is what makes these assertions evidence about production composition rather than * about a bespoke test wiring. */ async function install(options: InstallOptions = {}): Promise { const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-module-lifecycle-")); temporaryDirectories.push(modulesDirectory); for (const fixture of options.fixtures ?? defaultFixtures) { await createModule(modulesDirectory, fixture); } const modules = await discoverModules({ moduleSdkVersion: "0.1.0", modulesDirectory, platformVersion: "0.1.0", }); const store = options.store ?? createInMemoryModuleLifecycleStore( (options.bootDisabled ?? []).map((moduleId) => ({ enabled: false, moduleId })), ); const bootConfiguration = await store.readConfiguration(); const eligibility = resolveModuleEligibility(modules, bootConfiguration); const held = new Set(options.grants ?? []); const evaluator = createAuthorizationEvaluator({ grants: { hasActiveGrant: async (query) => held.has(String(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 authorizationStore = { 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, // Permission composition still runs over the whole discovered inventory, unchanged: a // disabled module's permissions stay registered and grantable, and nothing enforces them // because it mounts no routes. registry: composePlatformPermissionRegistry(modules), store: authorizationStore, }, dashboard: createInMemoryDashboardStore(), health: readyHealth(), moduleConfiguration: moduleConfigurationOptionFor(modules), moduleStorage: moduleStorageOptionFor(modules), moduleLifecycle: { snapshot: { discoveredModules: modules, eligibility: eligibility.entries, startedModuleIds: eligibility.eligibleModules.map((module) => module.manifest.id), }, store, }, moduleRoutes: Object.freeze([]), moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY, moduleServices: Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase, }) as ModuleRuntimeServices, moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules), runtime, trustedOrigins: [trustedOrigin], }); return { app, modules, store }; } function owner(): InstallOptions { return { platformOwner: true, principal: createAuthenticatedPrincipal(viewerId) }; } interface LifecycleEntry { readonly id: string; readonly version: string; readonly configured: { readonly enabled: boolean; readonly explicit: boolean }; readonly runtime: { readonly state: string; readonly reason?: string; readonly dependency?: { readonly kind: string; readonly moduleId: string }; }; readonly restartRequired: boolean; } async function readLifecycle(app: Hono): Promise { const response = await app.request(MODULE_LIFECYCLE_PATH, { headers: { origin: trustedOrigin }, }); expect(response.status).toBe(200); const body = (await response.json()) as { readonly modules: readonly LifecycleEntry[] }; return body.modules; } function setEnabled(app: Hono, moduleId: string, enabled: boolean, origin = trustedOrigin) { return app.request(`${MODULE_LIFECYCLE_PATH}/${moduleId}/enabled`, { body: JSON.stringify({ enabled }), headers: { "content-type": "application/json", origin }, method: "PUT", }); } describe("GET /api/v1/module-lifecycle", () => { it("reports every discovered module with both axes separated", async () => { const { app } = await install(owner()); expect(await readLifecycle(app)).toEqual([ { configured: { enabled: true, explicit: false }, id: "storage", restartRequired: false, runtime: { state: "running" }, version: "1.0.0", }, { configured: { enabled: true, explicit: false }, id: "announcements", restartRequired: false, runtime: { state: "running" }, version: "1.0.0", }, ]); }); it("explains a disabled module and the dependent it blocks, with a structured reason", async () => { const { app } = await install({ ...owner(), bootDisabled: ["storage"] }); expect(await readLifecycle(app)).toEqual([ { configured: { enabled: false, explicit: true }, id: "storage", restartRequired: false, runtime: { state: "disabled" }, version: "1.0.0", }, { configured: { enabled: true, explicit: false }, id: "announcements", restartRequired: false, runtime: { dependency: { kind: "module", moduleId: "storage" }, reason: "required_dependency_disabled", state: "blocked", }, version: "1.0.0", }, ]); }); it("is not cached and varies on origin and cookie", async () => { const { app } = await install(owner()); const response = await app.request(MODULE_LIFECYCLE_PATH, { headers: { origin: trustedOrigin }, }); expect(response.headers.get("Cache-Control")).toBe("no-store"); expect(response.headers.get("Vary")).toContain("Cookie"); }); it("refuses an anonymous caller", async () => { const { app } = await install(); const response = await app.request(MODULE_LIFECYCLE_PATH); expect(response.status).toBe(403); expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" }); }); it("refuses an ordinary authenticated user who holds no lifecycle permission", async () => { // The same principal that `core.modules.ui.read` (baseline `authenticated`) would admit: this // resource is deliberately restricted, because it is the only place a disabled module is // visible at all. const { app } = await install({ principal: createAuthenticatedPrincipal(viewerId) }); const inventory = await app.request(MODULE_UI_INVENTORY_PATH); const lifecycle = await app.request(MODULE_LIFECYCLE_PATH); expect(inventory.status).toBe(200); expect(lifecycle.status).toBe(403); }); it("admits a non-owner holding exactly core.modules.lifecycle.manage through a real grant", async () => { const { app } = await install({ grants: [String(CORE_MODULES_LIFECYCLE_MANAGE)], principal: createAuthenticatedPrincipal(viewerId), }); expect((await app.request(MODULE_LIFECYCLE_PATH)).status).toBe(200); // Holding it confers no module authority: the Dashboard's own manage permission is untouched by // this grant, so a lifecycle administrator cannot reorganize navigation. const reorganize = await app.request(`${DASHBOARD_NAVIGATION_PATH}/groups/media`, { body: JSON.stringify({ label: "Media", order: 10 }), headers: { "content-type": "application/json", origin: trustedOrigin }, method: "PUT", }); expect(reorganize.status).toBe(403); }); it("reports a sanitized 503 when the configuration cannot be read, never a driver detail", async () => { const failing: ModuleLifecycleStore = { readConfiguration: async () => { throw new ModuleLifecycleStoreError("MODULE_LIFECYCLE_READ_FAILED"); }, setEnabled: async () => { throw new ModuleLifecycleStoreError("MODULE_LIFECYCLE_WRITE_FAILED"); }, }; // Boot with a working store, then swap in the failing one for the request path only. const { app } = await install({ ...owner(), store: failingAfterBoot(failing) }); const response = await app.request(MODULE_LIFECYCLE_PATH, { headers: { origin: trustedOrigin }, }); expect(response.status).toBe(503); expect(await response.json()).toEqual({ code: "MODULE_LIFECYCLE_READ_FAILED" }); }); }); /** Answers the one boot-time read with an empty configuration, then fails every later call — so a * request-path failure can be exercised without making startup composition itself impossible. */ function failingAfterBoot(failing: ModuleLifecycleStore): ModuleLifecycleStore { let bootRead = false; return { async readConfiguration() { if (!bootRead) { bootRead = true; return { modules: [] }; } return failing.readConfiguration(); }, setEnabled: (input) => failing.setEnabled(input), }; } describe("PUT /api/v1/module-lifecycle/:moduleId/enabled", () => { it("persists a decision and reports honestly that the running process has not changed", async () => { const { app } = await install(owner()); const response = await setEnabled(app, "announcements", false); expect(response.status).toBe(200); expect(await response.json()).toEqual({ configured: { enabled: false, explicit: true }, id: "announcements", // Not `disabled`: this process is still serving that module. Claiming otherwise would be a // lie about a module that is still running. restartRequired: true, runtime: { state: "running" }, version: "1.0.0", }); }); it("marks the dependents of a newly disabled module as restart-pending too", async () => { const { app } = await install(owner()); await setEnabled(app, "storage", false); expect((await readLifecycle(app)).map((entry) => [entry.id, entry.restartRequired])).toEqual([ ["storage", true], ["announcements", true], ]); }); it("is idempotent, and re-enabling clears the pending restart", async () => { const { app } = await install(owner()); await setEnabled(app, "announcements", false); await setEnabled(app, "announcements", false); const reEnabled = await setEnabled(app, "announcements", true); expect(await reEnabled.json()).toEqual({ configured: { enabled: true, explicit: true }, id: "announcements", restartRequired: false, runtime: { state: "running" }, version: "1.0.0", }); }); it("refuses a module this process cannot see, rather than storing an unverifiable record", async () => { const { app, store } = await install(owner()); const response = await setEnabled(app, "not-installed", false); expect(response.status).toBe(404); expect(await response.json()).toEqual({ code: "MODULE_NOT_INSTALLED" }); expect((await store.readConfiguration()).modules).toEqual([]); }); it("refuses a malformed module identifier without reaching the store", async () => { const { app, store } = await install(owner()); const response = await setEnabled(app, "Not%20A%20Module", false); expect(response.status).toBe(404); expect((await store.readConfiguration()).modules).toEqual([]); }); it("rejects an untrusted origin before authorization or the store runs", async () => { const { app, store } = await install(owner()); const response = await setEnabled(app, "announcements", false, untrustedOrigin); expect(response.status).toBe(403); expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" }); expect((await store.readConfiguration()).modules).toEqual([]); }); it("rejects a missing origin", async () => { const { app } = await install(owner()); const response = await app.request(`${MODULE_LIFECYCLE_PATH}/announcements/enabled`, { body: JSON.stringify({ enabled: false }), headers: { "content-type": "application/json" }, method: "PUT", }); expect(response.status).toBe(403); expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" }); }); it("refuses an authenticated caller without the restricted permission", async () => { const { app, store } = await install({ principal: createAuthenticatedPrincipal(viewerId) }); const response = await setEnabled(app, "announcements", false); expect(response.status).toBe(403); expect((await store.readConfiguration()).modules).toEqual([]); }); it.each([ ["a non-boolean value", { enabled: "false" }], ["a missing field", {}], ["an unexpected key alongside a valid one", { enabled: false, force: true }], ["an array", []], ["a bare string", "disabled"], ])("rejects %s with a closed, structured validation failure", async (_label, body) => { const { app, store } = await install(owner()); const response = await app.request(`${MODULE_LIFECYCLE_PATH}/announcements/enabled`, { body: JSON.stringify(body), headers: { "content-type": "application/json", origin: trustedOrigin }, method: "PUT", }); expect(response.status).toBe(400); expect(await response.json()).toEqual({ code: "INVALID_REQUEST" }); expect((await store.readConfiguration()).modules).toEqual([]); }); it("enforces the platform body limit with the one sanitized 413 contract", async () => { const { app } = await install(owner()); const oversized = JSON.stringify({ enabled: "x".repeat(CORE_MUTATION_MAX_BODY_BYTES + 1024) }); const response = await app.request(`${MODULE_LIFECYCLE_PATH}/announcements/enabled`, { body: oversized, headers: { "content-type": "application/json", origin: trustedOrigin }, method: "PUT", }); expect(response.status).toBe(413); expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" }); }); it("never leaks a filesystem path, package name, or manifest path through either verb", async () => { const { app } = await install(owner()); const listed = await ( await app.request(MODULE_LIFECYCLE_PATH, { headers: { origin: trustedOrigin } }) ).text(); const mutated = await (await setEnabled(app, "announcements", false)).text(); for (const payload of [listed, mutated]) { expect(payload).not.toContain("/tmp"); expect(payload).not.toContain("@test/module-"); expect(payload).not.toContain("ekklesia.module.json"); expect(payload).not.toContain("dist/"); expect(payload).not.toContain("entrypoint"); } }); }); describe("lifecycle state governs module UI exposure", () => { it("keeps a disabled module out of the mountable inventory and out of Dashboard navigation", async () => { const { app } = await install({ bootDisabled: ["announcements"], platformOwner: true, principal: createAuthenticatedPrincipal(viewerId), }); const inventory = (await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as { readonly modules: readonly { readonly id: string }[]; }; const navigation = (await (await app.request(DASHBOARD_NAVIGATION_PATH)).json()) as { readonly groups: readonly { readonly modules: readonly { readonly name: string }[] }[]; readonly ungrouped: readonly { readonly module: { readonly id: string } }[]; }; // Discovered, validated, and declaring a real compiled UI artifact — and still absent, because // publication is built from the eligible set rather than the discovered one. expect(inventory.modules.map((entry) => entry.id)).toEqual(["storage"]); expect(navigation.ungrouped.map((entry) => entry.module.id)).toEqual(["storage"]); expect(navigation.groups).toEqual([]); }); it("keeps a dependency-blocked module out of both, exactly like an explicitly disabled one", async () => { const { app } = await install({ bootDisabled: ["storage"], platformOwner: true, principal: createAuthenticatedPrincipal(viewerId), }); const inventory = (await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as { readonly modules: readonly { readonly id: string }[]; }; expect(inventory.modules).toEqual([]); }); it("does not remove a module from navigation merely because a restart is pending", async () => { const { app } = await install({ platformOwner: true, principal: createAuthenticatedPrincipal(viewerId), }); await setEnabled(app, "announcements", false); const inventory = (await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as { readonly modules: readonly { readonly id: string }[]; }; // The module really is still mounted and still serving; hiding its UI now would break a viewer // mid-session for a change that has not taken effect. `restartRequired` is where that pending // change is reported. expect(inventory.modules.map((entry) => entry.id)).toEqual(["storage", "announcements"]); }); }); describe("the lifecycle prefix is a sibling of the module route namespace", () => { it("does not sit under /api/v1/modules/, where an installed module could collide with it", async () => { // `moduleRoutePathSchema` accepts `enabled` as an ordinary static segment, so a module named // `announcements` may legitimately register `PUT /enabled` — mounted at exactly // `/api/v1/modules/announcements/enabled`. If the platform published its lifecycle mutation // there, registration order would decide which one answers. expect(MODULE_LIFECYCLE_PATH.startsWith("/api/v1/modules/")).toBe(false); expect(MODULE_LIFECYCLE_PATH).toBe("/api/v1/module-lifecycle"); const { app } = await install(owner()); const collision = await app.request("/api/v1/modules/announcements/enabled", { body: JSON.stringify({ enabled: false }), headers: { "content-type": "application/json", origin: trustedOrigin }, method: "PUT", }); expect(collision.status).toBe(404); expect(await collision.json()).toEqual({ code: "NOT_FOUND" }); }); });