ADR 0033 gave Ekklesia a display host and ADR 0034 gave it a login page. Together, the only way to light a television in a foyer was to sign a human being into it and leave that session running on a screen anyone can walk up to: a person's credentials on a wall, a person's session as the thing signage depends on, and the display going dark when they are revoked or their session simply expires. A screen is not a person. Ekklesia now recognizes three principal classes — anonymous, authenticated human, and managed device — and a device is a first-class principal with its own credential and its own explicit permissions. It has no email, no password, and no row in the identity tables, and it satisfies the `authenticated` baseline never, so every route that settles for "somebody is signed in" stays closed to it. Device permissions live in their own table because ekklesia_core_grant.user_id is a non-null foreign key into the user table and making it polymorphic would rewrite the platform's central authorization record to serve one narrow case. That is a schema decision, not a second decision point: the evaluator gained one branch, so a device is allowed or denied by the same function, with the same deny-by-default, as anyone else — with no owner path it could reach and no baseline that stands in for a grant. The six-digit code is not the credential. It is printed across a screen in a public hallway, so it is treated as public: it identifies a request to an administrator and is worth nothing alone. Collecting the credential also requires a 256-bit claim secret held only in the requesting browser's HttpOnly cookie, and approving requires the restricted core.devices.manage permission. Without that split, reading a code off a wall would hand over the screen's identity. The permission a pairing asks for is read from the module's validated display declaration, never from the request, so a browser cannot ask to be granted core.devices.manage or another module's permission. Approval grants exactly that one permission. Credentials are stored only as SHA-256; the raw value exists in the Set-Cookie that issues it and the device's own cookie jar, and is never logged, returned in JSON, or placed in a URL. The cookie is HttpOnly, so the module display code running in that same page cannot read it. Revocation is a server-side column checked on every principal resolution, and re-pairing mints a new identity so an old credential can never come back. State transitions are conditional updates, so one code cannot be approved twice or claimed into two credentials under a race. /api/v1/principal still reports a device as anonymous: it is what the Dashboard uses to decide whether a person is signed in. Displays read their viewer from the descriptor instead, which already performed the authorization — so displays are descriptor-first while the Dashboard stays principal-first, and neither infers an identity by elimination. Proven end to end against real PostgreSQL and real HTTP: an unpaired screen shows a code, an owner approves it, the device opens the display descriptor and a realtime stream, and revocation closes both while the device still holds its cookie. An already-open SSE stream is bounded by realtime's existing five-minute reconnect rather than closed instantly, and that bound is documented rather than called immediate. Co-Authored-By: Claude Opus 5 <[email protected]>
363 lines
23 KiB
Markdown
363 lines
23 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, and the Dashboard's host-owned browser flow
|
||
are operational — see [API](api.md) 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](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.
|
||
|
||
## 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.
|
||
|
||
### 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.
|