feat: define module HTTP routes

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

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