import { isAuthenticatedPrincipal, type ModuleRuntimeServices, type ModuleSecretSource, type RegisteredModuleHttpRoute, } from "@ekklesia/contracts"; import type { AuthorizationEvaluator, AuthorizationStore, PermissionRegistry, } from "@ekklesia/core/authorization"; import type { DashboardStore } from "@ekklesia/core/dashboard"; import type { DeviceStore } from "@ekklesia/core/devices"; import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity"; import type { DiscoveredModule, ModuleConfigurationLookup, ModuleConfigurationSettings, ModuleConfigurationStore, ModuleLifecycleSnapshot, ModuleLifecycleStore, ModuleRealtimeRegistry, ModuleStorageLookup, ModuleUiPublicationRegistry, } from "@ekklesia/core/modules"; import type { Context, MiddlewareHandler } from "hono"; import { Hono } from "hono"; import { cors } from "hono/cors"; import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js"; import type { DashboardHostAssets } from "./dashboard-host-assets.js"; import { createDashboardHostRoutes } from "./dashboard-host-routes.js"; import { createDashboardRoutes } from "./dashboard-routes.js"; import { createDeviceRoutes } from "./device-routes.js"; import type { DisplayHostAssets } from "./display-host-assets.js"; import { createGrantsRoutes } from "./grants-routes.js"; import { type ApiHealthOptions, createHealthRoutes } from "./health-routes.js"; import { createModuleConfigurationRoutes } from "./module-configuration-routes.js"; import { mountModuleDisplayRoutes } from "./module-display-routes.js"; import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js"; import { createModuleLifecycleRoutes } from "./module-lifecycle-routes.js"; import { mountModuleRealtimeRoutes } from "./module-realtime-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 started runtime's immutable module-owned realtime registry. Required explicitly even when * empty, for the same reason as module routes: application composition never guesses what ran. */ readonly moduleRealtime: ModuleRealtimeRegistry; /** 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; /** * Managed display devices. Optional so every existing composition — and every test that has no * interest in devices — builds unchanged; when it is absent no pairing route is mounted, no * device cookie is ever read, and a device principal simply cannot come into being. */ readonly devices?: { readonly store: DeviceStore; /** Whether the deployment is reached over https, so the device cookies can assert `Secure` * without becoming unusable on a plaintext development origin. */ readonly secureCookies: boolean; }; /** Surface-owned display host assets, read once by production startup. Optional only when the * eligible publication registry contains no display entrypoint; route composition rejects an * inconsistent display-bearing registry synchronously. */ readonly displayHostAssets?: DisplayHostAssets; /** * The platform's operational module lifecycle: the store holding which modules are enabled, and the * boot snapshot describing what this process actually discovered, resolved, and started (ADR 0028). * Required rather than defaulted, like every other module option here — a caller exercising no * modules says so with an empty snapshot, never falls through to an implicit one. * * The snapshot must come from the same boot as `moduleRoutes` and `moduleUi`; that is what makes * "running" an observation of this process rather than a prediction. See * `./module-lifecycle-routes.js`. */ readonly moduleLifecycle: { readonly snapshot: ModuleLifecycleSnapshot; readonly store: ModuleLifecycleStore; }; /** * The platform's module configuration resource (ADR 0029): the discovered inventory whose * manifests declare what may be configured, the settings this process resolved at boot (used only * to compute `restartRequired`), the deployment's secret source, and the store. * * Required rather than defaulted, like every other module option here. `secrets` is explicit for * the same reason: a caller that supplies no module secrets says so, and a module requiring one is * then reported `secret_missing` rather than silently starting without it. */ readonly moduleConfiguration: { readonly discoveredModules: readonly DiscoveredModule[]; readonly bootSettings: ModuleConfigurationSettings; readonly secrets: ModuleSecretSource; readonly store: ModuleConfigurationStore; /** The per-module frozen snapshots the started runtime is actually using — the identical map * `startDiscoveredModuleRuntime` received, so a module's HTTP handler and its lifecycle hooks * read the same object rather than two independently resolved values. */ readonly snapshots: ModuleConfigurationLookup; }; /** * The platform's module object storage (ADR 0030): the per-module scoped facades the runtime * started with, and whether a host provider is configured at all. * * Required rather than defaulted, like every other module option here — a caller exercising no * storage says so with an empty map and `providerConfigured: false`, never falls through to an * implicit one. Nothing here carries an endpoint, a region, or a credential: those never leave * Core's provider. */ readonly moduleStorage: { readonly snapshots: ModuleStorageLookup; readonly providerConfigured: boolean; }; /** 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; /** * The built Dashboard's two files, read once by production startup (ADR 0044). Required rather * than optional, unlike `displayHostAssets`: every installation has a Dashboard, and a missing * shell build fails startup instead of leaving this origin answering `/` with a 404. See * `./dashboard-host-routes.js`. */ readonly dashboardHostAssets: DashboardHostAssets; /** * The readiness inputs behind the unauthenticated health routes (ADR 0044). Required rather than * defaulted, like every other dependency here: a composition that cannot reach a database says so * with a check that rejects, never falls through to an implicitly healthy one. `checkDatabase` is * the bare query; the routes themselves bound and serialize it. See `./health-routes.js`. */ readonly health: ApiHealthOptions; 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); } const EMAIL_SIGN_IN_PATH = "/api/auth/sign-in/email"; const EMAIL_SIGN_IN_SUCCESS_BODY = JSON.stringify({ ok: true }); /** * Better Auth publishes general-purpose session-management endpoints even when an application has * no client for them. These four can return the raw bearer-equivalent session token with the * pinned configuration: get/list/update return a session record; change-password returns a newly * minted token when asked to revoke other sessions. Ekklesia deliberately exposes only its narrow * `/api/v1/principal` projection and has no native session/password-management browser UI, so the * supported method/path pairs are closed at the outer HTTP boundary before Better Auth executes. */ const TOKEN_BEARING_AUTH_ENDPOINT_METHODS: Readonly> = Object.freeze({ "/api/auth/change-password": Object.freeze(["POST"]), "/api/auth/get-session": Object.freeze(["GET", "POST"]), "/api/auth/list-sessions": Object.freeze(["GET"]), "/api/auth/update-session": Object.freeze(["POST"]), }); function decodedRequestPathname(request: Request): string | undefined { const encodedPathname = new URL(request.url).pathname; try { // URL.pathname intentionally preserves percent escapes. Decode exactly once before comparing, // matching the maximum decoding a downstream HTTP router may safely perform; malformed // encodings are denied rather than delegated through an ambiguous parser boundary. return decodeURIComponent(encodedPathname); } catch { return undefined; } } function isDisabledTokenBearingAuthRequest(request: Request): boolean { const pathname = decodedRequestPathname(request); if (pathname === undefined) { return true; } // Better Auth is configured not to skip trailing slashes, but treating trailing-slash spellings // as the same denied resource prevents a router-option change from opening a bypass. This does // not collapse interior duplicate slashes or otherwise broaden path normalization. const canonicalPath = pathname.replace(/\/+$/u, "") || "/"; return TOKEN_BEARING_AUTH_ENDPOINT_METHODS[canonicalPath]?.includes(request.method) ?? false; } /** * Better Auth's native successful email sign-in response includes the newly created session token * and the complete user object in its JSON body even though the browser already received the * session through an HttpOnly cookie. The browser boundary must never make either value visible to * application JavaScript. Keep this deliberately narrower than a general Better Auth response * adapter: only the exact supported POST endpoint and a body-bearing successful response are * normalized. Every sign-in failure and every other delegated auth endpoint remains Better Auth's * native contract; the explicitly denied token-bearing endpoints above are never delegated. */ function browserSafeAuthResponse(request: Request, response: Response): Response { const headers = new Headers(response.headers); headers.set("Cache-Control", "no-store"); if ( request.method === "POST" && decodedRequestPathname(request) === EMAIL_SIGN_IN_PATH && response.ok && response.status !== 204 && response.status !== 205 ) { // The upstream length describes the deliberately discarded token/user JSON. Retaining it could // truncate or stall the replacement payload once an HTTP adapter serializes this response. headers.delete("Content-Length"); headers.set("Content-Type", "application/json; charset=UTF-8"); return new Response(EMAIL_SIGN_IN_SUCCESS_BODY, { headers, status: response.status, statusText: response.statusText, }); } return new Response(response.body, { headers, status: response.status, statusText: response.statusText, }); } /** * 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); // This gate intentionally precedes the body limiter and the generic Better Auth delegate. A // denied POST neither reads an attacker-controlled body nor executes any identity-runtime code; // it always has the platform's fixed, non-secret 404 shape. Query strings do not affect the // pathname comparison, and the CORS middleware above still applies the exact trusted-origin // policy to the response. app.on(["POST", "GET"], "/api/auth/*", async (c, next) => { if (!isDisabledTokenBearingAuthRequest(c.req.raw)) { return next(); } c.header("Cache-Control", "no-store"); return c.json({ code: "NOT_FOUND" }, 404); }); 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. return browserSafeAuthResponse(c.req.raw, response); } 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); } }); // Unauthenticated liveness/readiness under a platform-owned sibling prefix: like every route below, // it is mounted before module routes and never inside `/api/v1/modules/`, which installed modules // own entirely. See `./health-routes.js`. app.route("/", createHealthRoutes(options.health)); // The Dashboard page and its one script, at exact literal paths outside `/api/` (ADR 0044). They // grant no authority and resolve no principal; the page asks `/api/v1/principal` itself. See // `./dashboard-host-routes.js`. app.route("/", createDashboardHostRoutes(options.dashboardHostAssets)); 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, }), ); // A separate, chrome-free browser host. Exact page and descriptor routes are derived from the // same eligible/running publication registry as artifact bytes; a display-only module never // enters Dashboard navigation, and no request value can select a file or permission. mountModuleDisplayRoutes(app, { evaluator: options.authorization.evaluator, moduleUi: options.moduleUi, registry: options.authorization.registry, runtime, ...(options.displayHostAssets ? { assets: options.displayHostAssets } : {}), }); if (options.devices) { // Host-owned device identity. Siblings of `/api/v1/modules/...`, reachable by no module, and // mounted only when the deployment actually composed device support. app.route( "/", createDeviceRoutes({ devices: options.devices.store, evaluator: options.authorization.evaluator, moduleUi: options.moduleUi, registry: options.authorization.registry, runtime, secureCookies: options.devices.secureCookies, trustedOrigins: allowedOrigins, }), ); } // 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, }), ); // Mounted before module routes and, critically, under its own sibling prefix: `/api/v1/modules/` // belongs entirely to installed modules, so the platform's own lifecycle resource lives beside it // rather than inside it. See `./module-lifecycle-routes.js`. app.route( "/", createModuleLifecycleRoutes({ evaluator: options.authorization.evaluator, registry: options.authorization.registry, runtime, configuration: { secrets: options.moduleConfiguration.secrets, store: options.moduleConfiguration.store, }, storage: { providerConfigured: options.moduleStorage.providerConfigured }, snapshot: options.moduleLifecycle.snapshot, store: options.moduleLifecycle.store, trustedOrigins: allowedOrigins, }), ); // Same sibling-prefix rule as the lifecycle resource above: `/api/v1/modules/` belongs entirely // to installed modules. See `./module-configuration-routes.js`. app.route( "/", createModuleConfigurationRoutes({ bootSettings: options.moduleConfiguration.bootSettings, discoveredModules: options.moduleConfiguration.discoveredModules, evaluator: options.authorization.evaluator, registry: options.authorization.registry, runtime, secrets: options.moduleConfiguration.secrets, store: options.moduleConfiguration.store, trustedOrigins: allowedOrigins, }), ); // A sibling platform-owned prefix, never nested under `/api/v1/modules/` where installed modules // own every path. One exact route per runtime-published channel, authorized before subscription. mountModuleRealtimeRoutes(app, { evaluator: options.authorization.evaluator, realtime: options.moduleRealtime, registry: options.authorization.registry, runtime, }); mountModuleHttpRoutes(app, options.moduleRoutes, { configurations: options.moduleConfiguration.snapshots, storages: options.moduleStorage.snapshots, evaluator: options.authorization.evaluator, registry: options.authorization.registry, realtime: options.moduleRealtime, 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; }