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
+42 -3
View File
@@ -52,9 +52,10 @@ order never grants elevation — but is now **disabled by default, and cannot be
production at all** until durable abuse controls exist (Phase 5D pass 1, below). See
[API](docs/development/api.md) and
[ADR 0011](docs/architecture/decisions/0011-identity-http-transport-and-public-enrollment.md).
Email verification, password recovery, invitation/operator enrollment, and a supported production
first-user creation path still do not exist. Public registration remains disabled by default and
prohibited in production. The Dashboard now has a minimal authenticated shell and login/logout flow,
Email verification, password recovery, and invitation/operator enrollment still do not exist.
Public registration remains disabled by default and prohibited in production — and a fresh
production installation now has a supported way to get its first administrator anyway: Phase 7B
pass 1B's `pnpm identity:create-initial-owner` (below). The Dashboard now has a minimal authenticated shell and login/logout flow,
but not a finished product UI or a production asset-hosting deployment; jobs, production
approval-provider composition, feature modules, and the public Website remain later slices.
@@ -816,6 +817,44 @@ row, and start nothing — the change takes effect at the next `pnpm api:start`.
that breaks startup can be turned off without the API, since the HTTP lifecycle endpoint is served by
the process such a module takes down.
### Getting the first administrator into a fresh installation
A new installation has a login page and nothing to log in to: public registration is disabled by
default and cannot be enabled in production at all. One explicit, operator-invoked command creates
the initial identity and grants it platform ownership
([ADR 0035](docs/architecture/decisions/0035-initial-platform-owner-bootstrap.md)):
```bash
docker compose ... up -d # PostgreSQL and the rest of the stack
pnpm migrations:apply # the schema must already exist
pnpm identity:create-initial-owner -- --email [email protected]
pnpm api:start # then sign in at the Dashboard login page
```
It prompts for the password twice, invisibly, and never accepts one as a command-line argument —
`--password` is refused, because an argument is visible in shell history and in the process list.
For automated deployment it reads the password from standard input instead:
```bash
printf '%s' "$PASSWORD" | pnpm identity:create-initial-owner -- --email [email protected]
```
**No environment variable is read for the password and none belongs in `.env`**: it is used once to
create an account, not kept as deployment configuration. The command needs `DATABASE_URL` and
nothing else — no identity secret, no trusted origins, and no public base URL — so the first owner
can be created on a freshly provisioned server before TLS or DNS exist.
It runs **once**. Ownership bootstrap is single-use, so a second run is refused: it creates no
second owner, changes no existing password, and leaves the first owner untouched. It also never
applies migrations — an unmigrated database is reported, not silently fixed. If it created the
identity but failed before granting ownership, running the identical command again adopts that
identity and finishes, leaving its password alone. Creating *additional* users and owners belongs to
user administration, which does not exist yet.
This is not public signup and not a browser setup wizard: there is no `/signup`, `/register`, or
`/setup` route, no unauthenticated bootstrap endpoint, and no setup token. The login page stays
login-only.
`pnpm api:start` builds the workspace, then starts the identity HTTP transport
(`GET /api/v1/principal`, Better Auth mounted at `/api/auth/*`) against an already-migrated
`DATABASE_URL` — it never applies migrations itself. See [API](docs/development/api.md) for