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,279 @@
|
||||
# 0010: Core identity architecture and Better Auth adoption
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-07-21
|
||||
|
||||
## Context
|
||||
|
||||
Phase 3 delivered module discovery, module-owned Drizzle schemas and migrations, and
|
||||
Core-controlled transactional migration execution (ADRs 0006-0009). Authentication and identity
|
||||
were explicitly out of scope. `docs/architecture/platform-architecture.md` already names identity
|
||||
as the next architectural phase, and ADR 0001 places identity under Core, not under a feature
|
||||
module or a surface: "Core orchestrates generic platform infrastructure: startup, identity,
|
||||
permissions, configuration, database connectivity, storage, events, jobs, module discovery, and
|
||||
lifecycle."
|
||||
|
||||
Ekklesia adopts Better Auth as the underlying authentication library rather than writing
|
||||
session/credential handling from scratch. Better Auth ships a PostgreSQL/Drizzle adapter, a
|
||||
schema generator, and its own `migrate` command. None of that is a substitute for Ekklesia's
|
||||
existing migration contracts: ADR 0008 and ADR 0009 already define the one migration format,
|
||||
one checksum rule, one history table, one advisory lock, and one transactional executor that
|
||||
every persistent schema change in this repository must go through. This decision defines how
|
||||
identity is owned, how Better Auth is contained, and how its schema enters that existing pipeline
|
||||
without creating a second migration system or weakening any Phase 3 guarantee.
|
||||
|
||||
This is a bounded Phase 4A slice: it establishes the identity foundation only. It does not mount
|
||||
an HTTP handler, expose a sign-in endpoint, or make any provider or enrollment decision.
|
||||
|
||||
## Decision
|
||||
|
||||
### Core owns identity; Better Auth is an implementation detail behind it
|
||||
|
||||
Authentication and identity are a Core platform concern, exactly like database connectivity and
|
||||
migration execution. Better Auth is contained entirely behind Ekklesia-owned contracts and
|
||||
composition code under `core/src/identity/`:
|
||||
|
||||
- Modules must never import `better-auth`, `@better-auth/*`, or `auth` (the CLI package), and
|
||||
must never receive a Better Auth instance, session token, or raw database client. This is
|
||||
already structurally guaranteed today: `core → module` imports are impossible in either
|
||||
direction under `scripts/check-boundaries.ts`'s `core-is-feature-agnostic` and
|
||||
`modules-use-public-platform-contracts` rules, and modules may only import shared packages
|
||||
whose role is `contracts`, `module-sdk`, `testing`, or `tooling` — never `core` and never a
|
||||
Better Auth package. Phase 4A adds no exception to that matrix.
|
||||
- Surfaces will eventually authenticate through Core-exposed HTTP/API and client contracts, never
|
||||
by querying Better Auth's tables or importing Better Auth types directly. No such Core API
|
||||
exists yet; it is explicitly out of scope for this slice (see "Not yet operational" below).
|
||||
- Core's own public package barrel (`core/src/index.ts`) does not re-export anything from
|
||||
`core/src/identity/` in this slice. The Better Auth factory, the Drizzle adapter wiring, and the
|
||||
constructed instance stay internal to Core's identity module and are consumed only by Core's own
|
||||
code and tests, never by a public `@ekklesia/core` subpath. A later HTTP transport slice decides
|
||||
what (if anything) becomes part of Core's public surface.
|
||||
|
||||
### Better Auth's `user` row is an authentication identity, not a person profile
|
||||
|
||||
The four Better Auth core tables (`user`, `session`, `account`, `verification`) exist to answer
|
||||
"who is this session," nothing more. They are not, and must never become, the future
|
||||
church-person/profile/domain model. Church membership, ministry roles, contact history,
|
||||
attendance, permissions, and any other module-specific field must never be added as columns on
|
||||
these tables. A future person/profile module or Core extension, not this schema, is where that
|
||||
data belongs, linked only through the opaque user identifier below. No custom user fields are
|
||||
added in this slice, and none are anticipated by widening the Better Auth tables later — the
|
||||
correct extension point is a separate, explicitly designed model.
|
||||
|
||||
### Ekklesia identity contracts (`@ekklesia/contracts`)
|
||||
|
||||
`packages/contracts/src/identity.ts` defines the only identity-shaped values a module may
|
||||
eventually see:
|
||||
|
||||
- `EkklesiaUserId` — an opaque, validated, non-empty string (`ekklesiaUserIdSchema`). It carries
|
||||
no meaning beyond "a stable identifier for one Ekklesia identity." Callers never construct one
|
||||
by hand; they parse it via `parseEkklesiaUserId`/`safeParseEkklesiaUserId`.
|
||||
- `EkklesiaPrincipal` — a frozen, discriminated union of exactly two shapes:
|
||||
`{ kind: "anonymous" }` (the frozen `ANONYMOUS_PRINCIPAL` singleton) and
|
||||
`{ kind: "authenticated", userId: EkklesiaUserId }` (built only through
|
||||
`createAuthenticatedPrincipal`, which validates and freezes the result).
|
||||
|
||||
Deliberately absent from this contract, and out of scope for this PR: roles, permissions,
|
||||
organization or campus identifiers, group memberships, church profile fields, the user's email or
|
||||
any other personal data, Better Auth session tokens, cookie contents, and password information.
|
||||
Email and other personal data are therefore not automatically exposed to modules through this
|
||||
contract; a future slice that grants modules richer identity data must extend this type
|
||||
deliberately and re-justify each field. Roles and permissions remain intentionally deferred — this
|
||||
PR does not invent a generalized authorization framework. `packages/contracts` has no dependency on
|
||||
`better-auth`, so no Better Auth type can leak through this or any other public contracts export.
|
||||
|
||||
### Better Auth schema ownership and table naming
|
||||
|
||||
Better Auth's four required core models are declared once, by hand, as an explicit Drizzle
|
||||
schema at `core/src/identity/schema.ts`, using Core-owned physical table names that make ownership
|
||||
unambiguous:
|
||||
|
||||
| Better Auth model | Physical table |
|
||||
| --- | --- |
|
||||
| `user` | `ekklesia_core_user` |
|
||||
| `session` | `ekklesia_core_session` |
|
||||
| `account` | `ekklesia_core_account` |
|
||||
| `verification` | `ekklesia_core_verification` |
|
||||
|
||||
`core/src/identity/schema.ts` exports `identityAdapterSchema`, an explicit
|
||||
`{ user, session, account, verification }` map passed directly to `drizzleAdapter(db, { provider:
|
||||
"pg", schema: identityAdapterSchema })`. Table naming is therefore never inferred from Better
|
||||
Auth's defaults; it is an explicit, reviewed mapping, matching the Drizzle adapter's documented
|
||||
`schema` option. Columns, defaults, uniqueness, indexes, foreign keys (`ON DELETE CASCADE` from
|
||||
`session`/`account` to `user`), and timestamp types mirror the pinned `[email protected]`
|
||||
Drizzle/PostgreSQL generator output exactly; only the physical table names differ.
|
||||
|
||||
### Better Auth's own generator and migrate command are development aids only
|
||||
|
||||
`better-auth`/`auth`'s schema generator (`pnpm --filter @ekklesia/core identity:schema:generate`,
|
||||
which shells out to the pinned local `auth generate` CLI) is a development-time aid used to derive
|
||||
and later cross-check the canonical schema. It is never run against a production database, and it
|
||||
never writes to `core/src/identity/schema.ts` directly — a developer reviews its output and
|
||||
updates the checked-in schema deliberately. Better Auth's own `migrate` command is not used at
|
||||
all: per its documentation it only supports the built-in Kysely adapter, and even if it supported
|
||||
Drizzle it would still be a second, uncoordinated migration executor. Ekklesia has exactly one:
|
||||
the Core migration runner defined by ADR 0009.
|
||||
|
||||
### Core-owned migrations: one migration system, one executor
|
||||
|
||||
Identity's persistent schema is packaged as a first-class **Core-owned** migration source, not a
|
||||
disguised feature module. `core/drizzle/` holds the generated migration for the four identity
|
||||
tables in the exact format ADR 0008 defines (native Drizzle Kit 0.31.10 journal, per-tag SHA-256
|
||||
checksum of the exact SQL bytes, and an `_ekklesia.json` destructive-policy sidecar declaring
|
||||
`{"mode":"none"}` for this create-only migration). Core has no `ekklesia.module.json` manifest, no
|
||||
compiled server entrypoint, and is never passed through `discoverModules`; giving it one merely to
|
||||
reuse discovery would be exactly the "disguise Core identity as an installed feature module" this
|
||||
decision rejects, and would violate `core-is-feature-agnostic`.
|
||||
|
||||
Instead, `core/src/migrations/core-source.ts` reuses the exact same per-directory journal,
|
||||
snapshot, checksum, and destructive-policy validation module discovery already used for module
|
||||
migrations — the underlying inspection function was factored out of
|
||||
`core/src/modules/migration-discovery.ts` without changing its behavior for modules — against
|
||||
Core's own `core/drizzle` directory, attributed to the reserved source identifier `"core"`.
|
||||
`core/src/migrations/aggregate.ts`'s `discoverAllMigrations` merges Core's migrations with the
|
||||
existing module aggregate from `discoverModuleMigrations`, placing Core's migrations first, and
|
||||
rejects discovery outright (a `ModuleMigrationDiscoveryError` with code
|
||||
`MODULE_MIGRATION_ID_DUPLICATE`) if any installed module manifest ever declares `id: "core"`,
|
||||
so Core and module migration identities can never collide. Core has no dependency relationship
|
||||
with feature modules — per ADR 0001, Core underlies modules — so it is not part of the module
|
||||
dependency graph; it simply precedes every module in the merged aggregate. Existing dependency-first
|
||||
module ordering, identities, and checksums are untouched by this merge.
|
||||
|
||||
`core/src/migrations/runner.ts` and `core/src/migrations/postgres-database.ts` — the transactional
|
||||
executor, the advisory lock, the breakpoint splitter, and the applied-history table — require **no
|
||||
changes**. Both already operate purely on the generic `ModuleMigrationAggregate` shape (a flat
|
||||
migration list plus a `{moduleId, migrations}` list) and treat `moduleId` as an opaque string key;
|
||||
a Core-owned entry with `moduleId: "core"` is handled by every existing code path unmodified. The
|
||||
fixed history table `public.ekklesia_core_migrations` and its `module_id` column, established by
|
||||
ADR 0009, are not renamed or restructured for this decision: Core's identity migration honestly
|
||||
populates that same free-text column with the literal value `"core"`, exactly like any module
|
||||
populates it with its own module ID. ADR 0009's own contract already describes that column as
|
||||
"Owning module ID" while its enforcement treats it as an opaque source key, so this is a
|
||||
backward-compatible representation, not a special case. No existing valid history row's meaning
|
||||
changes, and no column is added or removed. Migrations remain forward-only, matching ADR 0008 and
|
||||
ADR 0009; there are no downgrade or uninstall migrations for identity either.
|
||||
|
||||
`core/src/cli/migration-command-support.ts`'s `discoverRepositoryMigrations` (the single chokepoint
|
||||
feeding `migrations:plan`, `migrations:apply`, and `migrations:validate`) and Doctor's
|
||||
`migrations.discovery`/`postgresql.plan` checks both now call `discoverAllMigrations`, so all three
|
||||
CLI commands and `pnpm doctor` represent Core's migration honestly and deterministically alongside
|
||||
module migrations, with Core listed first. A fresh database applies the Core identity migration
|
||||
through the existing `pnpm migrations:apply`; a second application is a genuine no-op, proven
|
||||
against real PostgreSQL. The identity schema is never created merely because a package is
|
||||
imported, Better Auth is constructed, or Doctor runs — discovery, planning, and Doctor's identity
|
||||
configuration check are read-only; only an explicit `pnpm migrations:apply` mutates the database.
|
||||
|
||||
### Better Auth construction boundary
|
||||
|
||||
`core/src/identity/factory.ts` exports `createIdentityAuth({ config, db })`, Core's explicit Better
|
||||
Auth composition boundary:
|
||||
|
||||
- Better Auth is constructed only when this function is called. Importing any identity module
|
||||
performs no PostgreSQL connection, no environment read, and no schema generation or migration —
|
||||
proven by unit tests that mock `pg`, `better-auth`, and `better-auth/adapters/drizzle` and
|
||||
assert those constructors are never invoked merely by import.
|
||||
- The Core-owned Drizzle database instance (`core/src/identity/database.ts`'s
|
||||
`createIdentityDrizzleDatabase`) and the validated `IdentityConfig`
|
||||
(`core/src/identity/config.ts`) are both injected explicitly; the factory reads no environment
|
||||
variables itself.
|
||||
- The identity schema mapping (`identityAdapterSchema`) is passed to `drizzleAdapter` explicitly,
|
||||
never left to Better Auth's naming defaults.
|
||||
- No provider or plugin beyond the four core models is enabled: no `emailAndPassword`, no social
|
||||
or OAuth provider, no organization/admin/2FA/passkey/username/magic-link plugin, no experimental
|
||||
feature. `betterAuth()` is called with exactly `database`, `secret`, `baseURL`, and
|
||||
`trustedOrigins` — nothing else — proven by a unit test asserting the exact option key set.
|
||||
- The function returns only a frozen, opaque `{ constructed: true }` marker. The constructed
|
||||
Better Auth instance, its HTTP handler, the injected database, and the Drizzle adapter are never
|
||||
returned, so they cannot leak through any public contract; this is the concrete meaning of
|
||||
"keep the Better Auth handler internal until the HTTP transport slice."
|
||||
|
||||
### Secrets, base URL, and trusted origins
|
||||
|
||||
`createIdentityConfig` requires an explicit secret of at least 32 characters (matching Better
|
||||
Auth's own documented minimum) and rejects a missing secret, a secret under 32 characters, and a
|
||||
trivially degenerate secret (every character identical) as a narrow, deterministic guard — it
|
||||
cannot measure true entropy, and operators remain responsible for generating a high-entropy value
|
||||
(for example `openssl rand -base64 32`). No built-in or default secret is ever provided, and no
|
||||
secret is ever generated or persisted automatically. Secret rotation is deferred: Better Auth
|
||||
supports a plural `secrets` list for non-destructive rotation, but Phase 4A accepts only a single
|
||||
`secret` value. A future slice may adopt the plural form without a schema change.
|
||||
|
||||
Base URL and trusted origins are explicit, typed configuration, never inferred from an incoming
|
||||
request. Every trusted origin must be an absolute `http`/`https` origin with no path, query, or
|
||||
fragment. Wildcard trusted origins (Better Auth supports `*`/`?`/`**` glob patterns) are rejected
|
||||
outright in this slice: cross-subdomain cookies are not enabled yet, so a wildcard origin would
|
||||
widen the trusted surface without a matching cookie-scope decision. This is deliberately narrower
|
||||
than upstream Better Auth, not an oversight; it may be revisited alongside a future ADR that
|
||||
enables cross-subdomain cookies. `createIdentityConfig` defaults to `"production"` validation when
|
||||
the environment is not specified, so an omitted environment never silently relaxes checks; in
|
||||
production mode, `http://` origins and loopback hosts (`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`)
|
||||
are rejected for both the base URL and every trusted origin. Local development origins (for
|
||||
example `http://localhost:3000`) are allowed only when the caller explicitly passes
|
||||
`environment: "development"` — never silently.
|
||||
|
||||
### Cookies
|
||||
|
||||
Host-only secure cookies remain the default direction. This decision does not enable
|
||||
`advanced.crossSubDomainCookies`; every cookie stays scoped to a single host, matching Better
|
||||
Auth's own default posture (`httpOnly` always, `secure` in production, `SameSite=Lax`). Enabling
|
||||
cross-subdomain cookies requires a future ADR that also addresses the matching trusted-origin
|
||||
widening.
|
||||
|
||||
### Deferred: providers, enrollment, and everything past schema construction
|
||||
|
||||
This slice does not enable any authentication provider (email/password, social, passkey, magic
|
||||
link, username, OAuth/OIDC) and does not implement any enrollment, invitation, verification, or
|
||||
password-reset flow — those are explicit provider and enrollment decisions reserved for later,
|
||||
separately reviewed slices. No `emailAndPassword` or other provider is enabled even internally;
|
||||
the factory constructs only the database adapter and base configuration required to validate
|
||||
schema wiring. Phase 4A does not mount an HTTP handler and exposes no authentication endpoint: it
|
||||
is acceptable, and expected, that no user can sign in after this PR. Two-factor authentication,
|
||||
Better Auth's admin and organization plugins, and any other plugin remain out of scope until a
|
||||
slice that deliberately adopts them.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- Discovering Core's migrations by giving it a synthetic `ekklesia.module.json` and passing it
|
||||
through `discoverModules` was rejected: Core is not an installed, optional, versioned package
|
||||
the way a feature module is, it has no server entrypoint to run, and forcing it through
|
||||
module discovery would violate `core-is-feature-agnostic` and misrepresent Core as a module in
|
||||
every diagnostic and history row.
|
||||
- Writing a second, Core-specific migration runner or history table for identity was rejected:
|
||||
ADR 0009 already defines one transactional executor and one history contract; a second one
|
||||
would be exactly the duplicated migration system this decision must avoid, and would fork the
|
||||
advisory-locking and rollback guarantees the runner already provides for free.
|
||||
- Renaming or restructuring `ekklesia_core_migrations`'s `module_id` column for a "cleaner" Core
|
||||
vocabulary was rejected: it is a breaking change to an already-shipped, ADR 0009-governed table
|
||||
for a purely aesthetic gain, and the column already functions as an opaque source key today.
|
||||
- Using Better Auth's own `migrate` command, or its programmatic `getMigrations` API, was
|
||||
rejected: both are documented as Kysely-only and do not apply to the Drizzle adapter at all;
|
||||
neither would coordinate with Ekklesia's advisory lock, checksum, or approval contract even if
|
||||
they did.
|
||||
- Relying on Better Auth's default (unrenamed) table names was rejected: `user`, `session`,
|
||||
`account`, and `verification` do not signal Core ownership and are far more likely to collide
|
||||
with a future module's own table names in the single shared `public` schema.
|
||||
- Supporting Better Auth's wildcard trusted-origin syntax immediately was rejected for this slice
|
||||
because it has no matching cookie-scope decision yet; narrowing to exact origins keeps the
|
||||
trusted surface auditable until cross-subdomain cookies are deliberately designed.
|
||||
- Putting church-person/profile fields directly on Better Auth's `user` table was rejected: it
|
||||
would conflate an authentication identity with a domain model that modules, not Core, are
|
||||
expected to own, and would make every future profile field a Core-owned, Better-Auth-coupled
|
||||
migration.
|
||||
|
||||
## Consequences
|
||||
|
||||
Ekklesia has exactly one migration format, one checksum rule, one advisory lock, and one
|
||||
transactional executor, and Core's identity schema is now the first non-module consumer of that
|
||||
pipeline, applied and integration-tested against real PostgreSQL. `pnpm doctor`,
|
||||
`pnpm migrations:plan`, and `pnpm migrations:apply` all represent Core's identity migration
|
||||
honestly, ordered before any module migration. No module can import Better Auth or receive a
|
||||
database client or session token; that boundary was already structurally enforced by
|
||||
`scripts/check-boundaries.ts` and required no new code to hold for identity.
|
||||
|
||||
What remains explicitly not operational after this PR: no HTTP server or API route, no mounted
|
||||
Better Auth handler, no sign-in or sign-up endpoint, no public registration or invitation flow, no
|
||||
email delivery, no password reset or email verification, no social or OAuth provider, no passkeys
|
||||
or magic links, no two-factor authentication, no roles or permissions, and no church
|
||||
member/person/profile modeling. 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.
|
||||
@@ -18,3 +18,4 @@ ADRs capture durable choices that affect more than one package or phase.
|
||||
- [0007: Module-owned Drizzle schemas and table isolation](0007-module-owned-drizzle-schemas.md) — Accepted
|
||||
- [0008: Module-owned migration contracts and offline validation](0008-module-owned-migration-contracts.md) — Accepted
|
||||
- [0009: Transactional module migration execution](0009-transactional-module-migrations.md) — Accepted
|
||||
- [0010: Core identity architecture and Better Auth adoption](0010-core-identity-and-better-auth-adoption.md) — Accepted
|
||||
|
||||
@@ -78,7 +78,18 @@ definition entrypoints, which the module contract requires to be side-effect-fre
|
||||
imports or starts module server entrypoints. It does not apply migrations or modify PostgreSQL, and
|
||||
it honestly skips object storage because no production Core storage service exists yet.
|
||||
|
||||
The next architectural phase is Phase 4 identity with Better Auth. Other target architecture not
|
||||
yet implemented includes production approval-provider composition, permission enforcement,
|
||||
physical storage-bucket provisioning, Surface SDK, richer public resource and action contracts,
|
||||
API server, dashboard, Builder, and reference feature modules.
|
||||
Phase 4A adds the Core identity foundation. Core owns a Better Auth-backed identity schema
|
||||
(`ekklesia_core_user`, `ekklesia_core_session`, `ekklesia_core_account`,
|
||||
`ekklesia_core_verification`) reachable only through Ekklesia-owned contracts and an explicit
|
||||
Core-owned Better Auth construction boundary; modules never import Better Auth or receive a raw
|
||||
database client. That schema ships as a first-class Core-owned migration source that reuses the
|
||||
exact migration format, checksum rule, planner, and transactional runner Phase 3 already
|
||||
established, ordered before module migrations in the same aggregate. See
|
||||
[ADR 0010](decisions/0010-core-identity-and-better-auth-adoption.md) and
|
||||
[the identity development guide](../development/identity.md). Phase 4A does not mount an HTTP
|
||||
handler or expose a sign-in endpoint; no user can authenticate yet.
|
||||
|
||||
The next architectural phase is identity HTTP transport and an explicit enrollment policy. Other
|
||||
target architecture not yet implemented includes production approval-provider composition,
|
||||
permission enforcement, physical storage-bucket provisioning, Surface SDK, richer public resource
|
||||
and action contracts, API server, dashboard, Builder, and reference feature modules.
|
||||
|
||||
@@ -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