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
+90
-25
@@ -5,13 +5,17 @@ import {
|
||||
createAuthorizationStore,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity";
|
||||
import type { DiscoveredModule } from "@ekklesia/core/modules";
|
||||
import {
|
||||
type DiscoveredModule,
|
||||
type StartedModuleRuntime,
|
||||
startDiscoveredModuleRuntime,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { serve, type ServerType } from "@hono/node-server";
|
||||
import type { Hono } from "hono";
|
||||
import { createApiApp } from "./app.js";
|
||||
import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js";
|
||||
import { type ApiDatabaseHandle, createApiDatabase } from "./database.js";
|
||||
import { composeStartupPermissionRegistry } from "./platform-permissions.js";
|
||||
import { prepareStartupModuleInventory } from "./platform-permissions.js";
|
||||
|
||||
const connectionTimeoutMs = 10_000;
|
||||
const operationTimeoutMs = 10_000;
|
||||
@@ -26,12 +30,13 @@ export interface StartApiOptions {
|
||||
/** Test-only escape hatch: never set by the executable entry point. See
|
||||
* `IdentityRateLimitOverride` for why production must leave this unset. */
|
||||
readonly rateLimitOverride?: IdentityRateLimitOverride;
|
||||
/** Test-only escape hatch: overrides the directory permission-composition module discovery
|
||||
* scans. Never set by the executable entry point. See `composeStartupPermissionRegistry`. */
|
||||
/** Test-only escape hatch: overrides the directory module discovery scans. Never set by the
|
||||
* executable entry point. See `prepareStartupModuleInventory`. */
|
||||
readonly modulesDirectoryOverride?: string;
|
||||
/** Test-only escape hatch: supplies an already-discovered module list directly to permission
|
||||
* composition, skipping filesystem discovery entirely. Never set by the executable entry
|
||||
* point. See `composeStartupPermissionRegistry`. */
|
||||
/** Test-only escape hatch: supplies an already-discovered module list directly, skipping
|
||||
* filesystem discovery entirely — this exact list drives both permission composition and module
|
||||
* runtime startup. Never set by the executable entry point. See
|
||||
* `prepareStartupModuleInventory`. */
|
||||
readonly discoveredModulesOverride?: readonly DiscoveredModule[];
|
||||
}
|
||||
|
||||
@@ -44,6 +49,17 @@ async function safeCloseDatabase(handle: ApiDatabaseHandle): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Same swallow-on-cleanup-path rule as `safeCloseDatabase`, used only while unwinding a startup
|
||||
* failure that already occurred after the module runtime started — never during normal shutdown,
|
||||
* where a module stop failure remains an observable result instead (see `startApi`'s `shutdown`). */
|
||||
async function safeStopModuleRuntime(moduleRuntime: StartedModuleRuntime): Promise<void> {
|
||||
try {
|
||||
await moduleRuntime.stop();
|
||||
} catch {
|
||||
// See safeCloseDatabase: never mask the startup failure already propagating.
|
||||
}
|
||||
}
|
||||
|
||||
function bindServer(app: Hono, host: string, port: number): Promise<ServerType> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
@@ -63,25 +79,36 @@ function bindServer(app: Hono, host: string, port: number): Promise<ServerType>
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit runtime composition, in a fixed order: discover every installed module's manifest and
|
||||
* compose the one immutable platform permission registry from Core's own declarations plus every
|
||||
* module's (`./platform-permissions.js`); only then construct the one shared PostgreSQL pool
|
||||
* (identity and authorization each get a typed Drizzle view over it — see `./database.js`), the
|
||||
* Core identity runtime, the Core authorization store and evaluator, and the Hono application;
|
||||
* finally start the Node HTTP server. Permission composition happens before any pool, Better
|
||||
* Auth instance, or HTTP server exists, so a discovery or composition failure — an invalid
|
||||
* Explicit runtime composition, in a fixed order:
|
||||
*
|
||||
* 1. Discover every installed module's manifest exactly once and compose the one immutable
|
||||
* platform permission registry from Core's own declarations plus every module's
|
||||
* (`prepareStartupModuleInventory`, `./platform-permissions.js`).
|
||||
* 2. Construct the one shared PostgreSQL pool (identity and authorization each get a typed
|
||||
* Drizzle view over it — see `./database.js`).
|
||||
* 3. Construct the Core identity runtime, authorization store, and evaluator.
|
||||
* 4. Start the module runtime from that exact same discovered inventory
|
||||
* (`startDiscoveredModuleRuntime`) — never rediscovering modules a second time.
|
||||
* 5. Create the Hono application, mounting the started module runtime's published routes.
|
||||
* 6. Bind the Node HTTP server.
|
||||
*
|
||||
* Discovery and permission composition happen before any pool, Better Auth instance, module
|
||||
* entrypoint import, or HTTP server exists, so a discovery or composition failure — an invalid
|
||||
* manifest, a dependency-graph problem, a module claiming the reserved `core.*` permission
|
||||
* namespace, or a duplicate permission declaration — leaves nothing to close: it propagates
|
||||
* directly, and startup never falls back to a Core-only registry. Returns a handle with an
|
||||
* idempotent `shutdown()` so both the executable entry point and tests can stop the server and
|
||||
* close the shared pool deterministically. Never applies migrations or generates schema — the
|
||||
* database must already be migrated through `pnpm migrations:apply`.
|
||||
* directly, and startup never falls back to a Core-only registry. Core infrastructure (the pool,
|
||||
* identity, authorization) exists before any executable module lifecycle code runs, and every
|
||||
* module route is published only after the entire module runtime has registered and started
|
||||
* successfully, so no request can ever reach a module before every module has started. Returns a
|
||||
* handle with an idempotent `shutdown()` so both the executable entry point and tests can stop the
|
||||
* server, the module runtime, and the shared pool deterministically. Never applies migrations or
|
||||
* generates schema — the database must already be migrated through `pnpm migrations:apply`.
|
||||
*/
|
||||
export async function startApi(
|
||||
config: ApiRuntimeConfig,
|
||||
options: StartApiOptions = {},
|
||||
): Promise<RunningApi> {
|
||||
const permissionRegistry = await composeStartupPermissionRegistry({
|
||||
const inventory = await prepareStartupModuleInventory({
|
||||
...(options.modulesDirectoryOverride === undefined
|
||||
? {}
|
||||
: { modulesDirectoryOverride: options.modulesDirectoryOverride }),
|
||||
@@ -114,16 +141,40 @@ export async function startApi(
|
||||
platformOwners: authorizationStore,
|
||||
});
|
||||
|
||||
const app = createApiApp({
|
||||
authorization: { evaluator, registry: permissionRegistry, store: authorizationStore },
|
||||
runtime,
|
||||
trustedOrigins: config.identity.trustedOrigins,
|
||||
});
|
||||
// Module load/register/start failures use Core's own existing rollback (already-started
|
||||
// modules are stopped, in reverse order, before this rejects) — there is no separate runtime
|
||||
// handle to stop here, only the pool this failure leaves open.
|
||||
let moduleRuntime: StartedModuleRuntime;
|
||||
try {
|
||||
moduleRuntime = await startDiscoveredModuleRuntime(inventory.modules);
|
||||
} catch (error) {
|
||||
await safeCloseDatabase(databaseHandle);
|
||||
throw error;
|
||||
}
|
||||
|
||||
let app: Hono;
|
||||
try {
|
||||
app = createApiApp({
|
||||
authorization: {
|
||||
evaluator,
|
||||
registry: inventory.permissionRegistry,
|
||||
store: authorizationStore,
|
||||
},
|
||||
moduleRoutes: moduleRuntime.routes,
|
||||
runtime,
|
||||
trustedOrigins: config.identity.trustedOrigins,
|
||||
});
|
||||
} catch (error) {
|
||||
await safeStopModuleRuntime(moduleRuntime);
|
||||
await safeCloseDatabase(databaseHandle);
|
||||
throw error;
|
||||
}
|
||||
|
||||
let server: ServerType;
|
||||
try {
|
||||
server = await bindServer(app, config.host, config.port);
|
||||
} catch (error) {
|
||||
await safeStopModuleRuntime(moduleRuntime);
|
||||
await safeCloseDatabase(databaseHandle);
|
||||
throw error;
|
||||
}
|
||||
@@ -133,7 +184,12 @@ export async function startApi(
|
||||
// would previously get an already-resolved `undefined` immediately, before the listener had
|
||||
// actually drained or the pool had actually closed. Storing the promise itself means every
|
||||
// caller — concurrent or sequential — awaits the exact same drain-then-close operation and
|
||||
// observes it complete (or fail) exactly once.
|
||||
// observes it complete (or fail) exactly once. Order matters: the HTTP listener is drained and
|
||||
// closed first so no new or in-flight request can reach a module handler while the module
|
||||
// runtime is stopping, then the module runtime stops, then the shared pool closes. Unlike the
|
||||
// startup-cleanup paths above, a module-stop failure here is not swallowed — it is a genuine
|
||||
// shutdown result a caller should observe — but database cleanup still always runs first, so a
|
||||
// stop failure never leaves the pool open.
|
||||
let shutdownPromise: Promise<void> | undefined;
|
||||
const shutdown = (): Promise<void> => {
|
||||
if (!shutdownPromise) {
|
||||
@@ -141,7 +197,16 @@ export async function startApi(
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
let moduleStopError: unknown;
|
||||
try {
|
||||
await moduleRuntime.stop();
|
||||
} catch (error) {
|
||||
moduleStopError = error;
|
||||
}
|
||||
await safeCloseDatabase(databaseHandle);
|
||||
if (moduleStopError) {
|
||||
throw moduleStopError;
|
||||
}
|
||||
})();
|
||||
}
|
||||
return shutdownPromise;
|
||||
|
||||
Reference in New Issue
Block a user