Phase 6D pass 1. The shell's navigation was a flat list of module ids, which is not a usable experience — and the obvious fix carries an obvious trap. Letting a module declare its folder, or modelling "Media" as something Livestream and Video belong to, turns a rendering preference into architecture: a folder becomes an owner, membership starts to look like a dependency, and the flat module graph acquires a hierarchy nobody decided on. So the rule comes first: a Dashboard group is a label on a rendering bucket. No manifest, no version, no artifact, no API, no lifecycle; nothing depends on it. Livestream depends on Video because it calls Video's functionality, and that orders startup. Livestream is shown under Media, and that orders nothing. No parent module, child module, owning module, or group-derived dependency appears anywhere in the contracts. No manifest change and no version 5. `manifest.name` has been required since version 1 and already is the module's product identity, so the shell renders "Livestream" instead of `livestream` with nothing added. `suggestedGroup` is deliberately deferred rather than shipped: it would force a hard manifest-version cutover for a hint, and its only correct semantics needs initialization logic that risks a suggestion overwriting an administrator's decision. Defaulting and ownership are different things. Two Core tables (migration 0003) hold groups and placements — server-side, because a church configures its Dashboard rather than one browser; platform-global, because no organization model exists to scope them to. `group_id` is `on delete set null`, so deleting a group ungroups its modules and a dangling reference is impossible. `module_id` carries no foreign key, because modules are packages rather than rows, so a placement for an uninstalled module is retained and restores its position on reinstall. `resolveDashboardNavigation` is a pure function of configuration plus the mountable set: an unplaced module is ungrouped and visible with no initialization write, an absent module renders nothing, empty groups are kept, and ordering is total with the module id as final tie-break. `GET /api/v1/dashboard/navigation` returns the resolved view, layered on top of the host-neutral module UI inventory rather than merged into it — each generic descriptor is embedded untouched, so a standalone host never inherits Dashboard semantics. Reading needs the permission the inventory already needs; organizing needs the new platform permission `core.dashboard.navigation.manage` through a narrow, idempotent, closed-validation mutation contract. No editor UI. The flatness rule is executable: a test discovers real fixtures with a genuine dependency, arranges them four ways including both in one folder and the dependency inverted in presentation, and asserts discovery order, dependency declarations, and the module set stay byte-identical. The end-to-end test organizes a real installation through the real API, mounts a module from inside a group, moves it, and asserts the navigation entry is byte-identical across the move and still mounts the same artifact. The shell's client for the generic inventory became dead code and was removed rather than kept speculatively; the endpoint itself is unchanged. Co-Authored-By: Claude Opus 5 <[email protected]>
591 lines
26 KiB
TypeScript
591 lines
26 KiB
TypeScript
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<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;
|
|
/** 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<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;
|
|
}
|
|
|
|
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`, 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 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<RunningApi> {
|
|
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<typeof createIdentityRuntime>;
|
|
let authorizationStore: ReturnType<typeof createAuthorizationStore>;
|
|
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<void> | undefined;
|
|
const shutdown = (): Promise<void> => {
|
|
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<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);
|
|
}
|
|
|
|
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<typeof createApiRuntimeConfig>[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<void> {
|
|
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;
|
|
});
|
|
}
|