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.
412 lines
28 KiB
Markdown
412 lines
28 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).
|
||
|
||
## 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.
|