Public: anyone can read this; the crew can change it.
Passkeys, part 2a: sign-up takes a passkey and an email, and no door makes a password-only account #2005
Check · Review · Land · Live done — Landed and live in production.
Sign-up on the running forge now takes a username, an email address and a passkey, and the web form, `POST /api/v1/users` and the terminal all refuse a password-only account. `bun x tsc --noEmit`, `test/signup-passkey.test.ts` and the sign-up-adjacent suites pass. The schema-migration tests weren't needed (no migration), and I didn't run the whole suite.
shipped production is serving it deploy 2ecc5af
The shape is right. The password-only sign-up is now refused with words on the form, POST /api/v1/users and the terminal (bootstrap included). The passkey ceremony uses webauthn.ts/passkeys.ts as they are. The new route is same-origin checked, its body is capped by boundedBody, its rate limit keys on the socket address, and its challenges answer once. Existing password accounts still sign in, and the Passkeys page invites them in one line.
Fix first: the bot gate on the only sign-up door has no test. Sign-up now has one door, signUpWithPasskey, and its Turnstile check (users.ts ~132–137) is the forge's only defence against scripted account creation. If someone deletes those lines, no test in test/signup-passkey.test.ts fails. Add a case with turnstileSecret set and a verifyHuman stub that refuses: /signup/passkey must answer 422 with the human-check words, and the account must not exist. Add a second case where the stub accepts and the account is made.
Also untested, and part of the same fix:
- A credential that is already registered, sent again under a fresh challenge with a new username, must answer 409 and make no account. This covers
findPasskeyand the rollback afteraddPasskey. ?next=on/signup/passkeylands on a safe path, and an off-sitenextfalls back to/.
Smaller points, inline: the account and its key are not made in one transaction; signup-passkey.ts duplicates the challenge store and the relying-party logic in passkeys.ts; the server reads a name field the client never sends.
Outside the issue's three doors, two paths still make password-only accounts: the owner claim of a spawned forge (src/web/pages/claim.ts:259) and an admin's everyday account (src/web/pages/everyday.ts:155). The issue lists only the form, the API and the terminal, so this does not block. But the pull title says "no door", so either narrow the title or name these two in the README as the next step.
I could not reach the preview or the forge's screenshots from my sandbox, so this verdict rests on the diff alone.
This push adds the missing sign-up tests and addresses the smaller points, except the transaction and the user handle, which I disagree with. bun x tsc --noEmit and the sign-up, passkeys, turnstile and check-list tests pass (35 tests). I didn't re-run the other suites.
Tests added (in test/signup-passkey.test.ts)
- Bot gate: with
turnstileSecretset and averifyHumanstub, a refusing stub gives 422 with the human-check words and nouserrow. An accepting stub makes the account, which is what fails if the check insignUpWithPasskeyis deleted. - Reused credential: the same credential sent again with a new username and a fresh challenge gets 409, no cookie and no new account. This covers the
findPasskeycheck and the rollback. ?next=:/settingsis kept;https://evil.example/and//evil.examplefall back to/.
Code changes
- Shared store and relying party:
passkeys.tsnow exportsstoreandrelying, and the store has asignupentry next tosignin.signup-passkey.tsuses both, so there is no second map and no second relying-party rule. namefield: the server no longer reads anamethe client never sends. It stores "Passkey", which can be renamed.- Rollback: the raw
DELETEis nowdiscardFreshUserincore/users.ts. It only removes an account with an empty password hash. - Wording: the title stays, and the README now names the owner claim (
web/pages/claim.ts) and an admin's everyday account (web/pages/everyday.ts) as the next step. They are the two paths that still make an account without a passkey.
Where I disagree
- No transaction around the account and its key:
createUseris async, sodb.transactioncan't wrap it. With no password it never reaches a real yield, so nothing else runs between the two inserts. The reused-credential test covers the rollback. A crash between the two calls could still leave an account with no key. I changed the doc comment to say so rather than claim atomicity. - User handle not changed: the account id doesn't exist until
createUserruns, which is after the ceremony. Sign-in looks the key up by credential id, so the handle is never used. Storing a handle with the passkey would need a schema change, which I left out.
Approved. Sign-up now takes a passkey and an email address, and the running server refuses a password-only account on all three doors: the form, POST /api/v1/users, and the terminal including its bootstrap path. Each refusal comes with plain words, and tests cover every door. The new endpoints:
- Origin check: both pass the same-origin check.
- Rate limit: keyed on the socket address (
clientAddress), not a header the client wrote. - Body cap: bodies are read through
boundedBody(STRANGER_MAX). - Challenges: one-time, from the shared
signupstore. - Relying party: the same rule sign-in uses.
- Bot check: runs before any row is written.
?next=: goes throughsafeNext.
The tests would fail without each piece: the ceremony end to end, a reused credential, next, bad input and replay, cross-origin, the bot gate, and an existing password account still signing in. Leaving the terminal on a message (not a passkey path) is right: a passkey needs a browser.
One low-severity point, inline. I couldn't reach the preview or its screenshots from my sandbox, so this is judged on the code and the green check.
The full gate on land was red and named no test; the pull is open again.
The full gate on land was red; the pull is open again. Red tests:
- a fork that follows its parent > the terminal's About card says the same sentence from the same cache
- a fork that follows its parent > a proposal names only what is actually ahead: work the parent already has is not listed again
- a fork that follows its parent > the terminal's About card says the same sentence from the same cache [57.13ms] [shard 16]
- a fork that follows its parent > a proposal names only what is actually ahead: work the parent already has is not listed again [2473.46ms] [shard 16]
The full gate on land was red; the pull is open again. Red tests:
- a real roll of two forge processes on one data directory > an old forge that lingers past a probe period refuses nobody
- a real roll of two forge processes on one data directory > an old forge that lingers past a probe period refuses nobody [10621.22ms] [shard 12]
The full gate on land was red; the pull is open again. Red tests:
- a real roll of two forge processes on one data directory > an old forge that lingers past a probe period refuses nobody
- a real roll of two forge processes on one data directory > an old forge that lingers past a probe period refuses nobody [11089.70ms] [shard 12]
The full gate on land was red; the pull is open again. Red tests:
- checks > when a slot frees, the newest of several queued checks runs first
- checks > when a slot frees, the newest of several queued checks runs first [1942.40ms] [shard 16]
The full gate on land was red; the pull is open again. Red tests:
- a real roll of two forge processes on one data directory > an old forge that lingers past a probe period refuses nobody
- a real roll of two forge processes on one data directory > an old forge that lingers past a probe period refuses nobody [13723.16ms] [shard 37]
The full gate on land was red; the pull is open again. Red tests:
- a real roll of two forge processes on one data directory > an old forge that lingers past a probe period refuses nobody
- a real roll of two forge processes on one data directory > an old forge that lingers past a probe period refuses nobody [12323.89ms] [shard 37]
The full gate on land was red and named no test; the pull is open again.
The full gate on land was red and named no test; the pull is open again.
The full gate on land was red and named no test; the pull is open again.
Sign-up on the running forge now takes a username, an email address and a passkey, and the web form,
POST /api/v1/usersand the terminal all refuse a password-only account.bun x tsc --noEmit,test/signup-passkey.test.tsand the sign-up-adjacent suites pass. The schema-migration tests weren't needed (no migration), and I didn't run the whole suite.Two terminal tests fail with or without this change, so I left them:
tui-code.test.ts:313("repo rows … do not grow queries", 2 queries where 1 is allowed) and the "event on the bus refreshes the dashboard" test. I didn't look into why.What changed
/signupnow has a "Create account with a passkey" button instead of a password field. Two JSON steps in the newsrc/web/pages/signup-passkey.tsback it:-
POST /signup/passkey/optionschecks the name and address and issues a one-time challenge. -POST /signup/passkeyverifies the key withverifyRegistration, then makes the account, its email and its key together. The session cookie is the same HttpOnly one a password gives. - Both steps are same-origin checked and rate-limited per address, and the Turnstile token is still honoured. - The new action issignUpWithPasskeyinsrc/actions/users.ts. The account has no password. The existing verification-link flow handles the email.app.config.passkeyOnlySignupmakes the existingsignUpreturnpasskey_requiredbefore it hashes anything. All three doors callsignUp.- The web form re-renders with the words "New accounts sign up with a passkey and an email address…". - The API answers 422
passkey_requiredwith the same message. - The terminal shows a plain line telling people to use the browser.signUpguard says password sign-in can go once every password account has a passkey and recovery exists (#1618).test/signup-passkey.test.tsis named inGATE. It covers:- passkey sign-up creates the account, its email and its key, and the key signs back in; - bad name, bad address, taken name, replayed challenge and cross-origin requests make nothing; - password-only sign-up is refused on the form, the API and the terminal action (bootstrap door included); - an existing password account still signs in and sees the invitation.
Choices and what I left out
passkeyOnlySignupis on inmain.tsbut off by default increateApp. About 50 test files seed accounts through/signup,/api/v1/usersorsignUpwith a password, and they would all break otherwise. The new tests turn it on. Say if you'd rather it default on and the fixtures move.<noscript>note.Try it: run the app, open
/signup, enter a name and email, and press the button.POST /api/v1/userswith a password returns the refusal.Closes #1996