feat: define module HTTP routes

Phase 5B, pass 1: framework-neutral module HTTP route contracts and runtime
registration. Adds @ekklesia/contracts' module-http.ts (methods, canonical
path grammar, route definition/context/handler types, and validation)
and Core's module runtime staging/publication of a module's declared
routes alongside its capabilities, gated on the module's own manifest
permissions. Routes are collected into StartedModuleRuntime as an
immutable snapshot but are not yet mounted into apps/api; that is pass 2.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
ekklesia
2026-07-24 18:36:23 -05:00
co-authored by Claude Sonnet 5
parent c9cf1f08e3
commit e296e083ec
10 changed files with 1304 additions and 28 deletions
+147 -21
View File
@@ -4,12 +4,17 @@ import {
type CapabilityRegistrar,
type CapabilityResolver,
capabilityIdSchema,
type ModuleHttpRouteRegistrar,
type ModuleId,
type ModuleRegistrationContext,
type ModuleRouteDefinitionInput,
type ModuleRuntimeContext,
type ModuleRuntimeDefinition,
moduleRouteShapeKey,
parseModuleRuntimeDefinition,
type RegisteredCapability,
type RegisteredModuleHttpRoute,
safeParseModuleRouteDefinition,
} from "@ekklesia/contracts";
import {
type DiscoveredModule,
@@ -27,6 +32,12 @@ export type ModuleRuntimeIssueCode =
| "MODULE_CAPABILITY_UNREGISTERED"
| "MODULE_REGISTER_FAILED"
| "MODULE_ROLLBACK_FAILED"
| "MODULE_ROUTE_DEFINITION_INVALID"
| "MODULE_ROUTE_DUPLICATE"
| "MODULE_ROUTE_METHOD_INVALID"
| "MODULE_ROUTE_PATH_INVALID"
| "MODULE_ROUTE_PERMISSION_INVALID"
| "MODULE_ROUTE_PERMISSION_UNDECLARED"
| "MODULE_RUNTIME_EXPORT_INVALID"
| "MODULE_RUNTIME_ID_MISMATCH"
| "MODULE_RUNTIME_IMPORT_FAILED"
@@ -53,6 +64,9 @@ export interface RunningModule {
export interface StartedModuleRuntime {
readonly capabilities: CapabilityRegistry;
readonly modules: readonly RunningModule[];
/** One immutable startup snapshot: discovered-module order, then registration order within each
* module. Never mutated after startup — there is no dynamic route addition or removal. */
readonly routes: readonly RegisteredModuleHttpRoute[];
stop(): Promise<void>;
}
@@ -63,11 +77,16 @@ interface LoadedModule {
readonly discovered: DiscoveredModule;
}
interface RegistrationStage {
readonly context: ModuleRegistrationContext;
interface CapabilityRegistrationStage {
readonly capabilities: CapabilityRegistrar;
commit(): readonly ModuleRuntimeIssue[];
}
interface HttpRouteRegistrationStage {
readonly http: ModuleHttpRouteRegistrar;
commit(): void;
}
class ModuleRuntimeSignal extends Error {
readonly issue: ModuleRuntimeIssue;
@@ -109,7 +128,7 @@ function defaultExport(moduleNamespace: unknown): unknown {
class StagedCapabilityRegistry {
readonly #registered = new Map<CapabilityId, RegisteredCapability>();
createRegistrationStage(module: DiscoveredModule): RegistrationStage {
createRegistrationStage(module: DiscoveredModule): CapabilityRegistrationStage {
const local = new Map<CapabilityId, RegisteredCapability>();
const declarations = new Map(
module.manifest.capabilities.map((declaration) => [declaration.id, declaration] as const),
@@ -154,13 +173,9 @@ class StagedCapabilityRegistry {
},
resolve,
});
const context: ModuleRegistrationContext = Object.freeze({
capabilities,
moduleId: module.manifest.id,
});
return {
context,
capabilities,
commit: (): readonly ModuleRuntimeIssue[] => {
const missing = [...declarations.keys()]
.filter((capabilityId) => !local.has(capabilityId))
@@ -252,6 +267,88 @@ class StagedCapabilityRegistry {
}
}
/**
* Stages a module's HTTP route registrations during `register`, exactly like
* `StagedCapabilityRegistry` stages capability provision: routes accumulate in a per-module local
* list, validated and rejected immediately (`ModuleRuntimeSignal`) at the `http.route(...)` call
* site, and are only merged into the shared, eventually-published list by `commit()` — called only
* after the module's `register` hook returns without throwing. Unlike capabilities, there is no
* "declared but never provided" concept for routes (a manifest never declares routes), so `commit`
* has nothing to validate; it only publishes what already passed validation.
*/
class StagedModuleHttpRoutes {
readonly #routes: RegisteredModuleHttpRoute[] = [];
createRegistrationStage(module: DiscoveredModule): HttpRouteRegistrationStage {
const local: RegisteredModuleHttpRoute[] = [];
const shapes = new Set<string>();
const ownedPermissionIds = new Set(
module.manifest.permissions.map((declaration) => declaration.id),
);
const http: ModuleHttpRouteRegistrar = Object.freeze({
route: (definition: ModuleRouteDefinitionInput): void => {
const parsed = safeParseModuleRouteDefinition(definition);
if (!parsed.success) {
throw new ModuleRuntimeSignal(
runtimeIssue(
parsed.code,
"register",
module.manifest.id,
`Module ${module.manifest.id} registered an invalid HTTP route.`,
),
);
}
// Ownership is checked against this module's own manifest-declared permissions only, never
// the composed platform registry — a module can therefore never gate a route with Core's or
// another module's permission, structurally, regardless of what that other permission is.
if (!ownedPermissionIds.has(parsed.data.permission)) {
throw new ModuleRuntimeSignal(
runtimeIssue(
"MODULE_ROUTE_PERMISSION_UNDECLARED",
"register",
module.manifest.id,
`Module ${module.manifest.id} registered an HTTP route with a permission its own manifest does not declare.`,
),
);
}
const shapeKey = moduleRouteShapeKey(parsed.data.method, parsed.data.path);
if (shapes.has(shapeKey)) {
throw new ModuleRuntimeSignal(
runtimeIssue(
"MODULE_ROUTE_DUPLICATE",
"register",
module.manifest.id,
`Module ${module.manifest.id} registered a duplicate HTTP route.`,
),
);
}
shapes.add(shapeKey);
local.push(
Object.freeze({
handler: parsed.data.handler,
method: parsed.data.method,
moduleId: module.manifest.id,
path: parsed.data.path,
permission: parsed.data.permission,
}),
);
},
});
return {
http,
commit: (): void => {
this.#routes.push(...local);
},
};
}
publish(): readonly RegisteredModuleHttpRoute[] {
return Object.freeze([...this.#routes]);
}
}
async function loadRuntimeDefinitions(
discoveredModules: readonly DiscoveredModule[],
): Promise<readonly LoadedModule[]> {
@@ -327,12 +424,19 @@ async function loadRuntimeDefinitions(
async function registerModules(
modules: readonly LoadedModule[],
registry: StagedCapabilityRegistry,
capabilityRegistry: StagedCapabilityRegistry,
httpRoutes: StagedModuleHttpRoutes,
): Promise<void> {
for (const module of modules) {
const stage = registry.createRegistrationStage(module.discovered);
const capabilityStage = capabilityRegistry.createRegistrationStage(module.discovered);
const routeStage = httpRoutes.createRegistrationStage(module.discovered);
const context: ModuleRegistrationContext = Object.freeze({
capabilities: capabilityStage.capabilities,
http: routeStage.http,
moduleId: module.discovered.manifest.id,
});
try {
await module.definition.register?.(stage.context);
await module.definition.register?.(context);
} catch (error) {
if (error instanceof ModuleRuntimeSignal) {
throw new ModuleRuntimeError([error.issue]);
@@ -346,10 +450,15 @@ async function registerModules(
),
]);
}
const issues = stage.commit();
const issues = capabilityStage.commit();
if (issues.length > 0) {
throw new ModuleRuntimeError(issues);
}
// Routes are only merged into the shared published list once this module's registration has
// fully succeeded (register hook did not throw, and every declared capability was provided) —
// a failed registration therefore publishes no routes for this module, matching the same
// all-or-nothing guarantee capabilities already have.
routeStage.commit();
}
}
@@ -405,16 +514,25 @@ async function startModules(
}
}
export async function startModuleRuntime(
options: StartModuleRuntimeOptions,
/**
* Starts the module runtime from a module list the caller already discovered — performs no
* module-directory scan or manifest read of its own, and preserves the given list's order exactly.
* `startModuleRuntime` below is the convenient discovery-plus-start wrapper most callers want;
* `apps/api`'s production startup (pass 2) will call this form directly so permission composition
* and module HTTP/runtime startup share Phase 5A's exact same one-time discovery rather than
* discovering modules twice.
*/
export async function startDiscoveredModuleRuntime(
discoveredModules: readonly DiscoveredModule[],
): Promise<StartedModuleRuntime> {
const discovered = await discoverModules(options);
const modules = await loadRuntimeDefinitions(discovered);
const stagedRegistry = new StagedCapabilityRegistry();
await registerModules(modules, stagedRegistry);
await startModules(modules, stagedRegistry);
const modules = await loadRuntimeDefinitions(discoveredModules);
const capabilityRegistry = new StagedCapabilityRegistry();
const httpRoutes = new StagedModuleHttpRoutes();
await registerModules(modules, capabilityRegistry, httpRoutes);
await startModules(modules, capabilityRegistry);
const capabilities = stagedRegistry.publish();
const capabilities = capabilityRegistry.publish();
const routes = httpRoutes.publish();
const runningModules = Object.freeze(
modules.map((module) =>
Object.freeze({
@@ -428,9 +546,10 @@ export async function startModuleRuntime(
return Object.freeze({
capabilities,
modules: runningModules,
routes,
stop: (): Promise<void> => {
stopPromise ??= (async () => {
const issues = await stopModules(modules, stagedRegistry, "stop");
const issues = await stopModules(modules, capabilityRegistry, "stop");
if (issues.length > 0) {
throw new ModuleRuntimeError(issues);
}
@@ -439,3 +558,10 @@ export async function startModuleRuntime(
},
});
}
export async function startModuleRuntime(
options: StartModuleRuntimeOptions,
): Promise<StartedModuleRuntime> {
const discovered = await discoverModules(options);
return startDiscoveredModuleRuntime(discovered);
}
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it, vi } from "vitest";
const readdir = vi.fn();
const readFile = vi.fn();
const realpath = vi.fn();
const stat = vi.fn();
vi.mock("node:fs/promises", async (importOriginal) => ({
...(await importOriginal<typeof import("node:fs/promises")>()),
readdir,
readFile,
realpath,
stat,
}));
describe("module HTTP contract and runtime import safety", () => {
it("performs no filesystem I/O merely by importing the module runtime or the module-http contract", async () => {
await import("../src/modules/index.js");
await import("@ekklesia/contracts");
expect(readdir).not.toHaveBeenCalled();
expect(readFile).not.toHaveBeenCalled();
expect(realpath).not.toHaveBeenCalled();
expect(stat).not.toHaveBeenCalled();
});
});
+489 -2
View File
@@ -1,14 +1,21 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { discoverModules } from "../src/modules/discovery.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>;
@@ -40,6 +47,7 @@ function manifestFor(
options: {
capabilities?: Array<{ id: string; version: string }>;
dependencies?: Record<string, unknown>;
permissions?: Array<{ id: string; baseline: string; description: string }>;
} = {},
): Record<string, unknown> {
return {
@@ -52,7 +60,7 @@ function manifestFor(
moduleSdk: "^0.1.0",
},
capabilities: options.capabilities ?? [],
permissions: [],
permissions: options.permissions ?? [],
...(options.dependencies ? { dependencies: options.dependencies } : {}),
entrypoints: {
server: "./dist/server/index.js",
@@ -370,3 +378,482 @@ describe("module runtime", () => {
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",
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", handler: ${noopHandlerSource()} });
http.route({ method: "GET", path: "/second", permission: "alpha.second.read", 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", 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", handler: ${noopHandlerSource()} });
http.route({ method: "POST", path: "/items", permission: "same-path.items.write", 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", handler: ${noopHandlerSource()} });
http.route({ method: "GET", path: "/items", permission: "dup.items.read", 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", handler: ${noopHandlerSource()} });
http.route({ method: "GET", path: "/events/:id", permission: "param-shape.events.read", handler: ${noopHandlerSource()} });
}
};`,
});
const error = await captureRuntimeError(modulesDirectory);
expect(runtimeIssueCodes(error)).toEqual(["MODULE_ROUTE_DUPLICATE"]);
});
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", 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", handler: ${noopHandlerSource()} });
}
};`,
});
expect(runtimeIssueCodes(await captureRuntimeError(badPathDirectory))).toEqual([
"MODULE_ROUTE_PATH_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", 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",
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", 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", handler: ${noopHandlerSource()} });
http.route({ method: "GET", path: "/authenticated", permission: "baselines.authenticated.read", handler: ${noopHandlerSource()} });
http.route({ method: "GET", path: "/restricted", permission: "baselines.restricted.read", 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",
"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",
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", 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",
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", 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", 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", 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);
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());
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();
});
});