# 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). ## 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`. | 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 deferred to Phase 4C**, which must introduce an explicit, auditable operator command that 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. ## Database connection limits `apps/api/src/server.ts` configures the identity PostgreSQL pool (`createIdentityDrizzleDatabase`, `core/src/identity/database.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. ## 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 listener-drain-then-pool-close operation, and that operation runs exactly once no matter how many times `shutdown()` is called. It stops the HTTP listener from accepting new connections and lets in-flight requests finish, then closes the PostgreSQL pool. 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 (identity construction failure, or the server failing to bind its listen address) always closes that pool before the failure propagates. ## 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":"alice@example.test","name":"Alice","password":""} → 200 {"token":null,"user":{"id":"2aKp4jPeV1QbXwyz1OH6IcAUfl2n1fc1","email":"alice@example.test", ...}} POST /api/auth/sign-in/email {"email":"alice@example.test","password":""} → 200 Set-Cookie: better-auth.session_token=; Max-Age=604800; Path=/; HttpOnly; SameSite=Lax; Secure {"redirect":false,"token":"","user":{"id":"2aKp4jPeV1QbXwyz1OH6IcAUfl2n1fc1", ...}} GET /api/v1/principal Cookie: better-auth.session_token= → 200 Cache-Control: no-store {"kind":"authenticated","userId":"2aKp4jPeV1QbXwyz1OH6IcAUfl2n1fc1"} POST /api/auth/sign-out Cookie: better-auth.session_token= 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= → 200 {"kind":"anonymous"} ``` ## Next phase Phase 4C: Core authorization (public / authenticated-baseline / relationship-derived / grants / platform-owner), an explicit, auditable platform-owner bootstrap command, and reversible relationship-derived access. Not a Dashboard Surface, which still depends on an authorization model that does not exist yet.