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 { Hono } from "hono"; import type { Context, MiddlewareHandler } from "hono"; import { cors } from "hono/cors"; import { createGrantsRoutes } from "./grants-routes.js"; import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-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; 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); app.on(["POST", "GET"], "/api/auth/*", async (c) => { try { return await runtime.handleAuthRequest(c.req.raw); } 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, }), ); 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; }