Files
Ekklesia/core/tests/module-runtime.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

1504 lines
60 KiB
TypeScript

import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type {
ModuleConfiguration,
ModuleDatabase,
ModuleRuntimeServices,
} from "@ekklesia/contracts";
import { afterEach, describe, expect, it, vi } from "vitest";
import { discoverModules } from "../src/modules/discovery.js";
import { resolveModuleEligibility } from "../src/modules/lifecycle.js";
import {
ModuleDiscoveryError,
ModuleRuntimeError,
type ModuleRuntimeIssueCode,
startDiscoveredModuleRuntime,
startModuleRuntime,
} from "../src/modules/index.js";
vi.mock("../src/modules/discovery.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../src/modules/discovery.js")>();
return { ...actual, discoverModules: vi.fn(actual.discoverModules) };
});
interface RuntimeModuleFixture {
readonly directory?: string;
readonly manifest?: Record<string, unknown>;
readonly packageJson?: Record<string, unknown>;
readonly serverSource: string;
}
const temporaryDirectories: string[] = [];
const eventSymbols: symbol[] = [];
afterEach(async () => {
const globalValues = globalThis as unknown as Record<PropertyKey, unknown>;
for (const symbol of eventSymbols.splice(0)) {
delete globalValues[symbol];
}
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
);
});
async function createModulesDirectory(): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-runtime-"));
temporaryDirectories.push(directory);
return directory;
}
function manifestFor(
id: string,
options: {
capabilities?: Array<{ id: string; version: string }>;
dependencies?: Record<string, unknown>;
permissions?: Array<{ id: string; baseline: string; description: string }>;
} = {},
): Record<string, unknown> {
return {
manifestVersion: 5,
id,
name: `${id} module`,
version: "0.1.0",
compatibility: {
platform: "^0.1.0",
moduleSdk: "^0.1.0",
},
capabilities: options.capabilities ?? [],
permissions: options.permissions ?? [],
...(options.dependencies ? { dependencies: options.dependencies } : {}),
entrypoints: {
server: "./dist/server/index.js",
},
};
}
async function createRuntimeModule(
modulesDirectory: string,
id: string,
fixture: RuntimeModuleFixture,
): Promise<void> {
const directoryName = fixture.directory ?? id;
const moduleDirectory = path.join(modulesDirectory, directoryName);
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
await Promise.all([
writeFile(
path.join(moduleDirectory, "package.json"),
JSON.stringify(
fixture.packageJson ?? {
name: `@test/module-${id}`,
version: "0.1.0",
type: "module",
ekklesia: { layer: "module" },
},
),
),
writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify(fixture.manifest ?? manifestFor(id)),
),
writeFile(path.join(moduleDirectory, "dist/server/index.js"), fixture.serverSource),
]);
}
function createEventRecorder(): { events: string[]; expression: string } {
const key = `ekklesia.runtime.test.${eventSymbols.length}.${Date.now()}.${Math.random()}`;
const symbol = Symbol.for(key);
const events: string[] = [];
(globalThis as unknown as Record<PropertyKey, unknown>)[symbol] = events;
eventSymbols.push(symbol);
return {
events,
expression: `globalThis[Symbol.for(${JSON.stringify(key)})]`,
};
}
/** Same globalThis-symbol technique as `createEventRecorder`, but for capturing an actual object
* reference (such as `context.services`) pushed from inside a dynamically imported fixture module,
* rather than a string label — dynamic `import()` of a file:// URL still shares this process's
* heap, so a reference pushed from the fixture and read back here is the exact same object. */
function createReferenceRecorder(): { values: unknown[]; expression: string } {
const key = `ekklesia.runtime.test.ref.${eventSymbols.length}.${Date.now()}.${Math.random()}`;
const symbol = Symbol.for(key);
const values: unknown[] = [];
(globalThis as unknown as Record<PropertyKey, unknown>)[symbol] = values;
eventSymbols.push(symbol);
return {
values,
expression: `globalThis[Symbol.for(${JSON.stringify(key)})]`,
};
}
/** A small typed fake `ModuleDatabase` — no PostgreSQL required for these unit tests, which only
* ever assert on context wiring and object identity, never on actual query behavior. */
function fakeModuleDatabase(): ModuleDatabase {
return Object.freeze({
delete: vi.fn(),
execute: vi.fn(),
insert: vi.fn(),
select: vi.fn(),
selectDistinct: vi.fn(),
selectDistinctOn: vi.fn(),
transaction: vi.fn(),
update: vi.fn(),
}) as unknown as ModuleDatabase;
}
function fakeModuleRuntimeServices(): ModuleRuntimeServices {
return Object.freeze({ database: fakeModuleDatabase() });
}
function runtimeOptions(
modulesDirectory: string,
services: ModuleRuntimeServices = fakeModuleRuntimeServices(),
) {
return {
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
services,
};
}
function runtimeIssueCodes(error: ModuleRuntimeError): ModuleRuntimeIssueCode[] {
return error.issues.map((issue) => issue.code);
}
async function captureRuntimeError(modulesDirectory: string): Promise<ModuleRuntimeError> {
try {
await startModuleRuntime(runtimeOptions(modulesDirectory));
} catch (error) {
if (error instanceof ModuleRuntimeError) {
return error;
}
throw error;
}
throw new Error("Expected module runtime startup to fail.");
}
describe("module runtime", () => {
it("discovers, loads, registers, starts, and stops dependency-first modules end to end", async () => {
const modulesDirectory = await createModulesDirectory();
const recorder = createEventRecorder();
await createRuntimeModule(modulesDirectory, "people", {
manifest: manifestFor("people", {
capabilities: [{ id: "people.records", version: "2.1.0" }],
}),
serverSource: `
export default {
id: "people",
version: "0.1.0",
register({ capabilities }) {
${recorder.expression}.push("people:register");
capabilities.provide("people.records", { kind: "people-records" });
},
start() { ${recorder.expression}.push("people:start"); },
stop() { ${recorder.expression}.push("people:stop"); }
};
`,
});
await createRuntimeModule(modulesDirectory, "calendar", {
manifest: manifestFor("calendar", {
capabilities: [{ id: "calendar.events", version: "1.0.0" }],
dependencies: {
capabilities: { required: { "people.records": "^2.0.0" } },
},
}),
serverSource: `
export default {
id: "calendar",
version: "0.1.0",
register({ capabilities }) {
${recorder.expression}.push("calendar:register");
const people = capabilities.resolve("people.records");
if (!people || people.version !== "2.1.0") throw new Error("people unavailable");
capabilities.provide("calendar.events", { kind: "calendar-events" });
},
start({ capabilities }) {
if (!capabilities.resolve("people.records")) throw new Error("people unavailable");
${recorder.expression}.push("calendar:start");
},
stop() { ${recorder.expression}.push("calendar:stop"); }
};
`,
});
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
expect(recorder.events).toEqual([
"people:register",
"calendar:register",
"people:start",
"calendar:start",
]);
expect(runtime.modules.map((module) => module.id)).toEqual(["people", "calendar"]);
expect(runtime.capabilities.resolve("people.records")).toMatchObject({
moduleId: "people",
version: "2.1.0",
});
expect(runtime.capabilities.list().map((capability) => capability.id)).toEqual([
"calendar.events",
"people.records",
]);
await Promise.all([runtime.stop(), runtime.stop()]);
expect(recorder.events.slice(-2)).toEqual(["calendar:stop", "people:stop"]);
});
it("completes discovery before importing any server entrypoint", async () => {
const modulesDirectory = await createModulesDirectory();
const recorder = createEventRecorder();
await createRuntimeModule(modulesDirectory, "valid", {
serverSource: `${recorder.expression}.push("valid:import"); export default { id: "valid", version: "0.1.0" };`,
});
await createRuntimeModule(modulesDirectory, "invalid", {
manifest: { ...manifestFor("invalid"), manifestVersion: 1 },
serverSource: `${recorder.expression}.push("invalid:import"); export default { id: "invalid", version: "0.1.0" };`,
});
await expect(startModuleRuntime(runtimeOptions(modulesDirectory))).rejects.toBeInstanceOf(
ModuleDiscoveryError,
);
expect(recorder.events).toEqual([]);
});
it("validates every imported definition before running registration hooks", async () => {
const modulesDirectory = await createModulesDirectory();
const recorder = createEventRecorder();
await createRuntimeModule(modulesDirectory, "alpha", {
serverSource: `export default {
id: "alpha", version: "0.1.0",
register() { ${recorder.expression}.push("alpha:register"); }
};`,
});
await createRuntimeModule(modulesDirectory, "beta", {
serverSource: `export default { id: "wrong-id", version: "0.1.0" };`,
});
await createRuntimeModule(modulesDirectory, "gamma", {
serverSource: `export default { id: "gamma", version: "9.0.0" };`,
});
const error = await captureRuntimeError(modulesDirectory);
expect(runtimeIssueCodes(error)).toEqual([
"MODULE_RUNTIME_ID_MISMATCH",
"MODULE_RUNTIME_VERSION_MISMATCH",
]);
expect(recorder.events).toEqual([]);
});
it("rejects incomplete and undeclared capability registration before startup", async () => {
const missingDirectory = await createModulesDirectory();
await createRuntimeModule(missingDirectory, "missing", {
manifest: manifestFor("missing", {
capabilities: [{ id: "missing.records", version: "1.0.0" }],
}),
serverSource: `export default { id: "missing", version: "0.1.0", register() {} };`,
});
let error = await captureRuntimeError(missingDirectory);
expect(runtimeIssueCodes(error)).toEqual(["MODULE_CAPABILITY_UNREGISTERED"]);
const undeclaredDirectory = await createModulesDirectory();
await createRuntimeModule(undeclaredDirectory, "undeclared", {
serverSource: `export default {
id: "undeclared", version: "0.1.0",
register({ capabilities }) { capabilities.provide("undeclared.extra", {}); }
};`,
});
error = await captureRuntimeError(undeclaredDirectory);
expect(runtimeIssueCodes(error)).toEqual(["MODULE_CAPABILITY_UNDECLARED"]);
});
it("restricts module capability lookup to declared ownership and dependencies", async () => {
const modulesDirectory = await createModulesDirectory();
await createRuntimeModule(modulesDirectory, "snooper", {
serverSource: `export default {
id: "snooper", version: "0.1.0",
register({ capabilities }) { capabilities.resolve("people.records"); }
};`,
});
const error = await captureRuntimeError(modulesDirectory);
expect(runtimeIssueCodes(error)).toEqual(["MODULE_CAPABILITY_ACCESS_UNDECLARED"]);
});
it("rejects malformed runtime capability IDs without echoing their contents", async () => {
const modulesDirectory = await createModulesDirectory();
const unsafeCapabilityId = "secret-token at /machine/private/path";
await createRuntimeModule(modulesDirectory, "unsafe", {
serverSource: `export default {
id: "unsafe", version: "0.1.0",
register({ capabilities }) {
capabilities.resolve(${JSON.stringify(unsafeCapabilityId)});
}
};`,
});
const error = await captureRuntimeError(modulesDirectory);
expect(runtimeIssueCodes(error)).toEqual(["MODULE_CAPABILITY_ID_INVALID"]);
expect(`${error.message}\n${error.stack}\n${JSON.stringify(error.issues)}`).not.toContain(
unsafeCapabilityId,
);
expect(error.stack).not.toContain(modulesDirectory);
});
it("rolls back started modules in reverse order and aggregates cleanup failures", async () => {
const modulesDirectory = await createModulesDirectory();
const recorder = createEventRecorder();
for (const [id, dependency, stopFails] of [
["one", undefined, false],
["two", "one", true],
["three", "two", false],
] as const) {
await createRuntimeModule(modulesDirectory, id, {
manifest: manifestFor(id, {
...(dependency
? { dependencies: { modules: { required: { [dependency]: "^0.1.0" } } } }
: {}),
}),
serverSource: `export default {
id: ${JSON.stringify(id)}, version: "0.1.0",
start() {
${recorder.expression}.push(${JSON.stringify(`${id}:start`)});
${id === "three" ? 'throw new Error("secret-token at /private/path");' : ""}
},
stop() {
${recorder.expression}.push(${JSON.stringify(`${id}:stop`)});
${stopFails ? 'throw new Error("rollback secret");' : ""}
}
};`,
});
}
const error = await captureRuntimeError(modulesDirectory);
expect(runtimeIssueCodes(error)).toEqual(["MODULE_START_FAILED", "MODULE_ROLLBACK_FAILED"]);
// The module whose `start` threw is rolled back *first*, then the previously started modules in
// reverse order — its own hook may have partial initialization to release before it threw.
expect(recorder.events).toEqual([
"one:start",
"two:start",
"three:start",
"three:stop",
"two:stop",
"one:stop",
]);
expect(JSON.stringify(error.issues)).not.toContain("secret");
expect(JSON.stringify(error.issues)).not.toContain("/private/path");
});
it("continues reverse shutdown after a module stop failure", async () => {
const modulesDirectory = await createModulesDirectory();
const recorder = createEventRecorder();
await createRuntimeModule(modulesDirectory, "provider", {
serverSource: `export default {
id: "provider", version: "0.1.0",
stop() { ${recorder.expression}.push("provider:stop"); }
};`,
});
await createRuntimeModule(modulesDirectory, "consumer", {
manifest: manifestFor("consumer", {
dependencies: { modules: { required: { provider: "^0.1.0" } } },
}),
serverSource: `export default {
id: "consumer", version: "0.1.0",
stop() { ${recorder.expression}.push("consumer:stop"); throw new Error("hidden"); }
};`,
});
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
await expect(runtime.stop()).rejects.toMatchObject({
issues: [expect.objectContaining({ code: "MODULE_STOP_FAILED", moduleId: "consumer" })],
});
expect(recorder.events).toEqual(["consumer:stop", "provider:stop"]);
});
it("reports import failures without leaking exception text or machine paths", async () => {
const modulesDirectory = await createModulesDirectory();
await createRuntimeModule(modulesDirectory, "broken", {
serverSource: `throw new Error("database-password at /machine/private/path");`,
});
const error = await captureRuntimeError(modulesDirectory);
expect(runtimeIssueCodes(error)).toEqual(["MODULE_RUNTIME_IMPORT_FAILED"]);
expect(JSON.stringify(error.issues)).not.toContain("database-password");
expect(JSON.stringify(error.issues)).not.toContain(modulesDirectory);
});
});
function noopHandlerSource(): string {
return "() => new Response(null, { status: 204 })";
}
function permissionDeclaration(
id: string,
baseline: "authenticated" | "public" | "restricted" = "restricted",
): { id: string; baseline: string; description: string } {
return { id, baseline, description: `Test permission ${id}.` };
}
describe("module HTTP routes", () => {
it("registers one route and publishes it on the started runtime", async () => {
const modulesDirectory = await createModulesDirectory();
await createRuntimeModule(modulesDirectory, "single", {
manifest: manifestFor("single", {
permissions: [permissionDeclaration("single.items.read")],
}),
serverSource: `export default {
id: "single", version: "0.1.0",
register({ http }) {
http.route({
method: "GET",
path: "/items",
permission: "single.items.read",
maxBodyBytes: 1024,
handler: ${noopHandlerSource()},
});
}
};`,
});
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
expect(runtime.routes).toHaveLength(1);
expect(runtime.routes[0]).toMatchObject({
moduleId: "single",
method: "GET",
path: "/items",
permission: "single.items.read",
});
await runtime.stop();
});
it("retains registration order within a module and discovery order across modules", async () => {
const modulesDirectory = await createModulesDirectory();
await createRuntimeModule(modulesDirectory, "alpha", {
manifest: manifestFor("alpha", {
permissions: [
permissionDeclaration("alpha.first.read"),
permissionDeclaration("alpha.second.read"),
],
}),
serverSource: `export default {
id: "alpha", version: "0.1.0",
register({ http }) {
http.route({ method: "GET", path: "/first", permission: "alpha.first.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
http.route({ method: "GET", path: "/second", permission: "alpha.second.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
}
};`,
});
await createRuntimeModule(modulesDirectory, "beta", {
manifest: manifestFor("beta", {
permissions: [permissionDeclaration("beta.third.read")],
}),
serverSource: `export default {
id: "beta", version: "0.1.0",
register({ http }) {
http.route({ method: "GET", path: "/third", permission: "beta.third.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
}
};`,
});
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
expect(
runtime.routes.map((route) => `${route.moduleId}:${route.method}:${route.path}`),
).toEqual(["alpha:GET:/first", "alpha:GET:/second", "beta:GET:/third"]);
await runtime.stop();
});
it("allows the same path under different methods", async () => {
const modulesDirectory = await createModulesDirectory();
await createRuntimeModule(modulesDirectory, "same-path", {
manifest: manifestFor("same-path", {
permissions: [
permissionDeclaration("same-path.items.read"),
permissionDeclaration("same-path.items.write"),
],
}),
serverSource: `export default {
id: "same-path", version: "0.1.0",
register({ http }) {
http.route({ method: "GET", path: "/items", permission: "same-path.items.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
http.route({ method: "POST", path: "/items", permission: "same-path.items.write", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
}
};`,
});
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
expect(runtime.routes.map((route) => `${route.method} ${route.path}`)).toEqual([
"GET /items",
"POST /items",
]);
await runtime.stop();
});
it("rejects a duplicate method/path route registered by the same module", async () => {
const modulesDirectory = await createModulesDirectory();
await createRuntimeModule(modulesDirectory, "dup", {
manifest: manifestFor("dup", {
permissions: [permissionDeclaration("dup.items.read")],
}),
serverSource: `export default {
id: "dup", version: "0.1.0",
register({ http }) {
http.route({ method: "GET", path: "/items", permission: "dup.items.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
http.route({ method: "GET", path: "/items", permission: "dup.items.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
}
};`,
});
const error = await captureRuntimeError(modulesDirectory);
expect(runtimeIssueCodes(error)).toEqual(["MODULE_ROUTE_DUPLICATE"]);
});
it("collides parameter-name-only path variants under the same method", async () => {
const modulesDirectory = await createModulesDirectory();
await createRuntimeModule(modulesDirectory, "param-shape", {
manifest: manifestFor("param-shape", {
permissions: [permissionDeclaration("param-shape.events.read")],
}),
serverSource: `export default {
id: "param-shape", version: "0.1.0",
register({ http }) {
http.route({ method: "GET", path: "/events/:eventId", permission: "param-shape.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
http.route({ method: "GET", path: "/events/:id", permission: "param-shape.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
}
};`,
});
const error = await captureRuntimeError(modulesDirectory);
expect(runtimeIssueCodes(error)).toEqual(["MODULE_ROUTE_DUPLICATE"]);
});
it.each([
["parameter registered first", "/events/:id", "/events/new"],
["static registered first", "/events/new", "/events/:id"],
["multi-position, parameter first", "/events/:id/edit", "/events/new/:action"],
["multi-position, static first", "/events/new/:action", "/events/:id/edit"],
["parameters on both sides", "/a/:left/c", "/a/b/:right"],
])("rejects routes that can both match one request path (%s) regardless of registration order", async (_label, firstPath, secondPath) => {
const modulesDirectory = await createModulesDirectory();
// Deliberately *different* permissions: the defect this rejects is that whichever route
// registered first would decide which permission governs the ambiguous path.
await createRuntimeModule(modulesDirectory, "overlap", {
manifest: manifestFor("overlap", {
permissions: [
permissionDeclaration("overlap.events.read"),
permissionDeclaration("overlap.events.create"),
],
}),
serverSource: `export default {
id: "overlap", version: "0.1.0",
register({ http }) {
http.route({ method: "GET", path: "${firstPath}", permission: "overlap.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
http.route({ method: "GET", path: "${secondPath}", permission: "overlap.events.create", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
}
};`,
});
const error = await captureRuntimeError(modulesDirectory);
expect(runtimeIssueCodes(error)).toEqual(["MODULE_ROUTE_OVERLAP"]);
});
it.each([
["two different static literals", "/events/new", "/events/archive"],
["different segment counts", "/events/:id", "/events/:id/edit"],
["a different static prefix", "/events/:id", "/people/:id"],
["module root against a one-segment path", "/", "/events"],
])("still allows disjoint routes (%s) and preserves their registration order", async (_label, firstPath, secondPath) => {
const modulesDirectory = await createModulesDirectory();
await createRuntimeModule(modulesDirectory, "disjoint", {
manifest: manifestFor("disjoint", {
permissions: [permissionDeclaration("disjoint.events.read")],
}),
serverSource: `export default {
id: "disjoint", version: "0.1.0",
register({ http }) {
http.route({ method: "GET", path: "${firstPath}", permission: "disjoint.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
http.route({ method: "GET", path: "${secondPath}", permission: "disjoint.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
}
};`,
});
const runtime = await startModuleRuntime({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
services: fakeModuleRuntimeServices(),
});
expect(runtime.routes.map((route) => route.path)).toEqual([firstPath, secondPath]);
await runtime.stop();
});
it("allows the same overlapping local path under different methods", async () => {
const modulesDirectory = await createModulesDirectory();
await createRuntimeModule(modulesDirectory, "methods", {
manifest: manifestFor("methods", {
permissions: [permissionDeclaration("methods.events.read")],
}),
serverSource: `export default {
id: "methods", version: "0.1.0",
register({ http }) {
http.route({ method: "GET", path: "/events/:id", permission: "methods.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
http.route({ method: "POST", path: "/events/new", permission: "methods.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
}
};`,
});
const runtime = await startModuleRuntime({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
services: fakeModuleRuntimeServices(),
});
expect(runtime.routes).toHaveLength(2);
await runtime.stop();
});
it("allows two different modules to declare the same overlapping local paths", async () => {
const modulesDirectory = await createModulesDirectory();
for (const moduleId of ["alpha", "beta"]) {
await createRuntimeModule(modulesDirectory, moduleId, {
manifest: manifestFor(moduleId, {
permissions: [permissionDeclaration(`${moduleId}.events.read`)],
}),
serverSource: `export default {
id: "${moduleId}", version: "0.1.0",
register({ http }) {
http.route({ method: "GET", path: "/events/:id", permission: "${moduleId}.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
}
};`,
});
}
// Each module has its own platform-owned mount prefix, so these can never collide.
const runtime = await startModuleRuntime({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
services: fakeModuleRuntimeServices(),
});
expect(runtime.routes.map((route) => route.moduleId).sort()).toEqual(["alpha", "beta"]);
await runtime.stop();
});
it("publishes no route from a module whose registration was rejected for an overlap", async () => {
const modulesDirectory = await createModulesDirectory();
await createRuntimeModule(modulesDirectory, "partial", {
manifest: manifestFor("partial", {
permissions: [permissionDeclaration("partial.events.read")],
}),
serverSource: `export default {
id: "partial", version: "0.1.0",
register({ http }) {
http.route({ method: "GET", path: "/healthy", permission: "partial.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
http.route({ method: "GET", path: "/events/:id", permission: "partial.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
http.route({ method: "GET", path: "/events/new", permission: "partial.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
},
start() { throw new Error("start must never run for a rejected registration"); }
};`,
});
const error = await captureRuntimeError(modulesDirectory);
expect(runtimeIssueCodes(error)).toEqual(["MODULE_ROUTE_OVERLAP"]);
// The earlier, perfectly valid `/healthy` route is not published either: registration is
// all-or-nothing, and the start hook above would have thrown had it ever run.
});
it("fails a malformed route with its specific structured issue code", async () => {
const badMethodDirectory = await createModulesDirectory();
await createRuntimeModule(badMethodDirectory, "bad-method", {
manifest: manifestFor("bad-method", {
permissions: [permissionDeclaration("bad-method.items.read")],
}),
serverSource: `export default {
id: "bad-method", version: "0.1.0",
register({ http }) {
http.route({ method: "get", path: "/items", permission: "bad-method.items.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
}
};`,
});
expect(runtimeIssueCodes(await captureRuntimeError(badMethodDirectory))).toEqual([
"MODULE_ROUTE_METHOD_INVALID",
]);
const badPathDirectory = await createModulesDirectory();
await createRuntimeModule(badPathDirectory, "bad-path", {
manifest: manifestFor("bad-path", {
permissions: [permissionDeclaration("bad-path.items.read")],
}),
serverSource: `export default {
id: "bad-path", version: "0.1.0",
register({ http }) {
http.route({ method: "GET", path: "items", permission: "bad-path.items.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
}
};`,
});
expect(runtimeIssueCodes(await captureRuntimeError(badPathDirectory))).toEqual([
"MODULE_ROUTE_PATH_INVALID",
]);
const badMaxBodyBytesDirectory = await createModulesDirectory();
await createRuntimeModule(badMaxBodyBytesDirectory, "bad-max-body-bytes", {
manifest: manifestFor("bad-max-body-bytes", {
permissions: [permissionDeclaration("bad-max-body-bytes.items.read")],
}),
serverSource: `export default {
id: "bad-max-body-bytes", version: "0.1.0",
register({ http }) {
http.route({ method: "GET", path: "/items", permission: "bad-max-body-bytes.items.read", maxBodyBytes: -1, handler: ${noopHandlerSource()} });
}
};`,
});
expect(runtimeIssueCodes(await captureRuntimeError(badMaxBodyBytesDirectory))).toEqual([
"MODULE_ROUTE_MAX_BODY_BYTES_INVALID",
]);
const badDefinitionDirectory = await createModulesDirectory();
await createRuntimeModule(badDefinitionDirectory, "bad-shape", {
manifest: manifestFor("bad-shape", {
permissions: [permissionDeclaration("bad-shape.items.read")],
}),
serverSource: `export default {
id: "bad-shape", version: "0.1.0",
register({ http }) {
http.route({
method: "GET",
path: "/items",
permission: "bad-shape.items.read",
baseline: "public",
handler: ${noopHandlerSource()},
});
}
};`,
});
expect(runtimeIssueCodes(await captureRuntimeError(badDefinitionDirectory))).toEqual([
"MODULE_ROUTE_DEFINITION_INVALID",
]);
});
it("rejects a route permission the registering module does not itself declare", async () => {
const modulesDirectory = await createModulesDirectory();
await createRuntimeModule(modulesDirectory, "undeclared", {
manifest: manifestFor("undeclared"),
serverSource: `export default {
id: "undeclared", version: "0.1.0",
register({ http }) {
http.route({ method: "GET", path: "/items", permission: "undeclared.items.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
}
};`,
});
const error = await captureRuntimeError(modulesDirectory);
expect(runtimeIssueCodes(error)).toEqual(["MODULE_ROUTE_PERMISSION_UNDECLARED"]);
});
it("rejects a module gating a route with a Core-owned permission it cannot declare", async () => {
const modulesDirectory = await createModulesDirectory();
await createRuntimeModule(modulesDirectory, "coreattempt", {
manifest: manifestFor("coreattempt"),
serverSource: `export default {
id: "coreattempt", version: "0.1.0",
register({ http }) {
http.route({
method: "GET",
path: "/items",
permission: "core.authorization.grants.manage",
maxBodyBytes: 1024,
handler: ${noopHandlerSource()},
});
}
};`,
});
const error = await captureRuntimeError(modulesDirectory);
expect(runtimeIssueCodes(error)).toEqual(["MODULE_ROUTE_PERMISSION_UNDECLARED"]);
});
it("rejects a module gating a route with another module's permission", async () => {
const modulesDirectory = await createModulesDirectory();
await createRuntimeModule(modulesDirectory, "alpha", {
manifest: manifestFor("alpha", {
permissions: [permissionDeclaration("alpha.items.read")],
}),
serverSource: `export default { id: "alpha", version: "0.1.0" };`,
});
await createRuntimeModule(modulesDirectory, "beta", {
manifest: manifestFor("beta"),
serverSource: `export default {
id: "beta", version: "0.1.0",
register({ http }) {
http.route({ method: "GET", path: "/items", permission: "alpha.items.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
}
};`,
});
const error = await captureRuntimeError(modulesDirectory);
expect(runtimeIssueCodes(error)).toEqual(["MODULE_ROUTE_PERMISSION_UNDECLARED"]);
});
it("accepts a declared public, authenticated, or restricted permission without interpreting its baseline", async () => {
const modulesDirectory = await createModulesDirectory();
await createRuntimeModule(modulesDirectory, "baselines", {
manifest: manifestFor("baselines", {
permissions: [
permissionDeclaration("baselines.public.read", "public"),
permissionDeclaration("baselines.authenticated.read", "authenticated"),
permissionDeclaration("baselines.restricted.read", "restricted"),
],
}),
serverSource: `export default {
id: "baselines", version: "0.1.0",
register({ http }) {
http.route({ method: "GET", path: "/public", permission: "baselines.public.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
http.route({ method: "GET", path: "/authenticated", permission: "baselines.authenticated.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
http.route({ method: "GET", path: "/restricted", permission: "baselines.restricted.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
}
};`,
});
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
expect(runtime.routes).toHaveLength(3);
for (const route of runtime.routes) {
expect(Object.keys(route).sort()).toEqual([
"handler",
"maxBodyBytes",
"method",
"moduleId",
"path",
"permission",
]);
}
await runtime.stop();
});
it("never invokes a route handler while registering or starting", async () => {
const modulesDirectory = await createModulesDirectory();
const recorder = createEventRecorder();
await createRuntimeModule(modulesDirectory, "never-invoked", {
manifest: manifestFor("never-invoked", {
permissions: [permissionDeclaration("never-invoked.items.read")],
}),
serverSource: `export default {
id: "never-invoked", version: "0.1.0",
register({ http }) {
http.route({
method: "GET",
path: "/items",
permission: "never-invoked.items.read",
maxBodyBytes: 1024,
handler: () => { ${recorder.expression}.push("handler:called"); return new Response(null, { status: 204 }); },
});
}
};`,
});
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
expect(recorder.events).toEqual([]);
await runtime.stop();
expect(recorder.events).toEqual([]);
});
it("publishes frozen, immutable routes as a frozen route list", async () => {
const modulesDirectory = await createModulesDirectory();
await createRuntimeModule(modulesDirectory, "frozen", {
manifest: manifestFor("frozen", {
permissions: [permissionDeclaration("frozen.items.read")],
}),
serverSource: `export default {
id: "frozen", version: "0.1.0",
register({ http }) {
http.route({ method: "GET", path: "/items", permission: "frozen.items.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
}
};`,
});
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
expect(Object.isFrozen(runtime.routes)).toBe(true);
expect(Object.isFrozen(runtime.routes[0])).toBe(true);
await runtime.stop();
});
it("does not let a module mutate the published route by mutating its own definition afterward", async () => {
const modulesDirectory = await createModulesDirectory();
await createRuntimeModule(modulesDirectory, "mutate", {
manifest: manifestFor("mutate", {
permissions: [
permissionDeclaration("mutate.items.read"),
permissionDeclaration("mutate.other.read"),
],
}),
serverSource: `export default {
id: "mutate", version: "0.1.0",
register({ http }) {
const definition = {
method: "GET",
path: "/items",
permission: "mutate.items.read",
maxBodyBytes: 1024,
handler: ${noopHandlerSource()},
};
http.route(definition);
definition.method = "POST";
definition.path = "/mutated";
definition.permission = "mutate.other.read";
}
};`,
});
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
expect(runtime.routes[0]).toMatchObject({
method: "GET",
path: "/items",
permission: "mutate.items.read",
});
await runtime.stop();
});
it("publishes no runtime when a later module's route registration fails", async () => {
const modulesDirectory = await createModulesDirectory();
await createRuntimeModule(modulesDirectory, "first-ok", {
manifest: manifestFor("first-ok", {
permissions: [permissionDeclaration("first-ok.items.read")],
}),
serverSource: `export default {
id: "first-ok", version: "0.1.0",
register({ http }) {
http.route({ method: "GET", path: "/items", permission: "first-ok.items.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
}
};`,
});
await createRuntimeModule(modulesDirectory, "second-bad", {
manifest: manifestFor("second-bad"),
serverSource: `export default {
id: "second-bad", version: "0.1.0",
register({ http }) {
http.route({ method: "GET", path: "/items", permission: "second-bad.items.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
}
};`,
});
const error = await captureRuntimeError(modulesDirectory);
expect(runtimeIssueCodes(error)).toEqual(["MODULE_ROUTE_PERMISSION_UNDECLARED"]);
expect(error.issues[0]?.moduleId).toBe("second-bad");
});
it("preserves existing rollback behavior when a registered route's module later fails to start", async () => {
const modulesDirectory = await createModulesDirectory();
const recorder = createEventRecorder();
await createRuntimeModule(modulesDirectory, "routed", {
manifest: manifestFor("routed", {
permissions: [permissionDeclaration("routed.items.read")],
}),
serverSource: `export default {
id: "routed", version: "0.1.0",
register({ http }) {
http.route({ method: "GET", path: "/items", permission: "routed.items.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
},
start() { ${recorder.expression}.push("routed:start"); },
stop() { ${recorder.expression}.push("routed:stop"); }
};`,
});
await createRuntimeModule(modulesDirectory, "failing", {
manifest: manifestFor("failing", {
dependencies: { modules: { required: { routed: "^0.1.0" } } },
}),
serverSource: `export default {
id: "failing", version: "0.1.0",
start() { throw new Error("boom"); }
};`,
});
const error = await captureRuntimeError(modulesDirectory);
expect(runtimeIssueCodes(error)).toEqual(["MODULE_START_FAILED"]);
expect(recorder.events).toEqual(["routed:start", "routed:stop"]);
});
});
describe("already-discovered module runtime", () => {
it("starts from an already-discovered inventory without a second directory scan", async () => {
const modulesDirectory = await createModulesDirectory();
await createRuntimeModule(modulesDirectory, "already-discovered", {
serverSource: `export default { id: "already-discovered", version: "0.1.0" };`,
});
vi.mocked(discoverModules).mockClear();
const discovered = await discoverModules(runtimeOptions(modulesDirectory));
expect(discoverModules).toHaveBeenCalledTimes(1);
const runtime = await startDiscoveredModuleRuntime(discovered, fakeModuleRuntimeServices());
expect(discoverModules).toHaveBeenCalledTimes(1);
expect(runtime.modules.map((module) => module.id)).toEqual(["already-discovered"]);
await runtime.stop();
});
it("preserves the given discovered-module list order exactly, without re-sorting", async () => {
const modulesDirectory = await createModulesDirectory();
await createRuntimeModule(modulesDirectory, "first", {
serverSource: `export default { id: "first", version: "0.1.0" };`,
});
await createRuntimeModule(modulesDirectory, "second", {
serverSource: `export default { id: "second", version: "0.1.0" };`,
});
const discovered = await discoverModules(runtimeOptions(modulesDirectory));
expect(discovered.map((module) => module.manifest.id)).toEqual(["first", "second"]);
const runtime = await startDiscoveredModuleRuntime(
[...discovered].reverse(),
fakeModuleRuntimeServices(),
);
expect(runtime.modules.map((module) => module.id)).toEqual(["second", "first"]);
await runtime.stop();
});
it("startModuleRuntime remains backward-compatible and discovers exactly once", async () => {
const modulesDirectory = await createModulesDirectory();
await createRuntimeModule(modulesDirectory, "compat", {
serverSource: `export default { id: "compat", version: "0.1.0" };`,
});
vi.mocked(discoverModules).mockClear();
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
expect(discoverModules).toHaveBeenCalledTimes(1);
await runtime.stop();
});
});
describe("module runtime services", () => {
it("never gives register a services field, and gives start and stop the exact same services object", async () => {
const modulesDirectory = await createModulesDirectory();
const services = fakeModuleRuntimeServices();
const registerServices = createReferenceRecorder();
const lifecycleServices = createReferenceRecorder();
await createRuntimeModule(modulesDirectory, "wired", {
serverSource: `export default {
id: "wired", version: "0.1.0",
register(context) { ${registerServices.expression}.push(context.services); },
start(context) { ${lifecycleServices.expression}.push(context.services); },
stop(context) { ${lifecycleServices.expression}.push(context.services); }
};`,
});
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory, services));
await runtime.stop();
expect(registerServices.values).toEqual([undefined]);
expect(lifecycleServices.values).toHaveLength(2);
expect(lifecycleServices.values[0]).toBe(services);
expect(lifecycleServices.values[1]).toBe(services);
});
it("shares the exact same services object identity across every module's start hook", async () => {
const modulesDirectory = await createModulesDirectory();
const services = fakeModuleRuntimeServices();
const recorder = createReferenceRecorder();
await createRuntimeModule(modulesDirectory, "alpha", {
serverSource: `export default {
id: "alpha", version: "0.1.0",
start(context) { ${recorder.expression}.push(context.services); }
};`,
});
await createRuntimeModule(modulesDirectory, "beta", {
manifest: manifestFor("beta", {
dependencies: { modules: { required: { alpha: "^0.1.0" } } },
}),
serverSource: `export default {
id: "beta", version: "0.1.0",
start(context) { ${recorder.expression}.push(context.services); }
};`,
});
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory, services));
expect(recorder.values).toEqual([services, services]);
await runtime.stop();
});
it("gives a rollback stop hook the same services object a start failure left available", async () => {
const modulesDirectory = await createModulesDirectory();
const services = fakeModuleRuntimeServices();
const recorder = createReferenceRecorder();
await createRuntimeModule(modulesDirectory, "started", {
serverSource: `export default {
id: "started", version: "0.1.0",
start(context) { ${recorder.expression}.push(context.services); },
stop(context) { ${recorder.expression}.push(context.services); }
};`,
});
await createRuntimeModule(modulesDirectory, "failing", {
manifest: manifestFor("failing", {
dependencies: { modules: { required: { started: "^0.1.0" } } },
}),
serverSource: `export default {
id: "failing", version: "0.1.0",
start() { throw new Error("boom"); }
};`,
});
await expect(
startModuleRuntime(runtimeOptions(modulesDirectory, services)),
).rejects.toBeInstanceOf(ModuleRuntimeError);
expect(recorder.values).toEqual([services, services]);
});
it("starts and stops a zero-module runtime without ever touching the provided services", async () => {
const modulesDirectory = await createModulesDirectory();
const services = fakeModuleRuntimeServices();
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory, services));
await runtime.stop();
expect(runtime.modules).toEqual([]);
expect(vi.mocked(services.database.select)).not.toHaveBeenCalled();
expect(vi.mocked(services.database.insert)).not.toHaveBeenCalled();
expect(vi.mocked(services.database.update)).not.toHaveBeenCalled();
expect(vi.mocked(services.database.delete)).not.toHaveBeenCalled();
expect(vi.mocked(services.database.execute)).not.toHaveBeenCalled();
expect(vi.mocked(services.database.transaction)).not.toHaveBeenCalled();
});
});
/**
* Pass 4: a module whose `start` hook fails owns cleanup of whatever it managed to acquire, and
* every stop/rollback hook runs under a finite platform deadline.
*/
describe("module startup rollback and bounded stop hooks", () => {
it("releases a partially initialized resource through the failing module's own stop hook", async () => {
const modulesDirectory = await createModulesDirectory();
const recorder = createEventRecorder();
await createRuntimeModule(modulesDirectory, "partial", {
serverSource: `let acquired = false;
export default {
id: "partial", version: "0.1.0",
start() {
acquired = true;
${recorder.expression}.push("partial:acquired");
throw new Error("secret-token at /private/path");
},
stop() {
if (acquired) { acquired = false; ${recorder.expression}.push("partial:released"); }
}
};`,
});
const error = await captureRuntimeError(modulesDirectory);
expect(runtimeIssueCodes(error)).toEqual(["MODULE_START_FAILED"]);
expect(recorder.events).toEqual(["partial:acquired", "partial:released"]);
expect(JSON.stringify(error.issues)).not.toContain("secret-token");
expect(JSON.stringify(error.issues)).not.toContain("/private/path");
});
it("reports the failing module's own rollback failure and still rolls back earlier modules", async () => {
const modulesDirectory = await createModulesDirectory();
const recorder = createEventRecorder();
await createRuntimeModule(modulesDirectory, "earlier", {
serverSource: `export default {
id: "earlier", version: "0.1.0",
stop() { ${recorder.expression}.push("earlier:stop"); }
};`,
});
await createRuntimeModule(modulesDirectory, "failing", {
manifest: manifestFor("failing", {
dependencies: { modules: { required: { earlier: "^0.1.0" } } },
}),
serverSource: `export default {
id: "failing", version: "0.1.0",
start() { throw new Error("start blew up"); },
stop() { ${recorder.expression}.push("failing:stop"); throw new Error("rollback blew up"); }
};`,
});
const error = await captureRuntimeError(modulesDirectory);
expect(runtimeIssueCodes(error)).toEqual(["MODULE_START_FAILED", "MODULE_ROLLBACK_FAILED"]);
expect(recorder.events).toEqual(["failing:stop", "earlier:stop"]);
});
it("gives a ModuleRuntimeSignal thrown during start the same rollback treatment", async () => {
const modulesDirectory = await createModulesDirectory();
const recorder = createEventRecorder();
// `capabilities.require` for a capability this module never declared throws a
// `ModuleRuntimeSignal` from inside `start`, rather than an ordinary Error.
await createRuntimeModule(modulesDirectory, "signal", {
serverSource: `export default {
id: "signal", version: "0.1.0",
start({ capabilities }) { capabilities.require("undeclared.thing"); },
stop() { ${recorder.expression}.push("signal:stop"); }
};`,
});
const error = await captureRuntimeError(modulesDirectory);
expect(error.issues[0]?.phase).toBe("start");
expect(recorder.events).toEqual(["signal:stop"]);
});
it("invokes each stop hook at most once across rollback", async () => {
const modulesDirectory = await createModulesDirectory();
const recorder = createEventRecorder();
await createRuntimeModule(modulesDirectory, "counted", {
serverSource: `export default {
id: "counted", version: "0.1.0",
start() { throw new Error("nope"); },
stop() { ${recorder.expression}.push("counted:stop"); }
};`,
});
await captureRuntimeError(modulesDirectory);
expect(recorder.events.filter((event) => event === "counted:stop")).toHaveLength(1);
});
it("bounds a never-settling rollback hook and continues with the earlier module", async () => {
const modulesDirectory = await createModulesDirectory();
const recorder = createEventRecorder();
await createRuntimeModule(modulesDirectory, "earlier", {
serverSource: `export default {
id: "earlier", version: "0.1.0",
stop() { ${recorder.expression}.push("earlier:stop"); }
};`,
});
await createRuntimeModule(modulesDirectory, "hanging", {
manifest: manifestFor("hanging", {
dependencies: { modules: { required: { earlier: "^0.1.0" } } },
}),
serverSource: `export default {
id: "hanging", version: "0.1.0",
start() { throw new Error("nope"); },
stop() { ${recorder.expression}.push("hanging:stop"); return new Promise(() => {}); }
};`,
});
// A short test-only deadline; production uses MODULE_STOP_HOOK_TIMEOUT_MS unchanged.
let captured: ModuleRuntimeError | undefined;
try {
await startModuleRuntime({ ...runtimeOptions(modulesDirectory), hookTimeoutMs: 25 });
} catch (error) {
captured = error as ModuleRuntimeError;
}
expect(captured).toBeInstanceOf(ModuleRuntimeError);
expect(runtimeIssueCodes(captured as ModuleRuntimeError)).toEqual([
"MODULE_START_FAILED",
"MODULE_ROLLBACK_TIMEOUT",
]);
// The timed-out hook did not block the earlier module's rollback.
expect(recorder.events).toEqual(["hanging:stop", "earlier:stop"]);
});
it("bounds a never-settling normal stop hook and continues with the earlier module", async () => {
const modulesDirectory = await createModulesDirectory();
const recorder = createEventRecorder();
await createRuntimeModule(modulesDirectory, "earlier", {
serverSource: `export default {
id: "earlier", version: "0.1.0",
stop() { ${recorder.expression}.push("earlier:stop"); }
};`,
});
await createRuntimeModule(modulesDirectory, "hanging", {
manifest: manifestFor("hanging", {
dependencies: { modules: { required: { earlier: "^0.1.0" } } },
}),
serverSource: `export default {
id: "hanging", version: "0.1.0",
stop() { ${recorder.expression}.push("hanging:stop"); return new Promise(() => {}); }
};`,
});
const runtime = await startModuleRuntime({
...runtimeOptions(modulesDirectory),
hookTimeoutMs: 25,
});
let captured: ModuleRuntimeError | undefined;
try {
await runtime.stop();
} catch (error) {
captured = error as ModuleRuntimeError;
}
expect(runtimeIssueCodes(captured as ModuleRuntimeError)).toEqual(["MODULE_STOP_TIMEOUT"]);
expect(recorder.events).toEqual(["hanging:stop", "earlier:stop"]);
// Repeated and concurrent callers share the memoized promise and never restart cleanup.
const [first, second] = await Promise.allSettled([runtime.stop(), runtime.stop()]);
expect(first.status).toBe("rejected");
expect(second.status).toBe("rejected");
expect(recorder.events.filter((event) => event === "hanging:stop")).toHaveLength(1);
});
it("never lets a hook that rejects after its deadline become an unhandled rejection", async () => {
const modulesDirectory = await createModulesDirectory();
await createRuntimeModule(modulesDirectory, "late", {
serverSource: `export default {
id: "late", version: "0.1.0",
stop() { return new Promise((_resolve, reject) => { setTimeout(() => reject(new Error("late")), 40); }); }
};`,
});
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown): void => {
unhandled.push(reason);
};
process.on("unhandledRejection", onUnhandled);
try {
const runtime = await startModuleRuntime({
...runtimeOptions(modulesDirectory),
hookTimeoutMs: 10,
});
await expect(runtime.stop()).rejects.toBeInstanceOf(ModuleRuntimeError);
// Give the late rejection time to fire, then confirm nothing escaped.
await new Promise((resolve) => setTimeout(resolve, 80));
expect(unhandled).toEqual([]);
} finally {
process.off("unhandledRejection", onUnhandled);
}
});
});
describe("module configuration delivery (ADR 0029)", () => {
it("hands each module its own frozen configuration in register, start, and stop", async () => {
const modulesDirectory = await createModulesDirectory();
const recorder = createReferenceRecorder();
await createRuntimeModule(modulesDirectory, "announcements", {
manifest: {
...manifestFor("announcements"),
configuration: [
{ key: "slideSeconds", type: "integer", default: 7 },
{ key: "bucket", type: "string", required: true },
],
},
serverSource: `export default {
id: "announcements", version: "0.1.0",
register(context) { ${recorder.expression}.push(context.config); },
start(context) { ${recorder.expression}.push(context.config); },
stop(context) { ${recorder.expression}.push(context.config); }
};`,
});
const discovered = await discoverModules(runtimeOptions(modulesDirectory));
const resolution = resolveModuleEligibility(
discovered,
{ modules: [] },
{ settings: { records: [{ key: "bucket", moduleId: "announcements", value: "slides" }] } },
);
const runtime = await startDiscoveredModuleRuntime(
resolution.eligibleModules,
fakeModuleRuntimeServices(),
{ configurations: resolution.configurations },
);
await runtime.stop();
expect(recorder.values).toHaveLength(3);
const [registerConfig, startConfig, stopConfig] = recorder.values as ModuleConfiguration[];
// Configuration is available during `register` too — it is an already-resolved in-memory
// snapshot, not a database-backed getter, so it reintroduces no I/O into a hook that must stay
// declarative.
expect(registerConfig?.getInteger("slideSeconds")).toBe(7);
expect(registerConfig?.getString("bucket")).toBe("slides");
// The exact same object across every hook, so two hooks can never disagree about a value.
expect(startConfig).toBe(registerConfig);
expect(stopConfig).toBe(registerConfig);
expect(Object.isFrozen(registerConfig)).toBe(true);
});
it("gives each module only its own configuration, never another module's", async () => {
const modulesDirectory = await createModulesDirectory();
const recorder = createReferenceRecorder();
for (const id of ["alpha", "bravo"]) {
await createRuntimeModule(modulesDirectory, id, {
manifest: {
...manifestFor(id),
configuration: [{ key: "label", type: "string", required: true }],
},
serverSource: `export default {
id: ${JSON.stringify(id)}, version: "0.1.0",
start(context) { ${recorder.expression}.push([context.moduleId, context.config.getString("label"), context.config.keys()]); }
};`,
});
}
const discovered = await discoverModules(runtimeOptions(modulesDirectory));
const resolution = resolveModuleEligibility(
discovered,
{ modules: [] },
{
settings: {
records: [
{ key: "label", moduleId: "alpha", value: "alpha-value" },
{ key: "label", moduleId: "bravo", value: "bravo-value" },
],
},
},
);
const runtime = await startDiscoveredModuleRuntime(
resolution.eligibleModules,
fakeModuleRuntimeServices(),
{ configurations: resolution.configurations },
);
await runtime.stop();
expect(recorder.values).toEqual([
["alpha", "alpha-value", ["label"]],
["bravo", "bravo-value", ["label"]],
]);
});
it("supplies an empty configuration, never undefined, to a module that declares none", async () => {
const modulesDirectory = await createModulesDirectory();
const recorder = createReferenceRecorder();
await createRuntimeModule(modulesDirectory, "plain", {
serverSource: `export default {
id: "plain", version: "0.1.0",
start(context) { ${recorder.expression}.push([typeof context.config, context.config.keys()]); }
};`,
});
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
await runtime.stop();
// Module code never has to branch on whether the platform supplied a configuration.
expect(recorder.values).toEqual([["object", []]]);
});
it("keeps the services object one shared identity even though configuration is per-module", async () => {
const modulesDirectory = await createModulesDirectory();
const services = fakeModuleRuntimeServices();
const recorder = createReferenceRecorder();
for (const id of ["alpha", "bravo"]) {
await createRuntimeModule(modulesDirectory, id, {
manifest: {
...manifestFor(id),
configuration: [{ key: "label", type: "string", default: id }],
},
serverSource: `export default {
id: ${JSON.stringify(id)}, version: "0.1.0",
start(context) { ${recorder.expression}.push(context.services); ${recorder.expression}.push(context.config); }
};`,
});
}
const discovered = await discoverModules(runtimeOptions(modulesDirectory, services));
const resolution = resolveModuleEligibility(
discovered,
{ modules: [] },
{ settings: { records: [] } },
);
const runtime = await startDiscoveredModuleRuntime(resolution.eligibleModules, services, {
configurations: resolution.configurations,
});
await runtime.stop();
const [alphaServices, alphaConfig, bravoServices, bravoConfig] = recorder.values;
// This is the reason configuration is a sibling of `services` rather than a field inside it:
// `services` is deliberately one shared object for the whole runtime, while configuration must
// differ per module. Both properties hold simultaneously.
expect(alphaServices).toBe(services);
expect(bravoServices).toBe(services);
expect(alphaConfig).not.toBe(bravoConfig);
expect((alphaConfig as ModuleConfiguration).getString("label")).toBe("alpha");
expect((bravoConfig as ModuleConfiguration).getString("label")).toBe("bravo");
});
});