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:
co-authored by
Claude Sonnet 5
parent
c9cf1f08e3
commit
e296e083ec
+147
-21
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user