feat: expose core identity transport
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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`:
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user