feat: create the initial platform owner from an operator command
CI / verify (pull_request) Successful in 41s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 1m18s

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:
ekklesia
2026-08-23 22:32:53 -05:00
co-authored by Claude Opus 5
parent c131543abf
commit c8fa13dd6a
16 changed files with 1767 additions and 12 deletions
@@ -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.
+1
View File
@@ -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