import { EMPTY_MODULE_CONFIGURATION, EMPTY_MODULE_STORAGE, type ModuleId, type ModuleRouteHandlerContext, type ModuleRuntimeServices, type RegisteredModuleHttpRoute, } from "@ekklesia/contracts"; import type { ModuleConfigurationLookup, ModuleRealtimeRegistry, ModuleStorageLookup, } from "@ekklesia/core/modules"; import type { Context, Hono } from "hono"; import { type AuthorizationMiddlewareOptions, getAuthorizedPrincipal, requirePermission, requireTrustedOrigin, } from "./authorization-middleware.js"; import { platformBodyLimit } from "./body-limits.js"; const MODULE_ROUTE_PREFIX = "/api/v1/modules"; const STATE_CHANGING_METHODS: ReadonlySet = 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; /** The same frozen services object the started module runtime's `start`/`stop` hooks already * received for this API runtime — never a fresh one built per request, and never touched for a * denied, trusted-origin-rejected, or `OPTIONS` request, since a handler is only ever invoked * after authorization succeeds. */ readonly services: ModuleRuntimeServices; /** * Per-module frozen configuration snapshots — the exact map the module runtime started from * (ADR 0029), so a handler and that module's `start`/`stop` hooks receive the identical object * rather than two independently resolved values. A module with no entry receives * `EMPTY_MODULE_CONFIGURATION`, so `context.config` is always present. * * Unlike `services`, which is one shared object, this is keyed by module id: a handler can only * ever reach its own module's configuration, because that is the only entry looked up for it. */ readonly configurations: ModuleConfigurationLookup; /** * Per-module scoped object storage — the exact map the module runtime started from (ADR 0030), so * a handler and that module's lifecycle hooks share one facade rather than two independently * resolved ones. * * Keyed by module id, like `configurations` and unlike `services`: a handler can only ever reach * its own module's storage, because that is the only entry looked up for it. */ readonly storages: ModuleStorageLookup; /** The same started registry whose scoped publishers lifecycle hooks receive. */ readonly realtime: ModuleRealtimeRegistry; } /** 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); } } /** * The root a module's own HTTP API is mounted at — the one place that URL shape is constructed. * * Exported because a browser host has to resolve `context.api.request("records")` * (`ModuleUiApiClient`, `@ekklesia/module-ui-sdk`) to a real Ekklesia URL, and a module UI must * never build that prefix itself: it is Ekklesia's private deployment detail, and a module that * hardcoded it could not be hosted anywhere else (ADR 0022). The server hands the resolved base to * the host through the module UI inventory (`./module-ui-inventory.js`) rather than letting the * browser re-spell this template, so this prefix can change without touching either. * * Percent-encoded for the same reason `moduleUiArtifactUrl` is: a value that somehow did not come * from a validated manifest can only ever become one segment's worth of text, never a new segment. * A real module id (lowercase kebab-case, `moduleIdSchema`) is unaffected by the encoding. */ export function moduleApiBaseUrl(moduleId: string): string { return `${MODULE_ROUTE_PREFIX}/${encodeURIComponent(moduleId)}`; } /** "/" 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//` (the root path with a trailing slash) is never produced here. */ function moduleRoutePath(moduleId: ModuleId, routePath: string): string { const base = moduleApiBaseUrl(moduleId); return routePath === "/" ? base : `${base}${routePath}`; } function sanitizedModuleHandlerFailure(c: Context): Response { c.header("Cache-Control", "no-store"); return c.json({ code: "MODULE_HANDLER_FAILED" }, 500); } /** * The one shared `Vary` parsing/canonicalization primitive: splits and trims comma-separated * tokens and de-duplicates them case-insensitively — `Vary` lists header *names*, which are * themselves case-insensitive, so a module's own `origin` must collapse with a later `Origin` * rather than appear as a separate, redundant entry — while preserving first-seen casing and * relative order. Never returns early on `*`; callers decide what a `*` token means for their own * policy (see `mergedVaryHeader` and `canonicalizeVaryHeader` below, the two callers that build on * this instead of re-implementing parsing/dedup independently). */ function parseVaryTokens(existingVary: string | null): Map { const canonicalByLowercase = new Map(); for (const token of (existingVary ?? "").split(",")) { const trimmed = token.trim(); if (trimmed.length === 0) { continue; } const key = trimmed.toLowerCase(); if (!canonicalByLowercase.has(key)) { canonicalByLowercase.set(key, trimmed); } } return canonicalByLowercase; } /** A literal `*` (RFC 9110 §12.5.5) must never be combined with any other value; since `*` already * means "may vary on anything," a strictly broader signal than any explicit list, its presence * collapses the result to `*` alone rather than producing an invalid combination. */ function serializeVaryTokens(tokens: ReadonlyMap): string { if (tokens.has("*")) { return "*"; } return [...tokens.values()].join(", "); } /** * Used only for a module's own response: always emits the canonical `Origin`/`Cookie` casing * regardless of how (or whether) a module already listed them, since every module response is * forced to vary on both (see `sanitizeModuleResponse`) — this is a deliberate product policy for * module responses specifically, not a generic property every response through the platform's CORS * boundary should have forced onto it (an `OPTIONS` preflight, for one, has no `Cookie` concept at * all). */ export function mergedVaryHeader(existingVary: string | null): string { const tokens = parseVaryTokens(existingVary); if (tokens.has("*")) { return "*"; } tokens.set("origin", "Origin"); tokens.set("cookie", "Cookie"); return serializeVaryTokens(tokens); } /** * Used at the app-level CORS boundary (`apps/api/src/app.ts`), which wraps every response — * `OPTIONS` preflights and Core's own routes included, not only module ones — after Hono's own * `cors()` has already appended its own `Vary: Origin`. Unlike `mergedVaryHeader`, this never * injects a token that was not already present; it only de-duplicates and collapses `*`, since a * generic boundary must not assume every response wants `Origin`/`Cookie` forced onto it. */ export function canonicalizeVaryHeader(existingVary: string): string { return serializeVaryTokens(parseVaryTokens(existingVary)); } /** * 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. Never reads or buffers `response.body` — it is passed * through to the new `Response` unread, so a streaming body is neither consumed nor blocked here. */ 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"); headers.set("Vary", mergedVaryHeader(headers.get("Vary"))); 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 carrying exactly `config`, `moduleId`, `params`, `principal`, `realtime`, * `services`, and `storage` — never a Hono `Context`, authorization store, raw provider, or * deployment-wide configuration. `services.database` is the module's only reachable database * surface, and the whole context is unreachable to anything short of a successfully authorized * handler invocation (a denied request, a rejected trusted-origin check, and `OPTIONS` never reach * this handler at all). * * 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); // The route's own conscious body contract (`route.maxBodyBytes` — 0 means no body accepted at // all) is enforced before permission evaluation ever runs, so a request *declared* oversized // never reaches authorization work, let alone the handler or the module database. In // `streamed` mode no body byte is read until the handler reads it, and the handler runs only // after authorization succeeds, so an unauthorized caller can never push an upload through // (ADR 0042). const routeBodyLimit = platformBodyLimit(route.maxBodyBytes, route.bodyMode); const path = moduleRoutePath(route.moduleId, route.path); const finalHandler = async (c: Context): Promise => { const context: ModuleRouteHandlerContext = Object.freeze({ config: options.configurations.get(route.moduleId) ?? EMPTY_MODULE_CONFIGURATION, moduleId: route.moduleId, storage: options.storages.get(route.moduleId) ?? EMPTY_MODULE_STORAGE, params: Object.freeze({ ...c.req.param() }), principal: getAuthorizedPrincipal(c), realtime: options.realtime.publisherFor(route.moduleId), services: options.services, }); 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, routeBodyLimit, requireRoutePermission, finalHandler, ); } else { app.on(route.method, path, routeBodyLimit, requireRoutePermission, finalHandler); } } }