Public: anyone can read this; nobody can change it.
The operator makes a forge's first admin from a terminal, with a passkey or for a headless agent's token #41
Check · Review · Land done · Live — Landed; nothing deploys from this branch, so this is the end of its path.
A forge with no admin has had two ways to get one, and both need a browser: `/signup` (passkey only, #1996, #669) and, on a spawned child, the claim link (passkey only). An agent that runs its own forge, or a child spawned for one, had no way in. This adds an operator door that needs no browser and does not reopen password or API signup.
shipped merged
Sign in to join this conversation.
new above
offline
A forge with no admin has had two ways to get one, and both need a browser:
/signup(passkey only, #1996, #669) and, on a spawned child, the claim link (passkey only). An agent that runs its own forge, or a child spawned for one, had no way in. This adds an operator door that needs no browser and does not reopen password or API signup.Design
bun src/main.ts --first-admin [--child <name>] [--url <origin>]mints acode (32 random bytes, base64url) and prints it to the operator's terminal, the only place it is ever shown. The forge keeps its sha256, a 15-minute deadline and a used-at in one
forge_settingrow (first_admin.code,updated_by = operator); minting again replaces it. Refused when a person already administers the forge. Opens no socket and migrates nothing: a data directory without aforge.dbis refused, so a newer tree never moves a live schema.--childfinds a hosted child through the spawn ledger and mints in its database at its address (parent.jsonurl).actions/first_admin.ts):-
/first-admin#<code>: the claim page's shape. The code rides the fragment, the GET holds nothing,/first-admin/optionsand the POST are same-origin, and the account is made with a passkey (its own ceremony store) and signed in. -POST /api/v1/first-admin {code, username, email, scope, name?, expiresIn?}: makes the admin and answers 201 once with a token of the asked scope (ro|rw|admin, required, no default) andexpiresAt/expiresIn. The token always expires (follow-up commit): a day by default, orexpiresInwhole seconds from 60 to 2592000 (30 days); anything else is 422invalid_expirybefore the code is read. It uses the repo's existing mechanism (token.expires_at, refused byverifyTokenonce past, swept by the worker). The default is OAuth's shortest lifetime (LIFETIMES.day) and an app token's TTL; the cap is OAuth's "month".it (compare-and-swap on the row), so two racers cannot both win; a refusal after that (reserved or taken name, key already registered) undoes the account and puts the code back.
The handover is extracted from the claim page as
handOverinspawn/claim.ts, now shared by both. The claim link then readsowned.first-adminis a reserved name. The sign-in page links/first-adminonlywhile the forge has no admin, as it links
/claimon an unclaimed child.a working credential,
--first-adminmints a recovery code instead of refusing. "Working" means a password, a passkey, an unexpired token, a live session, an SSH key or sign-in with the parent, on any admin, person or agent, suspended or not. - The code uses the same row, hash, 15 minutes and single compare-and-swap spend as a first-admin code. It is bound at mint to one active person admin (--user <name>picks one when there are several). - It is redeemed only atPOST /api/v1/first-admin/recover {code, scope, name?, expiresIn?}, for a new expiring token for that existing admin, under the same lifetime rules. No account is made. - Each kind of code is refused at the other's door. The credential check runs again at redemption, so a credential that appears after minting kills the code.required origin. The operator's first admin says
first-admin, a named exemption likeclaim(the operator's code is the proof), so a closed signup never holds it back, and it may make an admin. The guard listing every caller's origin names it. Recovery makes no account, so it has no origin of its own. Test fixtures sayseed. New test: with signup closed and an account already made, the code still makes the first admin (it fails iffirst-adminis held to the stranger rule).test/account-ceiling-reserve.test.tspredates the required origin).
Threat model
firstAdminProblemandredeemFirstAdmineach take one attempt from the address's and the forge's budget (signin.per_account,signin.per_forge) before the code is read, so no caller can skip it. A test calls the action directly and shows even the right code refused from a throttled address./api/v1/first-adminand/first-admin, and a first-admin code is refused at/recover. Tested both ways, and the wrong door spends nothing./signup,POST /api/v1/usersand the terminal keeppasskey_required. A test shows a password signup is refused even with a live code in the body.user.createdwithvia: first-adminand `with: passkeyplus the address;user.recoveredwithvia: first-admin-recovery, the token id, scope, expiry and address;token.createdfor each token, with itsexpiresAt`; one log line naming the user, never a code or token (tested for both paths).The headless admin has no password and no passkey: it can act only through the token it was given, which expires and is revocable on Settings → Tokens like any other. Tokens do not mint tokens, so once it lapses the account cannot sign in. If no other admin can either, the operator recovers it with
--first-admin(above) instead of anyone minting a forever admin token.Known limits (recovery). Two ways in are deliberately not counted as working credentials:
X-Plat-User). Edge forges have their own admin path.If either is the only way an admin can get in,
--first-adminstill issues a recovery code.Docs
README "Run it" now shows the real quickstart. Signup is passkey-only and the terminal no longer offers to create the first account, so the old password/curl example has been dead since #1996. The new example uses
--first-admin, then the token'sscopeand the admin'sadminConfirmfor later tokens, and notes that apps and agents are contained only with bubblewrap and socat onPATH. docs/SPAWN.md says how a parent's operator makes a child's first admin this way.Coordination: one account gate (review H1)
A parallel fix routes every account creation through one gate in
createUser, with a required origin: strangers passsignupClosedand the signup window, and trusted paths are named exemptions. This PR does not changecreateUser,rp.ts,federation.tsor any password path. It makes accounts in exactly one place,redeemFirstAdmininsrc/actions/first_admin.ts, under one named origin,FIRST_ADMIN_ORIGIN = "first-admin". That origin is an operator exemption like the claim, not a stranger's door. Whichever PR lands second passes that origin at that one call; there is no other call site.Relates to #650 (identity is the forge's own), #669 (no password-only signup) and #663 (signup stays bounded: this door is operator-only).
Tests run by hand (bun 1.3.14, CI env)
bun install --frozen-lockfile,bun scripts/gate.ts,tsc --noEmitandbun scripts/hollow.tsare clean.bun test test/first-admin.test.ts(both commits):Recovery, written first. Against the previous head (8ae3aa58), 8 of 16 failed; the 8 that passed are the refusal guards, since the old command refused every forge with an admin:
With d1e010b3,
bun test test/first-admin-recovery.test.ts(the log test was added after the first run):test/first-admin.test.ts: 24 pass. The CLI test's admin now holds a token, so it stays a refusal (a credential-less admin is recovered now). Related suites on d1e010b3: 23 files, 230 tests, 0 fail. Gate slice 2/3 on d1e010b3: 1704 tests in 162 files pass, withoutfollowing.test.ts(an order-dependent flake under load that passes 3/3 alone here),looks.test.tsandquotas-running.test.ts(both fail the same way on main on this runner).Earlier: related suites pass. After the expiry commit: actions, apps-token, claim-link, hollow, oauth-, openapi, proposal-token-, signup-passkey*, tokens and toolbar-actions, 177 tests in 17 files, 0 fail. Full-gate slice 2/3 (
--gate --shard 2/3, the slice that holds first-admin and hollow): 1710 tests in 162 files pass. Two files were left out because they fail the same way on main 1051d451 on this runner:looks.test.ts(3, screenshots) andquotas-running.test.ts(2 concurrent-check tests, 60 s timeouts).<!-- pipeline:validation -->
Validation
pipeline/validateon525f1876: baseline in 580s — bun 1.3.14, CI-shaped env (scrubbed vars, runner PATH), exact check.yml shard + forge.json commands.bun install --frozen-lockfileforge.json check.fastcheck.yml gate (1) pull_requestcheck.yml gate (2) pull_requestcheck.yml gate (3) pull_requesttest output (failures, totals)