Files
Ekklesia/apps/api/tests/module-lifecycle-routes.test.ts
T
ekklesiaandClaude Opus 5 baa689b2a7
CI / verify (pull_request) Successful in 35s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 46s
feat: add the module configuration and secrets contract
Modules had no way to be told anything. Announcements — the first real
product Ekklesia will absorb — has zero environment variables and compiles
a LAN IP address into its source, so this is the contract standing between
it and the module system.

- A module declares what it needs in its manifest, using six bounded types
  (string, integer, boolean, enum, url, secret) with no nesting and no
  regular expressions. Static declaration is what lets the platform validate
  configuration *before* deciding whether a module may start: an enabled
  module missing a required value is reported `blocked ·
  configuration_missing · <key>` rather than taking startup down from inside
  its own `start` hook, and Doctor reports the same while still importing no
  module code. That forces `manifestVersion: 5`, a hard cutover.
- Ordinary values persist in one Core table (migration 0005), one row per
  (module_id, key), stored as text until validated against the declaring
  module's current manifest. Nothing is coerced; an invalid stored value
  blocks the module rather than falling back to the default. A key the
  manifest no longer declares is retained and ignored.
- Secrets are never persisted. There is no encryption at rest in this
  repository, so a `secret` key is deployment-supplied and mapped in by the
  host under a name the module never sees. A secret declaration has no
  `default` field, the write API refuses secret material, and the HTTP view
  has no `value` field for one — absent by construction, not filtered.
- Modules receive a frozen `context.config` on the registration, runtime,
  and HTTP handler contexts: the same object identity, per module, beside
  `services` rather than inside it (services is deliberately one shared
  instance). No environment access, no database reference, no way to reach
  another module's values.
- Configuration decides eligibility through ADR 0028's existing resolver:
  three new blocked reasons, each with a distinct operator fix, propagating
  to required dependents through the same mechanism as a disabled module.
- Changes take effect at the next restart; both resources report
  restartRequired. New restricted permission
  `core.modules.configuration.manage`, separate from lifecycle, at the
  sibling prefix `/api/v1/module-configuration`.
- Migration participation stays independent of configuration validity.
  Endpoints and credentials for shared infrastructure stay platform
  concerns; a module declares which bucket it needs, not an S3 endpoint.

See ADR 0029.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-22 21:44:43 -05:00

575 lines
22 KiB
TypeScript

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,
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 { createInMemoryModuleLifecycleStore } from "./support/in-memory-module-lifecycle.js";
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.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<string, unknown>;
}
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
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: 5,
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<Installation> {
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(),
moduleConfiguration: moduleConfigurationOptionFor(modules),
moduleLifecycle: {
snapshot: {
discoveredModules: modules,
eligibility: eligibility.entries,
startedModuleIds: eligibility.eligibleModules.map((module) => module.manifest.id),
},
store,
},
moduleRoutes: Object.freeze([]),
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<readonly LifecycleEntry[]> {
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" });
});
});