diff --git a/core/src/modules/runtime.ts b/core/src/modules/runtime.ts index 111005d..42ab1de 100644 --- a/core/src/modules/runtime.ts +++ b/core/src/modules/runtime.ts @@ -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; } @@ -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(); - createRegistrationStage(module: DiscoveredModule): RegistrationStage { + createRegistrationStage(module: DiscoveredModule): CapabilityRegistrationStage { const local = new Map(); 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(); + 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 { @@ -327,12 +424,19 @@ async function loadRuntimeDefinitions( async function registerModules( modules: readonly LoadedModule[], - registry: StagedCapabilityRegistry, + capabilityRegistry: StagedCapabilityRegistry, + httpRoutes: StagedModuleHttpRoutes, ): Promise { 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 { - 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 => { 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 { + const discovered = await discoverModules(options); + return startDiscoveredModuleRuntime(discovered); +} diff --git a/core/tests/module-http-import.test.ts b/core/tests/module-http-import.test.ts new file mode 100644 index 0000000..1c425e9 --- /dev/null +++ b/core/tests/module-http-import.test.ts @@ -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()), + 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(); + }); +}); diff --git a/core/tests/module-runtime.test.ts b/core/tests/module-runtime.test.ts index 28b02d9..aefc443 100644 --- a/core/tests/module-runtime.test.ts +++ b/core/tests/module-runtime.test.ts @@ -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(); + return { ...actual, discoverModules: vi.fn(actual.discoverModules) }; +}); + interface RuntimeModuleFixture { readonly directory?: string; readonly manifest?: Record; @@ -40,6 +47,7 @@ function manifestFor( options: { capabilities?: Array<{ id: string; version: string }>; dependencies?: Record; + permissions?: Array<{ id: string; baseline: string; description: string }>; } = {}, ): Record { 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(); + }); +}); diff --git a/docs/module-development/README.md b/docs/module-development/README.md index b11449f..429158c 100644 --- a/docs/module-development/README.md +++ b/docs/module-development/README.md @@ -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 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/`: `"/"` 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 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 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 -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. +staged capability registry and an unpublished staged HTTP route list side by side, and starts them in +the same order. Neither the capability registry nor the route list is published — visible on +`StartedModuleRuntime` — unless every module registers and starts successfully. Shutdown is +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 diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 3fd62c0..8577526 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,6 +1,7 @@ export * from "./authorization.js"; export * from "./identifiers.js"; export * from "./identity.js"; +export * from "./module-http.js"; export * from "./module-lifecycle.js"; export * from "./module-manifest.js"; export * from "./module-migration.js"; diff --git a/packages/contracts/src/module-http.ts b/packages/contracts/src/module-http.ts new file mode 100644 index 0000000..ccbbdd6 --- /dev/null +++ b/packages/contracts/src/module-http.ts @@ -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 | PromiseLike; + +/** 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 = 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/` 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(); + 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; + +export function parseModuleRoutePath(input: unknown): ModuleRoutePath { + return moduleRoutePathSchema.parse(input); +} + +export function safeParseModuleRoutePath(input: unknown): z.ZodSafeParseResult { + 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>; +} + +/** 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; + +/** + * 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 { + 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; +} diff --git a/packages/contracts/src/module-runtime.ts b/packages/contracts/src/module-runtime.ts index 7bb16e7..e61d5c7 100644 --- a/packages/contracts/src/module-runtime.ts +++ b/packages/contracts/src/module-runtime.ts @@ -1,5 +1,6 @@ import type { CapabilityId, ModuleId } from "./identifiers.js"; import { moduleIdSchema } from "./identifiers.js"; +import type { ModuleHttpRouteRegistrar } from "./module-http.js"; import { semanticVersionSchema } from "./versions.js"; export type Awaitable = Value | PromiseLike; @@ -21,6 +22,10 @@ export interface CapabilityRegistrar extends CapabilityResolver { export interface ModuleRegistrationContext { 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; } diff --git a/packages/contracts/tests/module-http.test.ts b/packages/contracts/tests/module-http.test.ts new file mode 100644 index 0000000..ee281b1 --- /dev/null +++ b/packages/contracts/tests/module-http.test.ts @@ -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 { + 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); + } + }); +}); diff --git a/packages/module-sdk/tests/define-module-runtime.test.ts b/packages/module-sdk/tests/define-module-runtime.test.ts index e53213f..4fd4106 100644 --- a/packages/module-sdk/tests/define-module-runtime.test.ts +++ b/packages/module-sdk/tests/define-module-runtime.test.ts @@ -1,5 +1,11 @@ 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", () => { it("defines a frozen server runtime without duplicating the static manifest", async () => { @@ -22,4 +28,42 @@ describe("defineModuleRuntime", () => { }), ).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" }); + }); }); diff --git a/templates/module/tests/manifest.test.ts b/templates/module/tests/manifest.test.ts index 0b3b1e3..32b429f 100644 --- a/templates/module/tests/manifest.test.ts +++ b/templates/module/tests/manifest.test.ts @@ -70,6 +70,11 @@ describe("module template", () => { return undefined; }, }, + http: { + route() { + throw new Error("The template does not register any HTTP route."); + }, + }, moduleId: "example", });