Ekklesia could authenticate a person and, since Pass 1, send a transactional email — but it had no way to establish that an address belonged to whoever claimed it, and no way back in for someone who forgot their password. A verified address is now a precondition for holding a session. Better Auth enforces that after the password check and before any session exists, so an unverified sign-in returns 403 with no cookie. That is why no "unverified principal" was needed: a correct password on an unverified address carries exactly the authority of no password, and /api/v1/principal is unchanged. Better Auth keeps every token. Ekklesia adds no token table, no hashing, and no expiry clock. It supplies a recipient, a subject, and two bodies, and the mail provider supplies everything about SMTP. Verification and reset links are rebuilt from a validated Dashboard origin, taking only the token — never the URL Better Auth prebuilds from a caller-supplied callbackURL — so no request can steer where a recipient lands. Following a verification link mints no session, because mail infrastructure dereferences links. Reset revokes that person's other sessions and leaves managed devices alone. Recovery answers identically for an address with an account, without one, already verified, or undeliverable — including when SMTP is broken, which is why the delivery adapter never throws. The initial platform owner is the one exception: created already verified by an operator command with no mail prerequisite, so a fresh server can be signed in to before a relay exists. Migration 0007 extends that to owners of installations that already exist. Public signup stays disabled; verification is not a licence to self-register. Co-Authored-By: Claude Opus 5 <[email protected]>
481 lines
30 KiB
Markdown
481 lines
30 KiB
Markdown
# 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](api.md) and
|
||
[ADR 0011](../architecture/decisions/0011-identity-http-transport-and-public-enrollment.md) for
|
||
public registration, sign-in, and principal resolution over HTTP. This page covers the identity
|
||
foundation itself; see [ADR 0010](../architecture/decisions/0010-core-identity-and-better-auth-adoption.md)
|
||
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](api.md)) 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
|
||
|
||
```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
|
||
```
|
||
|
||
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](#how-a-principal-is-resolved)
|
||
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](api.md).
|
||
|
||
Core still returns Better Auth's normal `Response` to that consumer. At the browser-facing API
|
||
composition boundary, [ADR 0034](../architecture/decisions/0034-host-owned-browser-authentication.md)
|
||
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](../architecture/decisions/0012-identity-origin-csrf-protection-under-test.md)
|
||
and [API](api.md#logging):
|
||
`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](api.md)); 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](api.md#database-transport-tls); 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](../architecture/decisions/0018-identity-session-resolution-and-database-transport.md).
|
||
|
||
## What is and is not operational
|
||
|
||
Email/password sign-in, principal resolution, sign-out, the Dashboard's host-owned browser flow,
|
||
email verification, and password recovery are operational — see [API](api.md), ADR 0034, and
|
||
ADR 0038. 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 invitation flow, social or OAuth provider, passkey, magic link, two-factor authentication,
|
||
email-change verification, 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](authorization.md). 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](../architecture/decisions/0036-managed-device-identity-and-pairing.md)).
|
||
|
||
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.
|
||
|
||
## Email verification and password recovery
|
||
|
||
A verified email address is a **precondition for holding a session**
|
||
([ADR 0038](../architecture/decisions/0038-email-verification-and-password-recovery.md)). Better Auth
|
||
enforces this after the password is checked and before any session exists, so an unverified sign-in
|
||
returns `403 EMAIL_NOT_VERIFIED` and sets no cookie.
|
||
|
||
That is why there is no "unverified principal": a correct password on an unverified address carries
|
||
exactly the same authority as no password at all, and `/api/v1/principal` still reports only
|
||
`anonymous`, an authenticated human, or a device.
|
||
|
||
### Who owns what
|
||
|
||
| concern | owner |
|
||
| --- | --- |
|
||
| token minting, expiry, consumption, password hashing, session revocation | Better Auth |
|
||
| recipient, subject, plaintext and HTML bodies, link construction | `core/src/identity/notifications.ts` |
|
||
| SMTP host, port, TLS, credentials, sender identity | the mail provider ([ADR 0037](../architecture/decisions/0037-host-provided-outbound-email.md)) |
|
||
| the browser screens a person actually sees | the shell (`surfaces/shell`) |
|
||
|
||
Ekklesia implements no token table, no token hashing, and no expiry clock. A structural test asserts
|
||
that none appears.
|
||
|
||
### Token semantics, stated precisely
|
||
|
||
| | verification | password reset |
|
||
| --- | --- | --- |
|
||
| representation | signed JWT (not stored) | random value stored in `ekklesia_core_verification` |
|
||
| lifetime | 1 hour | 1 hour |
|
||
| single-use | **no** — valid until it expires | **yes** — consumed on use |
|
||
| replay | idempotent no-op, mints no session | refused with `INVALID_TOKEN` |
|
||
|
||
Verification is deliberately idempotent because mail scanners and prefetchers dereference links.
|
||
Following one never creates a session (`autoSignInAfterVerification: false`), so a link fetched by
|
||
something that is not the recipient hands out nothing.
|
||
|
||
### Links
|
||
|
||
Both links are rebuilt from validated configuration, taking **only the token** from Better Auth —
|
||
never the `url` it prebuilds from a caller-supplied `callbackURL`/`redirectTo`. No request parameter
|
||
can influence where a recipient lands.
|
||
|
||
They point at `IdentityConfig.dashboardOrigin` (`EKKLESIA_DASHBOARD_ORIGIN`), which defaults to the
|
||
identity base URL, must be a canonical origin, must be https and non-loopback in production, and must
|
||
be a trusted origin — checked at startup, so a misconfiguration fails once for the operator instead of
|
||
for every recipient. It is the authenticated Dashboard host, not the public church website.
|
||
|
||
**The `url` and `token` are credentials.** Nothing logs them, nothing puts them in an error, nothing
|
||
puts them in a diagnostic, and the browser strips the reset token from the address bar with
|
||
`history.replaceState` before rendering the form.
|
||
|
||
### Recovery is enumeration-safe
|
||
|
||
Every user-facing answer is identical for an address with an account, an address without one, an
|
||
already-verified address, and one whose message could not be delivered. The notification adapter
|
||
therefore **never throws** — Better Auth's `/send-verification-email` rethrows what its callback
|
||
throws, which would turn a broken relay into an account oracle. Failures are reported out of band:
|
||
|
||
```text
|
||
[identity] email-verification email was not delivered: MAIL_NOT_CONFIGURED
|
||
```
|
||
|
||
### Password reset and sessions
|
||
|
||
`revokeSessionsOnPasswordReset` is enabled: completing a reset deletes **every** session for that
|
||
person, including the browser that performed it, so the completion screen returns to sign-in.
|
||
Recovery is what someone uses when they think their credential is compromised, and leaving the
|
||
attacker signed in would make it cosmetic.
|
||
|
||
Managed device sessions are untouched — a device holds a separate credential digest, no email, and no
|
||
password.
|
||
|
||
### Resending
|
||
|
||
A sign-in attempt against an unverified account resends the message (`sendOnSignIn`). That path costs
|
||
nothing to secure because it already required the correct password.
|
||
`POST /api/auth/send-verification-email` is also available and is enumeration-safe on its own.
|
||
|
||
Better Auth rate-limits both mail-triggering paths at **3 requests per 60 seconds per IP**, and
|
||
sign-in at 3 per 10 seconds. That limiter is **in-memory and per process** — a real control for a
|
||
single deployment, and deliberately not claimed to be a distributed one.
|
||
|
||
### Without a configured relay
|
||
|
||
The platform starts, serves, and signs its bootstrap owner in. It configures the identical
|
||
verification policy and simply cannot deliver. Absent mail does **not** relax
|
||
`requireEmailVerification`, which is exactly why the initial owner is created already verified.
|
||
|
||
## 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](../architecture/decisions/0035-initial-platform-owner-bootstrap.md)):
|
||
|
||
```bash
|
||
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.
|
||
|
||
### This one account is created already verified
|
||
|
||
Every other human account must confirm its address by email before it may hold a session. This one
|
||
cannot: it is created on a freshly provisioned server, by an operator who already holds the database
|
||
credentials, at a moment when SMTP may not be configured, may not be reachable, and may not yet have
|
||
a domain to send from. Requiring an email round trip here would produce a working installation whose
|
||
only administrator can never sign in to configure the mail service the requirement depends on
|
||
([ADR 0038](../architecture/decisions/0038-email-verification-and-password-recovery.md)).
|
||
|
||
**The command has no mail prerequisite of any kind.** It marks Better Auth's own `emailVerified`
|
||
column — so the row is indistinguishable from one that verified normally — before granting ownership;
|
||
if that write fails, the grant and the one-time sentinel roll back together and the command remains
|
||
available. An adopted identity is marked verified too, because the alternative is bootstrap
|
||
succeeding and producing an owner who cannot sign in.
|
||
|
||
The exception is narrow: exactly one address, exactly once per installation. Its authority is
|
||
possession of the database, not possession of a mailbox. An account created any other way is still
|
||
subject to the ordinary policy.
|
||
|
||
Existing installations are covered by migration `0007_verified_bootstrap_owners`, which marks
|
||
currently-granted platform owners verified so enabling this policy cannot lock an operator out of a
|
||
platform they already run. It touches no other account.
|
||
|
||
### How future human accounts will become verified
|
||
|
||
There is no user administration and no invitation flow yet, and this documentation does not claim
|
||
either exists. When one is built, the path is already in place: an account created by any trusted
|
||
server-side means starts unverified, its first sign-in attempt sends a verification message, and it
|
||
becomes ordinary once the address is confirmed. The bootstrap owner remains the only exception.
|
||
|
||
### 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:
|
||
|
||
```bash
|
||
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](authorization.md#bootstrap-is-permanently-single-use)). 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.
|