From aaec9789c44234745f2f65584b3b674be27e5c0e Mon Sep 17 00:00:00 2001 From: ekklesia Date: Thu, 1 Oct 2026 16:47:01 -0500 Subject: [PATCH] feat(api): add unauthenticated health endpoints Add GET /api/v1/health/live and GET /api/v1/health/ready per ADR 0044. - live returns a fixed 200 {"status":"live"} whenever the process serves. - ready returns 200 {"status":"ready"} only when module startup has completed (and shutdown has not begun) and a database ping succeeds within 2s; otherwise a fixed 503 {"code":"NOT_READY"}. - The ping runs on the shared pool as one simple-protocol message with a transaction-local statement_timeout, so PostgreSQL cancels a stalled statement; the pool discards the connection on failure. - At most one ping is ever unsettled: probes share its outcome and, once its deadline passes, answer 503 immediately until it settles. - Both routes set Cache-Control: no-store, accept GET only (405 with Allow: GET otherwise, HEAD included), never resolve a principal, and are mounted in app.ts beside, never inside, /api/v1/modules/. - createApiApp gains a required `health` option; startApi supplies it and clears module readiness when shutdown begins. Co-Authored-By: Claude Opus 5.5 --- apps/api/src/app.ts | 13 + apps/api/src/database.ts | 23 ++ apps/api/src/health-routes.ts | 121 ++++++ apps/api/src/server.ts | 12 + apps/api/tests/api-http.integration.ts | 56 +++ apps/api/tests/app.test.ts | 23 ++ apps/api/tests/dashboard-routes.test.ts | 2 + apps/api/tests/health-routes.test.ts | 371 ++++++++++++++++++ apps/api/tests/lifecycle.test.ts | 35 ++ .../tests/module-configuration-routes.test.ts | 2 + apps/api/tests/module-display-routes.test.ts | 2 + apps/api/tests/module-http-routes.test.ts | 2 + .../api/tests/module-lifecycle-routes.test.ts | 2 + apps/api/tests/module-ui-host-e2e.test.ts | 2 + apps/api/tests/module-ui-inventory.test.ts | 2 + apps/api/tests/module-ui-routes.test.ts | 2 + apps/api/tests/support/ready-health.ts | 10 + docs/development/api.md | 47 +++ 18 files changed, 727 insertions(+) create mode 100644 apps/api/src/health-routes.ts create mode 100644 apps/api/tests/health-routes.test.ts create mode 100644 apps/api/tests/support/ready-health.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index b5e2c4c..547a6e8 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -31,6 +31,7 @@ import { createDashboardRoutes } from "./dashboard-routes.js"; import { createDeviceRoutes } from "./device-routes.js"; import type { DisplayHostAssets } from "./display-host-assets.js"; import { createGrantsRoutes } from "./grants-routes.js"; +import { type ApiHealthOptions, createHealthRoutes } from "./health-routes.js"; import { createModuleConfigurationRoutes } from "./module-configuration-routes.js"; import { mountModuleDisplayRoutes } from "./module-display-routes.js"; import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js"; @@ -137,6 +138,13 @@ export interface CreateApiAppOptions { * presentation only — nothing reachable through this store influences discovery, dependency * ordering, module startup, or any authorization decision. */ readonly dashboard: DashboardStore; + /** + * The readiness inputs behind the unauthenticated health routes (ADR 0044). Required rather than + * defaulted, like every other dependency here: a composition that cannot reach a database says so + * with a check that rejects, never falls through to an implicitly healthy one. `checkDatabase` is + * the bare query; the routes themselves bound and serialize it. See `./health-routes.js`. + */ + readonly health: ApiHealthOptions; readonly runtime: EkklesiaIdentityRuntime; /** Exact allowed browser origins. Never a wildcard; combined with `credentials: true`, only an * exact, reflected match is ever returned in `Access-Control-Allow-Origin`. Also the trusted set @@ -341,6 +349,11 @@ export function createApiApp(options: CreateApiAppOptions): Hono { } }); + // Unauthenticated liveness/readiness under a platform-owned sibling prefix: like every route below, + // it is mounted before module routes and never inside `/api/v1/modules/`, which installed modules + // own entirely. See `./health-routes.js`. + app.route("/", createHealthRoutes(options.health)); + app.route( "/", createGrantsRoutes({ diff --git a/apps/api/src/database.ts b/apps/api/src/database.ts index bfc4728..3151302 100644 --- a/apps/api/src/database.ts +++ b/apps/api/src/database.ts @@ -26,11 +26,31 @@ export interface ApiDatabaseHandle { readonly module: ModuleDatabase; readonly moduleConfiguration: ModuleConfigurationDrizzleDatabase; readonly moduleLifecycle: ModuleLifecycleDrizzleDatabase; + /** + * One trivial round trip on the shared pool, for readiness (`./health-routes.js`). The `SELECT 1` + * runs in a read-only transaction carrying its own `SET LOCAL statement_timeout`, so PostgreSQL + * cancels a stalled statement itself rather than the caller merely abandoning it; the pool's + * session-wide `statement_timeout`/`query_timeout` remain the outer bound. The four statements + * travel as one simple-protocol message, so the check costs a single round trip. On any failure + * the pool discards that connection instead of returning it, so a transaction left open by a + * cancelled statement never reaches another caller. Rejects with whatever the driver raised; no + * caller may surface it. + */ + readonly ping: (statementTimeoutMs: number) => Promise; /** Closes the one shared pool exactly once, regardless of how many typed views were created * over it. Safe to call from both a startup-failure cleanup path and normal shutdown. */ readonly close: () => Promise; } +function pingStatement(statementTimeoutMs: number): string { + // Interpolated rather than bound: neither SET nor a multi-statement simple query accepts + // parameters. Only a positive safe integer can ever reach the SQL text. + if (!Number.isSafeInteger(statementTimeoutMs) || statementTimeoutMs <= 0) { + throw new RangeError("The ping statement timeout must be a positive integer."); + } + return `BEGIN READ ONLY; SET LOCAL statement_timeout = ${statementTimeoutMs}; SELECT 1; COMMIT`; +} + /** * Composes the one physical PostgreSQL pool this process uses for Core's identity and * authorization schemas plus the module database facade: one validated, timeout-bounded @@ -58,5 +78,8 @@ export function createApiDatabase( module: createModuleDatabaseView(pool), moduleConfiguration: createModuleConfigurationView(pool), moduleLifecycle: createModuleLifecycleView(pool), + ping: async (statementTimeoutMs: number) => { + await pool.query(pingStatement(statementTimeoutMs)); + }, }); } diff --git a/apps/api/src/health-routes.ts b/apps/api/src/health-routes.ts new file mode 100644 index 0000000..9ef3bdd --- /dev/null +++ b/apps/api/src/health-routes.ts @@ -0,0 +1,121 @@ +import { Hono } from "hono"; + +/** + * The longest a readiness probe waits on the database before answering `503` (ADR 0044). Short + * enough for a Compose health check or an external monitor to treat a stall as "not ready" rather + * than as a hung probe, and well inside the shared pool's own 10s connection and operation + * timeouts, which bound the underlying query independently. + */ +export const READINESS_DATABASE_TIMEOUT_MS = 2_000; + +export const HEALTH_LIVE_PATH = "/api/v1/health/live"; +export const HEALTH_READY_PATH = "/api/v1/health/ready"; + +export interface ApiHealthOptions { + /** + * One trivial database round trip. Resolves when the database answered; rejects on any failure. + * The rejection is never read — not its message, code, or cause — so nothing a driver says can + * reach a response. The route bounds the wait and never overlaps two calls; this function only + * has to perform the query. + */ + readonly checkDatabase: () => Promise; + /** Whether module startup has completed. `false` again from the moment shutdown begins, so a + * draining instance stops reporting ready while it finishes in-flight requests. */ + readonly isModuleStartupComplete: () => boolean; +} + +const LIVE_BODY = Object.freeze({ status: "live" }); +const READY_BODY = Object.freeze({ status: "ready" }); +const NOT_READY_BODY = Object.freeze({ code: "NOT_READY" }); + +/** + * Wraps `checkDatabase` so at most one call is ever unsettled, and no probe waits longer than + * `timeoutMs`. + * + * Every probe arriving while a check is unsettled shares that check's outcome, including after its + * deadline has passed: the deadline resolves the shared outcome to `false`, so those later probes + * answer immediately. A new check starts only once the previous call has actually settled. A timed- + * out query still occupies a pool connection until the database or the pool's own timeouts end it, + * so starting a fresh query on every probe would let a stalled database drain the pool one health + * check at a time. + */ +function createBoundedDatabaseCheck( + checkDatabase: () => Promise, + timeoutMs: number, +): () => Promise { + let inFlight: Promise | undefined; + + return () => { + if (inFlight !== undefined) { + return inFlight; + } + let timer: ReturnType | undefined; + // The executor runs `checkDatabase` synchronously, so a synchronous throw is a rejection like + // any other rather than an exception escaping the route. + const settled = new Promise((resolve) => resolve(checkDatabase())).then( + () => true, + () => false, + ); + const outcome = Promise.race([ + settled, + new Promise((resolve) => { + timer = setTimeout(() => resolve(false), timeoutMs); + }), + ]); + inFlight = outcome; + void settled.then(() => { + clearTimeout(timer); + inFlight = undefined; + }); + return outcome; + }; +} + +/** The same fixed refusal `./module-realtime-routes.js` uses: Hono lets a GET handler answer HEAD, + * and these routes accept GET alone. */ +function methodNotAllowed(): Response { + return new Response(null, { + headers: { Allow: "GET", "Cache-Control": "no-store" }, + status: 405, + }); +} + +/** + * Unauthenticated liveness and readiness (ADR 0044), for Compose health checks and external + * monitoring. Neither route resolves a principal or reads a cookie, and both return fixed bodies + * that name no module, version, error, or connection detail; `pnpm doctor` remains the detailed + * operator report. They live under `/api/v1/health/`, a sibling of the `/api/v1/modules/` prefix that + * installed modules own entirely, so no module route can shadow them. + */ +export function createHealthRoutes(options: ApiHealthOptions): Hono { + const checkDatabase = createBoundedDatabaseCheck( + options.checkDatabase, + READINESS_DATABASE_TIMEOUT_MS, + ); + const routes = new Hono(); + + routes.all(HEALTH_LIVE_PATH, (c) => { + if (c.req.method !== "GET") { + return methodNotAllowed(); + } + c.header("Cache-Control", "no-store"); + return c.json(LIVE_BODY, 200); + }); + + routes.all(HEALTH_READY_PATH, async (c) => { + if (c.req.method !== "GET") { + return methodNotAllowed(); + } + c.header("Cache-Control", "no-store"); + if (!options.isModuleStartupComplete()) { + return c.json(NOT_READY_BODY, 503); + } + const databaseReady = await checkDatabase(); + // Checked again afterwards: shutdown may have begun while the query was in flight. + return databaseReady && options.isModuleStartupComplete() + ? c.json(READY_BODY, 200) + : c.json(NOT_READY_BODY, 503); + }); + + return routes; +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 8c1bc76..97c18c0 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -48,6 +48,7 @@ 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 { READINESS_DATABASE_TIMEOUT_MS } from "./health-routes.js"; import { prepareStartupModuleInventory } from "./platform-permissions.js"; const connectionTimeoutMs = 10_000; @@ -576,6 +577,10 @@ export async function startApi( 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({ @@ -620,6 +625,12 @@ export async function startApi( }, // A typed view over the same single pool as identity, authorization, and the module facade. dashboard: createDashboardStore(databaseHandle.dashboard), + // 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 @@ -677,6 +688,7 @@ export async function startApi( let shutdownPromise: Promise | undefined; const shutdown = (): Promise => { 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. diff --git a/apps/api/tests/api-http.integration.ts b/apps/api/tests/api-http.integration.ts index 7855e76..8669a3d 100644 --- a/apps/api/tests/api-http.integration.ts +++ b/apps/api/tests/api-http.integration.ts @@ -27,6 +27,12 @@ import { Client, type QueryResultRow } from "pg"; import { afterAll, describe, expect, it, vi } from "vitest"; import type { ClientAddressConfigInput } from "../src/client-address.js"; import { createApiRuntimeConfig } from "../src/config.js"; +import { createApiDatabase } from "../src/database.js"; +import { + HEALTH_LIVE_PATH, + HEALTH_READY_PATH, + READINESS_DATABASE_TIMEOUT_MS, +} from "../src/health-routes.js"; import { MODULE_UI_CACHE_CONTROL, MODULE_UI_CONTENT_TYPE, @@ -2977,6 +2983,56 @@ describe.sequential("live API — published module UI artifact", () => { }); }); +describe.sequential("live API — health", () => { + it("answers liveness and readiness with 200 against a migrated disposable PostgreSQL", async () => { + const database = await createDisposableDatabase("health"); + await applyCoreMigrations(database.url); + const running = await startMigratedApi(database.url); + try { + const origin = apiOrigin(running); + + const live = await fetch(`${origin}${HEALTH_LIVE_PATH}`); + expect(live.status).toBe(200); + expect(live.headers.get("cache-control")).toBe("no-store"); + expect(await live.json()).toEqual({ status: "live" }); + + const ready = await fetch(`${origin}${HEALTH_READY_PATH}`); + expect(ready.status).toBe(200); + expect(ready.headers.get("cache-control")).toBe("no-store"); + expect(await ready.json()).toEqual({ status: "ready" }); + } finally { + await running.shutdown(); + } + }); + + it("pings through the shared pool, and PostgreSQL itself cancels a statement that outlives the ping's transaction-local timeout", async () => { + const database = await createDisposableDatabase("health_ping"); + const handle = createApiDatabase(database.url); + try { + await expect(handle.ping(READINESS_DATABASE_TIMEOUT_MS)).resolves.toBeUndefined(); + } finally { + await handle.close(); + } + + // The ping's exact statement shape, with a statement that cannot finish inside its timeout: the + // server cancels it (query_canceled) long before the sleep would end, on a client that has no + // client-side timeout of its own. + const client = new Client({ connectionString: database.url }); + await client.connect(); + try { + const startedAt = Date.now(); + await expect( + client.query( + "BEGIN READ ONLY; SET LOCAL statement_timeout = 50; SELECT pg_sleep(5); COMMIT", + ), + ).rejects.toMatchObject({ code: "57014" }); + expect(Date.now() - startedAt).toBeLessThan(2_000); + } finally { + await client.end(); + } + }); +}); + describe.sequential("live API — public signup is disabled by default", () => { it("rejects sign-up with a stable sanitized 400 when publicSignupEnabled is not set, creating no user row", async () => { const database = await createDisposableDatabase("signup_disabled"); diff --git a/apps/api/tests/app.test.ts b/apps/api/tests/app.test.ts index 97c27e6..cfd3e45 100644 --- a/apps/api/tests/app.test.ts +++ b/apps/api/tests/app.test.ts @@ -25,6 +25,7 @@ import { } from "../src/app.js"; import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; +import { readyHealth } from "./support/ready-health.js"; import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js"; import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js"; import { emptyModuleStorage } from "./support/in-memory-module-storage.js"; @@ -104,6 +105,7 @@ function createAuthWiringApp(runtime: EkklesiaIdentityRuntime) { moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), + health: readyHealth(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, @@ -121,6 +123,7 @@ describe("Ekklesia API Hono application", () => { moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), + health: readyHealth(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, @@ -355,6 +358,7 @@ describe("Ekklesia API Hono application", () => { moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), + health: readyHealth(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, @@ -378,6 +382,7 @@ describe("Ekklesia API Hono application", () => { moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), + health: readyHealth(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, @@ -403,6 +408,7 @@ describe("Ekklesia API Hono application", () => { moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), + health: readyHealth(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, @@ -430,6 +436,7 @@ describe("Ekklesia API Hono application", () => { moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), + health: readyHealth(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, @@ -457,6 +464,7 @@ describe("Ekklesia API Hono application", () => { moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), + health: readyHealth(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, @@ -481,6 +489,7 @@ describe("Ekklesia API Hono application", () => { moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), + health: readyHealth(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, @@ -507,6 +516,7 @@ describe("Ekklesia API Hono application", () => { moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), + health: readyHealth(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, @@ -530,6 +540,7 @@ describe("Ekklesia API Hono application", () => { moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), + health: readyHealth(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, @@ -555,6 +566,7 @@ describe("Ekklesia API Hono application", () => { moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), + health: readyHealth(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, @@ -587,6 +599,7 @@ describe("Ekklesia API Hono application", () => { moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), + health: readyHealth(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, @@ -620,6 +633,7 @@ describe("Ekklesia API Hono application", () => { moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), + health: readyHealth(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, @@ -658,6 +672,7 @@ describe("Ekklesia API Hono application", () => { moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), + health: readyHealth(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime: fakeRuntime(), @@ -678,6 +693,7 @@ describe("Ekklesia API Hono application", () => { moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), + health: readyHealth(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime: fakeRuntime(), @@ -700,6 +716,7 @@ describe("Ekklesia API Hono application", () => { moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), + health: readyHealth(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime: fakeRuntime(), @@ -721,6 +738,7 @@ describe("Ekklesia API Hono application", () => { moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), + health: readyHealth(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime: fakeRuntime(), @@ -740,6 +758,7 @@ describe("Ekklesia API Hono application", () => { moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), + health: readyHealth(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime: fakeRuntime(), @@ -766,6 +785,7 @@ describe("Ekklesia API Hono application", () => { moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), + health: readyHealth(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime: fakeRuntime(), @@ -795,6 +815,7 @@ describe("Ekklesia API Hono application", () => { moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), + health: readyHealth(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }), runtime: fakeRuntime(), @@ -827,6 +848,7 @@ describe("Ekklesia API Hono application", () => { moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), + health: readyHealth(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize }, @@ -850,6 +872,7 @@ describe("Ekklesia API Hono application", () => { moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), + health: readyHealth(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime: fakeRuntime(), diff --git a/apps/api/tests/dashboard-routes.test.ts b/apps/api/tests/dashboard-routes.test.ts index 1a71f98..7c34463 100644 --- a/apps/api/tests/dashboard-routes.test.ts +++ b/apps/api/tests/dashboard-routes.test.ts @@ -27,6 +27,7 @@ import { createApiApp } from "../src/app.js"; import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js"; import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; +import { readyHealth } from "./support/ready-health.js"; import { createInMemoryModuleLifecycleStore, moduleLifecycleSnapshotFor, @@ -139,6 +140,7 @@ async function install(options: InstallOptions = {}): Promise<{ app: Hono }> { const app = createApiApp({ authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store }, dashboard: createInMemoryDashboardStore(options.configuration ?? {}), + health: readyHealth(), moduleRoutes: Object.freeze([]), moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY, moduleServices: Object.freeze({ diff --git a/apps/api/tests/health-routes.test.ts b/apps/api/tests/health-routes.test.ts new file mode 100644 index 0000000..37e5cbb --- /dev/null +++ b/apps/api/tests/health-routes.test.ts @@ -0,0 +1,371 @@ +import { + ANONYMOUS_PRINCIPAL, + type ModuleDatabase, + type ModuleRouteHandler, + type ModuleRuntimeServices, + parsePermissionId, + type RegisteredModuleHttpRoute, +} from "@ekklesia/contracts"; +import { + type AuthorizationEvaluator, + type AuthorizationStore, + createPermissionRegistry, +} from "@ekklesia/core/authorization"; +import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; +import { + createModuleUiPublicationRegistry, + EMPTY_MODULE_REALTIME_REGISTRY, +} from "@ekklesia/core/modules"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createApiApp } from "../src/app.js"; +import { + type ApiHealthOptions, + HEALTH_LIVE_PATH, + HEALTH_READY_PATH, + READINESS_DATABASE_TIMEOUT_MS, +} from "../src/health-routes.js"; +import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; +import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js"; +import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js"; +import { emptyModuleStorage } from "./support/in-memory-module-storage.js"; + +const trustedOrigin = "https://dashboard.example.test"; +const modulePermission = parsePermissionId("health.status.read"); + +/** What a real driver failure can carry, and what must therefore never reach a response. */ +const DRIVER_FAILURE_DETAIL = + "connect ECONNREFUSED postgresql://ekklesia:hunter2@db.internal:5432/ekklesia SELECT 1"; + +function fakeRuntime(): EkklesiaIdentityRuntime { + return { + handleAuthRequest: vi.fn(), + resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL), + }; +} + +function fakeStore(): AuthorizationStore { + return { + createGrant: vi.fn(), + grantPlatformOwner: vi.fn(), + hasActiveGrant: vi.fn().mockResolvedValue(false), + isPlatformOwner: vi.fn().mockResolvedValue(false), + listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }), + revokeGrant: vi.fn(), + revokePlatformOwner: vi.fn(), + }; +} + +function fakeModuleServices(): ModuleRuntimeServices { + return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase }); +} + +function healthOptions(overrides: Partial = {}) { + return { + checkDatabase: vi.fn<() => Promise>().mockResolvedValue(undefined), + isModuleStartupComplete: vi.fn<() => boolean>().mockReturnValue(true), + ...overrides, + }; +} + +function buildApp( + health: ApiHealthOptions, + moduleRoutes: readonly RegisteredModuleHttpRoute[] = Object.freeze([]), +) { + const evaluator: AuthorizationEvaluator = { + authorize: vi.fn().mockResolvedValue({ allowed: true }), + }; + const runtime = fakeRuntime(); + const app = createApiApp({ + authorization: { + evaluator, + registry: createPermissionRegistry([ + { baseline: "public", description: "Test permission.", id: modulePermission }, + ]), + store: fakeStore(), + }, + moduleRoutes, + moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY, + moduleServices: fakeModuleServices(), + moduleUi: createModuleUiPublicationRegistry([]), + moduleLifecycle: emptyModuleLifecycle(), + moduleConfiguration: emptyModuleConfiguration(), + moduleStorage: emptyModuleStorage(), + dashboard: createInMemoryDashboardStore(), + health, + runtime, + trustedOrigins: [trustedOrigin], + }); + return { app, evaluator, runtime }; +} + +/** Resolves once every already-queued microtask has run, using the real `setImmediate` — the only + * timer functions these tests fake are `setTimeout` and `clearTimeout`. */ +function drainMicrotasks(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} + +/** Whether `promise` settled with no fake time advanced at all: "promptly", made exact. */ +async function settlesWithoutWaiting(promise: Response | Promise): Promise { + let settled = false; + void Promise.resolve(promise).then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + await drainMicrotasks(); + return settled; +} + +async function expectNotReady(response: Response): Promise { + expect(response.status).toBe(503); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(await response.json()).toEqual({ code: "NOT_READY" }); +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("GET /api/v1/health/live", () => { + it("returns 200 with a fixed body and Cache-Control: no-store", async () => { + const health = healthOptions(); + const { app } = buildApp(health); + + const response = await app.request(HEALTH_LIVE_PATH); + + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(response.headers.get("content-type")).toMatch(/^application\/json/u); + expect(await response.json()).toEqual({ status: "live" }); + }); + + it("does not consult the database or module startup, so it stays 200 while readiness is 503", async () => { + const health = healthOptions({ + checkDatabase: vi.fn().mockRejectedValue(new Error(DRIVER_FAILURE_DETAIL)), + isModuleStartupComplete: vi.fn().mockReturnValue(false), + }); + const { app } = buildApp(health); + + const response = await app.request(HEALTH_LIVE_PATH); + + expect(response.status).toBe(200); + expect(health.checkDatabase).not.toHaveBeenCalled(); + expect(health.isModuleStartupComplete).not.toHaveBeenCalled(); + }); +}); + +describe("GET /api/v1/health/ready", () => { + it("returns 200 with a fixed body when the database answers and module startup completed", async () => { + const health = healthOptions(); + const { app } = buildApp(health); + + const response = await app.request(HEALTH_READY_PATH); + + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(await response.json()).toEqual({ status: "ready" }); + expect(health.checkDatabase).toHaveBeenCalledTimes(1); + }); + + it("returns a fixed 503 when the database query fails, carrying nothing from the failure", async () => { + const health = healthOptions({ + checkDatabase: vi.fn().mockRejectedValue(new Error(DRIVER_FAILURE_DETAIL)), + }); + const { app } = buildApp(health); + + const response = await app.request(HEALTH_READY_PATH); + const text = await response.clone().text(); + + await expectNotReady(response); + for (const fragment of ["ECONNREFUSED", "hunter2", "db.internal", "SELECT", "Error"]) { + expect(text).not.toContain(fragment); + } + }); + + it("treats a check that throws synchronously exactly like one that rejects", async () => { + const health = healthOptions({ + checkDatabase: vi.fn(() => { + throw new Error(DRIVER_FAILURE_DETAIL); + }), + }); + const { app } = buildApp(health); + + await expectNotReady(await app.request(HEALTH_READY_PATH)); + }); + + it("returns 503 without touching the database before module startup completed", async () => { + const health = healthOptions({ isModuleStartupComplete: vi.fn().mockReturnValue(false) }); + const { app } = buildApp(health); + + await expectNotReady(await app.request(HEALTH_READY_PATH)); + expect(health.checkDatabase).not.toHaveBeenCalled(); + }); + + it("returns 503 when shutdown began while the database query was in flight", async () => { + let startupComplete = true; + const health = healthOptions({ + checkDatabase: vi.fn(async () => { + startupComplete = false; + }), + isModuleStartupComplete: () => startupComplete, + }); + const { app } = buildApp(health); + + await expectNotReady(await app.request(HEALTH_READY_PATH)); + }); + + it("starts a fresh check for each sequential probe rather than caching a success", async () => { + const health = healthOptions(); + const { app } = buildApp(health); + + await app.request(HEALTH_READY_PATH); + await app.request(HEALTH_READY_PATH); + + expect(health.checkDatabase).toHaveBeenCalledTimes(2); + }); + + it("reports ready for a slow query that still answers inside the bound", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + let answer: () => void = () => undefined; + const health = healthOptions({ + checkDatabase: vi.fn( + () => + new Promise((resolve) => { + answer = resolve; + }), + ), + }); + const { app } = buildApp(health); + + const pending = app.request(HEALTH_READY_PATH); + await vi.advanceTimersByTimeAsync(READINESS_DATABASE_TIMEOUT_MS - 1); + answer(); + + expect((await pending).status).toBe(200); + }); +}); + +describe("readiness never piles up database checks", () => { + it("keeps exactly one check in flight for a query that never settles, while every probe answers 503 promptly", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const health = healthOptions({ + checkDatabase: vi.fn(() => new Promise(() => undefined)), + }); + const { app } = buildApp(health); + + // Probes arriving inside the first check's window share it and its single deadline. + const first = app.request(HEALTH_READY_PATH); + const concurrent = app.request(HEALTH_READY_PATH); + await drainMicrotasks(); + expect(health.checkDatabase).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(READINESS_DATABASE_TIMEOUT_MS); + await expectNotReady(await first); + await expectNotReady(await concurrent); + + // After the deadline the query is still unsettled: no new check starts, and each probe answers + // without waiting out another bound. + for (let probe = 0; probe < 20; probe += 1) { + const pending = app.request(HEALTH_READY_PATH); + expect(await settlesWithoutWaiting(pending)).toBe(true); + await expectNotReady(await pending); + } + expect(health.checkDatabase).toHaveBeenCalledTimes(1); + }); + + it("starts a new check only once the timed-out query has actually settled", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + let settleHungQuery: (error: Error) => void = () => undefined; + const checkDatabase = vi + .fn<() => Promise>() + .mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + settleHungQuery = reject; + }), + ) + .mockResolvedValue(undefined); + const { app } = buildApp(healthOptions({ checkDatabase })); + + const first = app.request(HEALTH_READY_PATH); + await vi.advanceTimersByTimeAsync(READINESS_DATABASE_TIMEOUT_MS); + await expectNotReady(await first); + await expectNotReady(await app.request(HEALTH_READY_PATH)); + expect(checkDatabase).toHaveBeenCalledTimes(1); + + // The database cancels the abandoned statement; the next probe may finally ask again. + settleHungQuery(new Error(DRIVER_FAILURE_DETAIL)); + await drainMicrotasks(); + + const recovered = await app.request(HEALTH_READY_PATH); + expect(recovered.status).toBe(200); + expect(checkDatabase).toHaveBeenCalledTimes(2); + }); +}); + +describe("health route boundaries", () => { + it.each([ + HEALTH_LIVE_PATH, + HEALTH_READY_PATH, + ])("%s accepts GET only, answering every other method with a fixed 405 and never checking readiness", async (path) => { + const health = healthOptions(); + const { app } = buildApp(health); + + for (const method of ["HEAD", "POST", "PUT", "PATCH", "DELETE"]) { + const response = await app.request(path, { method }); + expect(response.status, method).toBe(405); + expect(response.headers.get("allow")).toBe("GET"); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(await response.text()).toBe(""); + } + expect(health.checkDatabase).not.toHaveBeenCalled(); + expect(health.isModuleStartupComplete).not.toHaveBeenCalled(); + }); + + it.each([ + HEALTH_LIVE_PATH, + HEALTH_READY_PATH, + ])("%s is unauthenticated: it never resolves a principal or evaluates authorization, even with a cookie", async (path) => { + const { app, evaluator, runtime } = buildApp(healthOptions()); + + const response = await app.request(path, { + headers: { Cookie: "better-auth.session_token=anything", Origin: trustedOrigin }, + }); + + expect(response.status).toBe(200); + expect(runtime.resolvePrincipal).not.toHaveBeenCalled(); + expect(runtime.handleAuthRequest).not.toHaveBeenCalled(); + expect(evaluator.authorize).not.toHaveBeenCalled(); + }); + + it('cannot be shadowed by a module, even one whose id is "health"', async () => { + const moduleHandler = vi.fn( + async () => new Response("module", { status: 200 }), + ); + const moduleRoutes = ["/live", "/ready"].map( + (path) => + Object.freeze({ + bodyMode: "buffered", + handler: moduleHandler, + maxBodyBytes: 1024, + method: "GET", + moduleId: "health", + path, + permission: modulePermission, + }) as RegisteredModuleHttpRoute, + ); + const { app } = buildApp(healthOptions(), moduleRoutes); + + expect(await (await app.request(HEALTH_LIVE_PATH)).json()).toEqual({ status: "live" }); + expect(await (await app.request(HEALTH_READY_PATH)).json()).toEqual({ status: "ready" }); + expect(moduleHandler).not.toHaveBeenCalled(); + + // The module's own routes still live where every module route lives, and only there. + const moduleResponse = await app.request("/api/v1/modules/health/ready"); + expect(await moduleResponse.text()).toBe("module"); + expect(moduleHandler).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/api/tests/lifecycle.test.ts b/apps/api/tests/lifecycle.test.ts index dace951..2efe957 100644 --- a/apps/api/tests/lifecycle.test.ts +++ b/apps/api/tests/lifecycle.test.ts @@ -36,6 +36,11 @@ import { PermissionRegistryError } from "@ekklesia/core/authorization"; import { createApiApp } from "../src/app.js"; import { createApiRuntimeConfig } from "../src/config.js"; import { DisplayHostAssetError } from "../src/display-host-assets.js"; +import { + HEALTH_LIVE_PATH, + HEALTH_READY_PATH, + READINESS_DATABASE_TIMEOUT_MS, +} from "../src/health-routes.js"; import { installShutdownSignalHandlers, startApi } from "../src/server.js"; function validConfig() { @@ -447,6 +452,36 @@ describe("Ekklesia API shutdown lifecycle", () => { expect(poolEnd).toHaveBeenCalledTimes(1); }); + it("answers readiness with one bounded ping on the shared pool, and turns it 503 the moment shutdown begins", async () => { + const query = emptyQuery(); + poolConstructor.mockImplementation(function fakePool() { + return { end: vi.fn().mockResolvedValue(undefined), on: vi.fn(), query }; + }); + betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() }); + mockSuccessfulServe(); + + const running = await startApi(validConfig()); + query.mockClear(); + + const ready = await running.app.request(HEALTH_READY_PATH); + expect(ready.status).toBe(200); + expect(query).toHaveBeenCalledTimes(1); + // The statement timeout is server-side and scoped to this one transaction, so PostgreSQL itself + // cancels a stalled ping rather than the route merely abandoning it. + expect(query).toHaveBeenCalledWith( + `BEGIN READ ONLY; SET LOCAL statement_timeout = ${READINESS_DATABASE_TIMEOUT_MS}; SELECT 1; COMMIT`, + ); + + const shutdown = running.shutdown(); + const draining = await running.app.request(HEALTH_READY_PATH); + expect(draining.status).toBe(503); + expect(await draining.json()).toEqual({ code: "NOT_READY" }); + // Draining is not dead: liveness still answers while in-flight requests finish. + expect((await running.app.request(HEALTH_LIVE_PATH)).status).toBe(200); + expect(query).toHaveBeenCalledTimes(1); + await shutdown; + }); + it("keeps shutdown idempotent for calls made after the first has already resolved", async () => { const poolEnd = vi.fn().mockResolvedValue(undefined); poolConstructor.mockImplementation(function fakePool() { diff --git a/apps/api/tests/module-configuration-routes.test.ts b/apps/api/tests/module-configuration-routes.test.ts index 6f37cda..c8dca50 100644 --- a/apps/api/tests/module-configuration-routes.test.ts +++ b/apps/api/tests/module-configuration-routes.test.ts @@ -35,6 +35,7 @@ import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js"; import { MODULE_CONFIGURATION_PATH } from "../src/module-configuration-routes.js"; import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; +import { readyHealth } from "./support/ready-health.js"; import { createFakeModuleSecretSource, createInMemoryModuleConfigurationStore, @@ -189,6 +190,7 @@ async function install(options: InstallOptions = {}): Promise { store: authorizationStore, }, dashboard: createInMemoryDashboardStore(), + health: readyHealth(), moduleConfiguration: { bootSettings, discoveredModules: modules, diff --git a/apps/api/tests/module-display-routes.test.ts b/apps/api/tests/module-display-routes.test.ts index 644c6a2..9799e97 100644 --- a/apps/api/tests/module-display-routes.test.ts +++ b/apps/api/tests/module-display-routes.test.ts @@ -38,6 +38,7 @@ import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js"; import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js"; import { moduleDisplayUiArtifactUrl } from "../src/module-ui-routes.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; +import { readyHealth } from "./support/ready-health.js"; import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js"; import { createInMemoryModuleLifecycleStore, @@ -198,6 +199,7 @@ async function install(options: InstallOptions): Promise { store: fakeAuthorizationStore(), }, dashboard: createInMemoryDashboardStore(), + health: readyHealth(), ...(moduleUi.listDisplays().length > 0 && options.withAssets !== false ? { displayHostAssets: options.assets ?? displayHostAssets } : {}), diff --git a/apps/api/tests/module-http-routes.test.ts b/apps/api/tests/module-http-routes.test.ts index c7d1b3d..69acd55 100644 --- a/apps/api/tests/module-http-routes.test.ts +++ b/apps/api/tests/module-http-routes.test.ts @@ -23,6 +23,7 @@ import { } from "@ekklesia/core/modules"; import { describe, expect, it, vi } from "vitest"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; +import { readyHealth } from "./support/ready-health.js"; import { createApiApp } from "../src/app.js"; import { canonicalizeVaryHeader, @@ -123,6 +124,7 @@ function buildModuleApp(options: { moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), + health: readyHealth(), runtime, trustedOrigins: options.trustedOrigins ?? [trustedOrigin], }); diff --git a/apps/api/tests/module-lifecycle-routes.test.ts b/apps/api/tests/module-lifecycle-routes.test.ts index df10630..f7b94dd 100644 --- a/apps/api/tests/module-lifecycle-routes.test.ts +++ b/apps/api/tests/module-lifecycle-routes.test.ts @@ -33,6 +33,7 @@ import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js"; import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js"; import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; +import { readyHealth } from "./support/ready-health.js"; import { createInMemoryModuleLifecycleStore } from "./support/in-memory-module-lifecycle.js"; import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js"; import { moduleStorageOptionFor } from "./support/in-memory-module-storage.js"; @@ -171,6 +172,7 @@ async function install(options: InstallOptions = {}): Promise { store: authorizationStore, }, dashboard: createInMemoryDashboardStore(), + health: readyHealth(), moduleConfiguration: moduleConfigurationOptionFor(modules), moduleStorage: moduleStorageOptionFor(modules), moduleLifecycle: { diff --git a/apps/api/tests/module-ui-host-e2e.test.ts b/apps/api/tests/module-ui-host-e2e.test.ts index 33c7216..d34b6ca 100644 --- a/apps/api/tests/module-ui-host-e2e.test.ts +++ b/apps/api/tests/module-ui-host-e2e.test.ts @@ -36,6 +36,7 @@ import { } from "@ekklesia/shell"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; +import { readyHealth } from "./support/ready-health.js"; import { createApiApp } from "../src/app.js"; import { createDisplayHostAssets } from "../src/display-host-assets.js"; import { @@ -288,6 +289,7 @@ async function startHarness(options: HarnessOptions = {}): Promise { moduleConfiguration: moduleConfigurationOptionFor(modules), moduleStorage: moduleStorageOptionFor(modules), dashboard: createInMemoryDashboardStore(), + health: readyHealth(), runtime, trustedOrigins: ["https://dashboard.example.test"], }); diff --git a/apps/api/tests/module-ui-inventory.test.ts b/apps/api/tests/module-ui-inventory.test.ts index 43521d2..daa0cdf 100644 --- a/apps/api/tests/module-ui-inventory.test.ts +++ b/apps/api/tests/module-ui-inventory.test.ts @@ -25,6 +25,7 @@ import { import type { Hono } from "hono"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; +import { readyHealth } from "./support/ready-health.js"; import { createApiApp } from "../src/app.js"; import { moduleApiBaseUrl } from "../src/module-http-routes.js"; import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js"; @@ -208,6 +209,7 @@ async function install(options: InstallOptions): Promise { moduleConfiguration: moduleConfigurationOptionFor(modules), moduleStorage: moduleStorageOptionFor(modules), dashboard: createInMemoryDashboardStore(), + health: readyHealth(), runtime, trustedOrigins: [trustedOrigin], }); diff --git a/apps/api/tests/module-ui-routes.test.ts b/apps/api/tests/module-ui-routes.test.ts index 530433d..2b1f4fa 100644 --- a/apps/api/tests/module-ui-routes.test.ts +++ b/apps/api/tests/module-ui-routes.test.ts @@ -21,6 +21,7 @@ import { Window } from "happy-dom"; import type { Hono } from "hono"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; +import { readyHealth } from "./support/ready-health.js"; import { createApiApp } from "../src/app.js"; import { createDisplayHostAssets } from "../src/display-host-assets.js"; import { @@ -255,6 +256,7 @@ async function install( moduleConfiguration: moduleConfigurationOptionFor(modules), moduleStorage: moduleStorageOptionFor(modules), dashboard: createInMemoryDashboardStore(), + health: readyHealth(), runtime, trustedOrigins: [trustedOrigin], }); diff --git a/apps/api/tests/support/ready-health.ts b/apps/api/tests/support/ready-health.ts new file mode 100644 index 0000000..b6d75cb --- /dev/null +++ b/apps/api/tests/support/ready-health.ts @@ -0,0 +1,10 @@ +import type { ApiHealthOptions } from "../../src/health-routes.js"; + +/** Readiness inputs for a composition whose tests do not exercise health: the database answers and + * module startup has completed. Health behavior itself is tested in `../health-routes.test.ts`. */ +export function readyHealth(): ApiHealthOptions { + return { + checkDatabase: async () => undefined, + isModuleStartupComplete: () => true, + }; +} diff --git a/docs/development/api.md b/docs/development/api.md index 1cd9b90..a1d0cd5 100644 --- a/docs/development/api.md +++ b/docs/development/api.md @@ -77,6 +77,8 @@ No real values are shown here — configure per environment and never commit the | `/api/auth/{get-session,list-sessions,update-session,change-password}` | Deliberately unavailable | Token-bearing native session inspection/mutation endpoints are denied at the Ekklesia HTTP boundary with sanitized `404 {"code":"NOT_FOUND"}`. Use `/api/v1/principal`; no browser session-list/update or password-management contract exists. | | `POST\|GET /api/auth/*` | Better Auth (mounted directly) | Supported Better Auth routes retain their native shapes except for the successful email sign-in normalization above. The four denied paths above never reach Better Auth. See [Better Auth's email/password docs](https://www.better-auth.com/docs/authentication/email-password) for supported endpoints such as `/sign-up/email` and `/sign-out`. | | `GET /api/v1/principal` | Ekklesia | Returns the minimal `EkklesiaPrincipal` contract as JSON. `Cache-Control: no-store`. | +| `GET /api/v1/health/live` | Ekklesia | Unauthenticated liveness: fixed `200 {"status":"live"}` whenever the process is serving HTTP. `Cache-Control: no-store`; GET only. See [Health](#health). | +| `GET /api/v1/health/ready` | Ekklesia | Unauthenticated readiness: fixed `200 {"status":"ready"}` only when a bounded database ping succeeds and module startup completed, otherwise `503 {"code":"NOT_READY"}`. `Cache-Control: no-store`; GET only. See [Health](#health). | | `GET\|POST /api/v1/grants*` | Ekklesia | Explicit authorization grant management, gated by `core.authorization.grants.manage`. Listing is bounded keyset pagination; creation is unscoped-only and conflicts (`409`) on an equivalent effective grant; revocation clears every unrevoked equivalent. See [Authorization](authorization.md#explicit-grant-http-api). | | `/api/v1/modules//*` | Installed modules | Every route an installed module registered during its `register` hook, gated by that route's own declared permission. See [Module HTTP routes](#module-http-routes). | | `GET /api/v1/module-realtime//` | Installed modules | Snapshot-first Server-Sent Events for one registered module-local channel, gated by that channel's declared permission. See [Module realtime](#module-realtime). | @@ -1420,6 +1422,51 @@ TLS protects the *connection*, not the exposure: it does not make an internet-re safe, and the network boundary remains the deployment's own responsibility. See [ADR 0018](../architecture/decisions/0018-identity-session-resolution-and-database-transport.md). +## Health + +Two unauthenticated endpoints exist for Compose health checks and external monitoring +([ADR 0044](../architecture/decisions/0044-self-hosted-reference-deployment.md)). They are +deliberately uninformative: neither names a module, version, dependency, error, or connection +detail, and `pnpm doctor` remains the detailed operator report. + +| Request | Answer | +| --- | --- | +| `GET /api/v1/health/live` | Always `200 {"status":"live"}`. Liveness consults nothing; if the process can answer, it is live. | +| `GET /api/v1/health/ready`, ready | `200 {"status":"ready"}` | +| `GET /api/v1/health/ready`, not ready | `503 {"code":"NOT_READY"}` | +| Any other method on either path, including `HEAD` | `405` with `Allow: GET` and no body | + +Every answer sets `Cache-Control: no-store`. Neither route resolves a principal, reads a cookie, or +evaluates authorization. Both live under `/api/v1/health/`, a sibling of the `/api/v1/modules/` +prefix, so no module route can shadow them, including a module whose id is `health`. As for every +`/api/v1/*` route, an `OPTIONS` request is answered by the platform's CORS preflight and never +reaches the handlers. + +Readiness is `200` only when **both** of the following hold: + +- **Module startup completed, and shutdown has not begun.** `startApi()` binds the HTTP listener + only after the module runtime has started, so this holds whenever the server is answering. The + first `shutdown()` call clears it before anything drains, so a draining instance reports `503` + while liveness still reports `200`. It is checked again after the database ping, in case shutdown + began meanwhile. +- **The database answered a trivial query within 2 seconds** (`READINESS_DATABASE_TIMEOUT_MS`). + The ping runs on the one shared pool as a single message, + `BEGIN READ ONLY; SET LOCAL statement_timeout = 2000; SELECT 1; COMMIT`. The transaction-local + timeout means PostgreSQL cancels a stalled statement itself rather than leaving it running. On + any failure the pool discards that connection instead of reusing it. The pool's own 10-second + connection, query, and statement timeouts remain the outer bound, for example for a database + that cannot be reached at all. + +**Pings never pile up.** At most one ping is ever unsettled. Probes that arrive while it runs share +its result and its single 2-second deadline. Once the deadline passes they answer `503` immediately, +without starting another query, until that ping has actually settled. A stalled database therefore +holds at most one pool connection on behalf of health checks, however often a monitor polls. A +successful result is never cached: every probe after a settled ping runs a new one. + +A failed ping is never inspected. The response is the same fixed `503` whether the database +refused the connection, timed out, rejected the credentials, or was mid-restart, and nothing about +the failure is logged by the route. + ## Graceful shutdown `startApi()` (the composition function `apps/api/src/server.ts` exports, used by both the