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
@@ -294,12 +294,29 @@ before the id is even parsed as a valid `EkklesiaUserId` — this command never
|
||||
signup order or "the only user in the database"; the operator must name the exact, opaque id
|
||||
twice.
|
||||
|
||||
**The command grants authority; it does not create credentials.** Production public signup is
|
||||
prohibited, and the repository currently has no invitation flow or operator-owned user-creation
|
||||
command. A fresh production installation therefore has no supported path to create the initial
|
||||
identity this command requires. Development integration tests may enable signup, create a user,
|
||||
bootstrap that existing id, and prove its Dashboard session, but that is test composition — not a
|
||||
production first-user provisioning workflow. This gap must be closed before launch.
|
||||
**The command grants authority; it does not create credentials.** It requires a user id that
|
||||
already exists, which is the right shape when an identity is already there and only ownership is
|
||||
missing.
|
||||
|
||||
For a *fresh* installation, where no identity exists at all, use
|
||||
`pnpm identity:create-initial-owner` instead
|
||||
([ADR 0035](../architecture/decisions/0035-initial-platform-owner-bootstrap.md)):
|
||||
|
||||
```bash
|
||||
pnpm migrations:apply # the database must already be migrated
|
||||
DATABASE_URL=postgresql://... pnpm identity:create-initial-owner -- --email [email protected]
|
||||
```
|
||||
|
||||
It creates the identity through the identity system's own sign-up path and then performs this exact
|
||||
single-use grant, so both commands consume the same sentinel and neither weakens it. Whichever runs
|
||||
first is the one bootstrap this installation gets. The password is prompted for invisibly, or read
|
||||
from standard input for automation; it is never a command-line argument and never an environment
|
||||
variable. See [Identity](identity.md#creating-the-first-platform-owner) for the full operator
|
||||
workflow, including its rerun and recovery behaviour.
|
||||
|
||||
Development integration tests may still enable signup, create a user, bootstrap that existing id,
|
||||
and prove its Dashboard session; that remains test composition exercising the public signup
|
||||
transport, which is a different thing from the production bootstrap path.
|
||||
|
||||
### Bootstrap is permanently single-use
|
||||
|
||||
|
||||
Reference in New Issue
Block a user