feat(api): add unauthenticated health endpoints
CI / verify (pull_request) Successful in 45s
CI / infrastructure-smoke (pull_request) Successful in 12s
CI / migration-smoke (pull_request) Successful in 1m42s

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 <[email protected]>
This commit is contained in:
ekklesia
2026-10-01 16:47:01 -05:00
co-authored by Claude Opus 5.5
parent d593c10f1d
commit aaec9789c4
18 changed files with 727 additions and 0 deletions
+56
View File
@@ -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");