feat: mount module HTTP routes
Phase 5B, pass 2: production API mounting, authorization enforcement, and module-runtime lifecycle integration. apps/api now discovers the installed module inventory exactly once (prepareStartupModuleInventory), composes the platform permission registry from it, starts the module runtime from that same discovered list (startDiscoveredModuleRuntime, never rediscovering), and mounts StartedModuleRuntime's published routes under /api/v1/modules/<module-id> via a new apps/api-owned Hono adapter (module-http-routes.ts). Every module route is protected by the existing requirePermission middleware against the composed registry, state-changing methods additionally require a trusted Origin, OPTIONS remains entirely platform-owned, and a handler that throws, rejects, or returns a non-Response becomes a fixed, sanitized 500 MODULE_HANDLER_FAILED. Module-supplied CORS headers are stripped and every module response is forced to Cache-Control: no-store with Vary: Origin, Cookie. RunningApi shutdown now stops the module runtime between draining the HTTP server and closing the shared pool, and every startup failure path stops the runtime and/or closes the pool before propagating, without masking the original failure. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
e296e083ec
commit
dfaf324a65
@@ -54,7 +54,7 @@ async function readPackageVersion(packagePath: string): Promise<string> {
|
||||
return packageData.version;
|
||||
}
|
||||
|
||||
export interface ComposeStartupPermissionRegistryOptions {
|
||||
export interface PrepareStartupModuleInventoryOptions {
|
||||
/** Test-only escape hatch: overrides the directory module discovery scans for installed
|
||||
* modules. Never set by the executable entry point — production always discovers the real
|
||||
* repository's `modules/` directory. Platform and Module SDK versions are always read from the
|
||||
@@ -62,7 +62,8 @@ export interface ComposeStartupPermissionRegistryOptions {
|
||||
readonly modulesDirectoryOverride?: string;
|
||||
/** Test-only escape hatch: supplies an already-discovered module list directly, skipping the
|
||||
* filesystem discovery step entirely, while still running the real `composePlatformPermissionRegistry`
|
||||
* on it. Exists because a genuine exact permission-id collision between two modules is
|
||||
* on it — and, if startup progresses that far, driving module runtime startup with this exact
|
||||
* list too. Exists because a genuine exact permission-id collision between two modules is
|
||||
* unreachable through real discovery: module ids are unique (discovery itself rejects a
|
||||
* duplicate) and every permission id is namespaced by its own module's id, so no two
|
||||
* really-discovered modules can ever contribute the same permission id. Proving
|
||||
@@ -76,18 +77,35 @@ export interface ComposeStartupPermissionRegistryOptions {
|
||||
readonly discoveredModulesOverride?: readonly DiscoveredModule[];
|
||||
}
|
||||
|
||||
export type ComposeStartupPermissionRegistryOptions = PrepareStartupModuleInventoryOptions;
|
||||
|
||||
export interface StartupModuleInventory {
|
||||
/** The exact discovered-module list — in discovery order — permission composition ran against.
|
||||
* Production startup (`apps/api/src/server.ts`) passes this same list to
|
||||
* `startDiscoveredModuleRuntime` rather than letting the module runtime rediscover modules a
|
||||
* second time. */
|
||||
readonly modules: readonly DiscoveredModule[];
|
||||
readonly permissionRegistry: PermissionRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discovers every installed module's manifest and composes the one immutable platform permission
|
||||
* registry (Core's own declarations plus every module's) — the production source of truth
|
||||
* `apps/api` enforces every request against. Only manifests are read; no module server entrypoint
|
||||
* is imported or executed, and no module lifecycle hook runs. Read the platform and Module SDK
|
||||
* versions only when this function actually executes, never at import time.
|
||||
* Discovers every installed module's manifest exactly once and composes the one immutable platform
|
||||
* permission registry (Core's own declarations plus every module's) from that exact discovered
|
||||
* list — the production source of truth `apps/api` enforces every request against, and the exact
|
||||
* module inventory `startDiscoveredModuleRuntime` starts from. Only manifests are read here; no
|
||||
* module server entrypoint is imported or executed, and no module lifecycle hook runs. Read the
|
||||
* platform and Module SDK versions only when this function actually executes, never at import
|
||||
* time.
|
||||
*/
|
||||
export async function composeStartupPermissionRegistry(
|
||||
options: ComposeStartupPermissionRegistryOptions = {},
|
||||
): Promise<PermissionRegistry> {
|
||||
export async function prepareStartupModuleInventory(
|
||||
options: PrepareStartupModuleInventoryOptions = {},
|
||||
): Promise<StartupModuleInventory> {
|
||||
if (options.discoveredModulesOverride) {
|
||||
return composePlatformPermissionRegistry(options.discoveredModulesOverride);
|
||||
const modules = options.discoveredModulesOverride;
|
||||
return Object.freeze({
|
||||
modules,
|
||||
permissionRegistry: composePlatformPermissionRegistry(modules),
|
||||
});
|
||||
}
|
||||
|
||||
const repositoryRoot = resolveRepositoryRoot();
|
||||
@@ -102,5 +120,24 @@ export async function composeStartupPermissionRegistry(
|
||||
platformVersion,
|
||||
});
|
||||
|
||||
return composePlatformPermissionRegistry(modules);
|
||||
return Object.freeze({
|
||||
modules,
|
||||
permissionRegistry: composePlatformPermissionRegistry(modules),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Compatible wrapper for callers — currently only existing tests — that need this package's
|
||||
* composed registry without the discovered module list itself. (Doctor and `modules:validate` are
|
||||
* Core-owned and never call into `apps/api` at all; they call Core's own
|
||||
* `composePlatformPermissionRegistry` directly over their own already-discovered module list.)
|
||||
* Production `startApi` does not call this wrapper: it calls `prepareStartupModuleInventory`
|
||||
* directly so it can hand the exact same discovered list to `startDiscoveredModuleRuntime`, rather
|
||||
* than discarding it here and rediscovering modules a second time for runtime startup.
|
||||
*/
|
||||
export async function composeStartupPermissionRegistry(
|
||||
options: ComposeStartupPermissionRegistryOptions = {},
|
||||
): Promise<PermissionRegistry> {
|
||||
const inventory = await prepareStartupModuleInventory(options);
|
||||
return inventory.permissionRegistry;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user