Files
Ekklesia/apps/api/src/server.ts
T
ekklesia 1363608552 feat: add authorization bootstrap and HTTP enforcement
Wires Core's identity and authorization domains into apps/api over one
shared, validated PostgreSQL pool; adds an explicit, single-use,
concurrency-safe platform-owner bootstrap CLI; adds a smallest-possible
immutable permission registry with one Core-owned restricted permission
(core.authorization.grants.manage); and adds reusable Hono authorization
enforcement plus a narrowly scoped /api/v1/grants HTTP API for managing
explicit grants only, gated by an exact trusted Origin on every mutation.
2026-07-23 11:16:32 -05:00

204 lines
6.9 KiB
TypeScript

import path from "node:path";
import { pathToFileURL } from "node:url";
import {
createAuthorizationEvaluator,
createAuthorizationStore,
} from "@ekklesia/core/authorization";
import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity";
import { serve, type ServerType } from "@hono/node-server";
import type { Hono } from "hono";
import { createApiApp } from "./app.js";
import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js";
import { type ApiDatabaseHandle, createApiDatabase } from "./database.js";
const connectionTimeoutMs = 10_000;
const operationTimeoutMs = 10_000;
export interface RunningApi {
readonly app: Hono;
readonly server: ServerType;
shutdown(): Promise<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;
}
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.
}
}
function bindServer(app: Hono, host: string, port: number): Promise<ServerType> {
return new Promise((resolve, reject) => {
let settled = false;
const server = serve({ fetch: app.fetch, hostname: host, port }, () => {
if (!settled) {
settled = true;
resolve(server);
}
});
server.once("error", () => {
if (!settled) {
settled = true;
reject(new Error("The API server failed to start listening."));
}
});
});
}
/**
* Explicit runtime composition: constructs the one shared PostgreSQL pool (identity and
* authorization each get a typed Drizzle view over it — see `./database.js`), the Core identity
* runtime, the Core authorization store and evaluator, and the Hono application, then starts the
* Node HTTP server. Returns a handle with an idempotent `shutdown()` so both the executable entry
* point and tests can stop the server and close the shared pool deterministically. Never applies
* migrations or generates schema — the database must already be migrated through
* `pnpm migrations:apply`.
*/
export async function startApi(
config: ApiRuntimeConfig,
options: StartApiOptions = {},
): Promise<RunningApi> {
const databaseHandle = createApiDatabase(config.databaseUrl, {
connectionTimeoutMs,
operationTimeoutMs,
});
let runtime: ReturnType<typeof createIdentityRuntime>;
let authorizationStore: ReturnType<typeof createAuthorizationStore>;
try {
runtime = createIdentityRuntime({
config: config.identity,
db: databaseHandle.identity,
...(options.rateLimitOverride ? { rateLimitOverride: options.rateLimitOverride } : {}),
});
authorizationStore = createAuthorizationStore(databaseHandle.authorization);
} catch (error) {
await safeCloseDatabase(databaseHandle);
throw error;
}
const evaluator = createAuthorizationEvaluator({
grants: authorizationStore,
platformOwners: authorizationStore,
});
const app = createApiApp({
authorization: { evaluator, store: authorizationStore },
runtime,
trustedOrigins: config.identity.trustedOrigins,
});
let server: ServerType;
try {
server = await bindServer(app, config.host, config.port);
} catch (error) {
await safeCloseDatabase(databaseHandle);
throw error;
}
// Memoizes one shared promise rather than a boolean flag: a boolean only prevents a *second*
// cleanup from starting, but a caller that arrives while the first cleanup is still in flight
// would previously get an already-resolved `undefined` immediately, before the listener had
// actually drained or the pool had actually closed. Storing the promise itself means every
// caller — concurrent or sequential — awaits the exact same drain-then-close operation and
// observes it complete (or fail) exactly once.
let shutdownPromise: Promise<void> | undefined;
const shutdown = (): Promise<void> => {
if (!shutdownPromise) {
shutdownPromise = (async () => {
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
await safeCloseDatabase(databaseHandle);
})();
}
return shutdownPromise;
};
return Object.freeze({ app, server, shutdown });
}
/**
* Wires `SIGINT`/`SIGTERM` to `running.shutdown()` and only then exits — extracted from `main()`
* so it can be exercised directly in tests with a fake `running`/`exit`, without sending a real
* process signal or invoking the real `process.exit` (which would terminate the test runner
* itself). Production composition (`main()`, below) calls this with real `process.exit`.
*/
export function installShutdownSignalHandlers(
running: Pick<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 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);
return {
environment: process.env.NODE_ENV === "development" ? "development" : "production",
input: {
databaseUrl: process.env.DATABASE_URL,
host: process.env.EKKLESIA_API_HOST,
identity: {
baseUrl: process.env.EKKLESIA_IDENTITY_BASE_URL,
secret: process.env.EKKLESIA_IDENTITY_SECRET,
trustedOrigins,
},
port: process.env.EKKLESIA_API_PORT,
},
};
}
export async function main(): Promise<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;
});
}