202 lines
11 KiB
Markdown
202 lines
11 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.
|
||
|
||
## 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, and its default password-length policy
|
||
(8–128 characters) is kept unmodified.
|
||
|
||
## 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.
|
||
|
||
Better Auth's own CSRF/origin protections are never disabled
|
||
(`advanced.disableCSRFCheck`/`advanced.disableOriginCheck` are never set). Verified directly
|
||
against the running compiled server: any non-`GET`/`HEAD`/`OPTIONS` request carrying a session
|
||
cookie must also carry a trusted `Origin` (or `Referer`), or Better Auth itself rejects it with
|
||
`403`. See ADR 0011 for the exact verification method and an important caveat about reproducing
|
||
part of this guarantee inside the automated test suite.
|
||
|
||
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.
|
||
|
||
## 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.
|
||
|
||
## 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()` is idempotent: it
|
||
stops the HTTP listener from accepting new connections and lets in-flight requests finish, then
|
||
closes the PostgreSQL pool. The executable entry point wires `SIGINT`/`SIGTERM` to call it and
|
||
exits afterward; 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":"[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"}
|
||
```
|
||
|
||
## 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.
|