import { isAuthenticatedPrincipal } 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 } from "hono"; import { cors } from "hono/cors"; import { createGrantsRoutes } from "./grants-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 * `composeStartupPermissionRegistry`, never a Core-only fallback constructed here. */ readonly registry: PermissionRegistry; readonly store: AuthorizationStore; } export interface CreateApiAppOptions { readonly authorization: CreateApiAppAuthorizationOptions; 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-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); } /** * Pure Hono application factory: no I/O, no environment reads, no process side effects. 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. */ export function createApiApp(options: CreateApiAppOptions): Hono { const { runtime } = options; const allowedOrigins = new Set(options.trustedOrigins); const app = new Hono(); const corsMiddleware = cors({ allowHeaders: ["Content-Type"], allowMethods: ["GET", "POST", "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, }), ); 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; }