The operator makes a forge's first admin from a terminal, with a passkey or for a headless agent's token #41
No reviewers
Labels
No labels
agent-work
agent-working
ultracode
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
trevato-apps/forge!41
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "grokbot/forge:feat/first-admin-bootstrap"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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 personalready administers the forge. Opens no socket and migrates nothing: a data
directory without a
forge.dbis refused, so a newer tree never moves a liveschema.
--childfinds a hosted child through the spawn ledger and mints inits 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. Thetoken always expires (follow-up commit): a day by default, or
expiresInwhole seconds from 60 to 2592000 (30 days); anything else is 422
invalid_expirybefore the code is read. It uses the repo's existingmechanism (
token.expires_at, refused byverifyTokenonce past, swept bythe worker). The default is OAuth's shortest lifetime (
LIFETIMES.day) andan 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.d1e010b3; Trevor's option 3). When no admin hasa working credential,
--first-adminmints a recovery code instead ofrefusing. "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.
spend as a first-admin code. It is bound at mint to one active person
admin (
--user <name>picks one when there are several).POST /api/v1/first-admin/recover {code, scope, name?, expiresIn?}, for anew expiring token for that existing admin, under the same lifetime rules.
No account is made.
again at redemption, so a credential that appears after minting kills the
code.
525f1876). createUser now takes arequired origin. The operator's first admin says
first-admin, a namedexemption like
claim(the operator's code is the proof), so a closedsignup 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 say
seed. New test: with signup closed and anaccount already made, the code still makes the first admin (it fails if
first-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: passkeyThe 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'sadminConfirmforlater tokens, and notes that apps and agents are contained only with
bubblewrap and socat on
PATH. docs/SPAWN.md says how a parent's operatormakes 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 pass
signupClosedand the signup window,and trusted paths are named exemptions. This PR does not change
createUser,rp.ts,federation.tsor any password path. It makes accounts in exactly oneplace,
redeemFirstAdmininsrc/actions/first_admin.ts, under one namedorigin,
FIRST_ADMIN_ORIGIN = "first-admin". That origin is an operatorexemption 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 ond1e010b3: 23 files, 230 tests, 0 fail. Gate slice 2/3 ond1e010b3: 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 main1051d451on this runner:looks.test.ts(3, screenshots) andquotas-running.test.ts(2 concurrent-check tests, 60 s timeouts).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)
A headless first admin holds only an expiring token. It has no password or passkey, and tokens do not mint tokens, so when its token lapses the forge has an admin and no way in. And `--first-admin` refused because an admin existed. Now, when no admin has a working credential, the command mints a recovery code instead. "Working" means any of: a password, a passkey, a token that has not expired (or never does), a live session, an SSH key, or sign-in with the parent. Every admin counts here (person or agent, suspended or not), so the check refuses as broadly as it can (`adminCanSignIn`, `core/first_admin.ts`). - Same shape as the first-admin code: one `forge_setting` row, sha256 only, 15 minutes, spent by one compare-and-swap, replaced by the next mint. The row also holds the id of the admin it is for (`r`). - Bound at mint to one stranded admin: an active person admin, not suspended or removed. If there are several, `--user <name>` picks one. The command refuses a non-admin, an unknown name, `--user` on a forge with no admin, and any forge where an admin can still sign in. - Redeemed only at `POST /api/v1/first-admin/recover {code, scope, name?, expiresIn?}`. It mints a new token for that existing admin, with the same lifetime rules (a day by default, at most 30 days, never forever), and answers 201 `{user, scope, id, token, expiresAt, expiresIn}`. No account is made. A recovery code is refused at `/api/v1/first-admin` and `/first-admin`; a first-admin code is refused at `/recover`. - Checked again when redeemed: a credential that appears after minting kills the code. - Same throttle, inside the action, before the code is read (`recoverFirstAdmin`). Cross-origin is refused. Every refusal is the same 403. - Audited as `user.recovered` with `via: "first-admin-recovery"`, the token id, scope, expiry and address, plus the usual `token.created`. One log line names the user, never the code or the token. The existing CLI test's admin had no credential, so that forge is now recoverable. The test gives the admin a token, which keeps it a refusal test. Tests (test/first-admin-recovery.test.ts, written first: 8 of the 16 failed against the previous head, the 8 refusal guards passed): recovery end to end, once, then refused; the token's lifetime rules; the 15-minute expiry; each kind of code refused at the other's door; cross-origin and rate limit; nothing logged. Refused for each of the six credentials, held by the stranded admin or by another admin. Expired sessions and tokens don't count. A credential that appears later kills the code. `--user` with several stranded admins; a suspended admin is not recovered.