Files
Ekklesia/apps/api/src/server.ts
T
ekklesiaandClaude Opus 5.5 aaa7ebfd84
CI / verify (pull_request) Successful in 1m2s
CI / infrastructure-smoke (pull_request) Successful in 37s
CI / migration-smoke (pull_request) Successful in 1m52s
CI / standalone-module (pull_request) Successful in 26s
CI / image-build (pull_request) Successful in 28s
CI / deploy-smoke (same-origin) (pull_request) Successful in 43s
CI / deploy-smoke (split) (pull_request) Successful in 1m30s
fix: keep Drizzle out of every published declaration (ADR 0053)
@ekklesia/contracts still emitted a type-only import of
drizzle-orm/node-postgres for the legacy ModuleDatabase facade, so a
standalone module type-checked only because skipLibCheck hid it.

- ModuleDatabase, LegacyModuleRuntimeServices and
  AnyModuleRuntimeServices move to core/src/modules/database.ts, beside
  the facade Core builds; the facade itself is unchanged.
- contracts' runtime, route, and services generics no longer name an
  ORM: the database type is a plain parameter defaulting to the ABI v1
  executor, and the legacy definition's database is opaque until Core
  specializes it. Core and apps/api name the legacy types from Core.
- contracts drops its drizzle-orm optional peer and devDependency;
  Drizzle stays public only through @ekklesia/module-database/drizzle.
- The facade's type-level test moves from contracts to Core.
- Regression proof: the standalone fixture type-checks with
  skipLibCheck: false against the packed tarballs, with no drizzle-orm
  installed (the smoke asserts both). Against the previous contracts
  tarball the same check fails with TS2307 on drizzle-orm/node-postgres.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 07:40:43 -05:00

829 lines
39 KiB
TypeScript

import type { Socket } from "node:net";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { EMPTY_MODULE_SECRET_SOURCE, type ModuleSecretSource } from "@ekklesia/contracts";
import {
createAuthorizationEvaluator,
createAuthorizationStore,
} from "@ekklesia/core/authorization";
import { createDashboardStore } from "@ekklesia/core/dashboard";
import { createDeviceStore } from "@ekklesia/core/devices";
import { type HostEnvironment, loadHostConfiguration } from "@ekklesia/core/host";
import {
createIdentityNotifications,
createIdentityRuntime,
type IdentityNotificationReporter,
type IdentityRateLimitOverride,
} from "@ekklesia/core/identity";
import { createSmtpMailProvider, type MailProvider } from "@ekklesia/core/mail";
import {
type AnyModuleRuntimeServices,
createModuleConfigurationStore,
createModuleLifecycleStore,
createModuleUiPublicationRegistry,
type DiscoveredModule,
type ModuleConfigurationSettings,
type ModuleEligibilityResolution,
type ModuleRuntimeServicesLookup,
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 {
type ClientAddressResolver,
createClientAddressResolver,
wrapFetchWithClientAddress,
} from "./client-address.js";
import { type ApiRuntimeConfig, apiTrustedOrigins, createApiRuntimeConfig } from "./config.js";
import { loadDashboardHostAssets } from "./dashboard-host-assets.js";
import { type ApiDatabaseHandle, createApiDatabase } from "./database.js";
import { withDevicePrincipals } from "./device-principal.js";
import { loadDisplayHostAssets } from "./display-host-assets.js";
import { READINESS_DATABASE_TIMEOUT_MS } from "./health-routes.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<Record<string, string>> = 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<void>;
}
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;
/**
* Where a verification or reset message that could not be delivered is reported.
*
* Production composition leaves this unset and one fixed sanitized line is written. It exists as a
* seam because the *only* externally observable difference between "the relay is down" and "there
* is no such account" must be this report — every HTTP response is deliberately identical — so a
* test needs somewhere to observe the operational failure the user is never shown.
*/
readonly reportNotificationFailure?: IdentityNotificationReporter;
/** The deployment's raw `EKKLESIA_MODULES_DIR` (ADR 0048), a production input the executable
* entry point always passes from host configuration. See `prepareStartupModuleInventory`. */
readonly configuredModulesDirectory?: string | undefined;
/** Test-only escape hatch: overrides the directory module discovery scans, taking precedence
* over `configuredModulesDirectory`. 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<NodeServerTimeouts>;
/** 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<void> {
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 mail provider's transport. */
async function safeCloseMail(provider: MailProvider | undefined): Promise<void> {
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`, for the storage provider's own
* connection pool. */
async function safeCloseStorage(provider: StorageProvider | undefined): Promise<void> {
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<void> {
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<ConnectionClosingServer>;
return (
typeof candidate.closeIdleConnections === "function" &&
typeof candidate.closeAllConnections === "function"
);
}
async function drainHttpServer(
server: ServerType,
timeoutMs: number,
): Promise<"drained" | "forced"> {
let timer: ReturnType<typeof setTimeout> | undefined;
const closed = new Promise<void>((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<ServerType> {
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 Core identity runtime, authorization store, and evaluator from that pool.
* 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 the manifest-selected service map.
* 6. Create the Hono application, mounting the started module runtime's published routes, publishing
* UI for that same eligible set, and passing the same per-module service map to HTTP handlers.
* 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<RunningApi> {
const inventory = await prepareStartupModuleInventory({
configuredModulesDirectory: options.configuredModulesDirectory,
...(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,
});
// Both host providers are constructed from validated configuration **before** anything that uses
// them, and neither touches the network while being built: an S3 client opens no connection and a
// Nodemailer transport opens no socket, so an unreachable relay or storage service can never stop
// the platform from booting (ADR 0030, ADR 0037). An installation with neither configured gets
// `undefined` for both, which is a complete, valid deployment.
const storageProvider = config.storage ? createS3StorageProvider(config.storage) : undefined;
const mailProvider = config.mail ? createSmtpMailProvider(config.mail) : undefined;
let runtime: ReturnType<typeof createIdentityRuntime>;
let authorizationStore: ReturnType<typeof createAuthorizationStore>;
let deviceStore: ReturnType<typeof createDeviceStore>;
try {
runtime = createIdentityRuntime({
config: config.identity,
db: databaseHandle.identity,
// Identity's two outbound messages, composed here — the one place that knows both the
// validated identity configuration and the host's mail provider. Core's identity code learns
// a recipient, a subject, and two bodies and nothing whatsoever about SMTP (ADR 0038).
notifications: createIdentityNotifications({
authBaseUrl: config.identity.baseUrl,
dashboardOrigin: config.identity.dashboardOrigin,
mail: mailProvider,
...(options.reportNotificationFailure ? { report: options.reportNotificationFailure } : {}),
}),
...(options.rateLimitOverride ? { rateLimitOverride: options.rateLimitOverride } : {}),
});
authorizationStore = createAuthorizationStore(databaseHandle.authorization);
deviceStore = createDeviceStore(databaseHandle.devices);
} catch (error) {
await safeCloseStorage(storageProvider);
await safeCloseMail(mailProvider);
await safeCloseDatabase(databaseHandle);
throw error;
}
const evaluator = createAuthorizationEvaluator({
// Device authority reaches the one evaluator here. There is no second decision point: a device
// is allowed or denied by the same function, with the same deny-by-default, as a person.
devices: deviceStore,
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.
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 safeCloseMail(mailProvider);
await safeCloseDatabase(databaseHandle);
throw error;
}
const services: ModuleRuntimeServicesLookup = new Map<string, AnyModuleRuntimeServices>(
eligibility.eligibleModules.map((module) => [
module.manifest.id,
module.manifest.database?.abi === 1
? Object.freeze({ database: databaseHandle.moduleExecutor(module.manifest.id) })
: Object.freeze({ database: databaseHandle.module }),
]),
);
// 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<ReturnType<typeof loadDisplayHostAssets>> | undefined;
let dashboardHostAssets: Awaited<ReturnType<typeof loadDashboardHostAssets>>;
try {
displayHostAssets =
moduleUi.listDisplays().length === 0 ? undefined : await loadDisplayHostAssets();
// Unconditional, unlike the display host: every installation serves its Dashboard (ADR 0044),
// so a missing shell build fails here, before any module code is imported.
// The page learns its API origin from the API that serves it, so one build serves either
// topology (ADR 0045).
dashboardHostAssets = await loadDashboardHostAssets(config.identity.baseUrl);
} catch (error) {
await safeCloseStorage(storageProvider);
await safeCloseMail(mailProvider);
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 safeCloseMail(mailProvider);
await safeCloseDatabase(databaseHandle);
throw error;
}
// Set by the first `shutdown()` call, before anything is drained or stopped, so readiness turns
// `503` while the instance still finishes in-flight requests.
let shuttingDown = false;
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),
dashboardHostAssets,
// The app is only composed after `startDiscoveredModuleRuntime` resolved, so module startup
// has completed whenever it can answer; it stops counting as complete once shutdown begins.
health: {
checkDatabase: () => databaseHandle.ping(READINESS_DATABASE_TIMEOUT_MS),
isModuleStartupComplete: () => !shuttingDown,
},
devices: {
// `Secure` follows the deployment's own scheme, read from the already-validated identity
// base URL rather than from any request. Production is https by configuration policy, so a
// real deployment always gets a Secure device cookie; a plaintext development origin would
// silently drop one.
secureCookies: config.identity.baseUrl.startsWith("https://"),
store: deviceStore,
},
// Principal resolution, extended with managed devices. A human session still wins, and this
// is the only place a device credential ever becomes a principal.
runtime: withDevicePrincipals({ devices: deviceStore, runtime }),
// The configured origins plus the API's own (ADR 0045).
trustedOrigins: apiTrustedOrigins(config.identity),
});
} catch (error) {
await safeStopModuleRuntime(moduleRuntime);
await safeCloseStorage(storageProvider);
await safeCloseMail(mailProvider);
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 safeCloseMail(mailProvider);
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<void> | undefined;
const shutdown = (): Promise<void> => {
if (!shutdownPromise) {
shuttingDown = true;
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 safeCloseMail(mailProvider);
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<RunningApi, "shutdown">,
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);
}
/**
* Turns one deployment environment into this process's startup inputs.
*
* Every host infrastructure value comes from `loadHostConfiguration` — the same function
* `pnpm doctor` calls with the same environment — so the runtime and the diagnostic that reports on
* it can never disagree about what this deployment is configured to use. Only the two values that
* describe *this listener* rather than the host's infrastructure are read here.
*/
export function readConfigFromEnvironment(processEnvironment: HostEnvironment): {
readonly input: Parameters<typeof createApiRuntimeConfig>[0];
readonly environment: "development" | "production";
readonly moduleSecrets: ModuleSecretSource;
readonly modulesDirectory: string | undefined;
} {
const host = loadHostConfiguration(processEnvironment);
return {
environment: host.environment,
input: {
clientAddress: host.clientAddress,
databaseTransport: host.databaseTransport,
databaseUrl: host.databaseUrl,
host: processEnvironment.EKKLESIA_API_HOST,
identity: host.identity,
mail: host.mail,
port: processEnvironment.EKKLESIA_API_PORT,
storage: host.storage,
},
moduleSecrets: host.moduleSecrets,
modulesDirectory: host.modulesDirectory,
};
}
export async function main(): Promise<void> {
const { environment, input, moduleSecrets, modulesDirectory } = readConfigFromEnvironment(
process.env,
);
const config = createApiRuntimeConfig(input, { environment });
const running = await startApi(config, {
configuredModulesDirectory: modulesDirectory,
moduleSecrets,
});
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;
});
}