Every PostgreSQL connection this repository opened was plaintext, in every environment: no connection site set `ssl` at all, so credentials, session tokens, and row data crossed the network in the clear and nothing in configuration, startup, or `pnpm doctor` said so. The public identity URL boundary was also looser than it appeared. `core/src/database/transport.ts` is now the one typed policy. It has exactly two modes: `verify-full` (TLS required, certificate chain verified, and server identity verified against the configured host — the production default) and `disable` (the development/test default, refused outright in production). There is deliberately no encryption-only `require` mode, no chain-only `verify-ca`, and no `rejectUnauthorized: false` escape hatch anywhere in the configuration surface; every loose or aliased spelling is rejected in both environments, so a typo never quietly becomes a weaker mode. Production has no plaintext exception at all, not even for a loopback database — a connection string cannot prove the database is process-local. Verification is certificate *and* hostname. The policy supplies an explicit `checkServerIdentity` pinned to the host parsed from DATABASE_URL, because the pinned driver sets TLS `servername` only for non-IP hosts and Node performs no identity check when it is unset — so `rejectUnauthorized: true` alone accepts a certificate from any host the trusted CA ever signed when connecting by IP. Both halves are proven against a real TLS-enabled PostgreSQL, including that the same connection without the pinned callback succeeds. Every CA, certificate, and key is generated at test runtime and deleted afterwards. A DATABASE_URL may no longer carry its own TLS parameters. The pinned driver merges parsed connection-string values over explicitly passed options, so a `?sslmode=disable` in the URL would silently replace the policy's verified `ssl` object; that now fails with a stable code, reported by both `pnpm doctor` and the migration CLIs rather than as "failed unexpectedly". One shared environment reader keeps `process.env` out of Core's reusable constructors, and every executable that opens a connection resolves the policy identically: the API (validated before any pool, Better Auth instance, module runtime, or listener exists), both migration commands, the bootstrap CLI, and Doctor's own connection. Doctor reports it as a blocking failure, never a warning, naming only the effective mode. Separately, the identity base URL and every trusted origin must now be exactly one canonical origin — the WHATWG origin serialization must equal the supplied string — so a path, trailing slash, credentials, explicit default port, or non-canonical case is rejected rather than normalized into something that no longer matches what a browser sends. Production's loopback rejection now classifies the address instead of comparing against three literals, covering `.localhost`, all of 127.0.0.0/8, 0.0.0.0, `::`, expanded and IPv4-mapped forms. This is a configuration-shape guarantee only: it does not resolve DNS or prove reachability, and TLS protects the connection, not the exposure. Co-Authored-By: Claude Opus 5 <[email protected]>
632 lines
46 KiB
Markdown
632 lines
46 KiB
Markdown
# API
|
||
|
||
Phase 4B adds `apps/api`: a minimal Hono-based HTTP transport that mounts Better Auth and exposes
|
||
one Ekklesia-owned route to resolve the current principal. It does not add a Dashboard, Website, or
|
||
Mobile Surface, and it does not add roles, permissions, or a platform-owner concept — see
|
||
[ADR 0011](../architecture/decisions/0011-identity-http-transport-and-public-enrollment.md) for the
|
||
full architectural decision and [Identity](identity.md) for the identity foundation this builds on.
|
||
A follow-up corrective slice hardened this transport (sanitized Better Auth logging,
|
||
production-equivalent origin/CSRF protection under the test runner, bounded identity database
|
||
timeouts, idempotent shutdown, and pinned password-length boundaries) without changing any of the
|
||
above; see [ADR 0012](../architecture/decisions/0012-identity-origin-csrf-protection-under-test.md).
|
||
Phase 4C adds Core's authorization foundation and, on top of this transport, a platform-owner
|
||
bootstrap command plus an enforced explicit-grant HTTP API — see [Authorization](authorization.md).
|
||
Phase 5D pass 1 hardened this same public perimeter against an independent security audit: a
|
||
server-owned client-address resolution boundary for the rate limiter, public sign-up disabled by
|
||
default, an explicit request-body-size policy for every public route (including a module route's
|
||
own declared contract), and finite Node header/request timeouts — see
|
||
[ADR 0017](../architecture/decisions/0017-public-request-perimeter-hardening.md).
|
||
|
||
## What this is, and is not
|
||
|
||
After this slice it is possible to start the API, sign in, resolve a request to an Ekklesia
|
||
principal, and sign out; public account creation is also possible once explicitly enabled (see
|
||
[Public registration policy](#public-registration-policy)). It is **not** possible to verify an
|
||
email address, recover a forgotten password, use any provider besides email/password, reach a
|
||
Dashboard, or hold any role or permission — none of that exists yet. **Public registration is not
|
||
ready for a real public launch**: there is no email delivery service, so an unverified email is
|
||
trusted as entered and a forgotten password has no recovery path, and no durable, distributed
|
||
abuse control exists yet even with signup enabled.
|
||
|
||
## Starting the API
|
||
|
||
```bash
|
||
pnpm migrations:apply # the database must already be migrated — the API never does this itself
|
||
pnpm api:start
|
||
```
|
||
|
||
`pnpm api:start` follows the repository's build-first convention (`turbo run build`) so it works
|
||
from a clean checkout, then runs the compiled `apps/api/dist/server.js`. It reads configuration
|
||
from the environment (optionally via an uncommitted `.env`, loaded with
|
||
`--env-file-if-exists=.env`) and fails with a concise, sanitized error and a nonzero exit code if
|
||
required configuration is missing or invalid — it never applies migrations, creates the identity
|
||
tables, or repairs schema, and it fails safely (without mutating the database) if the required
|
||
schema is absent.
|
||
|
||
### Required configuration
|
||
|
||
No real values are shown here — configure per environment and never commit them:
|
||
|
||
| Variable | Requirement |
|
||
| --- | --- |
|
||
| `DATABASE_URL` | An absolute `postgres://` or `postgresql://` URL to an already-migrated database. It must **not** carry a TLS parameter (`sslmode`, `ssl`, `sslrootcert`, …) — see [Database transport (TLS)](#database-transport-tls). |
|
||
| `EKKLESIA_DATABASE_TLS_MODE` | Optional. Exactly `"verify-full"` or `"disable"`. Defaults to `verify-full` in production and `disable` in development/test. Production refuses `disable`. |
|
||
| `EKKLESIA_DATABASE_CA` | Optional. PEM text of a private certificate authority to trust in addition to the system store, for a database using an internal CA. |
|
||
| `EKKLESIA_API_HOST` | Optional. Listen address. Defaults to loopback (`127.0.0.1`). An explicit non-loopback address is permitted for future deployment. |
|
||
| `EKKLESIA_API_PORT` | Optional. Listen port, `0`–`65535` (`0` requests an OS-assigned port). Defaults to `3000`. |
|
||
| `EKKLESIA_IDENTITY_SECRET` | At least 32 characters, high entropy (for example `openssl rand -base64 32`). Same requirement as Phase 4A. |
|
||
| `EKKLESIA_IDENTITY_BASE_URL` | Exactly one canonical `http`/`https` origin — scheme, host, and non-default port only, with no path, trailing slash, query, fragment, credentials, or explicit default port. `https` is required in production; loopback and unspecified hosts are rejected in production. |
|
||
| `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` | Comma-separated list of canonical origins, validated identically to the base URL (no wildcards). Used for both Better Auth's own CSRF/origin checks and the API's CORS policy — there is only one trusted-origin list. |
|
||
| `EKKLESIA_PUBLIC_SIGNUP_ENABLED` | Optional, **development/test only**. Exactly `"true"` enables public sign-up when `NODE_ENV=development`; defaults to disabled, and a production configuration rejects `"true"` outright. Omitted, misspelled, or any other value never enables it. See [Public registration policy](#public-registration-policy). |
|
||
| `EKKLESIA_CLIENT_ADDRESS_MODE` | Optional. `"direct"` (default when unset) or `"trusted-proxy"`. See [Client-address resolution](#client-address-resolution). |
|
||
| `EKKLESIA_CLIENT_ADDRESS_HEADER` | Required only when `EKKLESIA_CLIENT_ADDRESS_MODE=trusted-proxy`. The one forwarded-header name the reverse proxy is trusted to set (for example `x-forwarded-for`). |
|
||
| `EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES` | Required only when `EKKLESIA_CLIENT_ADDRESS_MODE=trusted-proxy`. Comma-separated list of the proxy's own IPv4/IPv6 addresses or CIDR ranges — never a broad private range that also covers real clients. |
|
||
| `NODE_ENV` | `development` explicitly opts into relaxed (loopback/`http`) origin validation for local development. Anything else, including unset, validates as production. |
|
||
|
||
## Routes
|
||
|
||
| Route | Owner | Notes |
|
||
| --- | --- | --- |
|
||
| `POST\|GET /api/auth/*` | Better Auth (mounted directly) | Better Auth's own request/response shapes pass through unmodified — see [Better Auth's email/password docs](https://www.better-auth.com/docs/authentication/email-password) for the exact endpoints (`/sign-up/email`, `/sign-in/email`, `/sign-out`, `/get-session`, ...). |
|
||
| `GET /api/v1/principal` | Ekklesia | Returns the minimal `EkklesiaPrincipal` contract as JSON. `Cache-Control: no-store`. |
|
||
| `GET\|POST /api/v1/grants*` | Ekklesia | Explicit authorization grant management, gated by `core.authorization.grants.manage`. 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). |
|
||
|
||
Better Auth's handler is unreachable outside `/api/auth/*`; every other unmatched path returns a
|
||
deterministic `404 {"code":"NOT_FOUND"}`.
|
||
|
||
### `GET /api/v1/principal` response shapes
|
||
|
||
Anonymous (no session, or an expired/revoked/malformed session):
|
||
|
||
```json
|
||
{ "kind": "anonymous" }
|
||
```
|
||
|
||
Authenticated:
|
||
|
||
```json
|
||
{ "kind": "authenticated", "userId": "2aKp4jPeV1QbXwyz1OH6IcAUfl2n1fc1" }
|
||
```
|
||
|
||
Infrastructure failure (the identity session store was not usable while resolving the principal —
|
||
never silently reported as anonymous):
|
||
|
||
```json
|
||
{ "code": "PRINCIPAL_RESOLUTION_FAILED" }
|
||
```
|
||
returned with HTTP `503`.
|
||
|
||
A request carrying no cookies, or only unrelated cookies, never reaches that path and never performs
|
||
identity database work at all: only a request presenting the *configured session cookie* that Better
|
||
Auth resolved to `null` is ambiguous enough to check. When it is checked, a missing session table, a
|
||
dropped column, a revoked `SELECT` privilege, an unreachable database, and a query timeout all
|
||
produce this same sanitized `503` — carrying no relation name, column name, SQL, driver message,
|
||
host, or stack. See [Identity § How a principal is resolved](identity.md#how-a-principal-is-resolved).
|
||
|
||
This is deliberately not the same thing as Better Auth's own `GET /api/auth/get-session` response,
|
||
which returns Better Auth's full `{session, user}` shape (session id, expiry, IP address, user
|
||
agent, email, name, image, email-verification status, and more). `/api/v1/principal` is the
|
||
Ekklesia-owned contract every other Core/Surface consumer should use; it never contains email,
|
||
name, image, session id or expiry, session token, account/provider details, or any role,
|
||
permission, membership, or volunteer data.
|
||
|
||
## Public registration policy
|
||
|
||
**Public sign-up is disabled by default** (`EKKLESIA_PUBLIC_SIGNUP_ENABLED` unset or anything other
|
||
than exactly `"true"`). This is a strict, typed gate (`IdentityConfig.publicSignupEnabled`,
|
||
`core/src/identity/config.ts`) wired directly to Better Auth's own `emailAndPassword.disableSignUp`
|
||
flag (`core/src/identity/runtime.ts`) — there is no second, platform-owned signup gate, because the
|
||
pinned release's own mechanism already has every required property: every `POST
|
||
/api/auth/sign-up/email` call is rejected with a fixed, sanitized `400
|
||
{"code":"EMAIL_PASSWORD_SIGN_UP_DISABLED"}` and `Cache-Control: no-store` *before* Better Auth ever
|
||
looks up the email, so a disabled deployment never reveals whether an address is already
|
||
registered. Sign-in, session resolution, and sign-out are entirely unaffected by this gate.
|
||
|
||
**Production cannot enable public signup at all right now.** A production configuration that sets
|
||
the flag to `true` is rejected outright with the stable
|
||
`PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION` configuration error, and `pnpm doctor` reports that state
|
||
as a blocking `NOT READY` failure rather than a warning. This is not a preference: the durable
|
||
signup-abuse prerequisites a public registration endpoint needs — verified email or an invitation
|
||
requirement, and durable shared/edge throttling rather than this release's per-process in-memory
|
||
limiter — **do not exist in this repository yet**, so there is no production configuration in which
|
||
the flag could be honored safely. The rejection happens in `createIdentityConfig`, which
|
||
`apps/api`'s executable entry point calls *before* `startApi`, so such a deployment never opens a
|
||
PostgreSQL pool, constructs Better Auth, starts the module runtime, or binds a listener.
|
||
|
||
Development and test composition may still enable it explicitly, through the same typed path
|
||
(`EKKLESIA_PUBLIC_SIGNUP_ENABLED=true` with `NODE_ENV=development`, or
|
||
`{ environment: "development" }` programmatically) — that is how this repository's own integration
|
||
suite creates accounts over HTTP. When the prerequisites above are actually implemented and proven,
|
||
`validatePublicSignupEnabled` (`core/src/identity/config.ts`) is the single place that changes. See
|
||
[ADR 0017](../architecture/decisions/0017-public-request-perimeter-hardening.md).
|
||
|
||
Once enabled, the rest of this section's behavior is unchanged from ADR 0011. A
|
||
registered account is an **authentication identity** — proof that someone can present this email
|
||
and password — not a church-membership record; it does not imply attendance or involvement, and
|
||
nothing about it is recorded beyond Better Auth's own required `user`/`account` rows. Every
|
||
account, first or five-hundredth, receives identical baseline authenticated status: no Dashboard
|
||
access, no module-management access, no staff/volunteer-management access, and no
|
||
media/event/form/roster/people-management permission — Core has no authorization schema in this
|
||
slice to grant any of that, and no code path in this repository inspects how many accounts already
|
||
exist or treats an empty database as special. Baseline authenticated status is what will eventually
|
||
let a signed-in person use ownership-scoped personal features (watch history, continue watching,
|
||
saved content, preferences) — none of those features are implemented in this PR.
|
||
|
||
Explicit **platform-owner bootstrap** is now available as a local operator command — see
|
||
[Authorization](authorization.md#platform-owner-bootstrap) — and grants an *existing* identity
|
||
platform-owner authority; signup order is never part of that decision. Future
|
||
**relationship-derived access** (for example, a person added to a volunteer roster automatically
|
||
gaining narrow, reversible read access to that roster) belongs to a later roster slice, not this
|
||
one.
|
||
|
||
`emailAndPassword.autoSignIn` is set to `false`. Signing up creates the account but does **not**
|
||
automatically create a session — no `Set-Cookie` is sent, and the sign-up response's `token` field
|
||
is `null`. The caller must call `/api/auth/sign-in/email` explicitly afterward. This is deliberate:
|
||
per the pinned Better Auth release, `autoSignIn: false` activates its built-in email-enumeration
|
||
protection, so a duplicate sign-up returns the same `200` shape (with a fresh, unrelated random id)
|
||
whether or not the email was already registered, rather than revealing which. No custom
|
||
cryptography replaces Better Auth's own password hashing. Password length is pinned explicitly to
|
||
8–128 characters (`minPasswordLength`/`maxPasswordLength` in `core/src/identity/runtime.ts`) rather
|
||
than left on Better Auth's fallback default — the pinned release's default happens to be the same
|
||
8–128 range, but an implicit default can change silently on a future Better Auth upgrade, while an
|
||
explicit value cannot. `apps/api/tests/api-http.integration.ts` proves the exact boundary against a
|
||
real server: 7 characters rejected, 8 accepted, 128 accepted, 129 rejected.
|
||
|
||
## Origin, CORS, and cookies
|
||
|
||
The API reuses the same `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` list for both CORS and Better Auth's
|
||
own origin/CSRF checks — there is no second, competing configuration. CORS reflects a request's
|
||
`Origin` header back only when it exactly matches a configured trusted origin (never a wildcard,
|
||
since `Access-Control-Allow-Origin: *` cannot be combined with `Access-Control-Allow-Credentials:
|
||
true`); a disallowed origin never receives the header at all, though the request itself may still
|
||
be processed — CORS is a browser-enforced, response-header mechanism, not a server-side gate. A
|
||
request with no `Origin` header at all (native or server-to-server clients) is unaffected by CORS.
|
||
Note this is narrower than it sounds for `/api/auth/sign-in/email` and `/api/auth/sign-up/email`
|
||
specifically: any client built on the Fetch API — every browser, and also Node's own `fetch()` —
|
||
automatically sends a `Sec-Fetch-Mode: cors` header on every request, and Better Auth's first-login
|
||
CSRF protection treats that as "this came from a Fetch client" and requires a trusted `Origin` (or
|
||
`Referer`) on those two routes regardless of whether a cookie is present. A genuinely non-Fetch
|
||
caller (curl, or most backend HTTP client libraries, which send none of the `Sec-Fetch-*` headers)
|
||
is unaffected and needs no `Origin` header at all — see the dedicated raw-socket test in
|
||
`apps/api/tests/api-http.integration.ts` for the exact distinction proven against a real server.
|
||
|
||
Better Auth's own CSRF/origin protections are explicitly kept on:
|
||
`core/src/identity/runtime.ts` sets `advanced: { disableCSRFCheck: false, disableOriginCheck:
|
||
false }`. This is deliberately explicit rather than left unset — the pinned release's own default
|
||
is environment-sensitive (`skipOriginCheck` falls back to `true`, silently disabling both origin
|
||
and CSRF checking, whenever `isTest()` is true, which Vitest's default `NODE_ENV=test` triggers),
|
||
so an unset config would mean origin/CSRF protection is silently off under the test runner and on
|
||
in production. Setting both flags explicitly removes that environment-sensitive branch entirely,
|
||
so `pnpm test:migrations:integration` now automatically proves the exact production behavior: any
|
||
non-`GET`/`HEAD`/`OPTIONS` request carrying a session cookie must also carry a trusted `Origin` (or
|
||
`Referer`), or Better Auth rejects it with `403` (and the presented session remains valid
|
||
afterward); a no-cookie first authentication attempt from an untrusted origin is rejected the same
|
||
way; a trusted origin is accepted in both cases; and a denied origin never receives a permissive
|
||
CORS header. See [ADR 0012](../architecture/decisions/0012-identity-origin-csrf-protection-under-test.md)
|
||
for the root-cause investigation and the full account of what changed.
|
||
|
||
Cookies keep Better Auth's defaults: `HttpOnly` always, `SameSite=Lax` always, `Secure` when the
|
||
configured base URL is `https://`. Cross-subdomain cookies are not enabled and no cookie `Domain`
|
||
attribute is set, so every cookie is host-only. See [Client-address resolution](#client-address-resolution)
|
||
for how (and whether) a reverse proxy's forwarded headers are trusted — a separate concern from
|
||
CORS/CSRF above.
|
||
|
||
## Logging
|
||
|
||
Two independent, complementary layers, both configured explicitly in `core/src/identity/runtime.ts`
|
||
rather than left on any environment-sensitive default:
|
||
|
||
1. **`logger`.** Better Auth's own default (`level: "warn"`, plain `console.*` output) is replaced.
|
||
The pinned release's log call sites are not a stable, fully-audited contract — several pass a
|
||
raw internal exception, and at least one (the origin-check middleware) interpolates a
|
||
user-controlled `Origin` header directly into the message text. Rather than sanitize each call
|
||
site's message and arguments individually, Core's logger configuration drops the message and
|
||
every argument entirely and emits only a fixed, generic string
|
||
(`"[identity] Better Auth reported an error."`) via `console.error`, and only for error-level
|
||
events — nothing is ever emitted for warn/info/debug.
|
||
2. **`onAPIError: { throw: true }`.** Discovered while testing (1) above: the pinned release's own
|
||
HTTP transport dependency, `better-call`'s router, has an *unconditional*
|
||
`console.error("# SERVER_ERROR: ", error)` fallback for any thrown value it does not recognize
|
||
as one of Better Auth's own `APIError`s — this fires before Better Auth's `logger` option is
|
||
ever consulted, so no `logger` configuration can reach it. `onAPIError.throw: true` closes that
|
||
gap: Better Auth's own deliberate `APIError`s (wrong password, invalid origin, disabled sign-up,
|
||
password out of bounds, ...) still resolve to their normal, correct HTTP response completely
|
||
unaffected, but a genuinely unexpected failure (for example a database outage mid-request) now
|
||
propagates out of `auth.handler()` instead of being caught and printed raw internally by
|
||
`better-call`, reaching `handleAuthRequest`'s own try/catch and becoming a sanitized
|
||
`IdentityRuntimeError` like any other unexpected failure — and nothing is logged at all.
|
||
|
||
Both apply in every environment, including under Vitest: neither is a `NODE_ENV=test` special case.
|
||
A focused unit test (`core/tests/identity-runtime.test.ts`) simulates a hostile log call (a raw
|
||
exception carrying a password, a host, a port, a filesystem path, and a rejected origin) and asserts
|
||
none of it reaches `console.error`, and separately asserts `onAPIError` is configured; a live
|
||
integration test forces a real identity-database failure through Better Auth's own sign-in handler
|
||
and asserts nothing at all reaches `console.error`/`warn`/`log` in the real process.
|
||
|
||
## Client-address resolution
|
||
|
||
Better Auth's rate limiter (below) needs a trustworthy per-client identity, and the pinned
|
||
release's own client-address resolution only ever reads request headers — it cannot see the real
|
||
TCP socket peer. `apps/api` resolves and sanitizes that identity once, at the Node/API transport
|
||
boundary (`apps/api/src/client-address.ts`, wrapping the `fetch` callback passed to
|
||
`@hono/node-server`'s `serve()` in `apps/api/src/server.ts`), *before* Better Auth or any other
|
||
route ever sees the request. The resolution policy itself (`core/src/identity/client-address.ts`)
|
||
is framework-agnostic and lives in Core; only the Node-specific glue that reads
|
||
`env.incoming.socket.remoteAddress` lives in `apps/api`.
|
||
|
||
- **Direct mode** (the default, `EKKLESIA_CLIENT_ADDRESS_MODE` unset or `"direct"`). Trusts only
|
||
the actual immediate socket peer. No `X-Forwarded-For`, `Forwarded`, `CF-Connecting-IP`,
|
||
`X-Real-IP`, or any other request header is ever consulted for identity.
|
||
- **Trusted-proxy mode** (`EKKLESIA_CLIENT_ADDRESS_MODE=trusted-proxy`, with
|
||
`EKKLESIA_CLIENT_ADDRESS_HEADER` and `EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES` both required).
|
||
A forwarded header's content is trusted **only** when the actual immediate socket peer matches
|
||
one of the configured trusted-proxy addresses/CIDR ranges; otherwise the peer address itself is
|
||
used, exactly as in direct mode. A comma-separated forwarded chain is walked **right to left**:
|
||
trailing hops matching a trusted proxy are skipped, and the first hop from the right that does
|
||
not match is the resolved client address. A missing, empty, malformed, or entirely-trusted-hops
|
||
chain from an otherwise-trusted peer fails closed with a sanitized
|
||
`503 {"code":"CLIENT_ADDRESS_UNRESOLVED"}` — Better Auth, Core, and every module handler are all
|
||
unreachable in that case. IPv4, IPv6, and IPv4-mapped IPv6 (`::ffff:a.b.c.d`, the form a
|
||
dual-stack socket or a real proxy actually emits) all normalize consistently before comparison.
|
||
|
||
Whichever address is resolved is written into one fixed internal header
|
||
(`TRUSTED_CLIENT_ADDRESS_HEADER = "x-ekklesia-client-address"`, `core/src/identity/runtime.ts`),
|
||
**overwriting** — never appending to — any externally supplied copy of that exact header name, so
|
||
a caller can never choose its own "resolved" identity by sending it directly. Better Auth is
|
||
configured to trust only this one header (`advanced.ipAddress.ipAddressHeaders`), never its own
|
||
default (`x-forwarded-for`) and never `trustedProxies` (that pinned-release option only inspects
|
||
header *content*, never who actually connected — see [ADR 0017](../architecture/decisions/0017-public-request-perimeter-hardening.md)
|
||
for why that is insufficient on its own).
|
||
|
||
**A reverse proxy in front of this API must be configured to *overwrite*, not append to, the
|
||
configured forwarded header**, and the origin must not be reachable except through that proxy —
|
||
neither is something application-level validation can enforce; both are the deployment's own
|
||
network/firewall responsibility. `pnpm doctor`'s `identity.client-address` check distinguishes
|
||
three states: safe direct mode, an explicitly configured (and structurally valid) trusted-proxy
|
||
mode, and an invalid/contradictory configuration (which fails Doctor, not merely warns).
|
||
|
||
## Rate limiting
|
||
|
||
Better Auth's built-in rate limiting is always enabled, including by the executable entry point —
|
||
it is never disabled. Its built-in per-path rules (a strict default of 3 requests per 10 seconds on
|
||
sign-in/sign-up-prefixed paths) are preserved unmodified in production composition; only the
|
||
integration test suite injects a narrow, explicit override so it is not itself rate-limited by its
|
||
own many requests. Storage remains the default in-memory backend, which is **per-process**: no
|
||
protection across multiple API instances or restarts. Client identity for this limiter is now
|
||
always the resolved, server-owned address described above — never a caller-controlled header — so
|
||
rotating a spoofed `X-Forwarded-For` on every request no longer lets a caller choose a fresh
|
||
bucket, and unrelated real peers are never collapsed into one shared bucket merely because no
|
||
header was present. This closes the rate-limit-identity gap; it does not add distributed storage —
|
||
a genuinely multi-instance deployment still needs a shared or edge limiter, reserved for pass 5.
|
||
|
||
## Request body limits
|
||
|
||
Every public body-consuming route has an explicit, platform-enforced byte ceiling
|
||
(`apps/api/src/body-limits.ts`, built on Hono's pinned `hono/body-limit` middleware — verified
|
||
directly against its source to reject both an oversized declared `Content-Length` and an oversized
|
||
declared-length-free streamed body without ever buffering the whole payload first):
|
||
|
||
| Surface | Limit |
|
||
| --- | --- |
|
||
| `/api/auth/*` (every Better Auth route) | 8 KiB |
|
||
| `POST /api/v1/grants`, `POST /api/v1/grants/:grantId/revoke` | 16 KiB |
|
||
| A module route (`http.route(...)`'s own `maxBodyBytes`) | Up to 1 MiB, the platform ceiling (`MODULE_ROUTE_MAX_BODY_BYTES_CEILING`, `@ekklesia/contracts`) |
|
||
|
||
An oversized request always receives the identical stable, sanitized contract —
|
||
`413 {"code":"REQUEST_BODY_TOO_LARGE"}` with `Cache-Control: no-store` — and never invokes Better
|
||
Auth's business logic, a Core mutation, permission evaluation (where avoidable), a module handler,
|
||
or module database behavior. For a state-changing route the body limit is enforced after
|
||
`requireTrustedOrigin` but before permission evaluation, so a rejected cross-origin mutation's body
|
||
is never read for size at all, while an oversized same-origin request still fails before any
|
||
authorization work runs. See [Module HTTP routes](#module-http-routes) for the module-route body
|
||
contract itself, and [Module development](../module-development/README.md#http-routes) for the
|
||
module-authoring side of it. Large media transfer is explicitly out of scope for ordinary module
|
||
HTTP bodies — that belongs to a future dedicated storage design, never to widening this ceiling.
|
||
|
||
## Node request and header timeouts
|
||
|
||
`apps/api/src/server.ts` configures the underlying Node HTTP server (via `@hono/node-server`'s
|
||
`serverOptions`, passed straight through to `http.createServer`) with finite
|
||
`headersTimeout` (20s), `requestTimeout` (30s), and `keepAliveTimeout` (5s, Node's own default,
|
||
made explicit). Node's own documented behavior responds `408` and closes the connection without
|
||
ever invoking a request listener when either timeout is exceeded, so a timed-out connection cannot
|
||
invoke a handler or receive a second response by construction.
|
||
|
||
Two non-obvious details, both found only by reproducing them directly against the pinned Node 24
|
||
runtime through a real socket:
|
||
|
||
1. **Detection latency.** Node's `http.Server` only *checks* for a timeout violation when its
|
||
periodic `connectionsCheckingInterval` fires, and that option's own default is 30 seconds —
|
||
leaving it unset would silently let a violating connection sit for up to a further 30 seconds
|
||
past its configured timeout. `connectionsCheckingIntervalMs` is therefore also set explicitly
|
||
(5s).
|
||
2. **The observable event is `'clientError'`, not `'timeout'`.** Node's general server `'timeout'`
|
||
event belongs to `server.setTimeout()`'s idle-socket timer, which this server never arms; it
|
||
does **not** fire for `headersTimeout`/`requestTimeout`. Those surface as `'clientError'` with
|
||
`err.code === "ERR_HTTP_REQUEST_TIMEOUT"`. `apps/api/src/server.ts` therefore observes
|
||
`'clientError'`, and — because registering any `'clientError'` listener *suppresses* Node's own
|
||
default response entirely (`_http_server.js` only falls back when the emit returns `false`) —
|
||
that handler reproduces Node's default byte-for-byte rather than merely watching: `408 Request
|
||
Timeout` for a timeout, `431 Request Header Fields Too Large` for a header overflow, and `400
|
||
Bad Request` otherwise, each followed by destroying the socket, and each written only when the
|
||
socket is still writable and no response has already begun (so a handler that already responded
|
||
before an unread body timed out never receives a second, corrupting status line).
|
||
|
||
Exactly one fixed, sanitized operational line is logged per transport-level client failure — one
|
||
message for a timeout, one for any other malformed/oversized request. Neither ever contains the
|
||
request path, query, body, cookies, authorization value, client-address header, forwarding chain,
|
||
raw peer address, stack, or the caught error's own message (a parse failure's message is derived
|
||
from attacker-controlled input). This is proven at the real transport boundary by raw-socket
|
||
scenarios in `apps/api/tests/api-http.integration.ts` — a real slow-header connection, a real
|
||
slow-body connection, a malformed request line, and an oversized header block — each asserting the
|
||
exact response bytes, exactly one status line, socket closure, no handler invocation, and exactly
|
||
one sanitized log. See
|
||
[ADR 0017](../architecture/decisions/0017-public-request-perimeter-hardening.md) for the full
|
||
account.
|
||
|
||
## Module discovery, permission composition, and module runtime startup
|
||
|
||
`startApi()` composes production in one fixed order:
|
||
|
||
1. Discover every installed module's manifest exactly once and compose the one immutable platform
|
||
permission registry (Core's own declarations plus every module's) — `prepareStartupModuleInventory`,
|
||
`apps/api/src/platform-permissions.ts`.
|
||
2. Construct the one shared PostgreSQL pool.
|
||
3. Construct the module database facade and the frozen `ModuleRuntimeServices` object from that same
|
||
pool (see [Module database access](#module-database-access)).
|
||
4. Construct the Core identity runtime, authorization store, and evaluator.
|
||
5. Start the module runtime from that exact same discovered inventory
|
||
(`startDiscoveredModuleRuntime`) — modules are never rediscovered for this step — passing it the
|
||
services object every module's `start`/`stop` hook receives.
|
||
6. Create the Hono application, mounting the started module runtime's published routes and passing
|
||
the identical services object to every module HTTP handler.
|
||
7. Bind the 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 module claiming the reserved `core.*` permission namespace, a duplicate permission
|
||
declaration, ...) fails startup outright, before there is anything to close; startup never falls
|
||
back to a Core-only registry. Core infrastructure 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. See [Authorization](authorization.md#api-startup-discovery-composition-and-enforcement)
|
||
for the permission-composition detail, and [Module development](module-development/README.md) for
|
||
the manifest shape that produces these declarations.
|
||
|
||
Unlike discovery/composition/Doctor/`modules:validate` (which only ever read manifests), a
|
||
successful production startup does import each module's compiled server entrypoint and run its
|
||
`register`/`start` hooks — this is the one place in the platform that deliberately executes module
|
||
code, and only after every other startup step already listed has succeeded.
|
||
|
||
## Module HTTP routes
|
||
|
||
An installed module's routes (declared via `http.route(...)` in its `register` hook — see
|
||
[Module development](module-development/README.md#http-routes) for the module-authoring contract)
|
||
are mounted under `/api/v1/modules/<module-id>`, in the module runtime's published order, by
|
||
`apps/api/src/module-http-routes.ts`. Mounting never invokes a handler.
|
||
|
||
- **Enforcement.** Every route runs through the identical `requirePermission` middleware Core's own
|
||
routes use, with the route's own declared permission id as the only permission ever consulted —
|
||
its trusted baseline always comes from the composed platform registry, never from the route or
|
||
the request. A `public`, `authenticated`, and `restricted` module permission are enforced through
|
||
the exact same path; a public-baseline route still resolves and receives an authorized principal.
|
||
- **Trusted origin.** `POST`, `PUT`, `PATCH`, and `DELETE` module routes additionally require an
|
||
exact trusted `Origin` (see [Origin, CORS, and cookies](#origin-cors-and-cookies)) before
|
||
authorization ever runs, exactly like the existing grants-mutation routes. `GET` does not require
|
||
one. `OPTIONS` remains entirely platform-owned: a module cannot register it, and a CORS preflight
|
||
never reaches authorization or a handler. `GET` inherits Hono's own implicit `HEAD` handling
|
||
(same permission check, response body stripped); a module cannot separately author `HEAD`.
|
||
- **Body limit.** A route's own `maxBodyBytes` (see [Module development](module-development/README.md#http-routes)
|
||
and [Request body limits](#request-body-limits)) is enforced immediately after the trusted-origin
|
||
check and before permission evaluation — `0` means no body is accepted at all. An oversized
|
||
request receives the platform's stable `413 {"code":"REQUEST_BODY_TOO_LARGE"}` and never reaches
|
||
authorization, the handler, or the module database.
|
||
- **Handler input.** A handler receives the raw Fetch `Request` (so `Request.url`, headers, and
|
||
body all behave normally, and the full query string remains available) plus a frozen context of
|
||
exactly `moduleId`, the authorized `EkklesiaPrincipal`, frozen, Hono-decoded path parameters (an
|
||
empty frozen object for a route with none), and `services` — the same frozen
|
||
`ModuleRuntimeServices` object the module's own `start`/`stop` hooks received (see
|
||
[Module database access](#module-database-access)) — never a Hono `Context`, the authorization
|
||
store, storage, jobs, configuration, or secrets.
|
||
- **Handler failure.** A handler that throws, rejects, or returns anything other than a `Response`
|
||
becomes a fixed `500 {"code":"MODULE_HANDLER_FAILED"}` with `Cache-Control: no-store` — never the
|
||
caught value, a stack, the route's path or permission id, or the module id. A `Response` a
|
||
handler deliberately returns — including a 4xx/5xx one — passes through unchanged.
|
||
- **CORS and caching.** The platform remains the sole owner of CORS for module routes exactly like
|
||
every other `/api/v1/*` route (see [Origin, CORS, and cookies](#origin-cors-and-cookies), now
|
||
additionally advertising `PUT`/`PATCH`/`DELETE`); any CORS-control header a handler sets on its
|
||
own response is stripped before the response leaves the process. Every module response is forced
|
||
to `Cache-Control: no-store` and a `Vary` that always includes `Origin` and `Cookie` (a handler's
|
||
own legitimate `Vary` values are preserved alongside those) — deliberately conservative until a
|
||
dedicated cache-policy contract exists, since even a public-baseline handler receives an
|
||
authorized principal and may personalize its response.
|
||
- **Static snapshot.** Routes are part of the same static startup snapshot as the permission
|
||
registry: installing, removing, or editing a module's routes takes effect only on the next API
|
||
restart. Removing a module makes its former routes return the ordinary `404 {"code":"NOT_FOUND"}`.
|
||
|
||
## Module database access
|
||
|
||
`context.services.database` gives a module's `start`/`stop` lifecycle hooks and every authorized
|
||
HTTP handler a typed Drizzle query surface over its own tables — never during `register`, which
|
||
stays declarative and database-free. See
|
||
[Module development](module-development/README.md#database-access) for the module-authoring
|
||
contract; this section covers only how `apps/api` composes and wires it.
|
||
|
||
- **`ModuleDatabase`.** A restricted, typed view over `NodePgDatabase`
|
||
(`packages/contracts/src/module-database.ts`), exposing exactly `select`, `selectDistinct`,
|
||
`selectDistinctOn`, `insert`, `update`, `delete`, `execute`, and `transaction`, each preserving
|
||
Drizzle's own table-aware argument and result inference. It exposes no `$client`, no internal
|
||
session/dialect state, no schema-bound relational `query` builder, and no connection string,
|
||
pool, or raw `pg` client — not merely hidden by the TypeScript type, but physically absent from
|
||
the constructed object at runtime (`core/src/modules/database.ts`'s `createModuleDatabaseView`
|
||
builds a frozen object with each operation explicitly bound, rather than handing out Drizzle's
|
||
own instance).
|
||
- **One pool, one facade, one services object.** `apps/api/src/database.ts`'s `createApiDatabase`
|
||
wraps the one shared PostgreSQL pool in three independent typed Drizzle views — identity,
|
||
authorization, and now `module` (a `ModuleDatabase`). `startApi` builds one frozen
|
||
`ModuleRuntimeServices` object (currently just `{ database }`) from that pool and passes the
|
||
*same* object to every module's `start` and `stop` hook and to every module HTTP handler for the
|
||
lifetime of one running API — never a fresh object per hook or per request, and never a second
|
||
pool or facade.
|
||
- **No database access without authorization.** A handler's context — and therefore
|
||
`services.database` — is unreachable until `requirePermission` (and, for a state-changing
|
||
method, `requireTrustedOrigin`) has already succeeded; a denied request, a rejected-Origin
|
||
request, and an `OPTIONS` preflight never invoke a module handler at all, so they never touch a
|
||
module's database.
|
||
- **Errors.** A handler-thrown database error is sanitized through the exact same
|
||
`MODULE_HANDLER_FAILED` boundary described above — there is no separate, database-specific public
|
||
error contract. A module that deliberately catches its own database failure may still return any
|
||
valid `Response` of its own choosing.
|
||
- **Migrations stay explicit.** Constructing the facade performs no query and no migration.
|
||
Module schema and migration changes still follow the existing generation, validation, policy, and
|
||
deployment flow (see [Module development](module-development/README.md#database-access)); nothing
|
||
in module discovery, registration, start, or an HTTP request infers a schema or creates a table
|
||
automatically. See [ADR 0016](../architecture/decisions/0016-module-database-access.md) for the
|
||
complete design and its rejected alternatives.
|
||
|
||
## Database connection limits
|
||
|
||
`apps/api/src/server.ts` configures the one shared PostgreSQL pool (`createApiDatabase`,
|
||
`apps/api/src/database.ts`, built on `createValidatedPostgresPool` from
|
||
`core/src/database/pool.ts`) with both a connection timeout and an operation timeout (10 seconds
|
||
each), the same bounded, validated pattern `core/src/migrations/postgres-database.ts` already
|
||
uses. `connectionTimeoutMillis` alone only bounds *establishing* a connection; it does nothing once
|
||
a query is already running on one. The operation timeout additionally sets `pg`'s `query_timeout`
|
||
and `statement_timeout`, so a hung or unexpectedly slow query is aborted rather than holding a pool
|
||
connection (and its caller) indefinitely. An invalid timeout (non-integer, zero, negative, or
|
||
larger than PostgreSQL's own maximum) is rejected as configuration error before any pool is
|
||
constructed, sanitized the same way an invalid connection string already is. The identity,
|
||
authorization, and module domains all read from this one pool through independent typed Drizzle
|
||
views — see [Authorization](authorization.md#shared-database-composition) for why there is exactly
|
||
one pool, not two.
|
||
|
||
## Database transport (TLS)
|
||
|
||
`core/src/database/transport.ts` is the one typed policy deciding how *every* Ekklesia executable
|
||
reaches PostgreSQL. It has exactly two modes:
|
||
|
||
| Mode | Meaning | Default in |
|
||
| --- | --- | --- |
|
||
| `verify-full` | TLS required, certificate chain verified, **and** the server's identity verified against the configured host. | production |
|
||
| `disable` | No TLS. Refused outright in production. | development/test |
|
||
|
||
There is deliberately no encryption-only mode (`require`), no chain-only mode (`verify-ca`), and no
|
||
`rejectUnauthorized: false` escape hatch: encryption without identity verification is exactly what
|
||
an interception attack defeats. Every loose or aliased spelling — `VERIFY-FULL`, `" verify-full"`,
|
||
`require`, `prefer`, `no-verify`, `true`, `1`, empty — is rejected with `DATABASE_TLS_MODE_INVALID`
|
||
in *both* environments, so a typo can never quietly become a weaker mode.
|
||
|
||
**Production has no plaintext exception — not even for a loopback database.** `disable` in
|
||
production fails with `DATABASE_TLS_REQUIRED_IN_PRODUCTION` whether `DATABASE_URL` points at a
|
||
remote host, `127.0.0.1`, `localhost`, or `[::1]`. A connection string cannot prove the database is
|
||
actually process-local, so no "same host, therefore safe" carve-out exists.
|
||
|
||
Verification is certificate *and* hostname, not encryption alone. The policy sets
|
||
`rejectUnauthorized: true` and an explicit `checkServerIdentity` pinned to the host parsed from
|
||
`DATABASE_URL`. That second part closes a real gap rather than restating a default: the pinned `pg`
|
||
driver sets TLS `servername` only for non-IP hosts, and Node performs **no** identity check when it
|
||
is unset — so `rejectUnauthorized: true` alone accepts a certificate from any host the trusted CA
|
||
ever signed when you connect by IP address. Both halves are proven against a real TLS-enabled
|
||
PostgreSQL in `core/tests/database-tls.integration.ts`.
|
||
|
||
**`DATABASE_URL` may not carry its own TLS parameters.** `sslmode`, `ssl`, `sslcert`, `sslkey`,
|
||
`sslrootcert`, `sslpassword`, `sslnegotiation`, and `uselibpqcompat` are all rejected
|
||
(case-insensitively) with `DATABASE_TLS_CONNECTION_STRING_CONFLICT`. This is not stylistic: the
|
||
pinned driver merges parsed connection-string values *over* explicitly passed options, so a
|
||
`?sslmode=disable` in the URL would silently replace the policy's verified `ssl` object. TLS is
|
||
decided in exactly one place, and it is never the URL.
|
||
|
||
For a database behind an internal CA, supply the CA's PEM text in `EKKLESIA_DATABASE_CA`; it is
|
||
added to the trusted set for verification and never appears in any error, log, or `pnpm doctor`
|
||
output. Loading it from a file path or a secret store is out of scope here, as are certificate
|
||
rotation and client-certificate authentication.
|
||
|
||
The same policy is resolved identically by every executable that opens a connection — `apps/api`,
|
||
`pnpm migrations:plan`, `pnpm migrations:apply`, the platform-owner bootstrap CLI, and
|
||
`pnpm doctor`'s own connection — through one shared environment reader
|
||
(`core/src/cli/database-transport-environment.ts`), so `process.env` stays out of Core's reusable
|
||
constructors. `apps/api` validates it in `createApiRuntimeConfig`, *before* any pool, Better Auth
|
||
instance, module runtime, or listener exists. `pnpm doctor` reports it as `database.transport` in
|
||
its PostgreSQL section: a verified production policy passes, and an unsafe or invalid one is a
|
||
blocking `NOT READY` failure — never a warning — naming only the effective mode, never the URL,
|
||
host, port, credentials, or CA material.
|
||
|
||
TLS protects the *connection*, not the exposure: it does not make an internet-reachable database
|
||
safe, and the network boundary remains the deployment's own responsibility. See
|
||
[ADR 0018](../architecture/decisions/0018-identity-session-resolution-and-database-transport.md).
|
||
|
||
## Graceful shutdown
|
||
|
||
`startApi()` (the composition function `apps/api/src/server.ts` exports, used by both the
|
||
executable entry point and tests) returns `{app, server, shutdown}`. `shutdown()` memoizes one
|
||
shared promise the first time it is called: every caller, concurrent or sequential, awaits the
|
||
exact same operation, and that operation runs exactly once no matter how many times `shutdown()`
|
||
is called. In order: it stops the HTTP listener from accepting new connections and lets in-flight
|
||
requests finish; then stops the module runtime (so no module resource stops while a route handler
|
||
might still be in flight) — a module's `stop` hook may still use `services.database` here, since
|
||
the pool has not yet closed; then closes the one shared PostgreSQL pool (identity, authorization,
|
||
and the module database facade all read from it — see
|
||
[Authorization](authorization.md#shared-database-composition)). Unlike a startup-failure cleanup
|
||
path (below), a module-stop failure here is not swallowed — it is a genuine result a caller should
|
||
observe — but pool cleanup always still runs first, so a stop failure never leaves the pool open,
|
||
and no module code can run against it afterward. The executable entry point's `SIGINT`/`SIGTERM`
|
||
wiring is extracted into `installShutdownSignalHandlers(running, exit)` — the same function production
|
||
composition uses, but independently testable with a fake `running`/`exit` so a test can trigger "a
|
||
signal arrived" without sending a real process signal or invoking the real `process.exit`.
|
||
|
||
A startup failure after the database pool was already created always closes that pool before the
|
||
failure propagates: module-services/identity/authorization construction failing (no module runtime
|
||
was started yet — the frozen `ModuleRuntimeServices` object is built in the same step, from the
|
||
same pool); the server failing to bind its listen address; or an app-construction failure, both of
|
||
which also stop the module runtime first, since it may have already started successfully. A module
|
||
load/register/start failure uses Core's own existing rollback (already-started modules stop, in
|
||
reverse order, before the failure propagates) and then closes the pool — there is no separate
|
||
runtime handle to stop in that case, since `startDiscoveredModuleRuntime` never returned one.
|
||
|
||
## A real signup → sign-in → principal → sign-out transcript
|
||
|
||
Captured from this PR's own integration test against a real, migrated, disposable PostgreSQL
|
||
database, configured as **development** with public sign-up explicitly enabled — public sign-up is
|
||
disabled by default and cannot be enabled in production at all (see
|
||
[Public registration policy](#public-registration-policy)). Session tokens, cookie values, and
|
||
generated ids below are sanitized/truncated examples, not real credentials.
|
||
|
||
```text
|
||
POST /api/auth/sign-up/email
|
||
{"email":"[email protected]","name":"Alice","password":"<redacted>"}
|
||
→ 200
|
||
{"token":null,"user":{"id":"2aKp4jPeV1QbXwyz1OH6IcAUfl2n1fc1","email":"[email protected]", ...}}
|
||
|
||
POST /api/auth/sign-in/email
|
||
{"email":"[email protected]","password":"<redacted>"}
|
||
→ 200
|
||
Set-Cookie: better-auth.session_token=<redacted>; Max-Age=604800; Path=/; HttpOnly; SameSite=Lax; Secure
|
||
{"redirect":false,"token":"<redacted>","user":{"id":"2aKp4jPeV1QbXwyz1OH6IcAUfl2n1fc1", ...}}
|
||
|
||
GET /api/v1/principal
|
||
Cookie: better-auth.session_token=<redacted>
|
||
→ 200
|
||
Cache-Control: no-store
|
||
{"kind":"authenticated","userId":"2aKp4jPeV1QbXwyz1OH6IcAUfl2n1fc1"}
|
||
|
||
POST /api/auth/sign-out
|
||
Cookie: better-auth.session_token=<redacted>
|
||
Origin: https://dashboard.example.test
|
||
→ 200
|
||
Set-Cookie: better-auth.session_token=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax
|
||
{"success":true}
|
||
|
||
GET /api/v1/principal
|
||
Cookie: better-auth.session_token=<redacted, now revoked>
|
||
→ 200
|
||
{"kind":"anonymous"}
|
||
```
|
||
|
||
## What's next
|
||
|
||
See the repository root [README](../../README.md#current-status) and
|
||
[platform architecture](../architecture/platform-architecture.md#current-implementation-status)
|
||
for what has landed across every phase and what remains before Surface work begins. This document
|
||
tracks `apps/api`'s own HTTP, module-runtime, and database-composition contracts as they evolve.
|