feat: create the initial platform owner from an operator command
A fresh production installation deadlocked. ADR 0034 gave Ekklesia a login
page and ADR 0019 gave it a single-use platform-owner bootstrap, but that
bootstrap grants ownership to a user id that already exists and has no way
to bring one into being. Public signup is disabled by default and
production cannot enable it at all, so a newly deployed instance had a
login page, no account, and no supported way to create one — only hand-
written SQL or briefly opening public registration.
pnpm identity:create-initial-owner -- --email [email protected]
creates the identity and grants it ownership, in the same shape as the
other operator commands: trusted local tooling, reachable only by someone
who already holds the database credentials, serving no HTTP route. No
/signup, /register, or /setup route is added, no bootstrap endpoint, and
no setup token; public signup stays disabled in every environment and a
test asserts a freshly bootstrapped deployment still refuses
/api/auth/sign-up/email.
The identity is created through Better Auth's own sign-up path, so
hashing, salting, normalization and the 8-128 bound are the identity
system's rather than a second implementation that could drift. Because the
server's instance refuses account creation, the command builds its own
in-process instance; nothing in apps/api imports it and the running
server's posture is untouched.
The sign-up result is verified rather than trusted. Better Auth returns a
fabricated success for an already-registered address — a user object whose
id was never persisted, database unchanged. That is correct enumeration
defence on a public endpoint and a trap here, so the identity is always
read back from the database and that read, not the response, is what the
grant uses.
An existing usable identity is adopted: ownership is granted and its
password left alone, reported as an adoption rather than a creation. This
is the recovery path for the one partial failure possible, since sign-up
commits on Better Auth's connection and the grant is a separate
transaction. A credential-less row is refused instead — making it an owner
would consume the single use on an identity nobody can authenticate as. A
password is never reset and an account is never taken over.
Whole invocations are serialized by an advisory lock on their own key, so
a race cannot leave a stray signable-in account behind; the grant's own
locked single-use transaction is unchanged. There is no --force or reset.
The password is prompted for invisibly or read from stdin, never from
argv (--password is refused with an explanation) and never from an
environment variable, so nothing about it becomes permanent deployment
configuration. The command needs DATABASE_URL and nothing else — verified
that sign-up needs no secret, base URL, or trusted origins, and that the
resulting account signs in through a separately configured server — so the
first owner can be created before TLS or DNS exist. It never migrates.
Proven end to end rather than at the database: a fresh installation is
bootstrapped, signed in through the real browser login transport with
signup disabled, its principal read, and used to open an owner-protected
resource that stays closed to anonymous callers.
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c131543abf
commit
c8fa13dd6a
@@ -0,0 +1,182 @@
|
||||
# 0035: The first platform owner is created by an operator command, never by a browser
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-24
|
||||
- Extends: [0011](0011-identity-http-transport-and-public-enrollment.md),
|
||||
[0019](0019-durable-authorization-history-and-grant-management.md),
|
||||
[0034](0034-host-owned-browser-authentication.md)
|
||||
|
||||
## Context
|
||||
|
||||
[ADR 0034](0034-host-owned-browser-authentication.md) gave Ekklesia a host-owned login page, and
|
||||
authorization has had a single-use platform-owner bootstrap since
|
||||
[ADR 0019](0019-durable-authorization-history-and-grant-management.md). Between them they close
|
||||
every step of getting an administrator into the Dashboard except the first one.
|
||||
|
||||
`pnpm authorization:bootstrap-owner` grants ownership to a user id that **already exists**. It has
|
||||
no way to bring that user into being. Public signup is disabled by default and production cannot
|
||||
enable it at all — `IdentityConfig` rejects `publicSignupEnabled` in production outright
|
||||
(`PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION`), because the durable abuse controls that would justify a
|
||||
public registration endpoint do not exist. That is the right decision and this ADR does not revisit
|
||||
it.
|
||||
|
||||
The consequence was a fresh production installation that deadlocks:
|
||||
|
||||
```text
|
||||
deploy → migrate → login page loads → no account exists → nothing can create one → stuck
|
||||
```
|
||||
|
||||
The only ways through were creating rows by hand or briefly turning on public registration, and both
|
||||
are exactly what a supported platform should not ask an operator to do.
|
||||
|
||||
## Decision
|
||||
|
||||
One explicit, operator-invoked command creates the initial identity and grants it platform
|
||||
ownership:
|
||||
|
||||
```
|
||||
pnpm identity:create-initial-owner -- --email [email protected]
|
||||
```
|
||||
|
||||
It is deliberately the same shape as `pnpm authorization:bootstrap-owner` and `pnpm modules:enable`:
|
||||
trusted local tooling, reachable only by someone who already holds the database credentials, serving
|
||||
no HTTP route and mounted nowhere.
|
||||
|
||||
### What this is not
|
||||
|
||||
No `/signup`, `/register`, or `/setup` route. No unauthenticated bootstrap endpoint, no temporary
|
||||
setup route, no query-string bootstrap token, and no browser wizard. The login page stays
|
||||
login-only, and public signup stays disabled in every environment. A regression test asserts that a
|
||||
deployment which has just bootstrapped its owner still answers `/api/auth/sign-up/email` with
|
||||
`EMAIL_PASSWORD_SIGN_UP_DISABLED`.
|
||||
|
||||
### The identity is created through Better Auth's own sign-up path
|
||||
|
||||
Hashing, salting, email normalization, the `user`/`account` pair, and the 8–128 password bounds are
|
||||
the identity system's, applied exactly as they are for a browser sign-up. The command writes no user
|
||||
row and no password hash of its own — a second, divergent identity implementation would eventually
|
||||
produce an account that exists but cannot sign in.
|
||||
|
||||
Because the server's own instance refuses account creation (verified: `auth.api.signUpEmail` on it
|
||||
returns `EMAIL_PASSWORD_SIGN_UP_DISABLED`), the command constructs a **separate, in-process** Better
|
||||
Auth instance with sign-up enabled. Its safety comes from where it can be invoked, not from a weaker
|
||||
policy: it serves no route, nothing in `apps/api` imports it, and the running server's posture is
|
||||
untouched.
|
||||
|
||||
### The sign-up result is verified, never trusted
|
||||
|
||||
Better Auth returns a **fabricated success** when the address is already registered — a user object
|
||||
carrying an id that was never persisted, with the database unchanged. This was verified against the
|
||||
pinned release, and on a public endpoint it is correct behaviour: it refuses to tell an anonymous
|
||||
caller which addresses exist.
|
||||
|
||||
For this command it is a trap. A caller that believed the response would report an owner it never
|
||||
created and then grant ownership to an id that does not exist. So the identity is always read back
|
||||
from the database by address, and that read — never the sign-up response — is what the grant uses.
|
||||
|
||||
### Bootstrap runs once, and the existing sentinel decides
|
||||
|
||||
The guard is not new and is not weakened. `ekklesia_core_platform_owner_bootstrap` is an explicit
|
||||
singleton, deliberately *not* inferred from the continued existence of an owner row, and
|
||||
`bootstrapPlatformOwner` claims it inside the same locked transaction as the grant and its audit
|
||||
entry. There is no `--force`, no reset, and no override; "consumed" is permanent by design.
|
||||
|
||||
This command adds a cheap sentinel check **before** creating an identity, so a closed bootstrap
|
||||
never leaves a stray account behind. That check is an early exit, not the authority — the grant
|
||||
re-checks under its own lock, which is what actually makes it single-use.
|
||||
|
||||
### An existing identity is adopted, never overwritten
|
||||
|
||||
If the address already has a usable account and bootstrap is still open, ownership is granted to it
|
||||
and **its password is left completely alone**, reported as `adopted` so nobody is told an account was
|
||||
created when it was not.
|
||||
|
||||
This is the recovery path for the one partial failure the command can have. Sign-up commits through
|
||||
Better Auth's own connection and the grant runs in a separate transaction, so full transactionality
|
||||
across the two is not available: a crash between them leaves an identity with no ownership. Refusing
|
||||
would strand a fresh installation with no way forward but manual SQL. A password reset would be a
|
||||
silent takeover of an account the operator may not own. Adoption changes no credential.
|
||||
|
||||
That is safe here specifically because production has no other way to create an identity, so an
|
||||
account present on a fresh installation was put there by someone who already had database access.
|
||||
|
||||
**A credential-less row is refused instead.** A `user` row with no `account` row cannot sign in;
|
||||
granting ownership to one would consume the single use on an identity nobody can authenticate as and
|
||||
leave the installation unusable with no second chance. The command reports that and changes nothing,
|
||||
leaving bootstrap available.
|
||||
|
||||
### Failure leaves a recoverable, self-describing state
|
||||
|
||||
- Identity created, grant failed → the identity is real, the sentinel rolled back with the grant,
|
||||
and rerunning the same command with the same address adopts it and finishes. The error says so.
|
||||
- Nothing is deleted automatically. Removing a successfully created identity to "clean up" would be
|
||||
destroying an account on the basis of an error whose cause is not known.
|
||||
|
||||
### Whole invocations are serialized
|
||||
|
||||
The grant alone was already race-proof. The command additionally takes a transaction-scoped advisory
|
||||
lock — on its own key, distinct from the grant's, so the two cannot deadlock — across the entire
|
||||
check-create-grant sequence. Without it, two operators racing on a fresh installation would both
|
||||
pass the sentinel check and both create an identity before one lost the grant, leaving a stray
|
||||
account that can sign in and owns nothing. A test races five invocations and asserts one owner, one
|
||||
sentinel, and **one user row**.
|
||||
|
||||
### The password never becomes an argument, a variable, or an echo
|
||||
|
||||
It is prompted for, invisibly and twice, when a terminal is attached, and otherwise read from
|
||||
standard input for automated deployment:
|
||||
|
||||
```
|
||||
printf '%s' "$PASSWORD" | pnpm identity:create-initial-owner -- --email [email protected]
|
||||
```
|
||||
|
||||
`--password` is refused with an explanation rather than ignored. **No environment variable is read
|
||||
for it and none is added to `.env.example`**: a bootstrap password belongs to one invocation, not to
|
||||
a deployment's permanent configuration, and an environment variable is exactly the thing that
|
||||
becomes permanent. Nothing prints the password, its length, a hash, a token, or the database URL.
|
||||
|
||||
### It runs before DNS exists
|
||||
|
||||
The command needs `DATABASE_URL` and nothing else. It requires no identity secret, no trusted
|
||||
origins, and no public base URL — all three were tested against the pinned release: sign-up succeeds
|
||||
without them, and the resulting account signs in through a separately constructed instance carrying
|
||||
a *different* secret, because password hashes are salted per account and independent of the secret.
|
||||
|
||||
That matters operationally: an operator can create the first owner on a freshly provisioned server
|
||||
before TLS, DNS, or the public origin exist. The instance the command builds is given an ephemeral
|
||||
random secret so it is explicitly configured rather than silently defaulted; it never mints a
|
||||
session, so nothing is ever signed with it.
|
||||
|
||||
The forward-compatibility risk — a future release coupling password hashing to the secret — is held
|
||||
by the browser-login integration proof, which creates through this path and signs in through the
|
||||
real server with the real configured secret.
|
||||
|
||||
### It never migrates
|
||||
|
||||
A database that is unreachable and one that was never migrated are the same situation to an operator
|
||||
on a fresh server: the deployment step before this one has not happened. The command says so and
|
||||
changes nothing. Silently migrating a production schema from a bootstrap command would hide exactly
|
||||
the mistake worth surfacing.
|
||||
|
||||
## Consequences
|
||||
|
||||
The success criterion is proven end to end rather than at the database: an integration test
|
||||
bootstraps a fresh installation, signs in through `POST /api/auth/sign-in/email` on a deployment with
|
||||
signup disabled, reads the principal from `GET /api/v1/principal`, and opens a
|
||||
platform-owner-protected resource — while the same resource stays closed to an anonymous caller.
|
||||
|
||||
Creating *additional* users and owners is not this command's job and is deliberately absent. It
|
||||
belongs to user administration, which does not exist yet. Until it does, an installation has one
|
||||
owner, created once.
|
||||
|
||||
Rate limiting is unchanged. This is local trusted tooling, not an internet-facing login endpoint,
|
||||
and Phase 7B Pass 1's in-memory login rate-limit limitation is out of scope here.
|
||||
|
||||
The existing `signUpPlatformOwner` test helper in `apps/api/tests/api-http.integration.ts` still
|
||||
creates accounts over HTTP with public signup enabled. It is left alone: it exists to exercise the
|
||||
public signup transport itself, which is a different thing from the production bootstrap path, and
|
||||
collapsing them would weaken that coverage rather than share it.
|
||||
|
||||
This is a full-Ekklesia concern. Ekklesia Lite provides module runtime primitives and deliberately
|
||||
not user authentication, permission enforcement, or managed device identity, so nothing here belongs
|
||||
in a future Lite contract.
|
||||
@@ -43,3 +43,4 @@ ADRs capture durable choices that affect more than one package or phase.
|
||||
- [0032: Native module realtime is scoped snapshot state over SSE](0032-native-module-realtime-transport.md) — Accepted
|
||||
- [0033: Display UI is a separate, permission-gated module entrypoint](0033-display-kiosk-host-contract.md) — Accepted
|
||||
- [0034: Dashboard browser authentication is host-owned and principal-first](0034-host-owned-browser-authentication.md) — Accepted
|
||||
- [0035: The first platform owner is created by an operator command, never by a browser](0035-initial-platform-owner-bootstrap.md) — Accepted
|
||||
|
||||
Reference in New Issue
Block a user