import { isAuthenticatedPrincipal, type ModuleRuntimeServices, type RegisteredModuleHttpRoute, } from "@ekklesia/contracts"; import type { AuthorizationEvaluator, AuthorizationStore, PermissionRegistry, } from "@ekklesia/core/authorization"; import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity"; import type { DashboardStore } from "@ekklesia/core/dashboard"; import type { ModuleUiPublicationRegistry } from "@ekklesia/core/modules"; import { Hono } from "hono"; import type { Context, MiddlewareHandler } from "hono"; import { cors } from "hono/cors"; import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js"; import { createDashboardRoutes } from "./dashboard-routes.js"; import { createGrantsRoutes } from "./grants-routes.js"; import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js"; import { createModuleUiInventoryRoutes } from "./module-ui-inventory.js"; import { createModuleUiRoutes } from "./module-ui-routes.js"; export interface CreateApiAppAuthorizationOptions { readonly evaluator: AuthorizationEvaluator; /** The complete platform permission registry (Core's own declarations plus every installed * module's) — always the caller-composed registry `startApi` produces via * `prepareStartupModuleInventory`, never a Core-only fallback constructed here. */ readonly registry: PermissionRegistry; readonly store: AuthorizationStore; } export interface CreateApiAppOptions { readonly authorization: CreateApiAppAuthorizationOptions; /** The started module runtime's published, immutable route snapshot — always the caller-started * runtime's exact `routes` (`startDiscoveredModuleRuntime`, `apps/api/src/server.ts`), mounted in * publication order under `/api/v1/modules/`. Required rather than defaulted so a * caller not exercising modules must say so explicitly (an empty frozen array), never rely on an * implicit fallback. */ readonly moduleRoutes: readonly RegisteredModuleHttpRoute[]; /** The same frozen services object the caller's started module runtime already passed to every * module's `start`/`stop` hooks (`apps/api/src/server.ts`) — reference-identical, never a fresh * object built here, so a module's HTTP handlers and lifecycle hooks share exactly one * `ModuleDatabase` for the lifetime of this API runtime. */ readonly moduleServices: ModuleRuntimeServices; /** The platform's module UI publication policy — always the one Core builds from the exact * discovered inventory this runtime started (`createModuleUiPublicationRegistry`, * `apps/api/src/server.ts`). Required rather than defaulted, for the same reason `moduleRoutes` * is: a caller that publishes no module UI must say so explicitly with an empty registry, never * fall through to an implicit one. See `./module-ui-routes.js`. */ readonly moduleUi: ModuleUiPublicationRegistry; /** Core's persistence for how this Dashboard presents installed modules (ADR 0027). Required * rather than defaulted, like every other module option here: a caller that exercises no * Dashboard says so with an explicit store, never falls through to an implicit one. Grouping is * presentation only — nothing reachable through this store influences discovery, dependency * ordering, module startup, or any authorization decision. */ readonly dashboard: DashboardStore; readonly runtime: EkklesiaIdentityRuntime; /** Exact allowed browser origins. Never a wildcard; combined with `credentials: true`, only an * exact, reflected match is ever returned in `Access-Control-Allow-Origin`. Also the trusted set * `requireTrustedOrigin` checks for grant- and module-route-mutation routes — there is no second, * competing origin configuration. */ readonly trustedOrigins: readonly string[]; } function sanitizedErrorResponse(c: Context, error: unknown, status: 500 | 503) { const code = error instanceof IdentityRuntimeError ? error.code : "INTERNAL_ERROR"; c.header("Cache-Control", "no-store"); return c.json({ code }, status); } /** * Hono's own `cors()` unconditionally appends its own "Vary: Origin" after every response with a * dynamic-function `origin` — including one that already has its own `Vary` (module responses * force `Origin`/`Cookie`; `/api/v1/principal` sets `Origin, Cookie` directly; an `OPTIONS` * preflight sets its own) — so the header `cors()` itself leaves behind can still be an invalid * combined "*, Origin" or contain a case-different duplicate. This wraps `middleware` and * canonicalizes whatever `Vary` remains only *after* it has fully finished, using * `canonicalizeVaryHeader` — the same shared parsing/dedup primitive `mergedVaryHeader` (module * responses) builds on, so there is one implementation to keep in sync, not two that could diverge * — but never `mergedVaryHeader` itself: this boundary wraps *every* response, `OPTIONS` preflights * included, and must never inject `Origin`/`Cookie` onto a response that never had them, only * de-duplicate what is already there. Two return shapes have to be handled: a normal request lets * `middleware` call `next()` and mutate `c.res` in place, so the fix-up goes through `c.header()` * (reassigning `c.res` directly triggers Hono's own header-preserving merge and would silently * restore the uncanonicalized value); an `OPTIONS` preflight makes `cors()` return a new `Response` * directly without ever touching `c.res`, so the fix-up mutates that returned response's headers * instead. Neither path reads or clones the response body, so a streaming body is untouched either * way. Status, body, content type, ordinary headers, authorization, trusted-origin behavior, and * route ordering are all unaffected — only the final `Vary` value changes. */ function withCanonicalVary(middleware: MiddlewareHandler): MiddlewareHandler { return async (c, next) => { const result = await middleware(c, next); if (result instanceof Response) { const existingVary = result.headers.get("Vary"); if (existingVary !== null) { result.headers.set("Vary", canonicalizeVaryHeader(existingVary)); } return result; } const existingVary = c.res.headers.get("Vary"); if (existingVary !== null) { c.header("Vary", canonicalizeVaryHeader(existingVary)); } return result; }; } /** * Pure Hono application factory: no I/O, no environment reads, no process side effects, and it * never invokes a module route handler while composing routes. Suitable for unit and integration * tests via `app.fetch` directly, with a fully injected identity runtime and authorization * evaluator/store standing in for real Better Auth/PostgreSQL where appropriate. Synchronously * throws `ModuleRouteConfigurationError` (`./module-http-routes.js`) if `moduleRoutes` and * `authorization.registry` are inconsistent — a route naming a permission the registry does not * register, which should never happen since both derive from the same discovered inventory, but is * checked explicitly rather than silently mounting an unenforceable route. */ export function createApiApp(options: CreateApiAppOptions): Hono { const { runtime } = options; const allowedOrigins = new Set(options.trustedOrigins); const app = new Hono(); const corsMiddleware = withCanonicalVary( cors({ allowHeaders: ["Content-Type"], allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], credentials: true, maxAge: 600, origin: (origin) => (origin && allowedOrigins.has(origin) ? origin : undefined), }), ); app.use("/api/auth/*", corsMiddleware); app.use("/api/v1/*", corsMiddleware); const authBodyLimit = platformBodyLimit(AUTH_REQUEST_MAX_BODY_BYTES); app.on(["POST", "GET"], "/api/auth/*", authBodyLimit, async (c) => { try { const response = await runtime.handleAuthRequest(c.req.raw); // Every Better Auth response — including a session cookie — must never be cached by an // intermediary; constructing a fresh Response (rather than relying on `c.header()`, which // only affects `c.res`) guarantees this regardless of how the handler produced its result, // and never reads or buffers the body. const headers = new Headers(response.headers); headers.set("Cache-Control", "no-store"); return new Response(response.body, { headers, status: response.status, statusText: response.statusText, }); } catch (error) { return sanitizedErrorResponse(c, error, 500); } }); app.get("/api/v1/principal", async (c) => { c.header("Cache-Control", "no-store"); c.header("Vary", "Origin, Cookie"); try { const principal = await runtime.resolvePrincipal(c.req.raw.headers); return c.json( isAuthenticatedPrincipal(principal) ? { kind: "authenticated" as const, userId: principal.userId } : { kind: "anonymous" as const }, 200, ); } catch (error) { return sanitizedErrorResponse(c, error, 503); } }); app.route( "/", createGrantsRoutes({ evaluator: options.authorization.evaluator, registry: options.authorization.registry, runtime, store: options.authorization.store, trustedOrigins: allowedOrigins, }), ); // A platform-owned route, mounted exactly like Core's own: it serves only artifacts the injected // publication registry already approved, and it can never collide with a module's own routes, // which all live under `/api/v1/modules/`. See `./module-ui-routes.js` for why it is a sibling of // that prefix rather than nested inside it. app.route("/", createModuleUiRoutes({ registry: options.moduleUi })); // The browser host's bootstrap query, at the collection root of that same prefix. Unlike the // artifact route above it is authenticated (`core.modules.ui.read`) and per-principal, because it // is the module enumeration ADR 0025 deliberately withheld from the anonymous artifact route. app.route( "/", createModuleUiInventoryRoutes({ evaluator: options.authorization.evaluator, moduleUi: options.moduleUi, registry: options.authorization.registry, runtime, }), ); // Layered deliberately on top of the generic inventory above rather than merged into it: the // inventory stays the host-neutral "what is mountable" contract a standalone host could consume, // and this adds Ekklesia's own presentation of those same modules. See `./dashboard-routes.js`. app.route( "/", createDashboardRoutes({ evaluator: options.authorization.evaluator, moduleUi: options.moduleUi, registry: options.authorization.registry, runtime, store: options.dashboard, trustedOrigins: allowedOrigins, }), ); mountModuleHttpRoutes(app, options.moduleRoutes, { evaluator: options.authorization.evaluator, registry: options.authorization.registry, runtime, services: options.moduleServices, trustedOrigins: allowedOrigins, }); app.notFound((c) => { c.header("Cache-Control", "no-store"); return c.json({ code: "NOT_FOUND" }, 404); }); app.onError((_error, c) => sanitizedErrorResponse(c, _error, 500)); return app; }