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
@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]>
829 lines
39 KiB
TypeScript
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;
|
|
});
|
|
}
|