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
+47
View File
@@ -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/<module-id>/*` | 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/<module-id>/<channel>` | 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