feat: establish core identity foundation
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]>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
df0f20d8af
commit
679a5d225c
@@ -0,0 +1,144 @@
|
||||
# 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.
|
||||
@@ -19,7 +19,8 @@ pnpm format:check # formatting without mutation
|
||||
pnpm lint # Biome recommended rules
|
||||
pnpm boundaries # architectural directions, UI ownership, and cycles
|
||||
pnpm modules:validate # installed module manifests, compatibility, and dependency graph
|
||||
pnpm migrations:validate # offline module migration inventory and exact checksums
|
||||
pnpm migrations:validate # offline Core + module migration inventory and exact checksums
|
||||
pnpm identity:schema:validate # detects drift against the pinned Better Auth schema generator
|
||||
pnpm migrations:plan # read-only comparison with PostgreSQL applied history
|
||||
pnpm migrations:apply # locked, transactional application of the pending batch
|
||||
pnpm doctor # build-first, read-only human readiness dashboard
|
||||
@@ -41,9 +42,16 @@ schema entrypoints are loaded and validated for table ownership, namespacing, du
|
||||
foreign-key isolation; this command does not connect to PostgreSQL or run migrations.
|
||||
|
||||
`pnpm migrations:validate` also builds first, completes static module discovery, and atomically
|
||||
checks every declared stable Drizzle migration directory. It reports dependency-first module
|
||||
inventories and exact SHA-256 checksums without importing module code, connecting to PostgreSQL, or
|
||||
executing SQL. Modules with no migration history are valid.
|
||||
checks Core's own `core/drizzle` migration directory together with every declared module Drizzle
|
||||
migration directory. It reports Core's migration first, followed by dependency-first module
|
||||
inventories, with exact SHA-256 checksums, without importing module code, connecting to
|
||||
PostgreSQL, or executing SQL. Modules with no migration history are valid.
|
||||
|
||||
`pnpm identity:schema:validate` regenerates Better Auth's canonical schema with the pinned local
|
||||
`auth` CLI into a temporary, package-local directory, structurally compares every core model
|
||||
(columns, types, nullability, defaults, foreign keys, and indexes) against the checked-in
|
||||
`core/src/identity/schema.ts`, and always removes the temporary output. It requires no
|
||||
`DATABASE_URL`. See [Identity](identity.md) for the full upgrade workflow.
|
||||
|
||||
## Developer Doctor
|
||||
|
||||
@@ -66,8 +74,12 @@ Doctor reports a fixed, deterministic set of checks:
|
||||
- Schema discovery, including validated module and table counts. This imports compiled schema
|
||||
definitions, which the module contract requires to perform no configuration reads, I/O, or
|
||||
database connection.
|
||||
- Offline migration discovery, with safe identities and consistently truncated checksums.
|
||||
- Offline migration discovery for Core's own identity migration and every module, with safe
|
||||
identities and consistently truncated checksums, Core listed first.
|
||||
- PostgreSQL connectivity and read-only applied-history planning when `DATABASE_URL` is configured.
|
||||
- Identity runtime configuration: skipped (not a failure) when no secret, base URL, or trusted
|
||||
origins are set; otherwise validated with `createIdentityConfig` and reported pass or failure.
|
||||
This check never constructs Better Auth or opens a database connection.
|
||||
- Object storage as skipped while Core has no production storage client or configuration contract.
|
||||
|
||||
Every check has a stable ID and one of four states: pass, warning, failure, or skipped. Independent
|
||||
@@ -94,7 +106,8 @@ Data definitions
|
||||
✓ Schemas validated
|
||||
0 modules · 0 tables
|
||||
✓ Migrations validated
|
||||
0 migrations · checksums valid
|
||||
1 migration · checksums valid
|
||||
core:0000_identity_core_tables · f8f2086ef06f
|
||||
|
||||
PostgreSQL
|
||||
○ Connection check skipped
|
||||
@@ -102,6 +115,11 @@ PostgreSQL
|
||||
○ Migration plan skipped
|
||||
A validated migration inventory and configured PostgreSQL connection are required.
|
||||
|
||||
Identity
|
||||
○ Identity configuration skipped
|
||||
Core identity schema defines 4 tables (ekklesia_core_account, ekklesia_core_session, ekklesia_core_user, ekklesia_core_verification).
|
||||
Identity runtime configuration (signing key, base URL, trusted origins) is not set.
|
||||
|
||||
Object storage
|
||||
○ Check unavailable
|
||||
Core storage integration has not been implemented yet.
|
||||
@@ -112,8 +130,9 @@ Result: READY WITH WARNINGS
|
||||
Doctor is a local, human-readable summary. `pnpm verify`, `pnpm ci:verify`, infrastructure smoke,
|
||||
and migration smoke remain the authoritative automated evidence because they exercise formatting,
|
||||
boundaries, types, tests, builds, containers, and live mutation/rollback behavior that Doctor does
|
||||
not repeat. Phase 4 identity with Better Auth is the next architectural phase; Doctor does not
|
||||
diagnose identity, authorization, HTTP routing, runtime module health, or production storage yet.
|
||||
not repeat. Identity HTTP transport and an explicit enrollment policy are the next architectural
|
||||
phase; Doctor does not diagnose authorization, HTTP routing, runtime module health, or production
|
||||
storage yet, and it never constructs an operational Better Auth instance or claims sign-in works.
|
||||
|
||||
Migration planning and application require `DATABASE_URL`. Keep it in the uncommitted `.env`
|
||||
created from `.env.example`:
|
||||
|
||||
@@ -9,10 +9,15 @@ pnpm test
|
||||
pnpm verify
|
||||
```
|
||||
|
||||
These commands exercise package tests, repository boundaries, type checking, offline module and
|
||||
migration discovery, formatting, linting, and builds. Migration-runner unit tests use a narrow
|
||||
database/session port to prove deterministic planning, approval, transaction orchestration,
|
||||
cleanup, and sanitized errors. They do not claim to prove PostgreSQL behavior.
|
||||
These commands exercise package tests, repository boundaries, type checking, offline Core and
|
||||
module migration discovery, identity schema drift detection, formatting, linting, and builds.
|
||||
Migration-runner unit tests use a narrow database/session port to prove deterministic planning,
|
||||
approval, transaction orchestration, cleanup, and sanitized errors. They do not claim to prove
|
||||
PostgreSQL behavior. Identity unit tests cover principal/opaque-ID contracts, configuration
|
||||
validation (secret, base URL, and trusted-origin rules), the Better Auth construction boundary
|
||||
(explicit construction, exact schema mapping, no unexpected provider or plugin, sanitized
|
||||
failures), and Core migration discovery (deterministic ordering, the reserved `core` identity, and
|
||||
collision rejection) — all without a live database.
|
||||
|
||||
`pnpm verify` is intentionally Docker-free. It never plans or applies migrations against a live
|
||||
database.
|
||||
@@ -39,6 +44,14 @@ whole-batch DDL rollback, Drizzle breakpoint execution, real advisory-lock concu
|
||||
timeouts, lock release after failure, exact destructive approvals, and public-error sanitization.
|
||||
It does not make migrations automatic during Core startup.
|
||||
|
||||
A dedicated set of scenarios exercises the committed Core identity migration against real
|
||||
PostgreSQL: read-only planning on a fresh database, applying it and confirming exactly the four
|
||||
expected `ekklesia_core_*` tables and their indexes exist, a genuine no-op on a second apply, Core
|
||||
executing before a fixture module migration in one batch, previously applied module-only history
|
||||
staying byte-for-byte unchanged once Core is introduced, and a later module migration's failure
|
||||
rolling back cleanly without damaging the already-committed Core identity migration or leaking the
|
||||
advisory lock.
|
||||
|
||||
CI runs the same command in the dedicated `migration-smoke` job. After this change is merged, add
|
||||
the following pull-request status context to `main` branch protection:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user