178 lines
11 KiB
Markdown
178 lines
11 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, breakpoint splitter, 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
|
||
```
|
||
|
||
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 | An absolute `http`/`https` URL. In production, `https` is required and loopback hosts are rejected. |
|
||
| Trusted origins | One or more absolute `http`/`https` origins with no path, query, or fragment. Wildcards are rejected in this slice. In production, `https` is required and loopback hosts are rejected. |
|
||
|
||
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.
|
||
|
||
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).
|
||
|
||
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.
|
||
|
||
## 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/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.
|