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
+23 -6
View File
@@ -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
+84 -3
View File
@@ -262,6 +262,87 @@ roles/groups/teams/tenants model exists.
Core's authorization foundation (platform owners, explicit grants, and an immutable permission
registry) is covered separately in [Authorization](authorization.md). Its bootstrap command grants
an existing identity; it does not create one. Because production signup is prohibited and no
operator enrollment command exists, creating the initial production identity needed for owner
bootstrap remains an explicit operational gap.
an existing identity; it does not create one. Creating the *first* identity on a fresh installation
is what `pnpm identity:create-initial-owner` is for — see below.
## Creating the first platform owner
A fresh installation has a login page and nothing to log in to. Public registration is disabled by
default and production cannot enable it at all, so one explicit, operator-invoked command creates
the initial identity and grants it platform ownership
([ADR 0035](../architecture/decisions/0035-initial-platform-owner-bootstrap.md)):
```bash
pnpm migrations:apply # the schema must already exist; this command never migrates
DATABASE_URL=postgresql://... pnpm identity:create-initial-owner -- --email [email protected]
# Password: (typed invisibly)
# Confirm password:
# Initial platform owner created successfully.
# Email: [email protected]
# You can now sign in to the Ekklesia dashboard.
```
`--name` is optional and defaults to the email address; the identity model requires a display name,
and asking for one that nobody can edit yet would be a question without a purpose.
### The password never becomes an argument or a variable
It is prompted for twice, invisibly, when a terminal is attached. `--password` is **refused with an
explanation**, because a command-line argument is visible in shell history and to every process on
the machine through the process list. For automated deployment the password is read from standard
input:
```bash
printf '%s' "$PASSWORD" | pnpm identity:create-initial-owner -- --email [email protected]
```
**No environment variable is read for it, and none belongs in `.env`.** A bootstrap password is used
once to create an account; it is not deployment configuration, and an environment variable is
precisely the thing that quietly becomes permanent. Nothing prints the password, its length, a hash,
a session token, or the database URL.
### Required environment
`DATABASE_URL`, and the same optional transport variables every other Core command honours
(`NODE_ENV`, `EKKLESIA_DATABASE_TLS_MODE`, `EKKLESIA_DATABASE_CA`). Deliberately **not** the identity
secret, trusted origins, or a public base URL: none is needed to create an account, so the first
owner can be created on a freshly provisioned server before TLS or DNS exist.
### Password and email rules are the identity system's own
The 8–128 character bound and email validation are enforced by Better Auth through the same sign-up
path a browser would use, not by a separate CLI policy that could drift from it. The address is
normalized exactly as a browser sign-up normalizes it — stored lowercased — and the command reports
the stored address, so the one you are told to sign in with is the one that exists.
### It runs once
Ownership bootstrap is permanently single-use (see
[Authorization](authorization.md#bootstrap-is-permanently-single-use)). A second run is refused and:
- creates no second owner,
- changes no existing password,
- creates no stray identity, even when a different address is supplied.
### Rerun and recovery
| situation | behaviour |
| --- | --- |
| Bootstrap already completed | Refused. Nothing changes. |
| Identity exists, ownership was never granted | Ownership is granted to it and **its password is left unchanged**. Reported as an adoption, not a creation. |
| A `user` row exists with no password (cannot sign in) | Refused, and bootstrap stays available — granting ownership to an identity nobody can authenticate as would consume the single use and leave the installation unusable. |
| Identity created but the grant failed | Reported as incomplete. The identity is real and bootstrap is still available: run the identical command again with the same address to finish. |
| Database unmigrated or unreachable | Refused with a clear message. The command never migrates. |
Nothing is deleted automatically on failure: removing an account that was created successfully, on
the strength of a later error, would destroy something an operator may need.
### This is not signup
There is no `/signup`, `/register`, or `/setup` route, no unauthenticated bootstrap endpoint, no
temporary setup route, and no setup token. The login page stays login-only, and public registration
stays disabled in every environment — an integration test asserts that a freshly bootstrapped
deployment still refuses `POST /api/auth/sign-up/email`.
Creating additional users or owners is not this command's job. That belongs to user administration,
which does not exist yet.
+38
View File
@@ -506,6 +506,44 @@ outstanding.
consuming the single-use platform-owner bootstrap. This repository does not provide that way
yet.
### The initial-owner command is trusted local tooling
A fresh installation needs a first administrator, and public registration is disabled everywhere and
prohibited in production. `pnpm identity:create-initial-owner` fills that gap
([ADR 0035](../architecture/decisions/0035-initial-platform-owner-bootstrap.md)). What makes it safe
is **where it can be invoked**, not a weaker policy:
- **It is not reachable from a browser.** No `/signup`, `/register`, or `/setup` route, no
unauthenticated bootstrap endpoint, no temporary setup route, and no setup token. It serves no HTTP
route and nothing in `apps/api` imports it. Its authority is already having the database
credentials.
- **Public signup stays disabled.** The command constructs its own in-process identity instance
rather than relaxing the server's posture. A test asserts that a freshly bootstrapped deployment
still refuses `POST /api/auth/sign-up/email`.
- **The password never lands anywhere durable.** Prompted invisibly or read from standard input;
`--password` is refused rather than ignored, because an argument is visible in shell history and in
the process list. No environment variable is read for it and none is in `.env.example`, so it
cannot become permanent deployment configuration. It is never printed, logged, or placed in an
error.
- **It writes no password hash of its own.** Hashing, salting, normalization, and the 8–128 bound are
the identity system's, applied through the same sign-up path a browser uses — so a CLI-created
account can never diverge from a browser-created one.
- **It cannot become a standing superuser factory.** Ownership bootstrap is permanently single-use
and this command consumes the same sentinel; there is no `--force`, reset, or override. A second
run creates no owner, changes no password, and creates no stray identity.
- **It never takes over an account.** An existing usable identity is adopted — granted ownership with
its password untouched — and reported as an adoption. A password reset is never performed. An
identity that has no password at all is refused rather than made an owner nobody can sign in as,
which would consume the single use and leave the installation unusable.
- **Races cannot mint two owners.** Whole invocations are serialized by an advisory lock, on top of
the grant's own locked, single-use transaction.
- **Errors stay sanitized.** No SQL, connection string, driver detail, Better Auth response body, or
cause reaches the operator's terminal — only a stable code and a message describing what is still
true of the installation.
It deliberately does not solve creating *additional* users or owners. That is user administration,
which does not exist yet.
### Rate limiting is per-process today
Better Auth's authentication rate limiter is **in-memory and per API process**. That is acceptable
+23
View File
@@ -328,6 +328,29 @@ The canonical template builds and mounts both artifacts through `parseModuleUiEx
fixtures also retain no-display and backend-only cases. These are code-only tests: display hosting
adds no PostgreSQL, object-store, or Docker requirement beyond the existing suites.
A further suite (`apps/api/tests/initial-owner.integration.ts`, same harness) covers the initial
platform-owner bootstrap ([ADR 0035](../architecture/decisions/0035-initial-platform-owner-bootstrap.md)).
It deliberately does not stop at asserting rows: a fresh installation is bootstrapped and the
resulting account is then signed in through the exact browser-facing login transport, its principal
read from `GET /api/v1/principal`, and used to open a platform-owner-protected resource that stays
closed to an anonymous caller. Anything less would prove a user was written, not that an operator can
reach their dashboard.
Every scenario runs with `publicSignupEnabled: false` — the production posture — so nothing can
accidentally depend on the signup route a real deployment does not have, and one scenario asserts
that a freshly bootstrapped deployment still refuses `POST /api/auth/sign-up/email`. The suite also
pins the guard (a second run creates no owner, changes no password, and leaves no stray identity),
the 8–128 password bound and email validation coming from the identity system rather than a
CLI-specific policy, adoption of an existing identity without touching its password, refusal of a
credential-less row *without* consuming the single-use sentinel, a partial failure that stays
recoverable by rerunning, and five racing invocations yielding exactly one owner, one sentinel, and
one user row.
The command itself is exercised as a real child process — success and failure exit codes, the
password arriving on standard input and never in `argv`, `--password` refused, a missing
`DATABASE_URL`, and an unmigrated database refused rather than silently migrated — with assertions
that neither stdout nor stderr contains the password or the connection string.
### Which kind of timing proof lives where
Pass 4 introduced several deadlines, and their tests deliberately differ in kind: