Explicit-grant management could produce states an operator could not reason about, and pushed request-shaped problems into the database. Two identical createGrant calls produced two grants; five concurrent identical calls produced five. Revoking one returned success while the equivalent kept authorizing the same user — an operator could believe they had removed access that was still live. Two issuances are now the same authorization source when user_id, permission_id, scope, source_kind, and source_ref are all equal, with NULL equal only to NULL; expiry, creation metadata, actor, and grant id do not distinguish them. Creating one while an equivalent is currently effective fails with AUTHORIZATION_GRANT_ALREADY_EXISTS (HTTP 409, no-store) naming nothing about the grant that already exists, and writing no grant and no audit row. A revoked or expired equivalent does not block a fresh issuance. Conflict rather than idempotent return: the caller asked to record a new authorization with their own actor and expiry, and answering "done" while recording neither would hide that nothing happened. Enforcement is database-backed, since nothing constrains the number of API processes: every create and revoke touching one equivalence tuple takes a transaction-scoped advisory lock keyed by a hash of that tuple, encoded as a JSON array so NULL stays distinct from the empty string and a crafted scope cannot forge a separator. A collision can only make unrelated tuples serialize, never hide a conflict, because the decision is always an exact SQL predicate. Its single-key lock space is verified disjoint from the two-key space the migration runner and bootstrap use. A time-dependent partial unique index was rejected: now() is not immutable and cannot be indexed. One revocation now clears every unrevoked equivalent — including expired-but- unrevoked legacy rows, so the history is left in one state — with one audit row per grant actually revoked, each carrying its own id, in one transaction. Differently scoped or sourced grants are untouched. Historical duplicates are not silently revoked by migration; the store prevents new effective equivalents and an operator's explicit revocation resolves the class. The request boundary no longer leaks into PostgreSQL. GrantId accepts exactly the canonical lowercase hyphenated UUID the database emits, with no trimming and no case folding, so a malformed path parameter is a 400 decided after authorization but before any store call instead of reaching the UUID comparison and returning an indistinguishable 503; a canonical unknown id still returns 404. expiresAt accepts exactly YYYY-MM-DDTHH:mm:ss.sssZ, byte-for-byte equal to the parsed date's own toISOString, and must be strictly future — bare new Date silently rewrote 2026-02-31 to March 3rd and accepted locale, date-only, offset, and millisecond-free forms, and a past expiry was stored as a grant dead on arrival. The store validates the same thing before any SQL so trusted internal callers get the guarantee too. The generic HTTP surface now rejects a scope field in every form, including null. No route middleware supplies a scope to the evaluator, so a scope accepted here produced a grant that persisted, listed, and authorized nothing. Core's scope semantics are unchanged for internal callers; no resolver was added. Listing was unbounded and returned a user's entire history. It is now keyset pagination over created_at DESC, id DESC with a strict boundary on both values, so rows sharing a timestamp are neither skipped nor repeated, at most limit + 1 rows read, explicit-source filtering server-side on every page, and an opaque unsigned position cursor that is strictly decoded and reveals nothing on rejection. Core's list method requires a validated finite bound with no unbounded fallback anywhere. Co-Authored-By: Claude Opus 5 <[email protected]>
637 lines
46 KiB
Markdown
637 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). Pass 3 made the
|
||
explicit-grant API's own request contracts exact: canonical UUID grant ids, one canonical
|
||
future-only expiration format, unscoped-only creation, a `409` duplicate conflict, and bounded
|
||
keyset listing — see
|
||
[Authorization](authorization.md#explicit-grant-http-api) and
|
||
[ADR 0019](../architecture/decisions/0019-durable-authorization-history-and-grant-management.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`. 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). |
|
||
|
||
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.
|