Two problems in the migration commands: no timeouts, and SQL split on literal bytes. `migrations:plan` and `migrations:apply` constructed their connection with neither a connection deadline nor an operation deadline — a real server reported `statement_timeout = 0` on the migration connection. Both now default to 10 seconds for the connection deadline, the client query timeout, and PostgreSQL's own statement_timeout, applied at the one shared construction boundary so a future call site cannot silently omit them. History reads, advisory-lock queries, transaction control, migration SQL, and history writes are all covered. The existing lock-wait deadline, the Pass 2 TLS policy, and Doctor's and the bootstrap CLI's own timeouts are untouched, and no environment variable or flag was added. Explicit client fields do not win under the pinned driver. [email protected].0 builds parameters as `Object.assign({}, config, parse(config.connectionString))`, so the URL overrides passed options — verified directly, where an explicit statement_timeout of 10000 alongside `?statement_timeout=0` produced a server-reported 0, while a clean URL produced 10s. A DATABASE_URL carrying connect_timeout, query_timeout, statement_timeout, or an `options` value setting statement_timeout is therefore refused with the existing fixed configuration error, before any client is constructed and without echoing the URL, parameter, or value. Unrelated parameters are left alone. The runner split each migration on the literal `--> statement-breakpoint` bytes. Those bytes inside a string literal, a dollar-quoted function body, a quoted identifier, or a comment tore the migration into fragments that were no longer valid SQL — each case verified to produce two fragments where one was required. Each migration's exact SQL is now passed to the client once, with no values and no prepared-statement name, inside the existing transaction, and its history row inserted only after that batch succeeds. One transaction still spans all pending migrations; advisory locking, checksum validation, approval preflight, ordering, rollback, cleanup, and sanitized error identity are unchanged. The port method is renamed executeStatement to executeMigrationSql so the contract says what it receives. PostgreSQL parses the batch because it owns the only correct parser. A homegrown splitter would have to track single quotes, dollar quoting with arbitrary tags, quoted identifiers, line comments, nested block comments, and escape syntax to answer a question the server already answers, and a parser dependency would add supply-chain surface for the same reason. Drizzle's marker lines are ordinary `--` comments when the batch is sent whole. No custom SQL parser exists here. Co-Authored-By: Claude Opus 5 <[email protected]>
16 KiB
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 and ADR 0011 for public registration, sign-in, and principal resolution over HTTP. This page covers the identity foundation itself; see ADR 0010 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/*, orauth, and never receive a Better Auth instance, session token, or raw database client. This holds structurally today: modules cannot importcoreat all underscripts/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) is that transport for server-side/API consumers; no Dashboard, Website, or Mobile Surface exists yet. - Better Auth's
userrow 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 withparseEkklesiaUserId/safeParseEkklesiaUserId.EkklesiaPrincipal— a frozen{ kind: "anonymous" }(theANONYMOUS_PRINCIPALsingleton) or{ kind: "authenticated", userId }(built withcreateAuthenticatedPrincipal).
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
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 connect_timeout, query_timeout, statement_timeout, or an
options value carrying statement_timeout is refused with a sanitized configuration error,
because the pinned driver lets the connection string override explicitly passed options. 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:
- Deliberately update the pinned
better-auth,auth, anddrizzle-kitversions incore/package.json. - Regenerate the canonical schema with the pinned local CLI:
pnpm --filter @ekklesia/core identity:schema:generate(writes a local, gitignoredcore/.identity-schema-reference.ts— never committed). - 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 explicitekklesia_core_*naming. - Generate a new forward-only Core migration:
pnpm --filter @ekklesia/core identity:migration:generate(drizzle-kit, writes intocore/drizzle/). Add the matching entry tocore/drizzle/meta/_ekklesia.json, declaring{"mode":"none"}or{"mode":"approval-required","reason":"..."}for any destructive change. - Validate and test it through Ekklesia's existing migration system:
pnpm migrations:validate,pnpm identity:schema:validate, andpnpm test:migrations:integration. - 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
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.
Construction also explicitly configures four things the pinned release would otherwise leave on an
environment-sensitive or implicit default, all covered by ADR 0012
and API:
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); 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; 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.
- No cookies, or only unrelated cookies → no identity database work at all.
Cookie: x=yis not evidence that Better Auth ever attempted to resolve an Ekklesia session, so itsnullis unambiguous and the anonymous principal is returned immediately. An unauthenticated caller cannot drive identity database load with a cookie header they chose themselves. - 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), sobetter-auth.session_token_xandx_better-auth.session_tokenare correctly not the session cookie. The name itself is derived from the same options object passed to Better Auth, via its own exportedgetCookieshelper — including the__Secure-prefix anhttpsbase URL implies — so there is no second copy of it to drift. - 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 awhere falsepredicate. PostgreSQL resolves the relation, every named column, and the caller'sSELECTprivilege 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.
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 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 — not a Dashboard Surface, which still depends on an authorization model that only now exists and has not yet been built on.