import { pathToFileURL } from "node:url"; import { type CapabilityId, type CapabilityRegistrar, type CapabilityResolver, capabilityIdSchema, type ModuleHttpRouteRegistrar, type ModuleId, type ModuleRegistrationContext, type ModuleRouteDefinitionInput, type ModuleRuntimeContext, type ModuleRuntimeDefinition, type ModuleRuntimeServices, moduleRouteShapeKey, moduleRoutesOverlap, parseModuleRuntimeDefinition, type RegisteredCapability, type RegisteredModuleHttpRoute, safeParseModuleRouteDefinition, } from "@ekklesia/contracts"; import { type DiscoveredModule, discoverModules, type ModuleDiscoveryOptions, } from "./discovery.js"; export type ModuleRuntimePhase = "load" | "register" | "rollback" | "start" | "stop"; export type ModuleRuntimeIssueCode = | "MODULE_CAPABILITY_ACCESS_UNDECLARED" | "MODULE_CAPABILITY_DUPLICATE" | "MODULE_CAPABILITY_ID_INVALID" | "MODULE_CAPABILITY_UNDECLARED" | "MODULE_CAPABILITY_UNREGISTERED" | "MODULE_REGISTER_FAILED" | "MODULE_ROLLBACK_FAILED" | "MODULE_ROUTE_DEFINITION_INVALID" | "MODULE_ROUTE_DUPLICATE" | "MODULE_ROUTE_MAX_BODY_BYTES_INVALID" | "MODULE_ROUTE_METHOD_INVALID" | "MODULE_ROUTE_OVERLAP" | "MODULE_ROUTE_PATH_INVALID" | "MODULE_ROUTE_PERMISSION_INVALID" | "MODULE_ROUTE_PERMISSION_UNDECLARED" | "MODULE_RUNTIME_EXPORT_INVALID" | "MODULE_RUNTIME_ID_MISMATCH" | "MODULE_RUNTIME_IMPORT_FAILED" | "MODULE_RUNTIME_VERSION_MISMATCH" | "MODULE_START_FAILED" | "MODULE_STOP_FAILED"; export interface ModuleRuntimeIssue { readonly code: ModuleRuntimeIssueCode; readonly message: string; readonly moduleId: ModuleId; readonly phase: ModuleRuntimePhase; } export interface CapabilityRegistry extends CapabilityResolver { list(): readonly RegisteredCapability[]; } export interface RunningModule { readonly id: ModuleId; readonly version: string; } 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; } export interface StartModuleRuntimeOptions extends ModuleDiscoveryOptions { /** The same frozen services object every started module's `start`/`stop` hooks and (once * `apps/api` mounts routes) HTTP handlers receive for this runtime — a required production * dependency, never an optional test-only global. See `ModuleRuntimeServices`. */ readonly services: ModuleRuntimeServices; } interface LoadedModule { readonly definition: ModuleRuntimeDefinition; readonly discovered: DiscoveredModule; } interface CapabilityRegistrationStage { readonly capabilities: CapabilityRegistrar; commit(): readonly ModuleRuntimeIssue[]; } interface HttpRouteRegistrationStage { readonly http: ModuleHttpRouteRegistrar; commit(): void; } class ModuleRuntimeSignal extends Error { readonly issue: ModuleRuntimeIssue; constructor(issue: ModuleRuntimeIssue) { super(issue.message); this.name = "ModuleRuntimeSignal"; this.issue = issue; } } export class ModuleRuntimeError extends Error { readonly issues: readonly ModuleRuntimeIssue[]; constructor(issues: readonly ModuleRuntimeIssue[]) { super(`Module runtime failed with ${issues.length} issue(s).`); this.name = "ModuleRuntimeError"; this.stack = `${this.name}: ${this.message}`; this.issues = Object.freeze(issues.map((issue) => Object.freeze({ ...issue }))); } } function runtimeIssue( code: ModuleRuntimeIssueCode, phase: ModuleRuntimePhase, moduleId: ModuleId, message: string, ): ModuleRuntimeIssue { return { code, message, moduleId, phase }; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function defaultExport(moduleNamespace: unknown): unknown { return isRecord(moduleNamespace) ? moduleNamespace.default : undefined; } class StagedCapabilityRegistry { readonly #registered = new Map(); createRegistrationStage(module: DiscoveredModule): CapabilityRegistrationStage { const local = new Map(); const declarations = new Map( module.manifest.capabilities.map((declaration) => [declaration.id, declaration] as const), ); const resolve = (capabilityId: CapabilityId): RegisteredCapability | undefined => { this.#assertAccess(module, capabilityId, "register"); return local.get(capabilityId) ?? this.#registered.get(capabilityId); }; const capabilities: CapabilityRegistrar = Object.freeze({ provide: (capabilityId: CapabilityId, implementation: unknown): void => { this.#assertValidCapabilityId(module, capabilityId, "register"); const declaration = declarations.get(capabilityId); if (!declaration) { throw new ModuleRuntimeSignal( runtimeIssue( "MODULE_CAPABILITY_UNDECLARED", "register", module.manifest.id, `Module ${module.manifest.id} attempted to provide undeclared capability ${capabilityId}.`, ), ); } if (local.has(capabilityId) || this.#registered.has(capabilityId)) { throw new ModuleRuntimeSignal( runtimeIssue( "MODULE_CAPABILITY_DUPLICATE", "register", module.manifest.id, `Capability ${capabilityId} was provided more than once.`, ), ); } local.set( capabilityId, Object.freeze({ id: capabilityId, implementation, moduleId: module.manifest.id, version: declaration.version, }), ); }, resolve, }); return { capabilities, commit: (): readonly ModuleRuntimeIssue[] => { const missing = [...declarations.keys()] .filter((capabilityId) => !local.has(capabilityId)) .sort(); if (missing.length > 0) { return missing.map((capabilityId) => runtimeIssue( "MODULE_CAPABILITY_UNREGISTERED", "register", module.manifest.id, `Module ${module.manifest.id} did not provide declared capability ${capabilityId}.`, ), ); } for (const [capabilityId, capability] of local) { this.#registered.set(capabilityId, capability); } return []; }, }; } createRuntimeContext( module: DiscoveredModule, phase: "rollback" | "start" | "stop", services: ModuleRuntimeServices, ): ModuleRuntimeContext { const capabilities: CapabilityResolver = Object.freeze({ resolve: (capabilityId: CapabilityId): RegisteredCapability | undefined => { this.#assertAccess(module, capabilityId, phase); return this.#registered.get(capabilityId); }, }); return Object.freeze({ capabilities, moduleId: module.manifest.id, services }); } publish(): CapabilityRegistry { const capabilities = Object.freeze( [...this.#registered.values()].sort((left, right) => left.id < right.id ? -1 : left.id > right.id ? 1 : 0, ), ); const byId = new Map(capabilities.map((capability) => [capability.id, capability] as const)); return Object.freeze({ list: (): readonly RegisteredCapability[] => capabilities, resolve: (capabilityId: CapabilityId): RegisteredCapability | undefined => byId.get(capabilityId), }); } #assertAccess( module: DiscoveredModule, capabilityId: CapabilityId, phase: "register" | "rollback" | "start" | "stop", ): void { this.#assertValidCapabilityId(module, capabilityId, phase); const ownsCapability = module.manifest.capabilities.some( (declaration) => declaration.id === capabilityId, ); const declaresDependency = Object.hasOwn(module.manifest.dependencies.capabilities.required, capabilityId) || Object.hasOwn(module.manifest.dependencies.capabilities.optional, capabilityId); if (!ownsCapability && !declaresDependency) { throw new ModuleRuntimeSignal( runtimeIssue( "MODULE_CAPABILITY_ACCESS_UNDECLARED", phase, module.manifest.id, `Module ${module.manifest.id} attempted to resolve undeclared capability ${capabilityId}.`, ), ); } } #assertValidCapabilityId( module: DiscoveredModule, capabilityId: unknown, phase: "register" | "rollback" | "start" | "stop", ): asserts capabilityId is CapabilityId { if (!capabilityIdSchema.safeParse(capabilityId).success) { throw new ModuleRuntimeSignal( runtimeIssue( "MODULE_CAPABILITY_ID_INVALID", phase, module.manifest.id, `Module ${module.manifest.id} used an invalid capability identifier.`, ), ); } } } /** * 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.`, ), ); } // Distinct from a duplicate: these are *different* routes that can both match at least one // request path. With an order-sensitive matcher, whichever registered first would decide // which permission governs that path — a permission-policy decision no routing order may // make. Core rejects the ambiguity here instead of ranking by specificity, so the module // publishes none of its staged routes and no request can reach an ambiguous match. const overlapping = local.find((existing) => moduleRoutesOverlap(existing, parsed.data)); if (overlapping) { throw new ModuleRuntimeSignal( runtimeIssue( "MODULE_ROUTE_OVERLAP", "register", module.manifest.id, `Module ${module.manifest.id} registered HTTP routes that can both match the same request path.`, ), ); } shapes.add(shapeKey); local.push( Object.freeze({ handler: parsed.data.handler, maxBodyBytes: parsed.data.maxBodyBytes, 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 { const issues: ModuleRuntimeIssue[] = []; const loaded: LoadedModule[] = []; for (const discovered of discoveredModules) { let moduleNamespace: unknown; try { moduleNamespace = (await import( pathToFileURL(discovered.entrypoints.server).href )) as unknown; } catch { issues.push( runtimeIssue( "MODULE_RUNTIME_IMPORT_FAILED", "load", discovered.manifest.id, `Module ${discovered.manifest.id} server entrypoint could not be imported.`, ), ); continue; } let definition: ModuleRuntimeDefinition; try { definition = parseModuleRuntimeDefinition(defaultExport(moduleNamespace)); } catch { issues.push( runtimeIssue( "MODULE_RUNTIME_EXPORT_INVALID", "load", discovered.manifest.id, `Module ${discovered.manifest.id} server entrypoint has an invalid default export.`, ), ); continue; } let matchesManifest = true; if (definition.id !== discovered.manifest.id) { matchesManifest = false; issues.push( runtimeIssue( "MODULE_RUNTIME_ID_MISMATCH", "load", discovered.manifest.id, `Module runtime ID ${definition.id} does not match manifest ID ${discovered.manifest.id}.`, ), ); } if (definition.version !== discovered.manifest.version) { matchesManifest = false; issues.push( runtimeIssue( "MODULE_RUNTIME_VERSION_MISMATCH", "load", discovered.manifest.id, `Module runtime version ${definition.version} does not match manifest version ${discovered.manifest.version}.`, ), ); } if (matchesManifest) { loaded.push({ definition, discovered }); } } if (issues.length > 0) { throw new ModuleRuntimeError(issues); } return Object.freeze(loaded); } async function registerModules( modules: readonly LoadedModule[], capabilityRegistry: StagedCapabilityRegistry, httpRoutes: StagedModuleHttpRoutes, ): Promise { for (const module of modules) { 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?.(context); } catch (error) { if (error instanceof ModuleRuntimeSignal) { throw new ModuleRuntimeError([error.issue]); } throw new ModuleRuntimeError([ runtimeIssue( "MODULE_REGISTER_FAILED", "register", module.discovered.manifest.id, `Module ${module.discovered.manifest.id} registration failed.`, ), ]); } 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(); } } async function stopModules( modules: readonly LoadedModule[], registry: StagedCapabilityRegistry, phase: "rollback" | "stop", services: ModuleRuntimeServices, ): Promise { const issues: ModuleRuntimeIssue[] = []; for (const module of [...modules].reverse()) { try { await module.definition.stop?.( registry.createRuntimeContext(module.discovered, phase, services), ); } catch (error) { if (error instanceof ModuleRuntimeSignal) { issues.push(error.issue); } else { issues.push( runtimeIssue( phase === "rollback" ? "MODULE_ROLLBACK_FAILED" : "MODULE_STOP_FAILED", phase, module.discovered.manifest.id, `Module ${module.discovered.manifest.id} ${phase} failed.`, ), ); } } } return issues; } async function startModules( modules: readonly LoadedModule[], registry: StagedCapabilityRegistry, services: ModuleRuntimeServices, ): Promise { const started: LoadedModule[] = []; for (const module of modules) { try { await module.definition.start?.( registry.createRuntimeContext(module.discovered, "start", services), ); started.push(module); } catch (error) { const startIssue = error instanceof ModuleRuntimeSignal ? error.issue : runtimeIssue( "MODULE_START_FAILED", "start", module.discovered.manifest.id, `Module ${module.discovered.manifest.id} startup failed.`, ); const rollbackIssues = await stopModules(started, registry, "rollback", services); throw new ModuleRuntimeError([startIssue, ...rollbackIssues]); } } } /** * 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 calls this form directly (`prepareStartupModuleInventory`, * `apps/api/src/platform-permissions.ts`) so permission composition and module HTTP/runtime * startup share the exact same one-time discovery rather than discovering modules twice. */ export async function startDiscoveredModuleRuntime( discoveredModules: readonly DiscoveredModule[], services: ModuleRuntimeServices, ): Promise { const modules = await loadRuntimeDefinitions(discoveredModules); const capabilityRegistry = new StagedCapabilityRegistry(); const httpRoutes = new StagedModuleHttpRoutes(); await registerModules(modules, capabilityRegistry, httpRoutes); await startModules(modules, capabilityRegistry, services); const capabilities = capabilityRegistry.publish(); const routes = httpRoutes.publish(); const runningModules = Object.freeze( modules.map((module) => Object.freeze({ id: module.discovered.manifest.id, version: module.discovered.manifest.version, }), ), ); let stopPromise: Promise | undefined; return Object.freeze({ capabilities, modules: runningModules, routes, stop: (): Promise => { stopPromise ??= (async () => { const issues = await stopModules(modules, capabilityRegistry, "stop", services); if (issues.length > 0) { throw new ModuleRuntimeError(issues); } })(); return stopPromise; }, }); } export async function startModuleRuntime( options: StartModuleRuntimeOptions, ): Promise { const discovered = await discoverModules(options); return startDiscoveredModuleRuntime(discovered, options.services); }