Files
Ekklesia/core/src/modules/runtime.ts
T
ekklesiaandClaude Opus 5 800ecfdb92 fix: reject ambiguous module routes
A module can register both `GET /events/:id` and `GET /events/new`. Driven
through the pinned router, `/events/new` reached the parameter handler under
`demo.events.read` when the parameter route registered first, and the static
handler under `demo.events.create` when it registered second. Nothing rejected
the pair, so declaration order decided which permission guarded the path. That
is a permission policy, not a routing preference, and it must not be inferred
from array position.

Two routes of one module now overlap when they share a method and a segment
count and, at every position, either both segments are the same static literal
or at least one is a parameter. Under this grammar — static literals or `:name`
parameters, no wildcard, regex, optional, or catch-all — that is exactly "some
concrete request path matches both". `moduleRoutesOverlap` implements it in the
contracts package, where path semantics already live and Hono cannot reach.

Core checks it during staged registration, after the existing duplicate check,
so identical shapes (including paths differing only in parameter name) keep
returning MODULE_ROUTE_DUPLICATE and a genuine intersection returns the new
MODULE_ROUTE_OVERLAP. The rejected module publishes none of its staged routes or
capabilities, no start hook runs, and no listener binds. Rejection rather than a
specificity rule: ranking static above parameter would still silently pick which
permission applies. Disjoint routes keep their order; different methods,
different segment counts, and the same local path in two different modules
(each has its own platform-owned mount prefix) all remain legal.

The same requests also pinned down what handlers actually receive. Route
declarations are validated; route *parameters* are not, and the two are
unrelated. A single encoded segment arrives decoded as `/../admin`, `a\b`, an
embedded NUL, CR/LF, `1; drop`, or non-ASCII text — none of which any declared
path could contain. The contract now says so explicitly: decoded values are
untrusted, module-owned input that a handler must validate against its own exact
domain grammar before any database, filesystem, command, or identifier use, and
re-encoding is not a substitute. No generic validator, path-normalization layer,
or route field was added, because module parameters are module-owned domain
types and one platform-wide pattern would either reject legitimate values or
approve meaningless ones.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-26 23:10:51 -05:00

602 lines
20 KiB
TypeScript

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<void>;
}
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<string, unknown> {
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<CapabilityId, RegisteredCapability>();
createRegistrationStage(module: DiscoveredModule): CapabilityRegistrationStage {
const local = new Map<CapabilityId, RegisteredCapability>();
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<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.`,
),
);
}
// 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<readonly LoadedModule[]> {
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<void> {
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<readonly ModuleRuntimeIssue[]> {
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<void> {
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<StartedModuleRuntime> {
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<void> | undefined;
return Object.freeze({
capabilities,
modules: runningModules,
routes,
stop: (): Promise<void> => {
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<StartedModuleRuntime> {
const discovered = await discoverModules(options);
return startDiscoveredModuleRuntime(discovered, options.services);
}