feat: expose core identity transport
CI / verify (pull_request) Successful in 27s
CI / infrastructure-smoke (pull_request) Successful in 19s
CI / migration-smoke (pull_request) Successful in 26s

This commit is contained in:
ekklesia
2026-07-21 19:15:14 -05:00
parent 82fe338a8d
commit 91d8f5946f
33 changed files with 2672 additions and 224 deletions
+201
View File
@@ -0,0 +1,201 @@
# 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.
+31 -15
View File
@@ -1,11 +1,13 @@
# Identity
Phase 4A establishes Core's identity foundation: a Core-owned Better Auth database schema, a
Phase 4A established Core's identity foundation: a Core-owned Better Auth database schema, a
first-class Core-owned migration source, minimal opaque-identity contracts, and an explicit Better
Auth construction boundary. It does not expose a sign-in endpoint, mount an HTTP handler, or make
any provider or enrollment decision. See
[ADR 0010](../architecture/decisions/0010-core-identity-and-better-auth-adoption.md) for the full
architectural decision; this page is the practical developer guide.
Auth construction boundary. Phase 4B built the HTTP transport on top of it — see
[API](api.md) and
[ADR 0011](../architecture/decisions/0011-identity-http-transport-and-public-enrollment.md) for
public registration, sign-in, and principal resolution over HTTP. This page covers the identity
foundation itself; see [ADR 0010](../architecture/decisions/0010-core-identity-and-better-auth-adoption.md)
for that architectural decision.
## Ownership
@@ -18,7 +20,8 @@ surface concern. Better Auth is an implementation detail contained entirely behi
cannot import `core` at all under `scripts/check-boundaries.ts`'s boundary matrix, regardless of
what Core exposes.
- **Surfaces will eventually authenticate through Core-exposed HTTP/API and client contracts**,
never by querying Better Auth's tables directly. No such Core API exists yet.
never by querying Better Auth's tables directly. `apps/api` (Phase 4B, see [API](api.md)) is that
transport for server-side/API consumers; no Dashboard, Website, or Mobile Surface exists yet.
- Better Auth's `user` row is an authentication identity, not the future church-person/profile
model. Church membership, ministry roles, contact history, attendance, permissions, and other
module-specific fields must never be added to the identity tables; a future, separately designed
@@ -117,8 +120,8 @@ required. It is part of `pnpm verify`.
## Required identity configuration
`createIdentityConfig` (`core/src/identity/config.ts`) validates three explicit values before
`core/src/identity/factory.ts` constructs Better Auth. This document intentionally contains no
real values — configure them per environment, never commit them:
`core/src/identity/runtime.ts`'s `createIdentityRuntime` constructs Better Auth. This document
intentionally contains no real values — configure them per environment, never commit them:
| Value | Requirement |
| --- | --- |
@@ -133,12 +136,25 @@ raises a sanitized `IdentityConfigError` carrying only a stable code, never the
Secret rotation (Better Auth's plural `secrets` list) and cross-subdomain cookies are deferred; see
ADR 0010.
## The identity runtime (Core's Better Auth transport boundary)
`@ekklesia/core/identity` publicly exports `createIdentityRuntime({config, db})`
(`core/src/identity/runtime.ts`), which constructs Better Auth explicitly and returns a frozen
`EkklesiaIdentityRuntime` exposing exactly two Fetch-standard capabilities:
`handleAuthRequest(request): Promise<Response>` (delegates directly to Better Auth's handler; its
normal responses, including its own error responses, pass through unmodified) and
`resolvePrincipal(headers): Promise<EkklesiaPrincipal>` (never silently downgrades a database
outage to an anonymous result — see ADR 0011 for exactly how). Neither method, nor anything else
exported from `@ekklesia/core/identity`, exposes `auth.api`, Better Auth's session/user types, the
Drizzle adapter, the raw database, a session token, or Better Auth configuration. `apps/api` is the
only current consumer of this boundary; see [API](api.md).
## What is not operational yet
No HTTP server or API route exists, Better Auth's handler is not mounted, and there is no sign-in
or sign-up endpoint, public registration, invitation flow, email delivery, password reset, email
verification, social or OAuth provider, passkey, magic link, two-factor authentication, or roles
and permissions model. **No user can authenticate through this repository yet.** The next expected
slice is identity HTTP transport and an explicit enrollment policy (for example, how the first
administrator account is created) — not a Dashboard Surface, which depends on that transport slice
existing first.
No email verification, password recovery, invitation flow, email delivery, social or OAuth
provider, passkey, magic link, two-factor authentication, or roles/permissions/platform-owner model
exists. Public registration, sign-in, principal resolution, and sign-out over HTTP **are**
operational as of Phase 4B — see [API](api.md) for the transport and routes, and ADR 0011 for why
public registration is intentional and grants no elevation. The next expected slice is Phase 4C:
Core authorization and an explicit, auditable platform-owner bootstrap command — not a Dashboard
Surface, which still depends on an authorization model that does not exist yet.
+5 -3
View File
@@ -130,9 +130,11 @@ Result: READY WITH WARNINGS
Doctor is a local, human-readable summary. `pnpm verify`, `pnpm ci:verify`, infrastructure smoke,
and migration smoke remain the authoritative automated evidence because they exercise formatting,
boundaries, types, tests, builds, containers, and live mutation/rollback behavior that Doctor does
not repeat. Identity HTTP transport and an explicit enrollment policy are the next architectural
phase; Doctor does not diagnose authorization, HTTP routing, runtime module health, or production
storage yet, and it never constructs an operational Better Auth instance or claims sign-in works.
not repeat. Core authorization and an explicit platform-owner bootstrap command are the next
architectural phase; Doctor does not diagnose authorization, the `apps/api` HTTP transport, runtime
module health, or production storage yet, and it never constructs an operational Better Auth
instance, starts the API, or claims sign-in works. See [API](api.md) for `pnpm api:start` and the
identity HTTP transport this repository now has.
Migration planning and application require `DATABASE_URL`. Keep it in the uncommitted `.env`
created from `.env.example`:
+26 -5
View File
@@ -13,11 +13,16 @@ These commands exercise package tests, repository boundaries, type checking, off
module migration discovery, identity schema drift detection, formatting, linting, and builds.
Migration-runner unit tests use a narrow database/session port to prove deterministic planning,
approval, transaction orchestration, cleanup, and sanitized errors. They do not claim to prove
PostgreSQL behavior. Identity unit tests cover principal/opaque-ID contracts, configuration
validation (secret, base URL, and trusted-origin rules), the Better Auth construction boundary
(explicit construction, exact schema mapping, no unexpected provider or plugin, sanitized
failures), and Core migration discovery (deterministic ordering, the reserved `core` identity, and
collision rejection) — all without a live database.
PostgreSQL behavior. Identity unit tests cover branded opaque-ID contracts, principal shapes,
configuration validation (secret, base URL, and trusted-origin rules), the Core identity runtime
(explicit construction, exact schema mapping, only email/password and rate limiting enabled,
sanitized failures, and that a database outage during principal resolution is never silently
reported as anonymous), and Core migration discovery (deterministic ordering, the reserved `core`
identity, and collision rejection) — all without a live database. `apps/api`'s own unit tests cover
its pure Hono app factory (route shapes, CORS allow/deny/preflight, deterministic 404 and sanitized
error responses), its runtime configuration, and that importing any of its modules — including the
executable server module without calling its exported `main` — never reads the environment,
connects to PostgreSQL, or starts a server.
`pnpm verify` is intentionally Docker-free. It never plans or applies migrations against a live
database.
@@ -52,6 +57,22 @@ staying byte-for-byte unchanged once Core is introduced, and a later module migr
rolling back cleanly without damaging the already-committed Core identity migration or leaking the
advisory lock.
A second dedicated suite (`apps/api/tests/api-http.integration.ts`, included in the same
`pnpm test:migrations:integration` run) starts the real `apps/api` HTTP server against a freshly
migrated disposable database and exercises it with real HTTP requests: public sign-up (with no
automatic session, per `autoSignIn: false`), explicit sign-in producing a host-only
`HttpOnly`/`SameSite=Lax`/secure session cookie, `/api/v1/principal` for both the authenticated and
anonymous shapes, sign-out actually invalidating the session, a malformed or missing cookie
resolving as anonymous, duplicate sign-up creating no second identity row, two distinct accounts
producing identical principal shapes with no elevation and no owner/role/permission data at the
database level, no plaintext password ever stored, CORS never returning an allow-origin header for
an untrusted origin, Better Auth's handler being unreachable outside `/api/auth/*`, a database
outage during principal resolution producing a sanitized `503` rather than a false anonymous `200`,
starting the API against an unmigrated database creating no tables at all, and a clean listener/pool
shutdown. See [ADR 0011](../architecture/decisions/0011-identity-http-transport-and-public-enrollment.md)
for one CSRF/origin guarantee that was verified manually against the real server but could not be
reproduced reliably inside this test runner, and is documented rather than silently asserted.
CI runs the same command in the dedicated `migration-smoke` job. After this change is merged, add
the following pull-request status context to `main` branch protection: