Files
Ekklesia/docs/development/identity.md
T
ekklesiaandClaude Opus 5 fa3021e49b
CI / verify (pull_request) Successful in 42s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 1m23s
feat: add a host-provided outbound email transport
Ekklesia could authenticate people and pair devices but could not send a
single message. Better Auth has been configured with emailAndPassword
since ADR 0011 and neither of its mail hooks — sendResetPassword and
emailVerification.sendVerificationEmail — was ever implemented, so email
verification, password recovery, and invitations were all blocked on the
same missing thing.

That missing thing is a transport, not a Google Workspace integration.
Email is now a host-provided infrastructure service with a provider seam,
shaped exactly like object storage: feature code says "send this
transactional message" and the provider owns the host, port, TLS,
credentials, connection behaviour, and every transport error. No
provider's hostname appears anywhere in Core, and a test asserts it.
Google Workspace SMTP Relay is the expected first deployment and a
compatibility target — the same variables address Microsoft 365, a
transactional provider, or a local Postfix.

The contract is deliberately tiny: one recipient or a small bounded list,
a subject, text and/or HTML, an optional reply-to, and nothing else. There
is no `from` field at all — the sender is host configuration, so no
feature and no future module can choose who mail appears to come from,
which is how a platform otherwise acquires an impersonation primitive.
Control characters are refused rather than stripped in addresses, display
names, subjects, and reply-to values; writing that rule down was not
enough, because the first implementation trimmed an address before
checking it and silently accepted a trailing carriage return until a test
caught it.

Security modes are named rather than inferred from a boolean and a port,
which is genuinely ambiguous: `secure: false` on 587 means "connect in the
clear and hope STARTTLS is offered". `starttls` sets requireTLS so a
stripped upgrade fails the send instead of continuing in plaintext,
production refuses `plaintext` outright, and certificate verification has
no off switch. Credentials are optional as a pair, because a relay
authorized by source IP has neither and requiring them would make the
intended production arrangement unconfigurable; half a pair fails loudly.

Connection, greeting, and socket timeouts are all explicit — the
object-storage pass already showed what a network client on library
defaults does to a request. Nothing retries: a failure after the message
data has gone out is ambiguous, and retrying it turns one password-reset
request into several emails, so this is a single bounded attempt and
deliberately not an exactly-once promise.

Email stays optional. An installation with no SMTP host is a complete
deployment, nothing sends at startup, and constructing the transport opens
no socket. There is no POST /api/v1/send-email and no route that touches
the provider — an authenticated send endpoint is an open relay with extra
steps. Modules get nothing either; services are added one at a time when
something real needs one, and nothing does yet.

Proven against a real in-process SMTP server rather than a mock: delivery
with the host's sender on the wire, authentication and its sanitized
refusal, a rejected mailbox distinguished from an outage, a refused
connection, a silent server hitting its deadline, and a starttls transport
refusing to continue when the upgrade is not offered. No test sends real
email and no live credential exists in this repository or in CI.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-24 18:37:16 -05:00

24 KiB
Raw Blame History

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 and ADR 0011 for public registration, sign-in, and principal resolution over HTTP. This page covers the identity foundation itself; see ADR 0010 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 authenticate through Core-exposed HTTP/API contracts, never by querying Better Auth's tables directly. apps/api (Phase 4B, see API) is that transport. The Dashboard now owns its browser login/logout UX and uses only /api/auth/sign-in/email, /api/auth/sign-out, and the narrow /api/v1/principal contract; modules receive none of those controls. Website and Mobile authentication do not exist 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, whole-batch SQL execution, 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

Both migrations:plan and migrations:apply connect with fixed, finite defaults — a 10-second connection deadline, a 10-second client query timeout, and a 10-second PostgreSQL statement_timeout — applied at the one shared construction boundary so no command can omit them. A DATABASE_URL that tries to set statement_timeout, query_timeout, connectionTimeoutMillis, connect_timeout, or an options value naming statement_timeout is refused with a sanitized configuration error. Only statement_timeout actually overrides the selected policy under the pinned driver (measured: ?statement_timeout=0 made the server report 0); the others are rejected as unsupported configuration and defense in depth — query_timeout breaks the client rather than loosening it, and connectionTimeoutMillis pollutes a driver field the pure-JS client never consults. Unrelated parameters are untouched. This bounds each individual operation; it does not promise that a migration completes within ten seconds across retries or cleanup. Migrations remain explicit operator work and never run at application startup.

Each pending migration's SQL is sent to PostgreSQL as one complete batch, inside the existing transaction, so the server does all SQL parsing — Drizzle's --> statement-breakpoint lines are ordinary -- comments to it. No SQL parser exists in this repository, and the marker's bytes keep their ordinary meaning inside a string literal, dollar-quoted body, quoted identifier, or comment.

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 Exactly one canonical http/https origin — scheme, host, and non-default port only. In production, https is required and loopback/unspecified hosts are rejected.
Trusted origins One or more canonical http/https origins. Wildcards are rejected in this slice. In production, https is required and loopback/unspecified hosts are rejected.

"Canonical origin" is literal: the value must equal the WHATWG URL parser's own origin serialization of itself. A path (https://id.example.test/auth), a bare trailing slash (https://id.example.test/), a query, a fragment, embedded credentials, an explicitly written default port (https://id.example.test:443), or non-canonical case is rejected with BASE_URL_INVALID/TRUSTED_ORIGIN_INVALID rather than normalized behind your back — so the stored value always matches what a browser actually sends in an Origin header.

Production rejects loopback and unspecified hosts by classifying the address, not by comparing it against a short list: localhost and the whole reserved .localhost namespace (RFC 6761), all of 127.0.0.0/8 (not just 127.0.0.1), 0.0.0.0, ::1 in both compressed and expanded spellings, ::, and IPv4-mapped forms such as ::ffff:127.0.0.1. 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.

This is a guarantee about the shape of the configured value and nothing more. It does not resolve DNS and does not prove reachability: a public hostname that resolves to a loopback or private address passes this validation, and a firewall, network policy, or reverse proxy — not URL parsing — is what actually determines who can reach a deployment.

Secret rotation (Better Auth's plural secrets list) and cross-subdomain cookies are deferred; see ADR 0010. The Dashboard flow in ADR 0034 deliberately retains a host-only cookie: a future deployment must not assume sibling subdomains share a session.

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 and session resolution below). 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.

Core still returns Better Auth's normal Response to that consumer. At the browser-facing API composition boundary, ADR 0034 defines exactly two exceptions. First, any body-bearing successful 2xx from POST /api/auth/sign-in/email preserves Better Auth's status, headers, and HttpOnly Set-Cookie but replaces its token/user JSON with fixed {"ok":true}. Second, the token-bearing native paths /api/auth/get-session, /api/auth/list-sessions, /api/auth/update-session, and /api/auth/change-password receive a sanitized 404 without reaching Better Auth. The last path is denied even though password/account management is not built, because the pinned implementation can return a new raw session token when revoking other sessions.

Failures and every other supported auth endpoint retain their native semantics. The Dashboard ignores even the normalized sign-in body as identity and re-resolves /api/v1/principal, the only supported browser session read; no session token is exposed to browser application JavaScript or added to a module context.

Construction also explicitly configures four things the pinned release would otherwise leave on an environment-sensitive or implicit default, all covered by ADR 0012 and API: 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); an invalid value is rejected the same sanitized way an invalid connection string already is. It also accepts an optional transport policy — the PostgreSQL TLS decision described in API § Database transport; every production executable passes one.

How a principal is resolved

resolvePrincipal(headers) returns the anonymous principal for a missing, expired, revoked, or malformed session, and throws a sanitized PRINCIPAL_RESOLUTION_FAILED for an infrastructure failure. Telling those apart takes explicit work, because Better Auth's getSession swallows internal failures and returns null for them exactly as it does for a genuinely absent session.

  1. No cookies, or only unrelated cookies → no identity database work at all. Cookie: x=y is not evidence that Better Auth ever attempted to resolve an Ekklesia session, so its null is unambiguous and the anonymous principal is returned immediately. An unauthenticated caller cannot drive identity database load with a cookie header they chose themselves.
  2. The configured session cookie is present, and Better Auth returned null → the ambiguous case, and only then is the session store checked. Detection is an exact, whole-name cookie comparison (RFC 6265 parsing; names are case-sensitive and never percent-decoded), so better-auth.session_token_x and x_better-auth.session_token are correctly not the session cookie. The name itself is derived from the same options object passed to Better Auth, via its own exported getCookies helper — including the __Secure- prefix an https base URL implies — so there is no second copy of it to drift.
  3. The check reads the real session relation, not select 1: it plans a read of the exact columns the Better Auth adapter depends on under a where false predicate. PostgreSQL resolves the relation, every named column, and the caller's SELECT privilege while matching zero rows, so no session row, token, or user id can be read, logged, or enumerated by the check. It runs on the injected pool — never a second connection — under a bounded 3-second timeout.

A missing session table, a dropped or drifted column, a revoked SELECT privilege, an unreachable database, and a query timeout therefore all produce a sanitized 503 {"code":"PRINCIPAL_RESOLUTION_FAILED"} — never a silent anonymous result. The response body, logs, and error carry no relation name, column name, SQL, driver message, host, or stack. See ADR 0018.

What is and is not operational

Email/password sign-in, principal resolution, sign-out, and the Dashboard's host-owned browser flow are operational — see API and ADR 0034. Public registration remains a future product policy, not a production capability: it is disabled by default, may be explicitly enabled only for development/test, and production rejects it. No email verification, password recovery, invitation flow, email delivery, social or OAuth provider, passkey, magic link, two-factor authentication, or roles/groups/teams/tenants model exists.

Core's authorization foundation (platform owners, explicit grants, and an immutable permission registry) is covered separately in Authorization. Its bootstrap command grants an existing identity; it does not create one. Creating the first identity on a fresh installation is what pnpm identity:create-initial-owner is for — see below.

Three principal classes

Ekklesia recognizes anonymous, authenticated (a person), and device (a paired display) (ADR 0036).

A device is not a user: it has no row in the identity tables, no email, no password, and no human session. isAuthenticatedPrincipal returns false for it, which is what keeps every "somebody is signed in" route closed to a screen on a wall. Its credential is a separate HttpOnly cookie (ekklesia_device_session), resolved after Better Auth has been asked first — a human session always wins, so a browser holding both remains a person.

/api/v1/principal reports a device as anonymous on purpose. It is the endpoint the Dashboard uses to decide whether a person is signed in, and a device is not one.

Outbound email and the verification seam

Ekklesia has a host-provided SMTP transport (ADR 0037, see Outbound email) — but no identity flow uses it yet.

Better Auth exposes exactly two hooks for the flows that need mail, and this repository configures neither:

hook flow
emailAndPassword.sendResetPassword({ user, url, token }) Password reset
emailVerification.sendVerificationEmail({ user, url, token }) Email verification

Phase 7C Pass 2 implements them over the mail transport. startApi already constructs the provider and closes it on every shutdown path, so the remaining work is to inject it into createIdentityRuntime and write the two message bodies.

The url and token those hooks receive are credentials. The entire security of both flows is that only the recipient sees them, so they must never be logged, never appear in an error, and never reach a diagnostic. The transport already logs nothing and refuses to echo a message body.

Until that pass lands: email verification, password recovery, and invitations do not exist, and requireEmailVerification stays off — turning it on without a working sender would lock every account out.

Creating the first platform owner

A fresh installation has a login page and nothing to log in to. Public registration is disabled by default and production cannot enable it at all, so one explicit, operator-invoked command creates the initial identity and grants it platform ownership (ADR 0035):

pnpm migrations:apply   # the schema must already exist; this command never migrates
DATABASE_URL=postgresql://... pnpm identity:create-initial-owner -- --email [email protected]
# Password: (typed invisibly)
# Confirm password:
# Initial platform owner created successfully.
# Email: [email protected]
# You can now sign in to the Ekklesia dashboard.

--name is optional and defaults to the email address; the identity model requires a display name, and asking for one that nobody can edit yet would be a question without a purpose.

The password never becomes an argument or a variable

It is prompted for twice, invisibly, when a terminal is attached. --password is refused with an explanation, because a command-line argument is visible in shell history and to every process on the machine through the process list. For automated deployment the password is read from standard input:

printf '%s' "$PASSWORD" | pnpm identity:create-initial-owner -- --email [email protected]

No environment variable is read for it, and none belongs in .env. A bootstrap password is used once to create an account; it is not deployment configuration, and an environment variable is precisely the thing that quietly becomes permanent. Nothing prints the password, its length, a hash, a session token, or the database URL.

Required environment

DATABASE_URL, and the same optional transport variables every other Core command honours (NODE_ENV, EKKLESIA_DATABASE_TLS_MODE, EKKLESIA_DATABASE_CA). Deliberately not the identity secret, trusted origins, or a public base URL: none is needed to create an account, so the first owner can be created on a freshly provisioned server before TLS or DNS exist.

Password and email rules are the identity system's own

The 8–128 character bound and email validation are enforced by Better Auth through the same sign-up path a browser would use, not by a separate CLI policy that could drift from it. The address is normalized exactly as a browser sign-up normalizes it — stored lowercased — and the command reports the stored address, so the one you are told to sign in with is the one that exists.

It runs once

Ownership bootstrap is permanently single-use (see Authorization). A second run is refused and:

  • creates no second owner,
  • changes no existing password,
  • creates no stray identity, even when a different address is supplied.

Rerun and recovery

situation behaviour
Bootstrap already completed Refused. Nothing changes.
Identity exists, ownership was never granted Ownership is granted to it and its password is left unchanged. Reported as an adoption, not a creation.
A user row exists with no password (cannot sign in) Refused, and bootstrap stays available — granting ownership to an identity nobody can authenticate as would consume the single use and leave the installation unusable.
Identity created but the grant failed Reported as incomplete. The identity is real and bootstrap is still available: run the identical command again with the same address to finish.
Database unmigrated or unreachable Refused with a clear message. The command never migrates.

Nothing is deleted automatically on failure: removing an account that was created successfully, on the strength of a later error, would destroy something an operator may need.

This is not signup

There is no /signup, /register, or /setup route, no unauthenticated bootstrap endpoint, no temporary setup route, and no setup token. The login page stays login-only, and public registration stays disabled in every environment — an integration test asserts that a freshly bootstrapped deployment still refuses POST /api/auth/sign-up/email.

Creating additional users or owners is not this command's job. That belongs to user administration, which does not exist yet.