Files
Ekklesia/docs/development/api.md
T
ekklesia c38b572778
CI / verify (pull_request) Successful in 27s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 29s
docs: document module database access
Add ADR 0016 recording the accepted module database access design (ModuleDatabase, the frozen
ModuleRuntimeServices boundary, one pool/one facade/one services-object identity, explicit
migrations, and the trust model), with narrow cross-reference corrections to ADR 0006 and ADR 0015
where their original text is now inaccurate. Sweep docs/development/api.md,
docs/development/authorization.md, docs/module-development/README.md, docs/development/testing.md,
and docs/development/identity.md for the new database boundary and remaining Phase 4C staleness;
correct the stale "Phase 4C is next" roadmap paragraph in platform-architecture.md and the matching
gap in README's current-status narrative. Extend the module template with a permissioned
GET /records route reading through context.services.database, using the canonical
createModuleTable helper, with matching template tests.
2026-07-25 21:50:03 -05:00

412 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).
## What this is, and is not
After this slice it is possible to start the API, publicly create an ordinary account, sign in,
resolve a request to an Ekklesia principal, and sign out. 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 after this PR**: there is no email delivery service, so an unverified email is
trusted as entered and a forgotten password has no recovery path.
## 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. |
| `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` | An absolute `http`/`https` URL. `https` is required in production; loopback hosts are rejected in production. |
| `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` | Comma-separated list of absolute origins (no path/query/fragment, 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. |
| `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 database could not be reached while resolving the principal —
never silently reported as anonymous):
```json
{ "code": "PRINCIPAL_RESOLUTION_FAILED" }
```
returned with HTTP `503`.
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
Anyone may create an account (`emailAndPassword.enabled: true`, `disableSignUp: false`). 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. Trusted proxy headers are not configured in this
slice — a deployment behind a reverse proxy is future work.
## 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.
## 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 has two honest limitations:
it is **per-process** (no protection across multiple API instances or restarts), and because no
trusted-proxy IP policy is configured, Better Auth may fall back to a single shared bucket per path
rather than a genuine per-client bucket. This is not yet the final, distributed, production-grade
abuse-control design.
## 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`.
- **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.
## 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. 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.