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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user