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 CapabilityRegistrar,
type CapabilityResolver, type CapabilityResolver,
capabilityIdSchema, capabilityIdSchema,
type ModuleHttpRouteRegistrar,
type ModuleId, type ModuleId,
type ModuleRegistrationContext, type ModuleRegistrationContext,
type ModuleRouteDefinitionInput,
type ModuleRuntimeContext, type ModuleRuntimeContext,
type ModuleRuntimeDefinition, type ModuleRuntimeDefinition,
moduleRouteShapeKey,
parseModuleRuntimeDefinition, parseModuleRuntimeDefinition,
type RegisteredCapability, type RegisteredCapability,
type RegisteredModuleHttpRoute,
safeParseModuleRouteDefinition,
} from "@ekklesia/contracts"; } from "@ekklesia/contracts";
import { import {
type DiscoveredModule, type DiscoveredModule,
@@ -27,6 +32,12 @@ export type ModuleRuntimeIssueCode =
| "MODULE_CAPABILITY_UNREGISTERED" | "MODULE_CAPABILITY_UNREGISTERED"
| "MODULE_REGISTER_FAILED" | "MODULE_REGISTER_FAILED"
| "MODULE_ROLLBACK_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_EXPORT_INVALID"
| "MODULE_RUNTIME_ID_MISMATCH" | "MODULE_RUNTIME_ID_MISMATCH"
| "MODULE_RUNTIME_IMPORT_FAILED" | "MODULE_RUNTIME_IMPORT_FAILED"
@@ -53,6 +64,9 @@ export interface RunningModule {
export interface StartedModuleRuntime { export interface StartedModuleRuntime {
readonly capabilities: CapabilityRegistry; readonly capabilities: CapabilityRegistry;
readonly modules: readonly RunningModule[]; 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>; stop(): Promise<void>;
} }
@@ -63,11 +77,16 @@ interface LoadedModule {
readonly discovered: DiscoveredModule; readonly discovered: DiscoveredModule;
} }
interface RegistrationStage { interface CapabilityRegistrationStage {
readonly context: ModuleRegistrationContext; readonly capabilities: CapabilityRegistrar;
commit(): readonly ModuleRuntimeIssue[]; commit(): readonly ModuleRuntimeIssue[];
} }
interface HttpRouteRegistrationStage {
readonly http: ModuleHttpRouteRegistrar;
commit(): void;
}
class ModuleRuntimeSignal extends Error { class ModuleRuntimeSignal extends Error {
readonly issue: ModuleRuntimeIssue; readonly issue: ModuleRuntimeIssue;
@@ -109,7 +128,7 @@ function defaultExport(moduleNamespace: unknown): unknown {
class StagedCapabilityRegistry { class StagedCapabilityRegistry {
readonly #registered = new Map<CapabilityId, RegisteredCapability>(); readonly #registered = new Map<CapabilityId, RegisteredCapability>();
createRegistrationStage(module: DiscoveredModule): RegistrationStage { createRegistrationStage(module: DiscoveredModule): CapabilityRegistrationStage {
const local = new Map<CapabilityId, RegisteredCapability>(); const local = new Map<CapabilityId, RegisteredCapability>();
const declarations = new Map( const declarations = new Map(
module.manifest.capabilities.map((declaration) => [declaration.id, declaration] as const), module.manifest.capabilities.map((declaration) => [declaration.id, declaration] as const),
@@ -154,13 +173,9 @@ class StagedCapabilityRegistry {
}, },
resolve, resolve,
}); });
const context: ModuleRegistrationContext = Object.freeze({
capabilities,
moduleId: module.manifest.id,
});
return { return {
context, capabilities,
commit: (): readonly ModuleRuntimeIssue[] => { commit: (): readonly ModuleRuntimeIssue[] => {
const missing = [...declarations.keys()] const missing = [...declarations.keys()]
.filter((capabilityId) => !local.has(capabilityId)) .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( async function loadRuntimeDefinitions(
discoveredModules: readonly DiscoveredModule[], discoveredModules: readonly DiscoveredModule[],
): Promise<readonly LoadedModule[]> { ): Promise<readonly LoadedModule[]> {
@@ -327,12 +424,19 @@ async function loadRuntimeDefinitions(
async function registerModules( async function registerModules(
modules: readonly LoadedModule[], modules: readonly LoadedModule[],
registry: StagedCapabilityRegistry, capabilityRegistry: StagedCapabilityRegistry,
httpRoutes: StagedModuleHttpRoutes,
): Promise<void> { ): Promise<void> {
for (const module of modules) { 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 { try {
await module.definition.register?.(stage.context); await module.definition.register?.(context);
} catch (error) { } catch (error) {
if (error instanceof ModuleRuntimeSignal) { if (error instanceof ModuleRuntimeSignal) {
throw new ModuleRuntimeError([error.issue]); throw new ModuleRuntimeError([error.issue]);
@@ -346,10 +450,15 @@ async function registerModules(
), ),
]); ]);
} }
const issues = stage.commit(); const issues = capabilityStage.commit();
if (issues.length > 0) { if (issues.length > 0) {
throw new ModuleRuntimeError(issues); 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> { ): Promise<StartedModuleRuntime> {
const discovered = await discoverModules(options); const modules = await loadRuntimeDefinitions(discoveredModules);
const modules = await loadRuntimeDefinitions(discovered); const capabilityRegistry = new StagedCapabilityRegistry();
const stagedRegistry = new StagedCapabilityRegistry(); const httpRoutes = new StagedModuleHttpRoutes();
await registerModules(modules, stagedRegistry); await registerModules(modules, capabilityRegistry, httpRoutes);
await startModules(modules, stagedRegistry); await startModules(modules, capabilityRegistry);
const capabilities = stagedRegistry.publish(); const capabilities = capabilityRegistry.publish();
const routes = httpRoutes.publish();
const runningModules = Object.freeze( const runningModules = Object.freeze(
modules.map((module) => modules.map((module) =>
Object.freeze({ Object.freeze({
@@ -428,9 +546,10 @@ export async function startModuleRuntime(
return Object.freeze({ return Object.freeze({
capabilities, capabilities,
modules: runningModules, modules: runningModules,
routes,
stop: (): Promise<void> => { stop: (): Promise<void> => {
stopPromise ??= (async () => { stopPromise ??= (async () => {
const issues = await stopModules(modules, stagedRegistry, "stop"); const issues = await stopModules(modules, capabilityRegistry, "stop");
if (issues.length > 0) { if (issues.length > 0) {
throw new ModuleRuntimeError(issues); 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 { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; 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 { import {
ModuleDiscoveryError, ModuleDiscoveryError,
ModuleRuntimeError, ModuleRuntimeError,
type ModuleRuntimeIssueCode, type ModuleRuntimeIssueCode,
startDiscoveredModuleRuntime,
startModuleRuntime, startModuleRuntime,
} from "../src/modules/index.js"; } 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 { interface RuntimeModuleFixture {
readonly directory?: string; readonly directory?: string;
readonly manifest?: Record<string, unknown>; readonly manifest?: Record<string, unknown>;
@@ -40,6 +47,7 @@ function manifestFor(
options: { options: {
capabilities?: Array<{ id: string; version: string }>; capabilities?: Array<{ id: string; version: string }>;
dependencies?: Record<string, unknown>; dependencies?: Record<string, unknown>;
permissions?: Array<{ id: string; baseline: string; description: string }>;
} = {}, } = {},
): Record<string, unknown> { ): Record<string, unknown> {
return { return {
@@ -52,7 +60,7 @@ function manifestFor(
moduleSdk: "^0.1.0", moduleSdk: "^0.1.0",
}, },
capabilities: options.capabilities ?? [], capabilities: options.capabilities ?? [],
permissions: [], permissions: options.permissions ?? [],
...(options.dependencies ? { dependencies: options.dependencies } : {}), ...(options.dependencies ? { dependencies: options.dependencies } : {}),
entrypoints: { entrypoints: {
server: "./dist/server/index.js", server: "./dist/server/index.js",
@@ -370,3 +378,482 @@ describe("module runtime", () => {
expect(JSON.stringify(error.issues)).not.toContain(modulesDirectory); 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();
});
});
+75 -4
View File
@@ -177,6 +177,75 @@ Entrypoint imports must have no top-level side effects. Modules are trusted back
evaluation cannot be rolled back. Keep registration declarative; open resources in `start` and evaluation cannot be rolled back. Keep registration declarative; open resources in `start` and
close them in `stop`. close them in `stop`.
## HTTP routes
A module registers plain Fetch (`Request` in, `Response` out) route handlers during `register`,
through the `http` registrar `ModuleRegistrationContext` provides alongside `capabilities`:
```ts
import { defineModuleRuntime } from "@ekklesia/module-sdk";
export default defineModuleRuntime({
id: "calendar",
version: "0.1.0",
register({ capabilities, http }) {
capabilities.provide("calendar.events", calendarEvents);
http.route({
method: "GET",
path: "/events/:eventId",
permission: "calendar.events.read",
async handler(request, context) {
return Response.json({
eventId: context.params.eventId,
principal: context.principal.kind,
});
},
});
},
});
```
A route definition has exactly four fields — `method`, `path`, `permission`, `handler` — and nothing
else; an unknown field (a baseline, a mount prefix, CORS/origin policy, middleware, audit metadata,
authentication behavior, or another module's namespace) fails registration outright rather than
being ignored. `method` is one of `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, always uppercase; there is
no `OPTIONS` (the platform owns CORS preflight) and no wildcard/all-method registration.
`path` is local to the module, never the path a caller actually sends. The platform alone owns the
production mount prefix, `/api/v1/modules/<module-id>`: `"/"` is the module's own root, `"/events"`
means `/api/v1/modules/calendar/events`, and `"/events/:eventId"` is a parameterized route using the
same `:name` syntax a caller's request is matched against. A canonical path begins with `/`, a
non-root path never ends with `/`, and every segment is either a lowercase kebab-case static segment
or one `:parameterName` segment — no empty or repeated slashes, no query string or fragment, no
backslash or percent-encoding, no dot segments, and no wildcard/catch-all segment. A malformed path
is rejected outright, never silently normalized.
Every route — including a route meant to be publicly reachable — names exactly one `permission` the
owning module's own manifest declares; there is no way to register an unprotected, no-permission
route. A module cannot supply a baseline directly: the permission's trusted `public`, `authenticated`,
or `restricted` baseline always comes from the composed platform registry, exactly like any other
enforcement decision. Public access is therefore expressed by referencing a permission whose manifest
declaration has `baseline: "public"`, never by omitting a permission. A module may only reference a
permission its own manifest declares — never a Core-owned permission, and never another module's —
so it can never gate a route behind authority it does not itself own. The same local path may be
registered under more than one method, but registering the same module twice under the identical
method and path (or two paths that are identical once every parameter is treated as an anonymous
placeholder, such as `/events/:eventId` and `/events/:id` under `GET`) fails as a duplicate route.
The handler receives the standard `Request` plus a context of exactly `moduleId`, the caller's
already-authorized `EkklesiaPrincipal`, and the immutable decoded path parameters — never a database
connection, authorization store, grant/platform-owner state, storage, jobs, configuration, secrets,
a framework-specific request context, or a raw Better Auth session. The full query string remains
available through `Request.url`; there is no separate query or body abstraction.
This pass only defines, validates, and collects routes into `StartedModuleRuntime`'s immutable route
snapshot — Core never invokes a handler here. Mounting routes into the production `apps/api` Hono
application, enforcing each route's permission through `requirePermission`, applying
`requireTrustedOrigin`, mapping handler errors to sanitized responses, and wiring this into
`startApi`'s production lifecycle are later work.
## Drizzle schema definition ## Drizzle schema definition
A module with tables keeps the generator-facing declarations in `src/schema/tables.ts`: A module with tables keeps the generator-facing declarations in `src/schema/tables.ts`:
@@ -327,10 +396,12 @@ not misreported as a successful check.
Core completes discovery before importing any server entrypoint. It then loads and verifies every Core completes discovery before importing any server entrypoint. It then loads and verifies every
runtime definition before running hooks, registers all modules dependency-first into an unpublished runtime definition before running hooks, registers all modules dependency-first into an unpublished
staged capability registry, and starts them in the same order. Shutdown is reverse-order. If a later staged capability registry and an unpublished staged HTTP route list side by side, and starts them in
module fails to start, Core stops already-started modules in reverse order and reports any cleanup the same order. Neither the capability registry nor the route list is published — visible on
failures without leaking caught exceptions or machine paths. A failing `start` hook must clean up `StartedModuleRuntime` — unless every module registers and starts successfully. Shutdown is
its own partial work before rejecting. reverse-order. If a later module fails to start, Core stops already-started modules in reverse order
and reports any cleanup failures without leaking caught exceptions or machine paths. A failing
`start` hook must clean up its own partial work before rejecting.
## Start from the template ## Start from the template
+1
View File
@@ -1,6 +1,7 @@
export * from "./authorization.js"; export * from "./authorization.js";
export * from "./identifiers.js"; export * from "./identifiers.js";
export * from "./identity.js"; export * from "./identity.js";
export * from "./module-http.js";
export * from "./module-lifecycle.js"; export * from "./module-lifecycle.js";
export * from "./module-manifest.js"; export * from "./module-manifest.js";
export * from "./module-migration.js"; export * from "./module-migration.js";
+256
View File
@@ -0,0 +1,256 @@
import { z } from "zod";
import type { EkklesiaPrincipal } from "./identity.js";
import { type ModuleId, type PermissionId, permissionIdSchema } from "./identifiers.js";
// Not imported from `./module-runtime.js`'s own `Awaitable` (structurally identical) — importing
// it would create a source dependency cycle with that module, which also imports from this one for
// `ModuleHttpRouteRegistrar`. `scripts/check-boundaries.ts` rejects any source cycle, including one
// formed only by `import type`, so this tiny, fixed-shape utility type is kept private here instead.
type Awaitable<Value> = Value | PromiseLike<Value>;
/** The exact, canonical HTTP methods a module route may declare. Uppercase only — a module never
* gets `OPTIONS` (the platform owns CORS preflight) or a wildcard/all-methods registration. */
export const MODULE_HTTP_METHODS = ["DELETE", "GET", "PATCH", "POST", "PUT"] as const;
export type ModuleHttpMethod = (typeof MODULE_HTTP_METHODS)[number];
const moduleHttpMethods: ReadonlySet<string> = new Set(MODULE_HTTP_METHODS);
export function isModuleHttpMethod(value: unknown): value is ModuleHttpMethod {
return typeof value === "string" && moduleHttpMethods.has(value);
}
const MODULE_ROUTE_PATH_MAX_LENGTH = 256;
const staticSegmentPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
const parameterSegmentPattern = /^:[a-z][a-zA-Z0-9]*$/u;
/**
* A module-local route path — never the production-mounted `/api/v1/modules/<module-id>` path a
* caller actually sends (pass 2). Not branded (unlike `PermissionId`): like `PackageRelativePath`,
* this is a validated location, not an opaque cross-system identifier. `"/"` is the module root;
* any other canonical path begins with `/`, never ends with `/`, and is composed only of lowercase
* kebab-case static segments or single `:parameterName` segments — no empty or repeated slashes, no
* query string or fragment, no backslash or percent-encoding, no dot segments, and no wildcard or
* catch-all segment, since none of those can ever match either segment grammar. Malformed input is
* rejected outright rather than normalized, so a module can never register a path Core silently
* reinterpreted.
*/
export const moduleRoutePathSchema = z
.string()
.max(MODULE_ROUTE_PATH_MAX_LENGTH)
.superRefine((value, context) => {
if (
!value.startsWith("/") ||
value.includes("\\") ||
value.includes("%") ||
value.includes("?") ||
value.includes("#")
) {
context.addIssue({
code: "custom",
message:
"Module route paths must begin with / and must not contain backslashes, " +
"percent-encoding, query strings, or fragments.",
});
return;
}
if (value === "/") {
return;
}
if (value.endsWith("/")) {
context.addIssue({
code: "custom",
message: "Non-root module route paths must not end with /.",
});
return;
}
const segments = value.slice(1).split("/");
const parameterNames = new Set<string>();
for (const segment of segments) {
const isParameter = segment.startsWith(":");
const matchesGrammar = isParameter
? parameterSegmentPattern.test(segment)
: staticSegmentPattern.test(segment);
if (!matchesGrammar) {
context.addIssue({
code: "custom",
message:
"Module route path segments must be lowercase kebab-case static segments or a " +
"single :parameterName segment.",
});
return;
}
if (isParameter) {
const parameterName = segment.slice(1);
if (parameterNames.has(parameterName)) {
context.addIssue({
code: "custom",
message: "Module route path parameter names must be unique within the path.",
});
return;
}
parameterNames.add(parameterName);
}
}
});
export type ModuleRoutePath = z.output<typeof moduleRoutePathSchema>;
export function parseModuleRoutePath(input: unknown): ModuleRoutePath {
return moduleRoutePathSchema.parse(input);
}
export function safeParseModuleRoutePath(input: unknown): z.ZodSafeParseResult<ModuleRoutePath> {
return moduleRoutePathSchema.safeParse(input);
}
/**
* Produces a deterministic key identifying a route's shape — method plus path with every parameter
* segment collapsed to one placeholder — so `/events/:eventId` and `/events/:id` collide under the
* same method exactly like the identical route they are, regardless of the parameter's chosen name.
* Takes an already-validated `ModuleRoutePath`; never re-validates or echoes the original path.
*/
export function moduleRouteShapeKey(method: ModuleHttpMethod, path: ModuleRoutePath): string {
if (path === "/") {
return `${method} /`;
}
const shapeSegments = path
.slice(1)
.split("/")
.map((segment) => (segment.startsWith(":") ? ":param" : segment));
return `${method} /${shapeSegments.join("/")}`;
}
/**
* What a module's route handler receives about the caller and the match — never a database
* connection, authorization-store, grant-management, storage, jobs, configuration, secrets, the
* Hono `Context`, or a raw Better Auth session. `principal` is already authorized by the time a
* handler runs (pass 2 mounts and enforces); this pass never invokes a handler at all.
*/
export interface ModuleRouteHandlerContext {
readonly moduleId: ModuleId;
readonly principal: EkklesiaPrincipal;
readonly params: Readonly<Record<string, string>>;
}
/** A framework-neutral Fetch handler: standard `Request` in, standard `Response` out. The full
* query string remains available through `Request.url`; there is no competing query or body
* abstraction. */
export type ModuleRouteHandler = (
request: Request,
context: ModuleRouteHandlerContext,
) => Awaitable<Response>;
/**
* What a module passes to `http.route(...)` during `register`. `permission` is a plain string here
* (never the branded `PermissionId`) so a module can write a literal permission id without an
* explicit parse call first; `http.route` validates and brands it. The type has exactly these four
* fields — a module cannot specify a baseline, mount prefix, absolute path, CORS/origin policy,
* authentication behavior, grant/platform-owner state, middleware, audit metadata, another module's
* namespace, or an unprotected/no-permission route.
*/
export interface ModuleRouteDefinitionInput {
readonly method: ModuleHttpMethod;
readonly path: string;
readonly permission: string;
readonly handler: ModuleRouteHandler;
}
/** The validated, canonical form of a `ModuleRouteDefinitionInput` — what `http.route` produces
* only after every field passes its own grammar. */
export interface ModuleRouteDefinition {
readonly method: ModuleHttpMethod;
readonly path: ModuleRoutePath;
readonly permission: PermissionId;
readonly handler: ModuleRouteHandler;
}
/** The Fetch-based registrar exposed as `context.http` during `register` only — never on
* `ModuleRuntimeContext`, so `start`/`stop` cannot register a route after the fact. */
export interface ModuleHttpRouteRegistrar {
route(definition: ModuleRouteDefinitionInput): void;
}
/** One published route in `StartedModuleRuntime`'s immutable route snapshot — the same validated
* fields as `ModuleRouteDefinition` plus the owning module's ID, which the production mount prefix
* (pass 2) uses to keep two modules' routes from ever colliding regardless of their own local
* paths. */
export interface RegisteredModuleHttpRoute {
readonly moduleId: ModuleId;
readonly method: ModuleHttpMethod;
readonly path: ModuleRoutePath;
readonly permission: PermissionId;
readonly handler: ModuleRouteHandler;
}
const moduleRouteDefinitionKeys = new Set(["method", "path", "permission", "handler"]);
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function hasModuleRouteDefinitionShape(
value: unknown,
): value is { method: unknown; path: unknown; permission: unknown; handler: unknown } {
if (!isRecord(value)) {
return false;
}
const keys = Object.keys(value);
return (
keys.length === moduleRouteDefinitionKeys.size &&
keys.every((key) => moduleRouteDefinitionKeys.has(key)) &&
typeof value.handler === "function"
);
}
export type ModuleRouteDefinitionIssueCode =
| "MODULE_ROUTE_DEFINITION_INVALID"
| "MODULE_ROUTE_METHOD_INVALID"
| "MODULE_ROUTE_PATH_INVALID"
| "MODULE_ROUTE_PERMISSION_INVALID";
export type ModuleRouteDefinitionParseResult =
| { readonly success: true; readonly data: ModuleRouteDefinition }
| { readonly success: false; readonly code: ModuleRouteDefinitionIssueCode };
/**
* Validates every field-level rule a route definition must satisfy on its own — exact shape,
* canonical method, canonical path, well-formed permission id — but never whether the permission is
* actually declared by the registering module. That ownership check needs the module's own manifest
* and belongs to the caller (Core's module runtime), not this framework-neutral contract. Never
* echoes the rejected input in the returned code.
*/
export function safeParseModuleRouteDefinition(value: unknown): ModuleRouteDefinitionParseResult {
if (!hasModuleRouteDefinitionShape(value)) {
return { success: false, code: "MODULE_ROUTE_DEFINITION_INVALID" };
}
if (!isModuleHttpMethod(value.method)) {
return { success: false, code: "MODULE_ROUTE_METHOD_INVALID" };
}
const parsedPath = moduleRoutePathSchema.safeParse(value.path);
if (!parsedPath.success) {
return { success: false, code: "MODULE_ROUTE_PATH_INVALID" };
}
const parsedPermission = permissionIdSchema.safeParse(value.permission);
if (!parsedPermission.success) {
return { success: false, code: "MODULE_ROUTE_PERMISSION_INVALID" };
}
return {
success: true,
data: Object.freeze({
method: value.method,
path: parsedPath.data,
permission: parsedPermission.data,
handler: value.handler as ModuleRouteHandler,
}),
};
}
export function parseModuleRouteDefinition(value: unknown): ModuleRouteDefinition {
const result = safeParseModuleRouteDefinition(value);
if (!result.success) {
throw new TypeError("Invalid Ekklesia module route definition.");
}
return result.data;
}
+5
View File
@@ -1,5 +1,6 @@
import type { CapabilityId, ModuleId } from "./identifiers.js"; import type { CapabilityId, ModuleId } from "./identifiers.js";
import { moduleIdSchema } from "./identifiers.js"; import { moduleIdSchema } from "./identifiers.js";
import type { ModuleHttpRouteRegistrar } from "./module-http.js";
import { semanticVersionSchema } from "./versions.js"; import { semanticVersionSchema } from "./versions.js";
export type Awaitable<Value> = Value | PromiseLike<Value>; export type Awaitable<Value> = Value | PromiseLike<Value>;
@@ -21,6 +22,10 @@ export interface CapabilityRegistrar extends CapabilityResolver {
export interface ModuleRegistrationContext { export interface ModuleRegistrationContext {
readonly capabilities: CapabilityRegistrar; readonly capabilities: CapabilityRegistrar;
/** Available only during `register` — never on `ModuleRuntimeContext` — so a route can only ever
* be declared while Core is still building the immutable startup snapshot, never added or removed
* afterward from `start`, `stop`, or any other post-registration code path. */
readonly http: ModuleHttpRouteRegistrar;
readonly moduleId: ModuleId; readonly moduleId: ModuleId;
} }
@@ -0,0 +1,255 @@
import { describe, expect, it } from "vitest";
import {
isModuleHttpMethod,
MODULE_HTTP_METHODS,
type ModuleRouteDefinitionInput,
moduleRoutePathSchema,
moduleRouteShapeKey,
parseModuleRouteDefinition,
parseModuleRoutePath,
safeParseModuleRouteDefinition,
safeParseModuleRoutePath,
} from "../src/index.js";
function noopHandler(): Response {
return new Response(null, { status: 204 });
}
function validDefinition(
overrides: Partial<ModuleRouteDefinitionInput> = {},
): ModuleRouteDefinitionInput {
return {
method: "GET",
path: "/events/:eventId",
permission: "calendar.events.read",
handler: noopHandler,
...overrides,
};
}
describe("ModuleHttpMethod", () => {
it("accepts every supported method", () => {
for (const method of MODULE_HTTP_METHODS) {
expect(isModuleHttpMethod(method)).toBe(true);
}
expect(MODULE_HTTP_METHODS).toEqual(["DELETE", "GET", "PATCH", "POST", "PUT"]);
});
it("rejects lowercase, unknown, OPTIONS, HEAD, and wildcard methods", () => {
for (const method of ["get", "post", "TRACE", "OPTIONS", "HEAD", "*", "ALL", ""]) {
expect(isModuleHttpMethod(method)).toBe(false);
}
expect(isModuleHttpMethod(42)).toBe(false);
expect(isModuleHttpMethod(undefined)).toBe(false);
});
});
describe("moduleRoutePathSchema", () => {
it("accepts the root, static, nested, and parameterized canonical paths", () => {
for (const path of [
"/",
"/events",
"/events/upcoming",
"/events/:eventId",
"/events/:eventId/attendees/:attendeeId",
]) {
expect(parseModuleRoutePath(path)).toBe(path);
}
});
it("rejects paths that do not begin with /", () => {
expect(safeParseModuleRoutePath("events").success).toBe(false);
expect(safeParseModuleRoutePath("").success).toBe(false);
});
it("rejects a non-root path ending with /", () => {
expect(safeParseModuleRoutePath("/events/").success).toBe(false);
});
it("rejects empty and repeated segments", () => {
expect(safeParseModuleRoutePath("/events//upcoming").success).toBe(false);
expect(safeParseModuleRoutePath("//events").success).toBe(false);
});
it("rejects query strings and fragments", () => {
expect(safeParseModuleRoutePath("/events?limit=10").success).toBe(false);
expect(safeParseModuleRoutePath("/events#upcoming").success).toBe(false);
});
it("rejects backslashes and percent-encoded path syntax", () => {
expect(safeParseModuleRoutePath("/events\\upcoming").success).toBe(false);
expect(safeParseModuleRoutePath("/events%2Fupcoming").success).toBe(false);
expect(safeParseModuleRoutePath("/%2e%2e/events").success).toBe(false);
});
it("rejects dot segments and traversal syntax", () => {
expect(safeParseModuleRoutePath("/events/..").success).toBe(false);
expect(safeParseModuleRoutePath("/events/.").success).toBe(false);
expect(safeParseModuleRoutePath("/../events").success).toBe(false);
});
it("rejects wildcard or catch-all segments", () => {
expect(safeParseModuleRoutePath("/events/*").success).toBe(false);
expect(safeParseModuleRoutePath("/events/**").success).toBe(false);
expect(safeParseModuleRoutePath("/*").success).toBe(false);
});
it("rejects non-kebab-case static segments", () => {
expect(safeParseModuleRoutePath("/Events").success).toBe(false);
expect(safeParseModuleRoutePath("/events_list").success).toBe(false);
expect(safeParseModuleRoutePath("/events--upcoming").success).toBe(false);
expect(safeParseModuleRoutePath("/-events").success).toBe(false);
expect(safeParseModuleRoutePath("/events-").success).toBe(false);
});
it("rejects a malformed parameter segment", () => {
expect(safeParseModuleRoutePath("/events/:").success).toBe(false);
expect(safeParseModuleRoutePath("/events/:Event-Id").success).toBe(false);
expect(safeParseModuleRoutePath("/events/:1id").success).toBe(false);
});
it("rejects duplicate parameter names within one path", () => {
expect(safeParseModuleRoutePath("/events/:id/attendees/:id").success).toBe(false);
});
it("rejects an unreasonably long path", () => {
expect(safeParseModuleRoutePath(`/${"a".repeat(300)}`).success).toBe(false);
});
it("never echoes the rejected raw path in its issue messages", () => {
const secret = "/machine/private/path/traversal-attempt-%2e%2e";
const result = moduleRoutePathSchema.safeParse(secret);
expect(result.success).toBe(false);
expect(JSON.stringify(result.error?.issues)).not.toContain(secret);
});
});
describe("moduleRouteShapeKey", () => {
it("collapses different parameter names into the same shape for the same method", () => {
const eventId = parseModuleRoutePath("/events/:eventId");
const id = parseModuleRoutePath("/events/:id");
expect(moduleRouteShapeKey("GET", eventId)).toBe(moduleRouteShapeKey("GET", id));
});
it("distinguishes shapes by method and by static path structure", () => {
const path = parseModuleRoutePath("/events/:eventId");
expect(moduleRouteShapeKey("GET", path)).not.toBe(moduleRouteShapeKey("POST", path));
expect(moduleRouteShapeKey("GET", parseModuleRoutePath("/events"))).not.toBe(
moduleRouteShapeKey("GET", parseModuleRoutePath("/events/:eventId")),
);
});
it("treats the root path as its own shape", () => {
expect(moduleRouteShapeKey("GET", parseModuleRoutePath("/"))).toBe("GET /");
});
});
describe("safeParseModuleRouteDefinition", () => {
it("accepts a well-formed definition and brands its path and permission", () => {
const result = safeParseModuleRouteDefinition(validDefinition());
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toEqual({
method: "GET",
path: "/events/:eventId",
permission: "calendar.events.read",
handler: noopHandler,
});
expect(Object.isFrozen(result.data)).toBe(true);
}
});
it("rejects a non-object, null, or array definition", () => {
for (const value of [null, undefined, "route", 42, []]) {
expect(safeParseModuleRouteDefinition(value)).toEqual({
success: false,
code: "MODULE_ROUTE_DEFINITION_INVALID",
});
}
});
it("rejects an unknown route-definition field", () => {
expect(safeParseModuleRouteDefinition({ ...validDefinition(), baseline: "public" })).toEqual({
success: false,
code: "MODULE_ROUTE_DEFINITION_INVALID",
});
expect(
safeParseModuleRouteDefinition({ ...validDefinition(), mountPrefix: "/custom" }),
).toEqual({ success: false, code: "MODULE_ROUTE_DEFINITION_INVALID" });
});
it("rejects a missing required field", () => {
const { handler: _handler, ...withoutHandler } = validDefinition();
expect(safeParseModuleRouteDefinition(withoutHandler)).toEqual({
success: false,
code: "MODULE_ROUTE_DEFINITION_INVALID",
});
});
it("rejects a non-function handler", () => {
expect(
safeParseModuleRouteDefinition({ ...validDefinition(), handler: "not-a-function" }),
).toEqual({ success: false, code: "MODULE_ROUTE_DEFINITION_INVALID" });
});
it("rejects an invalid method", () => {
expect(safeParseModuleRouteDefinition({ ...validDefinition(), method: "get" })).toEqual({
success: false,
code: "MODULE_ROUTE_METHOD_INVALID",
});
});
it("rejects an invalid path", () => {
expect(safeParseModuleRouteDefinition({ ...validDefinition(), path: "events" })).toEqual({
success: false,
code: "MODULE_ROUTE_PATH_INVALID",
});
});
it("rejects an invalid permission id", () => {
expect(
safeParseModuleRouteDefinition({ ...validDefinition(), permission: "not namespaced" }),
).toEqual({ success: false, code: "MODULE_ROUTE_PERMISSION_INVALID" });
expect(safeParseModuleRouteDefinition({ ...validDefinition(), permission: "*" })).toEqual({
success: false,
code: "MODULE_ROUTE_PERMISSION_INVALID",
});
});
it("does not retain the caller's mutable definition object: mutating it afterward cannot change the parsed result", () => {
const source = { ...validDefinition() };
const result = safeParseModuleRouteDefinition(source);
expect(result.success).toBe(true);
source.method = "POST";
source.path = "/mutated";
source.permission = "mutated.permission.id";
if (result.success) {
expect(result.data.method).toBe("GET");
expect(result.data.path).toBe("/events/:eventId");
expect(result.data.permission).toBe("calendar.events.read");
}
});
});
describe("parseModuleRouteDefinition", () => {
it("returns the parsed definition for valid input", () => {
expect(parseModuleRouteDefinition(validDefinition())).toMatchObject({
method: "GET",
path: "/events/:eventId",
permission: "calendar.events.read",
});
});
it("throws without echoing the rejected input", () => {
const secret = "secret-token-value";
expect(() => parseModuleRouteDefinition({ ...validDefinition(), permission: secret })).toThrow(
"Invalid Ekklesia module route definition.",
);
try {
parseModuleRouteDefinition({ ...validDefinition(), permission: secret });
throw new Error("expected parseModuleRouteDefinition to throw");
} catch (error) {
expect(String(error)).not.toContain(secret);
}
});
});
@@ -1,5 +1,11 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { defineModuleRuntime } from "../src/index.js"; import {
defineModuleRuntime,
isModuleHttpMethod,
MODULE_HTTP_METHODS,
type ModuleHttpRouteRegistrar,
type ModuleRegistrationContext,
} from "../src/index.js";
describe("defineModuleRuntime", () => { describe("defineModuleRuntime", () => {
it("defines a frozen server runtime without duplicating the static manifest", async () => { it("defines a frozen server runtime without duplicating the static manifest", async () => {
@@ -22,4 +28,42 @@ describe("defineModuleRuntime", () => {
}), }),
).toThrow("Invalid Ekklesia module runtime definition."); ).toThrow("Invalid Ekklesia module runtime definition.");
}); });
it("re-exports the module-http contract so a module's register hook can declare routes", async () => {
expect(MODULE_HTTP_METHODS).toContain("GET");
expect(isModuleHttpMethod("GET")).toBe(true);
expect(isModuleHttpMethod("get")).toBe(false);
const registeredRoutes: unknown[] = [];
const http: ModuleHttpRouteRegistrar = {
route: (definition) => {
registeredRoutes.push(definition);
},
};
const runtime = defineModuleRuntime({
id: "example",
version: "0.1.0",
register({ http: registrar }) {
registrar.route({
method: "GET",
path: "/records",
permission: "example.records.read",
handler: () => new Response(null, { status: 204 }),
});
},
});
const context: ModuleRegistrationContext = {
capabilities: {
provide: () => undefined,
resolve: () => undefined,
},
http,
moduleId: "example",
};
await runtime.register?.(context);
expect(registeredRoutes).toHaveLength(1);
expect(registeredRoutes[0]).toMatchObject({ method: "GET", path: "/records" });
});
}); });
+5
View File
@@ -70,6 +70,11 @@ describe("module template", () => {
return undefined; return undefined;
}, },
}, },
http: {
route() {
throw new Error("The template does not register any HTTP route.");
},
},
moduleId: "example", moduleId: "example",
}); });