Files
Ekklesia/docs/development/identity.md
T
ekklesiaandClaude Sonnet 5 679a5d225c
CI / verify (pull_request) Successful in 25s
CI / infrastructure-smoke (pull_request) Successful in 12s
CI / migration-smoke (pull_request) Successful in 22s
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]>
2026-07-21 16:39:15 -05:00

8.5 KiB
Raw Blame History

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 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

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.