import type { Socket } from "node:net"; import path from "node:path"; import { pathToFileURL } from "node:url"; import type { ModuleRuntimeServices } from "@ekklesia/contracts"; import { createAuthorizationEvaluator, createAuthorizationStore, } from "@ekklesia/core/authorization"; import { createDashboardStore } from "@ekklesia/core/dashboard"; import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity"; import { createModuleUiPublicationRegistry, type DiscoveredModule, type StartedModuleRuntime, startDiscoveredModuleRuntime, } from "@ekklesia/core/modules"; import { serve, type ServerType } from "@hono/node-server"; import type { Hono } from "hono"; import { createApiApp } from "./app.js"; import { type ClientAddressConfigInput, type ClientAddressResolver, createClientAddressResolver, wrapFetchWithClientAddress, } from "./client-address.js"; import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js"; import { type ApiDatabaseHandle, createApiDatabase } from "./database.js"; import { prepareStartupModuleInventory } from "./platform-permissions.js"; const connectionTimeoutMs = 10_000; const operationTimeoutMs = 10_000; export interface NodeServerTimeouts { readonly headersTimeoutMs: number; readonly requestTimeoutMs: number; readonly keepAliveTimeoutMs: number; /** * How often Node re-scans open connections for a header/request timeout violation * (`http.Server`'s `connectionsCheckingInterval`). This is not merely a performance knob: Node * enforces `headersTimeout`/`requestTimeout` only when this periodic check runs, and its own * default (30s) is verified (`node --version` 24.15.0, direct `http.createServer` reproduction) * to let a violating connection sit for up to a full additional interval past its actual * timeout before Node ever closes it — so leaving this at Node's default would silently widen * both timeouts above by up to 30s in the worst case. Kept small relative to `headersTimeoutMs` * so a violation is detected promptly after it actually elapses. */ readonly connectionsCheckingIntervalMs: number; } /** * Finite Node HTTP server timeouts for this JSON-only API. Node's own defaults * (`headersTimeout`: 60s, `requestTimeout`: 300s) are generous enough to let a slow-header or * slow-body connection hold a socket for minutes with no reverse proxy in front; these values bound * that exposure while remaining comfortable for the small JSON payloads this API ever accepts (see * `./body-limits.js`). `headersTimeoutMs` must not exceed `requestTimeoutMs` — Node validates this * relationship itself at server construction — so headers alone may take up to 20s, and the * complete request (headers plus body) up to 30s, before Node itself responds `408` and closes the * connection without ever invoking a handler (subject to `connectionsCheckingIntervalMs`'s own * detection latency, above). `keepAliveTimeoutMs` matches Node's own default (5s) made explicit and * typed rather than left implicit; it bounds only the idle window between requests on a reused * connection, never an in-flight one. */ const PRODUCTION_NODE_SERVER_TIMEOUTS: NodeServerTimeouts = Object.freeze({ connectionsCheckingIntervalMs: 5_000, headersTimeoutMs: 20_000, keepAliveTimeoutMs: 5_000, requestTimeoutMs: 30_000, }); /** * The two fixed operational lines a transport-level client failure may produce. Neither ever * carries the remote address, request path, query, headers, cookies, body, forwarding chain, the * caught error's own message, or the malformed bytes that caused it — an attacker controls all of * those, and a parse failure's message in particular is derived directly from the rejected input. */ const NODE_SERVER_TIMEOUT_LOG_MESSAGE = "[server] A connection was closed for exceeding a configured header/request timeout."; const NODE_SERVER_CLIENT_ERROR_LOG_MESSAGE = "[server] A connection was closed after a malformed or oversized client request."; /** * Node's own documented default `clientError` responses, reproduced byte-for-byte (verified * directly against the pinned Node 24 runtime by driving each case through a real socket with no * listener attached). Registering *any* `clientError` listener suppresses that default entirely — * `_http_server.js` only falls back when `server.emit("clientError", ...)` returns `false` — so a * listener that merely observes would silently turn every 408/400/431 into a bare connection reset. * Reproducing the exact bytes is what lets the platform log the event without changing what a * caller actually receives. */ const NODE_CLIENT_ERROR_RESPONSES: Readonly> = Object.freeze({ ERR_HTTP_REQUEST_TIMEOUT: "HTTP/1.1 408 Request Timeout\r\nConnection: close\r\n\r\n", HPE_HEADER_OVERFLOW: "HTTP/1.1 431 Request Header Fields Too Large\r\nConnection: close\r\n\r\n", }); const NODE_CLIENT_ERROR_DEFAULT_RESPONSE = "HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n"; /** * Mirrors Node's own `socketOnError` fallback exactly: write the code-appropriate response only * when the socket is still writable *and* no response has already begun for the in-flight request, * then destroy the socket. The `_httpMessage` guard is what prevents a second, corrupting status * line when a handler already responded and only the (unread) request body subsequently timed out. */ function writeNodeClientErrorResponse(error: NodeJS.ErrnoException, socket: Socket): void { const inFlightResponse = (socket as Socket & { _httpMessage?: { headersSent?: boolean } }) ._httpMessage; const alreadyResponding = inFlightResponse?.headersSent === true; if (socket.writable && !alreadyResponding) { const code = typeof error.code === "string" ? error.code : ""; socket.write(NODE_CLIENT_ERROR_RESPONSES[code] ?? NODE_CLIENT_ERROR_DEFAULT_RESPONSE); } socket.destroy(); } export interface RunningApi { readonly app: Hono; readonly server: ServerType; shutdown(): Promise; } export interface StartApiOptions { /** Test-only escape hatch: never set by the executable entry point. See * `IdentityRateLimitOverride` for why production must leave this unset. */ readonly rateLimitOverride?: IdentityRateLimitOverride; /** Test-only escape hatch: overrides the directory module discovery scans. Never set by the * executable entry point. See `prepareStartupModuleInventory`. */ readonly modulesDirectoryOverride?: string; /** Test-only escape hatch: supplies an already-discovered module list directly, skipping * filesystem discovery entirely — this exact list drives both permission composition and module * runtime startup. Never set by the executable entry point. See * `prepareStartupModuleInventory`. */ readonly discoveredModulesOverride?: readonly DiscoveredModule[]; /** Test-only escape hatch: narrows the Node header/request/keep-alive timeouts so a transport * test can prove real timeout behavior deterministically without waiting out the production * values. Never set by the executable entry point. */ readonly nodeServerTimeoutOverride?: Partial; /** Test-only escape hatch: narrows the graceful HTTP drain deadline * (`HTTP_DRAIN_TIMEOUT_MS`) so a forced close can be proven in milliseconds. Never set by the * executable entry point. */ readonly drainTimeoutMsOverride?: number; /** Test-only escape hatch: narrows the per-module stop/rollback hook deadline * (`MODULE_STOP_HOOK_TIMEOUT_MS`). Never set by the executable entry point. */ readonly moduleHookTimeoutMsOverride?: number; } async function safeCloseDatabase(handle: ApiDatabaseHandle): Promise { try { await handle.close(); } catch { // Cleanup failures are swallowed here deliberately: they must never mask a startup failure // that is already propagating, and there is no raw driver detail safe to surface. } } /** Same swallow-on-cleanup-path rule as `safeCloseDatabase`, used only while unwinding a startup * failure that already occurred after the module runtime started — never during normal shutdown, * where a module stop failure remains an observable result instead (see `startApi`'s `shutdown`). */ async function safeStopModuleRuntime(moduleRuntime: StartedModuleRuntime): Promise { try { await moduleRuntime.stop(); } catch { // See safeCloseDatabase: never mask the startup failure already propagating. } } /** * The platform's fixed graceful-drain deadline. In-flight HTTP work has this long to finish after * the listener stops accepting new connections; after it, connections are closed so process * shutdown stays bounded. Not configurable in this slice. */ export const HTTP_DRAIN_TIMEOUT_MS = 30_000; /** * A shutdown that did not complete cleanly. Carries only fixed booleans and the already-sanitized * module-runtime error — never a request, socket, URL, credential, SQL, or stack detail — so an * operator can see *which* stages failed without any of it becoming a disclosure channel. */ export class ApiShutdownError extends Error { readonly drainTimedOut: boolean; readonly moduleStopFailed: boolean; readonly moduleStopError: unknown; constructor(drainTimedOut: boolean, moduleStopError: unknown) { super("The API did not shut down cleanly."); this.name = "ApiShutdownError"; this.drainTimedOut = drainTimedOut; this.moduleStopFailed = moduleStopError !== undefined; this.moduleStopError = moduleStopError; this.stack = `${this.name}: ${this.message}`; Object.freeze(this); } } /** * Stops accepting new connections, then waits up to `timeoutMs` for existing HTTP work to finish. * * At the deadline it closes idle and then still-active connections using Node's own supported * public `Server` APIs (`closeIdleConnections`/`closeAllConnections`, both called only after * `server.close()`), and stops waiting for the close callback. No private socket or server field is * touched. * * Force-closing a connection ends the transport. It does **not** prove that arbitrary in-process * handler JavaScript was canceled — a handler promise may still be running and may still settle. * What is guaranteed is that shutdown no longer waits for it, and that the pool closes afterwards, * so its late database work fails. */ /** `ServerType` is a union that also covers HTTP/2 servers, which do not expose these two methods. * `serve()` here always creates a plain `http.Server`, which does; narrowing with a real capability * check keeps that honest instead of asserting a cast. */ interface ConnectionClosingServer { closeAllConnections(): void; closeIdleConnections(): void; } function supportsConnectionClosing( server: ServerType, ): server is ServerType & ConnectionClosingServer { const candidate = server as Partial; return ( typeof candidate.closeIdleConnections === "function" && typeof candidate.closeAllConnections === "function" ); } async function drainHttpServer( server: ServerType, timeoutMs: number, ): Promise<"drained" | "forced"> { let timer: ReturnType | undefined; const closed = new Promise((resolve) => { server.close(() => resolve()); }); try { const outcome = await Promise.race([ closed.then(() => "drained" as const), new Promise<"forced">((resolve) => { timer = setTimeout(() => resolve("forced"), timeoutMs); }), ]); if (outcome === "drained") { return "drained"; } if (supportsConnectionClosing(server)) { server.closeIdleConnections(); server.closeAllConnections(); } return "forced"; } finally { clearTimeout(timer); // The close callback may still fire later; nothing awaits it, and it carries no rejection. } } /** * Binds the Node HTTP server. Two platform-owned transport concerns are applied at this exact * boundary, below Hono entirely: `wrapFetchWithClientAddress` resolves and sanitizes the caller's * address from the real socket peer before any route (including Better Auth's own rate limiter) * ever sees the request (see `./client-address.js`), and `serverOptions` gives Node finite * header/request/keep-alive timeouts so a slow-header or slow-body connection cannot hold a socket * indefinitely. A connection Node itself closes for exceeding one of these timeouts never reaches * `app.fetch` and never receives a second response — both are Node's own documented behavior for * `headersTimeout`/`requestTimeout`, not something this function re-implements. */ function bindServer( app: Hono, host: string, port: number, clientAddressResolver: ClientAddressResolver, timeouts: NodeServerTimeouts, ): Promise { return new Promise((resolve, reject) => { let settled = false; const server = serve( { fetch: wrapFetchWithClientAddress(app.fetch, clientAddressResolver), hostname: host, port, serverOptions: { connectionsCheckingInterval: timeouts.connectionsCheckingIntervalMs, headersTimeout: timeouts.headersTimeoutMs, keepAliveTimeout: timeouts.keepAliveTimeoutMs, requestTimeout: timeouts.requestTimeoutMs, }, }, () => { if (!settled) { settled = true; resolve(server); } }, ); // `headersTimeout`/`requestTimeout` do NOT surface as the server's general `'timeout'` event — // that event only fires for `server.setTimeout()`'s idle-socket timer, which this server never // arms. Verified directly against the pinned Node 24 runtime by driving a real slow-header and // slow-body connection through a real socket: the only server-level event either produces is // `'clientError'`, carrying `err.code === "ERR_HTTP_REQUEST_TIMEOUT"`. Observing the failure // therefore has to happen here, and — because registering a listener suppresses Node's own // default response — this handler must reproduce that default itself. server.on("clientError", (error: NodeJS.ErrnoException, socket: Socket) => { console.error( error.code === "ERR_HTTP_REQUEST_TIMEOUT" ? NODE_SERVER_TIMEOUT_LOG_MESSAGE : NODE_SERVER_CLIENT_ERROR_LOG_MESSAGE, ); writeNodeClientErrorResponse(error, socket); }); server.once("error", () => { if (!settled) { settled = true; reject(new Error("The API server failed to start listening.")); } }); }); } /** * Explicit runtime composition, in a fixed order: * * 1. Discover every installed module's manifest exactly once and compose the one immutable * platform permission registry from Core's own declarations plus every module's * (`prepareStartupModuleInventory`, `./platform-permissions.js`). * 2. Construct the one shared PostgreSQL pool (identity, authorization, and the module database * facade each get a typed Drizzle view over it — see `./database.js`). * 3. Construct the frozen `ModuleRuntimeServices` (currently just `database`) from that same pool, * then the Core identity runtime, authorization store, and evaluator. * 4. Start the module runtime from that exact same discovered inventory * (`startDiscoveredModuleRuntime`) — never rediscovering modules a second time — passing it the * same services object every module's `start`/`stop` hook receives. * 5. Create the Hono application, mounting the started module runtime's published routes and * passing the exact same services object to every module HTTP handler. * 6. Bind the Node HTTP server. * * Discovery and permission composition happen before any pool, Better Auth instance, module * entrypoint import, or HTTP server exists, so a discovery or composition failure — an invalid * manifest, a dependency-graph problem, a module claiming the reserved `core.*` permission * namespace, or a duplicate permission declaration — leaves nothing to close: it propagates * directly, and startup never falls back to a Core-only registry. Core infrastructure (the pool, * module database facade, identity, authorization) exists before any executable module lifecycle * code runs, and every module route is published only after the entire module runtime has * registered and started successfully, so no request can ever reach a module before every module * has started. Returns a handle with an idempotent `shutdown()` so both the executable entry point * and tests can stop the server, the module runtime, and the shared pool deterministically. Never * applies migrations, generates schema, or issues a module database query merely because modules * were discovered — the database must already be migrated through `pnpm migrations:apply`. */ export async function startApi( config: ApiRuntimeConfig, options: StartApiOptions = {}, ): Promise { const inventory = await prepareStartupModuleInventory({ ...(options.modulesDirectoryOverride === undefined ? {} : { modulesDirectoryOverride: options.modulesDirectoryOverride }), ...(options.discoveredModulesOverride === undefined ? {} : { discoveredModulesOverride: options.discoveredModulesOverride }), }); const databaseHandle = createApiDatabase(config.databaseUrl, { connectionTimeoutMs, operationTimeoutMs, // The one validated transport policy reaches this runtime's single physical pool here; the // identity, authorization, and module Drizzle views are all typed views over that same pool, // so TLS never introduces a second connection. transport: config.databaseTransport, }); let services: ModuleRuntimeServices; let runtime: ReturnType; let authorizationStore: ReturnType; try { services = Object.freeze({ database: databaseHandle.module }); runtime = createIdentityRuntime({ config: config.identity, db: databaseHandle.identity, ...(options.rateLimitOverride ? { rateLimitOverride: options.rateLimitOverride } : {}), }); authorizationStore = createAuthorizationStore(databaseHandle.authorization); } catch (error) { await safeCloseDatabase(databaseHandle); throw error; } const evaluator = createAuthorizationEvaluator({ grants: authorizationStore, platformOwners: authorizationStore, }); // Module load/register/start failures use Core's own existing rollback (already-started // modules are stopped, in reverse order, before this rejects) — there is no separate runtime // handle to stop here, only the pool this failure leaves open. let moduleRuntime: StartedModuleRuntime; try { moduleRuntime = await startDiscoveredModuleRuntime(inventory.modules, services, { ...(options.moduleHookTimeoutMsOverride === undefined ? {} : { hookTimeoutMs: options.moduleHookTimeoutMsOverride }), }); } catch (error) { await safeCloseDatabase(databaseHandle); throw error; } let app: Hono; try { app = createApiApp({ authorization: { evaluator, registry: inventory.permissionRegistry, store: authorizationStore, }, moduleRoutes: moduleRuntime.routes, moduleServices: services, // Built from the exact same discovered inventory, and only once the module runtime has // started — module startup is all-or-nothing, so every module in this list is a running // module, and a module that failed to load, register, or start never reaches this line at // all. Modules declaring no `entrypoints.ui` are simply absent from the registry. moduleUi: createModuleUiPublicationRegistry(inventory.modules), // A typed view over the same single pool as identity, authorization, and the module facade. dashboard: createDashboardStore(databaseHandle.dashboard), runtime, trustedOrigins: config.identity.trustedOrigins, }); } catch (error) { await safeStopModuleRuntime(moduleRuntime); await safeCloseDatabase(databaseHandle); throw error; } const clientAddressResolver = createClientAddressResolver(config.clientAddress); const timeouts: NodeServerTimeouts = { ...PRODUCTION_NODE_SERVER_TIMEOUTS, ...options.nodeServerTimeoutOverride, }; let server: ServerType; try { server = await bindServer(app, config.host, config.port, clientAddressResolver, timeouts); } catch (error) { await safeStopModuleRuntime(moduleRuntime); await safeCloseDatabase(databaseHandle); throw error; } // Memoizes one shared promise rather than a boolean flag: a boolean only prevents a *second* // cleanup from starting, but a caller that arrives while the first cleanup is still in flight // would previously get an already-resolved `undefined` immediately, before the listener had // actually drained or the pool had actually closed. Storing the promise itself means every // caller — concurrent or sequential — awaits the exact same drain-then-close operation and // observes it complete (or fail) exactly once. Order matters, and is now additionally // load-bearing for the module database facade: the HTTP listener is drained and closed first so // no new or in-flight request can reach a module handler while the module runtime is stopping; // then the module runtime stops — its stop hooks may still use `services.database`, which is // safe because the pool is not yet closed; only then does the shared pool close, so no module // code can run against it afterward. Unlike the startup-cleanup paths above, a module-stop // failure here is not swallowed — it is a genuine shutdown result a caller should observe — but // database cleanup still always runs first, so a stop failure never leaves the pool open. const drainTimeoutMs = options.drainTimeoutMsOverride ?? HTTP_DRAIN_TIMEOUT_MS; let shutdownPromise: Promise | undefined; const shutdown = (): Promise => { if (!shutdownPromise) { shutdownPromise = (async () => { const drainOutcome = await drainHttpServer(server, drainTimeoutMs); let moduleStopError: unknown; try { await moduleRuntime.stop(); } catch (error) { moduleStopError = error; } await safeCloseDatabase(databaseHandle); // Every stage above ran regardless of what the earlier ones did, so a drain timeout or a // module-stop failure can never leave the pool open. Both failures are reported together, // as fixed metadata: a forced drain is a genuine shutdown failure, not a success. if (drainOutcome === "forced" || moduleStopError) { throw new ApiShutdownError( drainOutcome === "forced", moduleStopError === undefined ? undefined : moduleStopError, ); } })(); } return shutdownPromise; }; return Object.freeze({ app, server, shutdown }); } /** * Wires `SIGINT`/`SIGTERM` to `running.shutdown()` and only then exits — extracted from `main()` * so it can be exercised directly in tests with a fake `running`/`exit`, without sending a real * process signal or invoking the real `process.exit` (which would terminate the test runner * itself). Production composition (`main()`, below) calls this with real `process.exit`. */ export function installShutdownSignalHandlers( running: Pick, exit: (code: number) => void = (code) => process.exit(code), ): void { let signalReceived = false; const handleSignal = (): void => { if (signalReceived) { return; } signalReceived = true; running .shutdown() .then(() => exit(0)) .catch(() => exit(1)); }; process.on("SIGINT", handleSignal); process.on("SIGTERM", handleSignal); } function readClientAddressInputFromEnvironment(): ClientAddressConfigInput | undefined { const mode = process.env.EKKLESIA_CLIENT_ADDRESS_MODE; if (mode === undefined) { // Omitted entirely: `createClientAddressConfig` defaults to direct mode. Reading `header`/ // `trustedProxies` only when `mode` is actually set keeps an operator from accidentally // half-configuring proxy mode by setting only one of the three variables. return undefined; } const trustedProxiesValue = process.env.EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES; return { header: process.env.EKKLESIA_CLIENT_ADDRESS_HEADER, mode, trustedProxies: trustedProxiesValue ?.split(",") .map((entry) => entry.trim()) .filter((entry) => entry.length > 0), }; } function readConfigFromEnvironment(): { readonly input: Parameters[0]; readonly environment: "development" | "production"; } { const trustedOriginsValue = process.env.EKKLESIA_IDENTITY_TRUSTED_ORIGINS; const trustedOrigins = trustedOriginsValue ?.split(",") .map((origin) => origin.trim()) .filter((origin) => origin.length > 0); const clientAddress = readClientAddressInputFromEnvironment(); return { environment: process.env.NODE_ENV === "development" ? "development" : "production", input: { ...(clientAddress === undefined ? {} : { clientAddress }), databaseTransport: { ...(process.env.EKKLESIA_DATABASE_CA === undefined ? {} : { certificateAuthority: process.env.EKKLESIA_DATABASE_CA }), ...(process.env.EKKLESIA_DATABASE_TLS_MODE === undefined ? {} : { mode: process.env.EKKLESIA_DATABASE_TLS_MODE }), }, databaseUrl: process.env.DATABASE_URL, host: process.env.EKKLESIA_API_HOST, identity: { baseUrl: process.env.EKKLESIA_IDENTITY_BASE_URL, publicSignupEnabled: process.env.EKKLESIA_PUBLIC_SIGNUP_ENABLED, secret: process.env.EKKLESIA_IDENTITY_SECRET, trustedOrigins, }, port: process.env.EKKLESIA_API_PORT, }, }; } export async function main(): Promise { const { environment, input } = readConfigFromEnvironment(); const config = createApiRuntimeConfig(input, { environment }); const running = await startApi(config); const address = running.server.address(); const boundPort = address && typeof address === "object" ? address.port : config.port; console.log(`Ekklesia API listening on ${config.host}:${boundPort}`); installShutdownSignalHandlers(running); } function isDirectExecution(): boolean { const entrypoint = process.argv[1]; return ( entrypoint !== undefined && import.meta.url === pathToFileURL(path.resolve(entrypoint)).href ); } if (isDirectExecution()) { main().catch(() => { console.error("Ekklesia API failed to start."); process.exitCode = 1; }); }