pg-connection-string retains arbitrary query keys, so the driver's own configuration key connectionTimeoutMillis reaches ConnectionParameters straight from a URL — and it was not on the rejected list, while spellings that do nothing were. Rejecting it closes that gap. The precedence claims that guard was built on were partly wrong, so every source was measured against the installed [email protected].0, a real blackhole listener, and a real PostgreSQL rather than inferred from the merge: statement_timeout=0 / =900000 server reported 0 and 15min — wins outright query_timeout=0 overrides the field, but the parsed value is the string "0", which is truthy, so every query gets a 0 ms read timer and fails nondeterministically — it breaks the client rather than loosening the bound connectionTimeoutMillis=0/900000 pollutes connectionParameters.connect_timeout (10 -> 0 or 900 s), but that field only feeds getLibpqConnectionString(), which the pure-JS client never calls; the effective timer was unchanged, with an explicit 500 ms still expiring at ~500 ms either way connect_timeout=0 no effect: recomputed from the explicit field options=-c statement_timeout=… no effect, including a backslash-escaped spelling — the named startup parameter wins PGOPTIONS / PGCONNECT_TIMEOUT no effect unrelated parameters no effect, deliberately untouched Only statement_timeout is a genuine downgrade. The rest are rejected as unsupported configuration and defense in depth: no legitimate DATABASE_URL carries a driver option, the polluted connect_timeout would take effect on the native binding, and query_timeout produces intermittent failures an operator could not diagnose. That distinction is now stated where it was previously asserted wrongly — the earlier comment and ADR text claimed connect_timeout overrode the explicit field, which measurement disproved. Rejection still happens at the one shared construction boundary, before any pg.Client exists, with the existing fixed POSTGRES_MIGRATION_CONFIGURATION_INVALID and no URL, host, port, credential, parameter, or value in the message. The 10 s defaults, narrower test overrides, Pass 2 TLS policy and its URL-conflict checks, and every unrelated parameter are unchanged. Co-Authored-By: Claude Opus 5 <[email protected]>
246 lines
16 KiB
Markdown
246 lines
16 KiB
Markdown
# Identity
|
||
|
||
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. 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
|
||
|
||
Authentication and identity are a Core platform concern (ADR 0001), not a feature module and not a
|
||
surface concern. Better Auth is an implementation detail contained entirely behind
|
||
`core/src/identity/`:
|
||
|
||
- **Modules never import `better-auth`, `@better-auth/*`, or `auth`, and never receive a Better
|
||
Auth instance, session token, or raw database client.** This holds structurally today: modules
|
||
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. `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
|
||
model owns that data, linked only through the opaque user identifier.
|
||
|
||
## The four Core identity tables
|
||
|
||
| Better Auth model | Physical table |
|
||
| --- | --- |
|
||
| `user` | `ekklesia_core_user` |
|
||
| `session` | `ekklesia_core_session` |
|
||
| `account` | `ekklesia_core_account` |
|
||
| `verification` | `ekklesia_core_verification` |
|
||
|
||
Declared once, by hand, at `core/src/identity/schema.ts`, and passed explicitly to Better Auth's
|
||
Drizzle adapter as `identityAdapterSchema`. Columns, defaults, uniqueness, foreign keys (cascading
|
||
delete from `session`/`account` to `user`), and indexes mirror the pinned
|
||
`[email protected]` generator output exactly; only the table names differ, so ownership is
|
||
unambiguous inside the shared `public` PostgreSQL schema. No custom fields are added in this
|
||
slice.
|
||
|
||
## Ekklesia identity contracts
|
||
|
||
`@ekklesia/contracts` exports the only identity-shaped values available today:
|
||
|
||
- `EkklesiaUserId` — an opaque, validated string. Parse untrusted input with
|
||
`parseEkklesiaUserId`/`safeParseEkklesiaUserId`.
|
||
- `EkklesiaPrincipal` — a frozen `{ kind: "anonymous" }` (the `ANONYMOUS_PRINCIPAL` singleton) or
|
||
`{ kind: "authenticated", userId }` (built with `createAuthenticatedPrincipal`).
|
||
|
||
No roles, permissions, organization/campus IDs, group memberships, profile fields, email, session
|
||
tokens, or password data are exposed through this contract. Extending it requires a deliberate,
|
||
separately reviewed change.
|
||
|
||
## Core-owned migrations coexist with module migrations
|
||
|
||
Identity's schema ships as a first-class Core-owned migration source at `core/drizzle/`, in the
|
||
exact Drizzle Kit / Ekklesia migration format ADR 0008 and ADR 0009 define — the same journal,
|
||
per-file SHA-256 checksum, and `_ekklesia.json` destructive-policy sidecar every module migration
|
||
uses. Core is not a module: it has no `ekklesia.module.json` manifest and is never passed through
|
||
module discovery. Instead, `core/src/migrations/core-source.ts` discovers `core/drizzle` directly
|
||
under the reserved source identifier `"core"`, and
|
||
`core/src/migrations/aggregate.ts`'s `discoverAllMigrations` merges it with the existing module
|
||
aggregate, **Core first**. An installed module manifest that ever declares `id: "core"` is rejected
|
||
at discovery time so Core and module migration identities can never collide.
|
||
|
||
The transactional runner, advisory lock, whole-batch SQL execution, and the fixed
|
||
`public.ekklesia_core_migrations` history table (ADR 0009) are unchanged: Core's migration honestly
|
||
populates the existing `module_id` column with the literal value `"core"`, exactly like a module
|
||
populates it with its own module ID. There is exactly one migration system and one executor.
|
||
|
||
## Validating and applying migrations
|
||
|
||
```bash
|
||
pnpm migrations:validate # offline; Core's migration plus every module's, Core first
|
||
pnpm migrations:plan # read-only comparison against PostgreSQL applied history
|
||
pnpm migrations:apply # locked, transactional application of the pending batch
|
||
pnpm doctor # includes Core's migration in its discovery/plan checks
|
||
```
|
||
|
||
Both `migrations:plan` and `migrations:apply` connect with fixed, finite defaults — a 10-second
|
||
connection deadline, a 10-second client query timeout, and a 10-second PostgreSQL
|
||
`statement_timeout` — applied at the one shared construction boundary so no command can omit them.
|
||
A `DATABASE_URL` that tries to set `statement_timeout`, `query_timeout`, `connectionTimeoutMillis`,
|
||
`connect_timeout`, or an `options` value naming `statement_timeout` is refused with a sanitized
|
||
configuration error. Only `statement_timeout` actually overrides the selected policy under the
|
||
pinned driver (measured: `?statement_timeout=0` made the server report `0`); the others are rejected
|
||
as unsupported configuration and defense in depth — `query_timeout` breaks the client rather than
|
||
loosening it, and `connectionTimeoutMillis` pollutes a driver field the pure-JS client never
|
||
consults. Unrelated parameters are untouched. This bounds each individual operation; it does **not**
|
||
promise that a migration completes within ten seconds across retries or cleanup. Migrations remain explicit operator work and never run at application
|
||
startup.
|
||
|
||
Each pending migration's SQL is sent to PostgreSQL as **one complete batch**, inside the existing
|
||
transaction, so the server does all SQL parsing — Drizzle's `--> statement-breakpoint` lines are
|
||
ordinary `--` comments to it. No SQL parser exists in this repository, and the marker's bytes keep
|
||
their ordinary meaning inside a string literal, dollar-quoted body, quoted identifier, or comment.
|
||
|
||
No new commands are required to include identity: it flows through the existing migration
|
||
commands automatically. A fresh database applies the Core identity migration through
|
||
`pnpm migrations:apply` like any other pending migration; a second application is a genuine no-op.
|
||
Doctor, planning, and importing any identity module never create the identity tables — only an
|
||
explicit `pnpm migrations:apply` mutates the database.
|
||
|
||
## Upgrading the Better Auth schema
|
||
|
||
Better Auth schema changes must never be applied to production directly. Follow this workflow
|
||
whenever the pinned Better Auth version changes:
|
||
|
||
1. Deliberately update the pinned `better-auth`, `auth`, and `drizzle-kit` versions in
|
||
`core/package.json`.
|
||
2. Regenerate the canonical schema with the pinned local CLI:
|
||
`pnpm --filter @ekklesia/core identity:schema:generate` (writes a local, gitignored
|
||
`core/.identity-schema-reference.ts` — never committed).
|
||
3. Review the diff between that reference file and the checked-in
|
||
`core/src/identity/schema.ts`, deliberately updating the latter (table names, relations, and
|
||
any structural change) while preserving Core's explicit `ekklesia_core_*` naming.
|
||
4. Generate a new forward-only Core migration:
|
||
`pnpm --filter @ekklesia/core identity:migration:generate` (drizzle-kit, writes into
|
||
`core/drizzle/`). Add the matching entry to `core/drizzle/meta/_ekklesia.json`, declaring
|
||
`{"mode":"none"}` or `{"mode":"approval-required","reason":"..."}` for any destructive change.
|
||
5. Validate and test it through Ekklesia's existing migration system:
|
||
`pnpm migrations:validate`, `pnpm identity:schema:validate`, and
|
||
`pnpm test:migrations:integration`.
|
||
6. Never edit an already-applied migration. Editing changes its checksum, and Core's runner
|
||
refuses an applied migration whose packaged bytes no longer match its recorded history.
|
||
|
||
`pnpm identity:schema:validate` automates steps 2–3 as a read-only check: it regenerates Better
|
||
Auth's canonical schema into a temporary, package-local directory with the pinned local CLI,
|
||
structurally compares every core model's columns, types, nullability, defaults, foreign keys, and
|
||
indexes against the checked-in schema, and always removes the temporary output — no `DATABASE_URL`
|
||
required. It is part of `pnpm verify`.
|
||
|
||
## Required identity configuration
|
||
|
||
`createIdentityConfig` (`core/src/identity/config.ts`) validates three explicit values before
|
||
`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 |
|
||
| --- | --- |
|
||
| Secret | At least 32 characters, high entropy (for example `openssl rand -base64 32`). No built-in default is ever provided; a missing or too-short secret is rejected explicitly. |
|
||
| Base URL | Exactly one canonical `http`/`https` **origin** — scheme, host, and non-default port only. In production, `https` is required and loopback/unspecified hosts are rejected. |
|
||
| Trusted origins | One or more canonical `http`/`https` origins. Wildcards are rejected in this slice. In production, `https` is required and loopback/unspecified hosts are rejected. |
|
||
|
||
**"Canonical origin" is literal**: the value must equal the WHATWG URL parser's own origin
|
||
serialization of itself. A path (`https://id.example.test/auth`), a bare trailing slash
|
||
(`https://id.example.test/`), a query, a fragment, embedded credentials, an explicitly written
|
||
default port (`https://id.example.test:443`), or non-canonical case is rejected with
|
||
`BASE_URL_INVALID`/`TRUSTED_ORIGIN_INVALID` rather than normalized behind your back — so the stored
|
||
value always matches what a browser actually sends in an `Origin` header.
|
||
|
||
Production rejects loopback and unspecified hosts by classifying the address, not by comparing it
|
||
against a short list: `localhost` and the whole reserved `.localhost` namespace (RFC 6761), all of
|
||
`127.0.0.0/8` (not just `127.0.0.1`), `0.0.0.0`, `::1` in both compressed and expanded spellings,
|
||
`::`, and IPv4-mapped forms such as `::ffff:127.0.0.1`. Local development origins (for example
|
||
`http://localhost:3000`) are only accepted when the caller explicitly passes
|
||
`environment: "development"` — never silently. Every configuration failure raises a sanitized
|
||
`IdentityConfigError` carrying only a stable code, never the offending value.
|
||
|
||
This is a guarantee about the *shape* of the configured value and nothing more. It does not resolve
|
||
DNS and does not prove reachability: a public hostname that resolves to a loopback or private
|
||
address passes this validation, and a firewall, network policy, or reverse proxy — not URL
|
||
parsing — is what actually determines who can reach a deployment.
|
||
|
||
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 and [session resolution](#how-a-principal-is-resolved)
|
||
below). 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).
|
||
|
||
Construction also explicitly configures four things the pinned release would otherwise leave on an
|
||
environment-sensitive or implicit default, all covered by [ADR 0012](../architecture/decisions/0012-identity-origin-csrf-protection-under-test.md)
|
||
and [API](api.md#logging):
|
||
`advanced.disableCSRFCheck`/`disableOriginCheck` are both pinned to `false` (the default silently
|
||
skips both under a test runner), Better Auth's `logger` is replaced with one that drops its message
|
||
and arguments and emits only a fixed, generic string for error-level events, `onAPIError: { throw:
|
||
true }` routes a genuinely unexpected failure out to `handleAuthRequest`'s own sanitized boundary
|
||
instead of letting Better Auth's `better-call` transport dependency log it raw through a path no
|
||
`logger` configuration can reach, and `emailAndPassword.minPasswordLength`/`maxPasswordLength` are
|
||
pinned to `8`/`128` instead of left on Better Auth's fallback default.
|
||
|
||
`createIdentityDrizzleDatabase` (`core/src/identity/database.ts`) also accepts an optional
|
||
`operationTimeoutMs`, applied as both `pg`'s `query_timeout` and `statement_timeout` — bounding a
|
||
hung or slow query, which `connectionTimeoutMs` alone cannot do since it only bounds establishing
|
||
the connection. `apps/api` sets both timeouts explicitly (see [API](api.md)); an invalid value is
|
||
rejected the same sanitized way an invalid connection string already is. It also accepts an optional
|
||
`transport` policy — the PostgreSQL TLS decision described in
|
||
[API § Database transport](api.md#database-transport-tls); every production executable passes one.
|
||
|
||
## How a principal is resolved
|
||
|
||
`resolvePrincipal(headers)` returns the anonymous principal for a missing, expired, revoked, or
|
||
malformed session, and throws a sanitized `PRINCIPAL_RESOLUTION_FAILED` for an infrastructure
|
||
failure. Telling those apart takes explicit work, because Better Auth's `getSession` swallows
|
||
internal failures and returns `null` for them exactly as it does for a genuinely absent session.
|
||
|
||
1. **No cookies, or only unrelated cookies → no identity database work at all.** `Cookie: x=y` is
|
||
not evidence that Better Auth ever attempted to resolve an Ekklesia session, so its `null` is
|
||
unambiguous and the anonymous principal is returned immediately. An unauthenticated caller
|
||
cannot drive identity database load with a cookie header they chose themselves.
|
||
2. **The configured session cookie is present, and Better Auth returned `null` → the ambiguous
|
||
case, and only then is the session store checked.** Detection is an exact, whole-name cookie
|
||
comparison (RFC 6265 parsing; names are case-sensitive and never percent-decoded), so
|
||
`better-auth.session_token_x` and `x_better-auth.session_token` are correctly *not* the session
|
||
cookie. The name itself is derived from the same options object passed to Better Auth, via its
|
||
own exported `getCookies` helper — including the `__Secure-` prefix an `https` base URL implies
|
||
— so there is no second copy of it to drift.
|
||
3. **The check reads the real session relation**, not `select 1`: it plans a read of the exact
|
||
columns the Better Auth adapter depends on under a `where false` predicate. PostgreSQL resolves
|
||
the relation, every named column, and the caller's `SELECT` privilege while matching zero rows,
|
||
so no session row, token, or user id can be read, logged, or enumerated by the check. It runs on
|
||
the injected pool — never a second connection — under a bounded 3-second timeout.
|
||
|
||
A missing session table, a dropped or drifted column, a revoked `SELECT` privilege, an unreachable
|
||
database, and a query timeout therefore all produce a sanitized
|
||
`503 {"code":"PRINCIPAL_RESOLUTION_FAILED"}` — never a silent anonymous result. The response body,
|
||
logs, and error carry no relation name, column name, SQL, driver message, host, or stack. See
|
||
[ADR 0018](../architecture/decisions/0018-identity-session-resolution-and-database-transport.md).
|
||
|
||
## What is not operational yet
|
||
|
||
No email verification, password recovery, invitation flow, email delivery, social or OAuth
|
||
provider, passkey, magic link, two-factor authentication, or roles/groups/teams/tenants 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. Core's authorization foundation
|
||
(platform owners, explicit grants, an immutable permission registry) landed in Phase 4C and is
|
||
covered separately — see [Authorization](authorization.md) — not a Dashboard Surface, which still
|
||
depends on an authorization model that only now exists and has not yet been built on.
|