Files
Ekklesia/docs/development/api.md
T
ekklesia 91d8f5946f
CI / verify (pull_request) Successful in 27s
CI / infrastructure-smoke (pull_request) Successful in 19s
CI / migration-smoke (pull_request) Successful in 26s
feat: expose core identity transport
2026-07-21 19:15:14 -05:00

11 KiB
Raw Blame History

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 for the full architectural decision and Identity 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

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 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):

{ "kind": "anonymous" }

Authenticated:

{ "kind": "authenticated", "userId": "2aKp4jPeV1QbXwyz1OH6IcAUfl2n1fc1" }

Infrastructure failure (the identity database could not be reached while resolving the principal — never silently reported as anonymous):

{ "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.

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.