# 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 authenticate through Core-exposed HTTP/API contracts**, never by querying Better Auth's tables directly. `apps/api` (Phase 4B, see [API](api.md)) is that transport. The Dashboard now owns its browser login/logout UX and uses only `/api/auth/sign-in/email`, `/api/auth/sign-out`, and the narrow `/api/v1/principal` contract; modules receive none of those controls. Website and Mobile authentication do not exist 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 `better-auth@1.6.23` 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 Dashboard flow in ADR 0034 deliberately retains a host-only cookie: a future deployment must not assume sibling subdomains share a session. ## 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` (delegates directly to Better Auth's handler; its normal responses, including its own error responses, pass through unmodified) and `resolvePrincipal(headers): Promise` (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). Core still returns Better Auth's normal `Response` to that consumer. At the browser-facing API composition boundary, [ADR 0034](../architecture/decisions/0034-host-owned-browser-authentication.md) defines exactly two exceptions. First, any body-bearing successful `2xx` from `POST /api/auth/sign-in/email` preserves Better Auth's status, headers, and HttpOnly `Set-Cookie` but replaces its token/user JSON with fixed `{"ok":true}`. Second, the token-bearing native paths `/api/auth/get-session`, `/api/auth/list-sessions`, `/api/auth/update-session`, and `/api/auth/change-password` receive a sanitized `404` without reaching Better Auth. The last path is denied even though password/account management is not built, because the pinned implementation can return a new raw session token when revoking other sessions. Failures and every other supported auth endpoint retain their native semantics. The Dashboard ignores even the normalized sign-in body as identity and re-resolves `/api/v1/principal`, the only supported browser session read; no session token is exposed to browser application JavaScript or added to a module context. 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 and is not operational Email/password sign-in, principal resolution, sign-out, and the Dashboard's host-owned browser flow are operational — see [API](api.md) and ADR 0034. Public registration remains a future product policy, not a production capability: it is disabled by default, may be explicitly enabled only for development/test, and production rejects it. 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. Core's authorization foundation (platform owners, explicit grants, and an immutable permission registry) is covered separately in [Authorization](authorization.md). Its bootstrap command grants an existing identity; it does not create one. Creating the *first* identity on a fresh installation is what `pnpm identity:create-initial-owner` is for — see below. ## Three principal classes Ekklesia recognizes **anonymous**, **authenticated** (a person), and **device** (a paired display) ([ADR 0036](../architecture/decisions/0036-managed-device-identity-and-pairing.md)). A device is not a user: it has no row in the identity tables, no email, no password, and no human session. `isAuthenticatedPrincipal` returns `false` for it, which is what keeps every "somebody is signed in" route closed to a screen on a wall. Its credential is a separate HttpOnly cookie (`ekklesia_device_session`), resolved after Better Auth has been asked first — a human session always wins, so a browser holding both remains a person. `/api/v1/principal` reports a device as `anonymous` on purpose. It is the endpoint the Dashboard uses to decide whether a person is signed in, and a device is not one. ## Creating the first platform owner A fresh installation has a login page and nothing to log in to. Public registration is disabled by default and production cannot enable it at all, so one explicit, operator-invoked command creates the initial identity and grants it platform ownership ([ADR 0035](../architecture/decisions/0035-initial-platform-owner-bootstrap.md)): ```bash pnpm migrations:apply # the schema must already exist; this command never migrates DATABASE_URL=postgresql://... pnpm identity:create-initial-owner -- --email owner@example.org # Password: (typed invisibly) # Confirm password: # Initial platform owner created successfully. # Email: owner@example.org # You can now sign in to the Ekklesia dashboard. ``` `--name` is optional and defaults to the email address; the identity model requires a display name, and asking for one that nobody can edit yet would be a question without a purpose. ### The password never becomes an argument or a variable It is prompted for twice, invisibly, when a terminal is attached. `--password` is **refused with an explanation**, because a command-line argument is visible in shell history and to every process on the machine through the process list. For automated deployment the password is read from standard input: ```bash printf '%s' "$PASSWORD" | pnpm identity:create-initial-owner -- --email owner@example.org ``` **No environment variable is read for it, and none belongs in `.env`.** A bootstrap password is used once to create an account; it is not deployment configuration, and an environment variable is precisely the thing that quietly becomes permanent. Nothing prints the password, its length, a hash, a session token, or the database URL. ### Required environment `DATABASE_URL`, and the same optional transport variables every other Core command honours (`NODE_ENV`, `EKKLESIA_DATABASE_TLS_MODE`, `EKKLESIA_DATABASE_CA`). Deliberately **not** the identity secret, trusted origins, or a public base URL: none is needed to create an account, so the first owner can be created on a freshly provisioned server before TLS or DNS exist. ### Password and email rules are the identity system's own The 8–128 character bound and email validation are enforced by Better Auth through the same sign-up path a browser would use, not by a separate CLI policy that could drift from it. The address is normalized exactly as a browser sign-up normalizes it — stored lowercased — and the command reports the stored address, so the one you are told to sign in with is the one that exists. ### It runs once Ownership bootstrap is permanently single-use (see [Authorization](authorization.md#bootstrap-is-permanently-single-use)). A second run is refused and: - creates no second owner, - changes no existing password, - creates no stray identity, even when a different address is supplied. ### Rerun and recovery | situation | behaviour | | --- | --- | | Bootstrap already completed | Refused. Nothing changes. | | Identity exists, ownership was never granted | Ownership is granted to it and **its password is left unchanged**. Reported as an adoption, not a creation. | | A `user` row exists with no password (cannot sign in) | Refused, and bootstrap stays available — granting ownership to an identity nobody can authenticate as would consume the single use and leave the installation unusable. | | Identity created but the grant failed | Reported as incomplete. The identity is real and bootstrap is still available: run the identical command again with the same address to finish. | | Database unmigrated or unreachable | Refused with a clear message. The command never migrates. | Nothing is deleted automatically on failure: removing an account that was created successfully, on the strength of a later error, would destroy something an operator may need. ### This is not signup There is no `/signup`, `/register`, or `/setup` route, no unauthenticated bootstrap endpoint, no temporary setup route, and no setup token. The login page stays login-only, and public registration stays disabled in every environment — an integration test asserts that a freshly bootstrapped deployment still refuses `POST /api/auth/sign-up/email`. Creating additional users or owners is not this command's job. That belongs to user administration, which does not exist yet.