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
@@ -0,0 +1,153 @@
|
||||
import type {
|
||||
ModuleId,
|
||||
ModuleRouteHandlerContext,
|
||||
RegisteredModuleHttpRoute,
|
||||
} from "@ekklesia/contracts";
|
||||
import type { Context, Hono } from "hono";
|
||||
import {
|
||||
type AuthorizationMiddlewareOptions,
|
||||
getAuthorizedPrincipal,
|
||||
requirePermission,
|
||||
requireTrustedOrigin,
|
||||
} from "./authorization-middleware.js";
|
||||
|
||||
const MODULE_ROUTE_PREFIX = "/api/v1/modules";
|
||||
const STATE_CHANGING_METHODS: ReadonlySet<string> = new Set(["DELETE", "PATCH", "POST", "PUT"]);
|
||||
const STRIPPED_CORS_RESPONSE_HEADERS = [
|
||||
"access-control-allow-credentials",
|
||||
"access-control-allow-headers",
|
||||
"access-control-allow-methods",
|
||||
"access-control-allow-origin",
|
||||
"access-control-expose-headers",
|
||||
"access-control-max-age",
|
||||
];
|
||||
|
||||
export interface ModuleHttpRoutesOptions extends AuthorizationMiddlewareOptions {
|
||||
readonly trustedOrigins: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/** Never carries the offending permission id, route path, module id, or any other configuration
|
||||
* detail — a forged or inconsistent route snapshot is a startup-time composition bug, not
|
||||
* something to describe in detail to whatever eventually logs it. */
|
||||
export class ModuleRouteConfigurationError extends Error {
|
||||
constructor() {
|
||||
super("A published module HTTP route references a permission that is not registered.");
|
||||
this.name = "ModuleRouteConfigurationError";
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
/** "/" is the module's own root and maps to the bare module prefix, never a required trailing
|
||||
* slash; every other local path appends directly. Root and non-root therefore never collide, and
|
||||
* `/api/v1/modules/<module-id>/` (the root path with a trailing slash) is never produced here. */
|
||||
function moduleRoutePath(moduleId: ModuleId, routePath: string): string {
|
||||
return routePath === "/"
|
||||
? `${MODULE_ROUTE_PREFIX}/${moduleId}`
|
||||
: `${MODULE_ROUTE_PREFIX}/${moduleId}${routePath}`;
|
||||
}
|
||||
|
||||
function sanitizedModuleHandlerFailure(c: Context): Response {
|
||||
c.header("Cache-Control", "no-store");
|
||||
return c.json({ code: "MODULE_HANDLER_FAILED" }, 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* The platform owns CORS and caching for every module route: strips any CORS-control header a
|
||||
* handler set (defense in depth — the outer `cors()` middleware already computes the authoritative
|
||||
* values for the actual response) and forces `Cache-Control: no-store` plus a `Vary` that always
|
||||
* includes `Origin` and `Cookie`, since even a public-baseline handler receives an authorized
|
||||
* principal and may personalize its response. A separate cache-policy contract is future work;
|
||||
* this is the conservative default until then. A module's own legitimate `Vary` values (if any)
|
||||
* are preserved alongside the forced ones.
|
||||
*/
|
||||
function sanitizeModuleResponse(response: Response): Response {
|
||||
const headers = new Headers(response.headers);
|
||||
for (const headerName of STRIPPED_CORS_RESPONSE_HEADERS) {
|
||||
headers.delete(headerName);
|
||||
}
|
||||
headers.set("Cache-Control", "no-store");
|
||||
const varyValues = new Set(
|
||||
(headers.get("Vary") ?? "")
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0),
|
||||
);
|
||||
varyValues.add("Origin");
|
||||
varyValues.add("Cookie");
|
||||
headers.set("Vary", [...varyValues].join(", "));
|
||||
return new Response(response.body, {
|
||||
headers,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts `StartedModuleRuntime`'s published, immutable route snapshot into `app`, in publication
|
||||
* order — never sorted or mutated, and never invoking a handler while mounting. Every route is
|
||||
* protected by the exact same `requirePermission` middleware Core's own routes use, with
|
||||
* `route.permission` as the only permission id ever consulted and its baseline coming exclusively
|
||||
* from `options.registry`; a state-changing method (`POST`/`PUT`/`PATCH`/`DELETE`) additionally
|
||||
* requires `requireTrustedOrigin` before authorization ever runs. `GET` inherits Hono's own
|
||||
* implicit `HEAD` handling, which re-enters this exact same middleware chain and strips the
|
||||
* response body afterward — there is no separate `HEAD` registration or permission path.
|
||||
* `route.handler` is invoked only after authorization succeeds, with the raw Fetch `Request` and a
|
||||
* frozen context — never a Hono `Context`, database, store, or configuration.
|
||||
*
|
||||
* Because the routes and registry both derive from the same discovered inventory
|
||||
* (`prepareStartupModuleInventory`), every registered route permission should already be
|
||||
* registered; this loop still checks that invariant explicitly and throws
|
||||
* `ModuleRouteConfigurationError` before mounting anything further if it ever does not hold, so a
|
||||
* forged or hand-built route snapshot fails app construction instead of silently mounting an
|
||||
* unenforceable route. `requirePermission`'s own per-request registry lookup remains the live,
|
||||
* defense-in-depth enforcement regardless.
|
||||
*/
|
||||
export function mountModuleHttpRoutes(
|
||||
app: Hono,
|
||||
routes: readonly RegisteredModuleHttpRoute[],
|
||||
options: ModuleHttpRoutesOptions,
|
||||
): void {
|
||||
const middlewareOptions: AuthorizationMiddlewareOptions = {
|
||||
evaluator: options.evaluator,
|
||||
registry: options.registry,
|
||||
runtime: options.runtime,
|
||||
};
|
||||
const requireOrigin = requireTrustedOrigin(options.trustedOrigins);
|
||||
|
||||
for (const route of routes) {
|
||||
if (!options.registry.has(route.permission)) {
|
||||
throw new ModuleRouteConfigurationError();
|
||||
}
|
||||
|
||||
const requireRoutePermission = requirePermission(middlewareOptions, route.permission);
|
||||
const path = moduleRoutePath(route.moduleId, route.path);
|
||||
const finalHandler = async (c: Context): Promise<Response> => {
|
||||
const context: ModuleRouteHandlerContext = Object.freeze({
|
||||
moduleId: route.moduleId,
|
||||
params: Object.freeze({ ...c.req.param() }),
|
||||
principal: getAuthorizedPrincipal(c),
|
||||
});
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await route.handler(c.req.raw, context);
|
||||
} catch {
|
||||
return sanitizedModuleHandlerFailure(c);
|
||||
}
|
||||
if (!(response instanceof Response)) {
|
||||
return sanitizedModuleHandlerFailure(c);
|
||||
}
|
||||
return sanitizeModuleResponse(response);
|
||||
};
|
||||
|
||||
// Hono's `on()` overloads resolve by literal call arity, not by the runtime length of a spread
|
||||
// array — each branch below is therefore a distinct, fixed-arity call rather than one call
|
||||
// spreading a variable-length handlers array.
|
||||
if (STATE_CHANGING_METHODS.has(route.method)) {
|
||||
app.on(route.method, path, requireOrigin, requireRoutePermission, finalHandler);
|
||||
} else {
|
||||
app.on(route.method, path, requireRoutePermission, finalHandler);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user