import type { Socket } from "node:net"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { EMPTY_MODULE_SECRET_SOURCE, type ModuleRuntimeServices, type ModuleSecretSource, } 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 { createEnvironmentModuleSecretSource, createModuleConfigurationStore, createModuleLifecycleStore, createModuleUiPublicationRegistry, type DiscoveredModule, type ModuleConfigurationSettings, type ModuleEligibilityResolution, resolveModuleEligibility, type StartedModuleRuntime, startDiscoveredModuleRuntime, } from "@ekklesia/core/modules"; import { createModuleStorageLookup, createS3StorageProvider, type StorageProvider, } from "@ekklesia/core/storage"; import { type ServerType, serve } from "@hono/node-server"; import type { Hono } from "hono"; import { createApiApp } from "./app.js"; import { loadDisplayHostAssets } from "./display-host-assets.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; /** * Where deployment-supplied module secrets come from (ADR 0029) — the one *production* dependency * in this options object rather than a test-only escape hatch. * * It is a dependency rather than a field on `ApiRuntimeConfig` because it is a lookup, not a * validated value, and because the set of secrets depends on which modules are discovered — which * is not known when configuration is validated. The executable entry point supplies an * environment-backed source; omitting it supplies nothing, so a module declaring a required secret * is reported `secret_missing` rather than silently starting without it. */ readonly moduleSecrets?: ModuleSecretSource; } 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`, for the storage provider's own * connection pool. */ async function safeCloseStorage(provider: StorageProvider | undefined): Promise { if (!provider) { return; } try { await provider.close(); } catch { // See safeCloseDatabase: never mask a failure that is already propagating. } } /** 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. Read the stored module enablement configuration and resolve, from that exact same discovered * inventory, which modules are eligible to run (`resolveModuleEligibility`). * 5. Start the module runtime from the eligible subset (`startDiscoveredModuleRuntime`) — never * rediscovering modules a second time — passing it the same services object every module's * `start`/`stop` hook receives. * 6. Create the Hono application, mounting the started module runtime's published routes, publishing * UI for that same eligible set, and passing the exact same services object to every module HTTP * handler. * 7. 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. A module that is disabled, or blocked by a disabled required dependency, is never * imported at all: no entrypoint evaluation, no lifecycle hook, no route, no capability, and no * published UI artifact — there is no per-request "is this module enabled?" check anywhere, because * there is nothing mounted to check. * * Step 4 makes startup depend on a reachable, migrated database. If the enablement configuration * cannot be read, startup fails closed with Core's sanitized `MODULE_LIFECYCLE_READ_FAILED` rather * than starting every discovered module: the platform cannot know what an administrator decided, and * starting a module they disabled is worse than not starting at all. * * 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, }); // Read the administrator's enablement decisions and resolve which discovered modules may actually // start, before a single module entrypoint is imported. A failure here fails startup rather than // falling back to "start everything": the platform cannot know what it was told, and starting a // module an administrator disabled is worse than not starting at all. // The host's object-storage provider, constructed from validated configuration before any module // runs — and **without touching the network**. Building the client opens no connection, so a // storage outage can never stop the platform from booting; a bucket is ensured lazily on first // use instead (ADR 0030). An installation with no storage configured gets `undefined` here, which // is a complete, valid deployment. const storageProvider = config.storage ? createS3StorageProvider(config.storage) : undefined; const moduleLifecycleStore = createModuleLifecycleStore(databaseHandle.moduleLifecycle); const moduleConfigurationStore = createModuleConfigurationStore( databaseHandle.moduleConfiguration, ); const moduleSecrets = options.moduleSecrets ?? EMPTY_MODULE_SECRET_SOURCE; let eligibility: ModuleEligibilityResolution; let moduleConfigurationSettings: ModuleConfigurationSettings; try { // Both axes are read before a single module entrypoint is imported, and resolved together in // one pass (ADR 0029): a module is eligible only if an administrator enabled it, its own // required configuration is satisfiable, and every module it requires is eligible too. The pass // also produces each eligible module's frozen configuration snapshot, so the configuration that // made a module eligible is the identical object it later receives. // Read sequentially rather than concurrently, deliberately: these are two tiny queries, and // racing them would make the reported failure depend on which rejected first. An operator // debugging a broken deployment must get the same error code every time. const lifecycleConfiguration = await moduleLifecycleStore.readConfiguration(); const settings = await moduleConfigurationStore.readSettings(); moduleConfigurationSettings = settings; eligibility = resolveModuleEligibility( inventory.modules, lifecycleConfiguration, { secrets: moduleSecrets, settings }, { providerConfigured: storageProvider !== undefined }, ); } catch (error) { await safeCloseStorage(storageProvider); await safeCloseDatabase(databaseHandle); throw error; } // One scoped facade per eligible module, over the exact buckets that module's own manifest // declares. Built from the eligible set so a blocked module never receives storage at all. const moduleStorages = createModuleStorageLookup( storageProvider, config.storage?.bucketPrefix ?? "", eligibility.eligibleModules.map((module) => ({ logicalBucketIds: module.manifest.storage.buckets.map((bucket) => bucket.id), moduleId: module.manifest.id, })), ); // This snapshot is inert metadata only: construction reads no artifact and executes no module. // It lets the API prove that a display host build is available *before* importing any eligible // module code. The snapshot is not exposed anywhere unless the complete runtime subsequently // starts and application composition succeeds. const moduleUi = createModuleUiPublicationRegistry(eligibility.eligibleModules); let displayHostAssets: Awaited> | undefined; try { displayHostAssets = moduleUi.listDisplays().length === 0 ? undefined : await loadDisplayHostAssets(); } catch (error) { await safeCloseStorage(storageProvider); await safeCloseDatabase(databaseHandle); throw error; } // 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. // // Only eligible modules are started, and startup remains all-or-nothing across *that* set: an // ineligible module is never imported, so its `register`/`start` hooks never run, it publishes no // routes and no capabilities, and it cannot take startup down. Eligibility filtering preserves // discovery's topological order, so a started module's required dependencies still precede it. let moduleRuntime: StartedModuleRuntime; try { moduleRuntime = await startDiscoveredModuleRuntime(eligibility.eligibleModules, services, { configurations: eligibility.configurations, storages: moduleStorages, ...(options.moduleHookTimeoutMsOverride === undefined ? {} : { hookTimeoutMs: options.moduleHookTimeoutMsOverride }), }); } catch (error) { await safeCloseStorage(storageProvider); await safeCloseDatabase(databaseHandle); throw error; } let app: Hono; try { app = createApiApp({ authorization: { evaluator, registry: inventory.permissionRegistry, store: authorizationStore, }, moduleRoutes: moduleRuntime.routes, moduleRealtime: moduleRuntime.realtime, moduleServices: services, // Built from the exact **eligible** set the runtime just started, never the raw discovered // inventory, and exposed only after startup succeeded — so the published set is the running // set. A disabled or dependency-blocked module therefore publishes no artifact, appears in no // browser inventory, and appears in no resolved Dashboard navigation, by construction rather // than by a filter somewhere downstream. Modules declaring no UI role are simply absent. moduleUi, ...(displayHostAssets === undefined ? {} : { displayHostAssets }), moduleLifecycle: { // The boot snapshot: what this process discovered, what it resolved, and what the runtime // reports it actually started — read from the runtime rather than assumed equal to the // eligible set. snapshot: Object.freeze({ discoveredModules: inventory.modules, eligibility: eligibility.entries, startedModuleIds: Object.freeze(moduleRuntime.modules.map((module) => module.id)), }), store: moduleLifecycleStore, }, moduleConfiguration: { // The settings this process actually resolved from, so `restartRequired` is a comparison // against what is running rather than a guess. bootSettings: moduleConfigurationSettings, discoveredModules: inventory.modules, secrets: moduleSecrets, snapshots: eligibility.configurations, store: moduleConfigurationStore, }, moduleStorage: { providerConfigured: storageProvider !== undefined, snapshots: moduleStorages, }, // 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 safeCloseStorage(storageProvider); 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 safeCloseStorage(storageProvider); 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. Realtime subscriptions are closed *before* HTTP // drain begins: they are intentionally long-lived HTTP responses and would otherwise keep every // clean shutdown open until the forced-drain deadline. 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 () => { // Close long-lived streams so HTTP can drain, but leave publishers active until the drain // completes: an in-flight module command may still commit and publish its resulting state. // `moduleRuntime.stop()` deactivates publishers immediately before stop hooks run. moduleRuntime.realtime.closeSubscriptions(); const drainOutcome = await drainHttpServer(server, drainTimeoutMs); let moduleStopError: unknown; try { await moduleRuntime.stop(); } catch (error) { moduleStopError = error; } // After the module runtime has stopped, so a `stop` hook may still use its storage // facade, and before the pool closes, matching the existing ordering discipline exactly. await safeCloseStorage(storageProvider); 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, // The one place this process turns its environment into storage configuration. The names are // the `S3_*` ones ADR 0004 already established for this repository's Compose stack, and Core's // validator never reads `process.env` itself. storage: { accessKey: process.env.S3_ACCESS_KEY, bucketPrefix: process.env.S3_BUCKET_PREFIX, // Optional, and unset is the ordinary case: it matters only where the name Core reaches // storage by is not a name a browser could resolve. deliveryEndpoint: process.env.S3_DELIVERY_ENDPOINT, endpoint: process.env.S3_ENDPOINT, forcePathStyle: process.env.S3_FORCE_PATH_STYLE, region: process.env.S3_REGION, secretKey: process.env.S3_SECRET_KEY, }, }, }; } export async function main(): Promise { const { environment, input } = readConfigFromEnvironment(); const config = createApiRuntimeConfig(input, { environment }); // The one place this process turns its environment into module secrets. Core's factory takes the // environment as an argument and never touches `process.env` itself, preserving the rule that // only executables read the environment. const running = await startApi(config, { moduleSecrets: createEnvironmentModuleSecretSource(process.env), }); 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; }); }