ADR 0033 gave Ekklesia a display host and ADR 0034 gave it a login page. Together, the only way to light a television in a foyer was to sign a human being into it and leave that session running on a screen anyone can walk up to: a person's credentials on a wall, a person's session as the thing signage depends on, and the display going dark when they are revoked or their session simply expires. A screen is not a person. Ekklesia now recognizes three principal classes — anonymous, authenticated human, and managed device — and a device is a first-class principal with its own credential and its own explicit permissions. It has no email, no password, and no row in the identity tables, and it satisfies the `authenticated` baseline never, so every route that settles for "somebody is signed in" stays closed to it. Device permissions live in their own table because ekklesia_core_grant.user_id is a non-null foreign key into the user table and making it polymorphic would rewrite the platform's central authorization record to serve one narrow case. That is a schema decision, not a second decision point: the evaluator gained one branch, so a device is allowed or denied by the same function, with the same deny-by-default, as anyone else — with no owner path it could reach and no baseline that stands in for a grant. The six-digit code is not the credential. It is printed across a screen in a public hallway, so it is treated as public: it identifies a request to an administrator and is worth nothing alone. Collecting the credential also requires a 256-bit claim secret held only in the requesting browser's HttpOnly cookie, and approving requires the restricted core.devices.manage permission. Without that split, reading a code off a wall would hand over the screen's identity. The permission a pairing asks for is read from the module's validated display declaration, never from the request, so a browser cannot ask to be granted core.devices.manage or another module's permission. Approval grants exactly that one permission. Credentials are stored only as SHA-256; the raw value exists in the Set-Cookie that issues it and the device's own cookie jar, and is never logged, returned in JSON, or placed in a URL. The cookie is HttpOnly, so the module display code running in that same page cannot read it. Revocation is a server-side column checked on every principal resolution, and re-pairing mints a new identity so an old credential can never come back. State transitions are conditional updates, so one code cannot be approved twice or claimed into two credentials under a race. /api/v1/principal still reports a device as anonymous: it is what the Dashboard uses to decide whether a person is signed in. Displays read their viewer from the descriptor instead, which already performed the authorization — so displays are descriptor-first while the Dashboard stays principal-first, and neither infers an identity by elimination. Proven end to end against real PostgreSQL and real HTTP: an unpaired screen shows a code, an owner approves it, the device opens the display descriptor and a realtime stream, and revocation closes both while the device still holds its cookie. An already-open SSE stream is bounded by realtime's existing five-minute reconnect rather than closed instantly, and that bound is documented rather than called immediate. Co-Authored-By: Claude Opus 5 <[email protected]>
794 lines
37 KiB
TypeScript
794 lines
37 KiB
TypeScript
import type { Socket } from "node:net";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import {
|
|
EMPTY_MODULE_SECRET_SOURCE,
|
|
type ModuleRuntimeServices,
|
|
type ModuleSecretSource,
|
|
} from "@ekklesia/contracts";
|
|
import {
|
|
createAuthorizationEvaluator,
|
|
createAuthorizationStore,
|
|
} from "@ekklesia/core/authorization";
|
|
import { createDashboardStore } from "@ekklesia/core/dashboard";
|
|
import { createDeviceStore } from "@ekklesia/core/devices";
|
|
import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity";
|
|
import {
|
|
createEnvironmentModuleSecretSource,
|
|
createModuleConfigurationStore,
|
|
createModuleLifecycleStore,
|
|
createModuleUiPublicationRegistry,
|
|
type DiscoveredModule,
|
|
type ModuleConfigurationSettings,
|
|
type ModuleEligibilityResolution,
|
|
resolveModuleEligibility,
|
|
type StartedModuleRuntime,
|
|
startDiscoveredModuleRuntime,
|
|
} from "@ekklesia/core/modules";
|
|
import {
|
|
createModuleStorageLookup,
|
|
createS3StorageProvider,
|
|
type StorageProvider,
|
|
} from "@ekklesia/core/storage";
|
|
import { type ServerType, serve } from "@hono/node-server";
|
|
import type { Hono } from "hono";
|
|
import { createApiApp } from "./app.js";
|
|
import {
|
|
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 { withDevicePrincipals } from "./device-principal.js";
|
|
import { loadDisplayHostAssets } from "./display-host-assets.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;
|
|
/**
|
|
* 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 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 frozen `ModuleRuntimeServices` (currently just `database`) from that same pool,
|
|
* then the Core identity runtime, authorization store, and evaluator.
|
|
* 4. Read the stored module enablement configuration and resolve, from that exact same discovered
|
|
* inventory, which modules are eligible to run (`resolveModuleEligibility`).
|
|
* 5. Start the module runtime from the eligible subset (`startDiscoveredModuleRuntime`) — never
|
|
* rediscovering modules a second time — passing it the same services object every module's
|
|
* `start`/`stop` hook receives.
|
|
* 6. Create the Hono application, mounting the started module runtime's published routes, publishing
|
|
* UI for that same eligible set, and passing the exact same services object to every module HTTP
|
|
* handler.
|
|
* 7. Bind the Node HTTP server.
|
|
*
|
|
* Discovery and permission composition happen before any pool, Better Auth instance, module
|
|
* entrypoint import, or HTTP server exists, so a discovery or composition failure — an invalid
|
|
* manifest, a dependency-graph problem, a module claiming the reserved `core.*` permission
|
|
* namespace, or a duplicate permission declaration — leaves nothing to close: it propagates
|
|
* directly, and startup never falls back to a Core-only registry. Core infrastructure (the pool,
|
|
* module database facade, identity, authorization) exists before any executable module lifecycle
|
|
* code runs, and every module route is published only after the entire module runtime has
|
|
* registered and started successfully, so no request can ever reach a module before every module
|
|
* has started. A module that is disabled, or blocked by a disabled required dependency, is never
|
|
* imported at all: no entrypoint evaluation, no lifecycle hook, no route, no capability, and no
|
|
* published UI artifact — there is no per-request "is this module enabled?" check anywhere, because
|
|
* there is nothing mounted to check.
|
|
*
|
|
* Step 4 makes startup depend on a reachable, migrated database. If the enablement configuration
|
|
* cannot be read, startup fails closed with Core's sanitized `MODULE_LIFECYCLE_READ_FAILED` rather
|
|
* than starting every discovered module: the platform cannot know what an administrator decided, and
|
|
* starting a module they disabled is worse than not starting at all.
|
|
*
|
|
* Returns a handle with an idempotent `shutdown()` so both the executable entry point and tests can
|
|
* stop the server, the module runtime, and the shared pool deterministically. Never applies
|
|
* migrations, generates schema, or issues a module database query merely because modules were
|
|
* discovered — the database must already be migrated through `pnpm migrations:apply`.
|
|
*/
|
|
export async function startApi(
|
|
config: ApiRuntimeConfig,
|
|
options: StartApiOptions = {},
|
|
): Promise<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>;
|
|
let deviceStore: ReturnType<typeof createDeviceStore>;
|
|
try {
|
|
services = Object.freeze({ database: databaseHandle.module });
|
|
runtime = createIdentityRuntime({
|
|
config: config.identity,
|
|
db: databaseHandle.identity,
|
|
...(options.rateLimitOverride ? { rateLimitOverride: options.rateLimitOverride } : {}),
|
|
});
|
|
authorizationStore = createAuthorizationStore(databaseHandle.authorization);
|
|
deviceStore = createDeviceStore(databaseHandle.devices);
|
|
} catch (error) {
|
|
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.
|
|
// The host's object-storage provider, constructed from validated configuration before any module
|
|
// runs — and **without touching the network**. Building the client opens no connection, so a
|
|
// storage outage can never stop the platform from booting; a bucket is ensured lazily on first
|
|
// use instead (ADR 0030). An installation with no storage configured gets `undefined` here, which
|
|
// is a complete, valid deployment.
|
|
const storageProvider = config.storage ? createS3StorageProvider(config.storage) : undefined;
|
|
|
|
const moduleLifecycleStore = createModuleLifecycleStore(databaseHandle.moduleLifecycle);
|
|
const moduleConfigurationStore = createModuleConfigurationStore(
|
|
databaseHandle.moduleConfiguration,
|
|
);
|
|
const moduleSecrets = options.moduleSecrets ?? EMPTY_MODULE_SECRET_SOURCE;
|
|
let eligibility: ModuleEligibilityResolution;
|
|
let moduleConfigurationSettings: ModuleConfigurationSettings;
|
|
try {
|
|
// Both axes are read before a single module entrypoint is imported, and resolved together in
|
|
// one pass (ADR 0029): a module is eligible only if an administrator enabled it, its own
|
|
// required configuration is satisfiable, and every module it requires is eligible too. The pass
|
|
// also produces each eligible module's frozen configuration snapshot, so the configuration that
|
|
// made a module eligible is the identical object it later receives.
|
|
// Read sequentially rather than concurrently, deliberately: these are two tiny queries, and
|
|
// racing them would make the reported failure depend on which rejected first. An operator
|
|
// debugging a broken deployment must get the same error code every time.
|
|
const lifecycleConfiguration = await moduleLifecycleStore.readConfiguration();
|
|
const settings = await moduleConfigurationStore.readSettings();
|
|
moduleConfigurationSettings = settings;
|
|
eligibility = resolveModuleEligibility(
|
|
inventory.modules,
|
|
lifecycleConfiguration,
|
|
{ secrets: moduleSecrets, settings },
|
|
{ providerConfigured: storageProvider !== undefined },
|
|
);
|
|
} catch (error) {
|
|
await safeCloseStorage(storageProvider);
|
|
await safeCloseDatabase(databaseHandle);
|
|
throw error;
|
|
}
|
|
|
|
// One scoped facade per eligible module, over the exact buckets that module's own manifest
|
|
// declares. Built from the eligible set so a blocked module never receives storage at all.
|
|
const moduleStorages = createModuleStorageLookup(
|
|
storageProvider,
|
|
config.storage?.bucketPrefix ?? "",
|
|
eligibility.eligibleModules.map((module) => ({
|
|
logicalBucketIds: module.manifest.storage.buckets.map((bucket) => bucket.id),
|
|
moduleId: module.manifest.id,
|
|
})),
|
|
);
|
|
|
|
// This snapshot is inert metadata only: construction reads no artifact and executes no module.
|
|
// It lets the API prove that a display host build is available *before* importing any eligible
|
|
// module code. The snapshot is not exposed anywhere unless the complete runtime subsequently
|
|
// starts and application composition succeeds.
|
|
const moduleUi = createModuleUiPublicationRegistry(eligibility.eligibleModules);
|
|
let displayHostAssets: Awaited<ReturnType<typeof loadDisplayHostAssets>> | undefined;
|
|
try {
|
|
displayHostAssets =
|
|
moduleUi.listDisplays().length === 0 ? undefined : await loadDisplayHostAssets();
|
|
} catch (error) {
|
|
await safeCloseStorage(storageProvider);
|
|
await safeCloseDatabase(databaseHandle);
|
|
throw error;
|
|
}
|
|
|
|
// Module load/register/start failures use Core's own existing rollback (already-started
|
|
// modules are stopped, in reverse order, before this rejects) — there is no separate runtime
|
|
// handle to stop here, only the pool this failure leaves open.
|
|
//
|
|
// Only eligible modules are started, and startup remains all-or-nothing across *that* set: an
|
|
// ineligible module is never imported, so its `register`/`start` hooks never run, it publishes no
|
|
// routes and no capabilities, and it cannot take startup down. Eligibility filtering preserves
|
|
// discovery's topological order, so a started module's required dependencies still precede it.
|
|
let moduleRuntime: StartedModuleRuntime;
|
|
try {
|
|
moduleRuntime = await startDiscoveredModuleRuntime(eligibility.eligibleModules, services, {
|
|
configurations: eligibility.configurations,
|
|
storages: moduleStorages,
|
|
...(options.moduleHookTimeoutMsOverride === undefined
|
|
? {}
|
|
: { hookTimeoutMs: options.moduleHookTimeoutMsOverride }),
|
|
});
|
|
} catch (error) {
|
|
await safeCloseStorage(storageProvider);
|
|
await safeCloseDatabase(databaseHandle);
|
|
throw error;
|
|
}
|
|
|
|
let app: Hono;
|
|
try {
|
|
app = createApiApp({
|
|
authorization: {
|
|
evaluator,
|
|
registry: inventory.permissionRegistry,
|
|
store: authorizationStore,
|
|
},
|
|
moduleRoutes: moduleRuntime.routes,
|
|
moduleRealtime: moduleRuntime.realtime,
|
|
moduleServices: services,
|
|
// Built from the exact **eligible** set the runtime just started, never the raw discovered
|
|
// inventory, and exposed only after startup succeeded — so the published set is the running
|
|
// set. A disabled or dependency-blocked module therefore publishes no artifact, appears in no
|
|
// browser inventory, and appears in no resolved Dashboard navigation, by construction rather
|
|
// than by a filter somewhere downstream. Modules declaring no UI role are simply absent.
|
|
moduleUi,
|
|
...(displayHostAssets === undefined ? {} : { displayHostAssets }),
|
|
moduleLifecycle: {
|
|
// The boot snapshot: what this process discovered, what it resolved, and what the runtime
|
|
// reports it actually started — read from the runtime rather than assumed equal to the
|
|
// eligible set.
|
|
snapshot: Object.freeze({
|
|
discoveredModules: inventory.modules,
|
|
eligibility: eligibility.entries,
|
|
startedModuleIds: Object.freeze(moduleRuntime.modules.map((module) => module.id)),
|
|
}),
|
|
store: moduleLifecycleStore,
|
|
},
|
|
moduleConfiguration: {
|
|
// The settings this process actually resolved from, so `restartRequired` is a comparison
|
|
// against what is running rather than a guess.
|
|
bootSettings: moduleConfigurationSettings,
|
|
discoveredModules: inventory.modules,
|
|
secrets: moduleSecrets,
|
|
snapshots: eligibility.configurations,
|
|
store: moduleConfigurationStore,
|
|
},
|
|
moduleStorage: {
|
|
providerConfigured: storageProvider !== undefined,
|
|
snapshots: moduleStorages,
|
|
},
|
|
// A typed view over the same single pool as identity, authorization, and the module facade.
|
|
dashboard: createDashboardStore(databaseHandle.dashboard),
|
|
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 }),
|
|
trustedOrigins: config.identity.trustedOrigins,
|
|
});
|
|
} catch (error) {
|
|
await safeStopModuleRuntime(moduleRuntime);
|
|
await safeCloseStorage(storageProvider);
|
|
await safeCloseDatabase(databaseHandle);
|
|
throw error;
|
|
}
|
|
|
|
const clientAddressResolver = createClientAddressResolver(config.clientAddress);
|
|
const timeouts: NodeServerTimeouts = {
|
|
...PRODUCTION_NODE_SERVER_TIMEOUTS,
|
|
...options.nodeServerTimeoutOverride,
|
|
};
|
|
|
|
let server: ServerType;
|
|
try {
|
|
server = await bindServer(app, config.host, config.port, clientAddressResolver, timeouts);
|
|
} catch (error) {
|
|
await safeStopModuleRuntime(moduleRuntime);
|
|
await safeCloseStorage(storageProvider);
|
|
await safeCloseDatabase(databaseHandle);
|
|
throw error;
|
|
}
|
|
|
|
// Memoizes one shared promise rather than a boolean flag: a boolean only prevents a *second*
|
|
// cleanup from starting, but a caller that arrives while the first cleanup is still in flight
|
|
// would previously get an already-resolved `undefined` immediately, before the listener had
|
|
// actually drained or the pool had actually closed. Storing the promise itself means every
|
|
// caller — concurrent or sequential — awaits the exact same drain-then-close operation and
|
|
// observes it complete (or fail) exactly once. Realtime subscriptions are closed *before* HTTP
|
|
// drain begins: they are intentionally long-lived HTTP responses and would otherwise keep every
|
|
// clean shutdown open until the forced-drain deadline. Order matters, and is now additionally
|
|
// load-bearing for the module database facade: the HTTP listener is drained and closed first so
|
|
// no new or in-flight request can reach a module handler while the module runtime is stopping;
|
|
// then the module runtime stops — its stop hooks may still use `services.database`, which is
|
|
// safe because the pool is not yet closed; only then does the shared pool close, so no module
|
|
// code can run against it afterward. Unlike the startup-cleanup paths above, a module-stop
|
|
// failure here is not swallowed — it is a genuine shutdown result a caller should observe — but
|
|
// database cleanup still always runs first, so a stop failure never leaves the pool open.
|
|
const drainTimeoutMs = options.drainTimeoutMsOverride ?? HTTP_DRAIN_TIMEOUT_MS;
|
|
let shutdownPromise: Promise<void> | undefined;
|
|
const shutdown = (): Promise<void> => {
|
|
if (!shutdownPromise) {
|
|
shutdownPromise = (async () => {
|
|
// Close long-lived streams so HTTP can drain, but leave publishers active until the drain
|
|
// completes: an in-flight module command may still commit and publish its resulting state.
|
|
// `moduleRuntime.stop()` deactivates publishers immediately before stop hooks run.
|
|
moduleRuntime.realtime.closeSubscriptions();
|
|
const drainOutcome = await drainHttpServer(server, drainTimeoutMs);
|
|
let moduleStopError: unknown;
|
|
try {
|
|
await moduleRuntime.stop();
|
|
} catch (error) {
|
|
moduleStopError = error;
|
|
}
|
|
// After the module runtime has stopped, so a `stop` hook may still use its storage
|
|
// facade, and before the pool closes, matching the existing ordering discipline exactly.
|
|
await safeCloseStorage(storageProvider);
|
|
await safeCloseDatabase(databaseHandle);
|
|
// Every stage above ran regardless of what the earlier ones did, so a drain timeout or a
|
|
// module-stop failure can never leave the pool open. Both failures are reported together,
|
|
// as fixed metadata: a forced drain is a genuine shutdown failure, not a success.
|
|
if (drainOutcome === "forced" || moduleStopError) {
|
|
throw new ApiShutdownError(
|
|
drainOutcome === "forced",
|
|
moduleStopError === undefined ? undefined : moduleStopError,
|
|
);
|
|
}
|
|
})();
|
|
}
|
|
return shutdownPromise;
|
|
};
|
|
|
|
return Object.freeze({ app, server, shutdown });
|
|
}
|
|
|
|
/**
|
|
* Wires `SIGINT`/`SIGTERM` to `running.shutdown()` and only then exits — extracted from `main()`
|
|
* so it can be exercised directly in tests with a fake `running`/`exit`, without sending a real
|
|
* process signal or invoking the real `process.exit` (which would terminate the test runner
|
|
* itself). Production composition (`main()`, below) calls this with real `process.exit`.
|
|
*/
|
|
export function installShutdownSignalHandlers(
|
|
running: Pick<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,
|
|
// The one place this process turns its environment into storage configuration. The names are
|
|
// the `S3_*` ones ADR 0004 already established for this repository's Compose stack, and Core's
|
|
// validator never reads `process.env` itself.
|
|
storage: {
|
|
accessKey: process.env.S3_ACCESS_KEY,
|
|
bucketPrefix: process.env.S3_BUCKET_PREFIX,
|
|
// Optional, and unset is the ordinary case: it matters only where the name Core reaches
|
|
// storage by is not a name a browser could resolve.
|
|
deliveryEndpoint: process.env.S3_DELIVERY_ENDPOINT,
|
|
endpoint: process.env.S3_ENDPOINT,
|
|
forcePathStyle: process.env.S3_FORCE_PATH_STYLE,
|
|
region: process.env.S3_REGION,
|
|
secretKey: process.env.S3_SECRET_KEY,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function main(): Promise<void> {
|
|
const { environment, input } = readConfigFromEnvironment();
|
|
const config = createApiRuntimeConfig(input, { environment });
|
|
// The one place this process turns its environment into module secrets. Core's factory takes the
|
|
// environment as an argument and never touches `process.env` itself, preserving the rule that
|
|
// only executables read the environment.
|
|
const running = await startApi(config, {
|
|
moduleSecrets: createEnvironmentModuleSecretSource(process.env),
|
|
});
|
|
|
|
const address = running.server.address();
|
|
const boundPort = address && typeof address === "object" ? address.port : config.port;
|
|
console.log(`Ekklesia API listening on ${config.host}:${boundPort}`);
|
|
|
|
installShutdownSignalHandlers(running);
|
|
}
|
|
|
|
function isDirectExecution(): boolean {
|
|
const entrypoint = process.argv[1];
|
|
return (
|
|
entrypoint !== undefined && import.meta.url === pathToFileURL(path.resolve(entrypoint)).href
|
|
);
|
|
}
|
|
|
|
if (isDirectExecution()) {
|
|
main().catch(() => {
|
|
console.error("Ekklesia API failed to start.");
|
|
process.exitCode = 1;
|
|
});
|
|
}
|