import path from "node:path"; import { pathToFileURL } from "node:url"; import { createAuthorizationEvaluator, createAuthorizationStore, } from "@ekklesia/core/authorization"; import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity"; import { serve, type ServerType } from "@hono/node-server"; import type { Hono } from "hono"; import { createApiApp } from "./app.js"; import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js"; import { type ApiDatabaseHandle, createApiDatabase } from "./database.js"; const connectionTimeoutMs = 10_000; const operationTimeoutMs = 10_000; 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; } 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. } } function bindServer(app: Hono, host: string, port: number): Promise { return new Promise((resolve, reject) => { let settled = false; const server = serve({ fetch: app.fetch, hostname: host, port }, () => { if (!settled) { settled = true; resolve(server); } }); server.once("error", () => { if (!settled) { settled = true; reject(new Error("The API server failed to start listening.")); } }); }); } /** * Explicit runtime composition: constructs the one shared PostgreSQL pool (identity and * authorization each get a typed Drizzle view over it — see `./database.js`), the Core identity * runtime, the Core authorization store and evaluator, and the Hono application, then starts the * Node HTTP server. Returns a handle with an idempotent `shutdown()` so both the executable entry * point and tests can stop the server and close the shared pool deterministically. Never applies * migrations or generates schema — the database must already be migrated through * `pnpm migrations:apply`. */ export async function startApi( config: ApiRuntimeConfig, options: StartApiOptions = {}, ): Promise { const databaseHandle = createApiDatabase(config.databaseUrl, { connectionTimeoutMs, operationTimeoutMs, }); let runtime: ReturnType; let authorizationStore: ReturnType; try { 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, }); const app = createApiApp({ authorization: { evaluator, store: authorizationStore }, runtime, trustedOrigins: config.identity.trustedOrigins, }); let server: ServerType; try { server = await bindServer(app, config.host, config.port); } catch (error) { 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. let shutdownPromise: Promise | undefined; const shutdown = (): Promise => { if (!shutdownPromise) { shutdownPromise = (async () => { await new Promise((resolve) => { server.close(() => resolve()); }); await safeCloseDatabase(databaseHandle); })(); } 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 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); return { environment: process.env.NODE_ENV === "development" ? "development" : "production", input: { databaseUrl: process.env.DATABASE_URL, host: process.env.EKKLESIA_API_HOST, identity: { baseUrl: process.env.EKKLESIA_IDENTITY_BASE_URL, 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; }); }