Adds Core-owned identity contracts, a Core-owned Better Auth database schema (ekklesia_core_user/session/account/verification), a first-class Core-owned migration source that coexists with module-owned migrations under the exact existing migration system, and an explicit Better Auth construction boundary. No authentication endpoint or sign-in UI is exposed yet (ADR 0010). Co-Authored-By: Claude Sonnet 5 <[email protected]>
145 lines
8.5 KiB
Markdown
145 lines
8.5 KiB
Markdown
# Identity
|
||
|
||
Phase 4A establishes 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.
|
||
|
||
## 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. No such Core API 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/factory.ts` 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.
|
||
|
||
## 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.
|