The operator makes a forge's first admin from a terminal, with a passkey or for a headless agent's token #41

Merged
grokbot merged 7 commits from grokbot/forge:feat/first-admin-bootstrap into main 2026-10-17 22:50:25 +00:00
Owner

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 a
    code (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_setting row (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 a forge.db is refused, so a newer tree never moves a live
    schema. --child finds a hosted child through the spawn ledger and mints in
    its database at its address (parent.json url).
  • Redemption, two doors, one action (actions/first_admin.ts):
    • /first-admin#<code>: the claim page's shape. The code rides the fragment,
      the GET holds nothing, /first-admin/options and 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) and expiresAt/expiresIn. The
      token always expires (follow-up commit): a day by default, or expiresIn
      whole seconds from 60 to 2592000 (30 days); anything else is 422
      invalid_expiry before the code is read. It uses the repo's existing
      mechanism (token.expires_at, refused by verifyToken once 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".
  • The code is spent before the account is made, in the statement that checks
    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.
  • On a spawned child the organisation is handed over exactly as a claim does it.
    The handover is extracted from the claim page as handOver in
    spawn/claim.ts, now shared by both. The claim link then reads owned.
  • first-admin is a reserved name. The sign-in page links /first-admin only
    while the forge has no admin, as it links /claim on an unclaimed child.
  • Recovery (third commit, d1e010b3; Trevor's option 3). When no admin has
    a working credential, --first-admin mints 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 at
      POST /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.
  • Onto #43's one gate (fourth commit, 525f1876). createUser now takes a
    required origin. The operator's first admin says first-admin, a named
    exemption like claim (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 say seed. New test: with signup closed and an
    account already made, the code still makes the first admin (it fails if
    first-admin is held to the stranger rule).
  • Stacked on #54, which fixes main's tsc (test/account-ceiling-reserve.test.ts
    predates the required origin).

Threat model

Threat Answer
A stranger mints a code No web or API route mints. Minting needs the forge's code run against its data directory, which is operator access.
The code leaks from storage, a snapshot or a backup Only its sha256 is stored. A test scans every byte of the database for the code.
The code leaks in transit or in logs The browser link carries it in the fragment, which is never sent. The API takes it in the JSON body, never a path or query string. Nothing logs it: a test captures every console line at debug through a refusal, a passkey ceremony and a token redemption. The CLI prints the agent line as "the part after #" so the code appears once.
Guessing 256 bits. Every refusal is the same 403 with the same words, so a guess never learns wrong from used, expired, replaced or owned. The throttle sits inside the action, not at the doors (the shape of review H2): firstAdminProblem and redeemFirstAdmin each 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.
Replay or race Single use via the compare-and-swap above. 15-minute TTL. A first-admin code is dead once any person is admin; a recovery code is dead once any admin can sign in. Both are checked at mint and at every redemption.
Recovery used to take over a working forge Refused at mint and at redemption while any admin has any working credential (six kinds, each tested on the stranded admin and on another admin). It needs operator access, like every code. It only gives a token to an admin who already exists, and never makes or promotes an account.
Recovery used to make an account, or a first-admin code to take over an admin Codes are typed: a recovery code is refused at /api/v1/first-admin and /first-admin, and a first-admin code is refused at /recover. Tested both ways, and the wrong door spends nothing.
CSRF or a hostile page Both doors refuse a cross-origin request before reading the code. The web POST reads the code only from the form body.
Weakening signup /signup, POST /api/v1/users and the terminal keep passkey_required. A test shows a password signup is refused even with a live code in the body.
A leaked headless admin token It expires: a day by default, 30 days at most, never forever. A test shows it refused (401) once past its expiry and that no token of the first admin lacks one. Revocable on Settings → Tokens before then.
Audit user.created with via: first-admin and `with: passkey

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:

  • A verified email. An admin with one could recover by email, but mail may not be configured, so the check doesn't rely on it.
  • Sign-in by the platform edge header (X-Plat-User). Edge forges have their own admin path.

If either is the only way an admin can get in, --first-admin still 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's scope and the admin's adminConfirm for
later tokens, and notes that apps and agents are contained only with
bubblewrap and socat on PATH. 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 pass signupClosed and the signup window,
and trusted paths are named exemptions. This PR does not change createUser,
rp.ts, federation.ts or any password path. It makes accounts in exactly one
place, redeemFirstAdmin in src/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 --noEmit and bun scripts/hollow.ts are clean.

bun test test/first-admin.test.ts (both commits):

(pass) the code itself > is 32 random bytes, and the forge keeps only its sha256
(pass) the code itself > works once: the second spend is refused
(pass) the code itself > dies after 15 minutes
(pass) the code itself > a newer code replaces the older one
(pass) the code itself > is worth nothing once a person is admin, and cannot be minted then
(pass) headless: POST /api/v1/first-admin > ordinary signup still takes a passkey only, even with a live code in the body
(pass) headless: POST /api/v1/first-admin > a refused request spends nothing: no scope, a bad name, a wrong code
(pass) headless: POST /api/v1/first-admin > a cross-origin request is refused before the code is read
(pass) headless: POST /api/v1/first-admin > makes the admin once, with no password and a token of the asked scope
(pass) headless: POST /api/v1/first-admin > the audit keeps who was made and how; no table holds the code
(pass) the headless token always expires > a day by default, as an OAuth token's shortest and an app's token; at most 30 days
(pass) the headless token always expires > never forever, never past the cap: a bad expiresIn is refused and spends nothing
(pass) the headless token always expires > a caller's own lifetime is kept, and the token stops working when it lapses
(pass) the headless token always expires > no token the first admin holds lives forever
(pass) a person: /first-admin with a passkey > sign-in links the page only while the forge has no admin
(pass) a person: /first-admin with a passkey > the page holds no code and takes it from the fragment
(pass) a person: /first-admin with a passkey > without a key nothing is made or spent
(pass) a person: /first-admin with a passkey > with a passkey: the admin is made, signed in, and the code is spent
(pass) nothing the forge logs carries the code > every line, at debug, through a refusal, a passkey and a token
(pass) guessing is rate-limited > an address that keeps trying waits
(pass) the throttle is in the action, so no door can skip it > calling redeem directly, a guessing address is cut off before the code is read; another address is not
(pass) the operator's command: --first-admin > refuses a data directory with no forge database, and bad flags
(pass) the operator's command: --first-admin > beside a running forge: prints a working link at FORGE_URL, then refuses once there is an admin
(pass) a spawned child, made admin by its parent's operator > --child mints in the child's database, at the child's address, and the child's org is handed over
 24 pass
 0 fail
Ran 24 tests across 1 file.

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:

(fail) a forge whose only admin has no working credential > the operator gets a recovery code, and it mints a new expiring token for that admin, once
(fail) a forge whose only admin has no working credential > the recovered token always expires: a day by default, never past 30 days
(fail) a forge whose only admin has no working credential > dies after 15 minutes
(fail) a forge whose only admin has no working credential > a recovery code makes no account, and a first-admin code recovers nobody
(fail) a forge whose only admin has no working credential > refused cross-origin, and guessing is rate-limited
(pass) refused while any admin has a working credential > the stranded admin itself, or another admin, has a password
(pass) refused while any admin has a working credential > the stranded admin itself, or another admin, has a passkey
(pass) refused while any admin has a working credential > the stranded admin itself, or another admin, has an unexpired token
(pass) refused while any admin has a working credential > the stranded admin itself, or another admin, has a token that never expires
(pass) refused while any admin has a working credential > the stranded admin itself, or another admin, has a live session
(pass) refused while any admin has a working credential > the stranded admin itself, or another admin, has an SSH key
(pass) refused while any admin has a working credential > the stranded admin itself, or another admin, has sign-in with the parent
(fail) refused while any admin has a working credential > an expired session and an expired token are not working credentials
(fail) refused while any admin has a working credential > a credential that appears after the code is minted kills it
(fail) which admin is recovered > with several stranded admins the operator names one with --user; nobody else
(pass) which admin is recovered > a suspended admin is not recovered
 8 pass
 8 fail

With d1e010b3, bun test test/first-admin-recovery.test.ts (the log test was added after the first run):

(pass) a forge whose only admin has no working credential > the operator gets a recovery code, and it mints a new expiring token for that admin, once
(pass) a forge whose only admin has no working credential > the recovered token always expires: a day by default, never past 30 days
(pass) a forge whose only admin has no working credential > dies after 15 minutes
(pass) a forge whose only admin has no working credential > a recovery code makes no account, and a first-admin code recovers nobody
(pass) a forge whose only admin has no working credential > refused cross-origin, and guessing is rate-limited
(pass) nothing the forge logs carries a recovery code or its token > every line, at debug, through a refusal and a recovery
(pass) refused while any admin has a working credential > the stranded admin itself, or another admin, has a password
(pass) refused while any admin has a working credential > the stranded admin itself, or another admin, has a passkey
(pass) refused while any admin has a working credential > the stranded admin itself, or another admin, has an unexpired token
(pass) refused while any admin has a working credential > the stranded admin itself, or another admin, has a token that never expires
(pass) refused while any admin has a working credential > the stranded admin itself, or another admin, has a live session
(pass) refused while any admin has a working credential > the stranded admin itself, or another admin, has an SSH key
(pass) refused while any admin has a working credential > the stranded admin itself, or another admin, has sign-in with the parent
(pass) refused while any admin has a working credential > an expired session and an expired token are not working credentials
(pass) refused while any admin has a working credential > a credential that appears after the code is minted kills it
(pass) which admin is recovered > with several stranded admins the operator names one with --user; nobody else
(pass) which admin is recovered > a suspended admin is not recovered
 17 pass
 0 fail

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, without following.test.ts (an order-dependent flake under load that passes 3/3 alone here), looks.test.ts and quotas-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) and quotas-running.test.ts (2 concurrent-check tests, 60 s timeouts).

Validation

pipeline/validate on 525f1876: baseline in 580s — bun 1.3.14, CI-shaped env (scrubbed vars, runner PATH), exact check.yml shard + forge.json commands.

step result time
bun install --frozen-lockfile ok (exit 0) 0s
forge.json check.fast BASELINE (exit 1) 119s
check.yml gate (1) pull_request BASELINE (exit 1) 15s
check.yml gate (2) pull_request ok (exit 0) 261s
check.yml gate (3) pull_request ok (exit 0) 185s
  • ⚠️ node v20.19.2 here, v22 on CI (tsc runs under node)
  • ⚠️ check.yml gate (1) pull_request stopped early at a BASELINE failure (fail-fast): its later files were not run in this step
test output (failures, totals)
▶ bun install --frozen-lockfile
  ok  exit 0  0s
▶ forge.json check.fast
(fail) egress, reported: an undeclared public host goes through and is counted; a private one never does > an undeclared public host is let through and counted [0.92ms] [shard 10]
Ran 3573 tests across 353 files in 115.9s; 1 failed.
classify: reran test/contain.test.ts test/spawn-chain-doors.test.ts test/web.test.ts  alone on origin/main 006d3977 and on this branch
  BASELINE  exit 1  119s
▶ check.yml gate (1) pull_request
(fail) egress, reported: an undeclared public host goes through and is counted; a private one never does > an undeclared public host is let through and counted [2.41ms] [shard 3]
Ran 110 tests across 3 files in 14.2s; 1 failed.
classify: reran test/contain.test.ts  alone on origin/main 006d3977 and on this branch
  BASELINE  exit 1  15s
▶ check.yml gate (2) pull_request
Ran 1465 tests across 144 files in 261.4s (144 shards).
  ok  exit 0  261s
▶ check.yml gate (3) pull_request
Ran 1528 tests across 144 files in 184.2s (144 shards).
  ok  exit 0  185s
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 a code (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_setting` row (`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 a `forge.db` is refused, so a newer tree never moves a live schema. `--child` finds a hosted child through the spawn ledger and mints in its database at its address (`parent.json` url). - Redemption, two doors, one action (`actions/first_admin.ts`): - `/first-admin#<code>`: the claim page's shape. The code rides the fragment, the GET holds nothing, `/first-admin/options` and 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) and `expiresAt`/`expiresIn`. The token always expires (follow-up commit): a day by default, or `expiresIn` whole seconds from 60 to 2592000 (30 days); anything else is 422 `invalid_expiry` before the code is read. It uses the repo's existing mechanism (`token.expires_at`, refused by `verifyToken` once 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". - The code is spent before the account is made, in the statement that checks 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. - On a spawned child the organisation is handed over exactly as a claim does it. The handover is extracted from the claim page as `handOver` in `spawn/claim.ts`, now shared by both. The claim link then reads `owned`. - `first-admin` is a reserved name. The sign-in page links `/first-admin` only while the forge has no admin, as it links `/claim` on an unclaimed child. - **Recovery (third commit, d1e010b3; Trevor's option 3).** When no admin has a working credential, `--first-admin` mints 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 at `POST /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. - **Onto #43's one gate (fourth commit, 525f1876).** createUser now takes a required origin. The operator's first admin says `first-admin`, a named exemption like `claim` (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 say `seed`. New test: with signup closed and an account already made, the code still makes the first admin (it fails if `first-admin` is held to the stranger rule). - Stacked on #54, which fixes main's tsc (`test/account-ceiling-reserve.test.ts` predates the required origin). ## Threat model | Threat | Answer | | --- | --- | | A stranger mints a code | No web or API route mints. Minting needs the forge's code run against its data directory, which is operator access. | | The code leaks from storage, a snapshot or a backup | Only its sha256 is stored. A test scans every byte of the database for the code. | | The code leaks in transit or in logs | The browser link carries it in the fragment, which is never sent. The API takes it in the JSON body, never a path or query string. Nothing logs it: a test captures every console line at debug through a refusal, a passkey ceremony and a token redemption. The CLI prints the agent line as "the part after #" so the code appears once. | | Guessing | 256 bits. Every refusal is the same 403 with the same words, so a guess never learns wrong from used, expired, replaced or owned. The throttle sits inside the action, not at the doors (the shape of review H2): `firstAdminProblem` and `redeemFirstAdmin` each 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. | | Replay or race | Single use via the compare-and-swap above. 15-minute TTL. A first-admin code is dead once any person is admin; a recovery code is dead once any admin can sign in. Both are checked at mint and at every redemption. | | Recovery used to take over a working forge | Refused at mint and at redemption while any admin has any working credential (six kinds, each tested on the stranded admin and on another admin). It needs operator access, like every code. It only gives a token to an admin who already exists, and never makes or promotes an account. | | Recovery used to make an account, or a first-admin code to take over an admin | Codes are typed: a recovery code is refused at `/api/v1/first-admin` and `/first-admin`, and a first-admin code is refused at `/recover`. Tested both ways, and the wrong door spends nothing. | | CSRF or a hostile page | Both doors refuse a cross-origin request before reading the code. The web POST reads the code only from the form body. | | Weakening signup | `/signup`, `POST /api/v1/users` and the terminal keep `passkey_required`. A test shows a password signup is refused even with a live code in the body. | | A leaked headless admin token | It expires: a day by default, 30 days at most, never forever. A test shows it refused (401) once past its expiry and that no token of the first admin lacks one. Revocable on Settings → Tokens before then. | | Audit | `user.created` with `via: first-admin` and `with: passkey|token` plus the address; `user.recovered` with `via: first-admin-recovery`, the token id, scope, expiry and address; `token.created` for each token, with its `expiresAt`; 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: - **A verified email.** An admin with one could recover by email, but mail may not be configured, so the check doesn't rely on it. - **Sign-in by the platform edge header (`X-Plat-User`).** Edge forges have their own admin path. If either is the only way an admin can get in, `--first-admin` still 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's `scope` and the admin's `adminConfirm` for later tokens, and notes that apps and agents are contained only with bubblewrap and socat on `PATH`. 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 pass `signupClosed` and the signup window, and trusted paths are named exemptions. This PR does not change `createUser`, `rp.ts`, `federation.ts` or any password path. It makes accounts in exactly one place, `redeemFirstAdmin` in `src/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 --noEmit` and `bun scripts/hollow.ts` are clean. `bun test test/first-admin.test.ts` (both commits): ``` (pass) the code itself > is 32 random bytes, and the forge keeps only its sha256 (pass) the code itself > works once: the second spend is refused (pass) the code itself > dies after 15 minutes (pass) the code itself > a newer code replaces the older one (pass) the code itself > is worth nothing once a person is admin, and cannot be minted then (pass) headless: POST /api/v1/first-admin > ordinary signup still takes a passkey only, even with a live code in the body (pass) headless: POST /api/v1/first-admin > a refused request spends nothing: no scope, a bad name, a wrong code (pass) headless: POST /api/v1/first-admin > a cross-origin request is refused before the code is read (pass) headless: POST /api/v1/first-admin > makes the admin once, with no password and a token of the asked scope (pass) headless: POST /api/v1/first-admin > the audit keeps who was made and how; no table holds the code (pass) the headless token always expires > a day by default, as an OAuth token's shortest and an app's token; at most 30 days (pass) the headless token always expires > never forever, never past the cap: a bad expiresIn is refused and spends nothing (pass) the headless token always expires > a caller's own lifetime is kept, and the token stops working when it lapses (pass) the headless token always expires > no token the first admin holds lives forever (pass) a person: /first-admin with a passkey > sign-in links the page only while the forge has no admin (pass) a person: /first-admin with a passkey > the page holds no code and takes it from the fragment (pass) a person: /first-admin with a passkey > without a key nothing is made or spent (pass) a person: /first-admin with a passkey > with a passkey: the admin is made, signed in, and the code is spent (pass) nothing the forge logs carries the code > every line, at debug, through a refusal, a passkey and a token (pass) guessing is rate-limited > an address that keeps trying waits (pass) the throttle is in the action, so no door can skip it > calling redeem directly, a guessing address is cut off before the code is read; another address is not (pass) the operator's command: --first-admin > refuses a data directory with no forge database, and bad flags (pass) the operator's command: --first-admin > beside a running forge: prints a working link at FORGE_URL, then refuses once there is an admin (pass) a spawned child, made admin by its parent's operator > --child mints in the child's database, at the child's address, and the child's org is handed over 24 pass 0 fail Ran 24 tests across 1 file. ``` 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: ``` (fail) a forge whose only admin has no working credential > the operator gets a recovery code, and it mints a new expiring token for that admin, once (fail) a forge whose only admin has no working credential > the recovered token always expires: a day by default, never past 30 days (fail) a forge whose only admin has no working credential > dies after 15 minutes (fail) a forge whose only admin has no working credential > a recovery code makes no account, and a first-admin code recovers nobody (fail) a forge whose only admin has no working credential > refused cross-origin, and guessing is rate-limited (pass) refused while any admin has a working credential > the stranded admin itself, or another admin, has a password (pass) refused while any admin has a working credential > the stranded admin itself, or another admin, has a passkey (pass) refused while any admin has a working credential > the stranded admin itself, or another admin, has an unexpired token (pass) refused while any admin has a working credential > the stranded admin itself, or another admin, has a token that never expires (pass) refused while any admin has a working credential > the stranded admin itself, or another admin, has a live session (pass) refused while any admin has a working credential > the stranded admin itself, or another admin, has an SSH key (pass) refused while any admin has a working credential > the stranded admin itself, or another admin, has sign-in with the parent (fail) refused while any admin has a working credential > an expired session and an expired token are not working credentials (fail) refused while any admin has a working credential > a credential that appears after the code is minted kills it (fail) which admin is recovered > with several stranded admins the operator names one with --user; nobody else (pass) which admin is recovered > a suspended admin is not recovered 8 pass 8 fail ``` With d1e010b3, `bun test test/first-admin-recovery.test.ts` (the log test was added after the first run): ``` (pass) a forge whose only admin has no working credential > the operator gets a recovery code, and it mints a new expiring token for that admin, once (pass) a forge whose only admin has no working credential > the recovered token always expires: a day by default, never past 30 days (pass) a forge whose only admin has no working credential > dies after 15 minutes (pass) a forge whose only admin has no working credential > a recovery code makes no account, and a first-admin code recovers nobody (pass) a forge whose only admin has no working credential > refused cross-origin, and guessing is rate-limited (pass) nothing the forge logs carries a recovery code or its token > every line, at debug, through a refusal and a recovery (pass) refused while any admin has a working credential > the stranded admin itself, or another admin, has a password (pass) refused while any admin has a working credential > the stranded admin itself, or another admin, has a passkey (pass) refused while any admin has a working credential > the stranded admin itself, or another admin, has an unexpired token (pass) refused while any admin has a working credential > the stranded admin itself, or another admin, has a token that never expires (pass) refused while any admin has a working credential > the stranded admin itself, or another admin, has a live session (pass) refused while any admin has a working credential > the stranded admin itself, or another admin, has an SSH key (pass) refused while any admin has a working credential > the stranded admin itself, or another admin, has sign-in with the parent (pass) refused while any admin has a working credential > an expired session and an expired token are not working credentials (pass) refused while any admin has a working credential > a credential that appears after the code is minted kills it (pass) which admin is recovered > with several stranded admins the operator names one with --user; nobody else (pass) which admin is recovered > a suspended admin is not recovered 17 pass 0 fail ``` `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, without `following.test.ts` (an order-dependent flake under load that passes 3/3 alone here), `looks.test.ts` and `quotas-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) and `quotas-running.test.ts` (2 concurrent-check tests, 60 s timeouts). <!-- pipeline:validation --> ### Validation `pipeline/validate` on `525f1876`: **baseline** in 580s — bun 1.3.14, CI-shaped env (scrubbed vars, runner PATH), exact check.yml shard + forge.json commands. | step | result | time | |---|---|---| | `bun install --frozen-lockfile` | ok (exit 0) | 0s | | `forge.json check.fast` | BASELINE (exit 1) | 119s | | `check.yml gate (1) pull_request` | BASELINE (exit 1) | 15s | | `check.yml gate (2) pull_request` | ok (exit 0) | 261s | | `check.yml gate (3) pull_request` | ok (exit 0) | 185s | - ⚠️ node v20.19.2 here, v22 on CI (tsc runs under node) - ⚠️ check.yml gate (1) pull_request stopped early at a BASELINE failure (fail-fast): its later files were not run in this step <details><summary>test output (failures, totals)</summary> ``` ▶ bun install --frozen-lockfile ok exit 0 0s ▶ forge.json check.fast (fail) egress, reported: an undeclared public host goes through and is counted; a private one never does > an undeclared public host is let through and counted [0.92ms] [shard 10] Ran 3573 tests across 353 files in 115.9s; 1 failed. classify: reran test/contain.test.ts test/spawn-chain-doors.test.ts test/web.test.ts alone on origin/main 006d3977 and on this branch BASELINE exit 1 119s ▶ check.yml gate (1) pull_request (fail) egress, reported: an undeclared public host goes through and is counted; a private one never does > an undeclared public host is let through and counted [2.41ms] [shard 3] Ran 110 tests across 3 files in 14.2s; 1 failed. classify: reran test/contain.test.ts alone on origin/main 006d3977 and on this branch BASELINE exit 1 15s ▶ check.yml gate (2) pull_request Ran 1465 tests across 144 files in 261.4s (144 shards). ok exit 0 261s ▶ check.yml gate (3) pull_request Ran 1528 tests across 144 files in 184.2s (144 shards). ok exit 0 185s ``` </details>
The operator makes a forge's first admin from a terminal, with a passkey or for a headless agent's token
Some checks failed
preview / preview (pull_request) Failing after 2s
check / gate (1) (pull_request) Failing after 14s
check / gate (3) (pull_request) Failing after 15s
check / gate (2) (pull_request) Failing after 17s
check / check (pull_request) Failing after 0s
e56ecb99c5
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 a
  code (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_setting` row (`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 a `forge.db` is refused, so a newer tree never moves a live
  schema. `--child` finds a hosted child through the spawn ledger and mints in
  its database at its address (`parent.json` url).
- Redemption, two doors, one action (`actions/first_admin.ts`):
  - `/first-admin#<code>`: the claim page's shape. The code rides the fragment,
    the GET holds nothing, `/first-admin/options` and 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?}`: makes the
    admin and answers 201 once with a token of the asked scope (`ro|rw|admin`,
    required, no default).
- The code is spent before the account is made, in the statement that checks
  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.
- On a spawned child the organisation is handed over exactly as a claim does it.
  The handover is extracted from the claim page as `handOver` in
  `spawn/claim.ts`, now shared by both. The claim link then reads `owned`.
- `first-admin` is a reserved name. The sign-in page links `/first-admin` only
  while the forge has no admin, as it links `/claim` on an unclaimed child.

## Threat model

| Threat | Answer |
| --- | --- |
| A stranger mints a code | No web or API route mints. Minting needs the forge's code run against its data directory, which is operator access. |
| The code leaks from storage, a snapshot or a backup | Only its sha256 is stored. A test scans every byte of the database for the code. |
| The code leaks in transit or in logs | The browser link carries it in the fragment, which is never sent. The API takes it in the JSON body, never a path or query string. Nothing logs it: a test captures every console line at debug through a refusal, a passkey ceremony and a token redemption. The CLI prints the agent line as "the part after #" so the code appears once. |
| Guessing | 256 bits. Every refusal is the same 403 with the same words, so a guess never learns wrong from used, expired, replaced or owned. The throttle sits inside the action, not at the doors (the shape of review H2): `firstAdminProblem` and `redeemFirstAdmin` each 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. |
| Replay or race | Single use via the compare-and-swap above. 15-minute TTL. Dead once any person is admin, checked at mint and at every redemption. |
| CSRF or a hostile page | Both doors refuse a cross-origin request before reading the code. The web POST reads the code only from the form body. |
| Weakening signup | `/signup`, `POST /api/v1/users` and the terminal keep `passkey_required`. A test shows a password signup is refused even with a live code in the body. |
| Audit | `user.created` with `via: first-admin` and `with: passkey|token` plus the address; `token.created` for the token; one log line naming the user. |

The headless admin has no password and no passkey: it can act only through the
token it was given, which is revocable on Settings → Tokens like any other.

## 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's `scope` and the admin's `adminConfirm` for
later tokens, and notes that apps and agents are contained only with
bubblewrap and socat on `PATH`. 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 pass `signupClosed` and the signup window,
and trusted paths are named exemptions. This PR does not change `createUser`,
`rp.ts`, `federation.ts` or any password path. It makes accounts in exactly one
place, `redeemFirstAdmin` in `src/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).
The first admin's headless token always expires: a day by default, at most 30 days
Some checks failed
check / gate (1) (pull_request) Has been cancelled
check / gate (2) (pull_request) Has been cancelled
check / gate (3) (pull_request) Has been cancelled
check / check (pull_request) Has been cancelled
preview / preview (pull_request) Has been cancelled
7c5f73450f
`POST /api/v1/first-admin` minted an admin-scope token that lived until
revoked. Now it never mints one without an expiry, on the repo's own
mechanism: `token.expires_at`, refused by `verifyToken` once past and swept
by the worker.

- Default 86400 s, a day: OAuth's shortest `LIFETIMES` choice and an app
  token's TTL (`APP_TOKEN_TTL_MS`). The constants are read from `LIFETIMES`.
- The caller may send `expiresIn`, whole seconds from 60 to 2592000 (30 days,
  OAuth's "month"). Anything else (0, negative, over the cap, fractional, a
  string, a boolean) is 422 `invalid_expiry`, checked before the code is read,
  so it spends nothing.
- The 201 answer adds `expiresAt` and `expiresIn`; `token.created` records
  `expiresAt` when there is one (`actions/users.ts` `createToken` takes the
  optional expiry through to `core/tokens.ts`).
- README and the CLI's printed hint say so, and that this account (no
  password, no passkey; tokens do not mint tokens) cannot sign in once its
  token lapses.

Tests: default lifetime held in the row; boundaries; ten bad values refused
without spending the code; a caller's hour kept; the token refused (401) once
expired; the audit carries the expiry; no token of the first admin lacks one.
Merge branch 'main' into feat/first-admin-bootstrap
Some checks failed
preview / preview (pull_request) Has been skipped
check / gate (3) (pull_request) Successful in 3m52s
check / gate (1) (pull_request) Successful in 10m43s
check / check (pull_request) Has been cancelled
check / gate (2) (pull_request) Has been cancelled
8ae3aa5808
A forge whose admins all lost their way in is recovered by the operator: --first-admin prints a recovery code that buys the stranded admin a new expiring token
All checks were successful
check / gate (2) (pull_request) Successful in 5m10s
preview / preview (pull_request) Has been skipped
check / gate (3) (pull_request) Successful in 3m55s
check / gate (1) (pull_request) Successful in 10m50s
check / check (pull_request) Successful in 1s
d1e010b328
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.
The ceiling-reserve test's account says its origin, so main type-checks again now that createUser takes one
All checks were successful
check / gate (1) (pull_request) Successful in 29s
check / gate (2) (pull_request) Successful in 16s
check / gate (3) (pull_request) Successful in 16s
check / check (pull_request) Successful in 0s
preview / preview (pull_request) Has been skipped
b9c2abf292
#52 (dev/ceiling-reserve) was written before #43 made createUser's origin required, and both merged, so test/account-ceiling-reserve.test.ts:46 failed tsc on main 006d3977 (TS2345: property 'origin' is missing). The fixture uses `seed`, the origin for test fixtures, as every other test does.
# Conflicts:
#	src/http/api.ts
The operator's first admin is a named exemption at createUser's one gate, so a closed signup never holds it back
All checks were successful
check / gate (2) (pull_request) Successful in 5m23s
check / gate (3) (pull_request) Successful in 4m1s
check / gate (1) (pull_request) Successful in 11m9s
check / check (pull_request) Successful in 1s
preview / preview (pull_request) Has been skipped
525f187682
With #43, createUser takes a required origin and holds stranger doors to the signup setting. The first-admin code is the operator's proof (only the operator's terminal is shown it), so redeemFirstAdmin says origin `first-admin`, an exemption like `claim`, and makes an admin. Recovery does not make an account (it mints a token for an existing admin), so it needs no origin. Test fixtures say `seed`.
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
trevato-apps/forge!41
No description provided.