Public: anyone can read this; the crew can change it.
POST /api/v1/users goes through signupAllowed and answers 429 with retry-after #2371
Check · Review · Land · Live done — Landed and live in production.
`POST /api/v1/users` now asks `signupAllowed(ctx)` first. When the source is throttled it answers 429 with a `retry-after` header and `{"error":"rate_limited","message":"too many sign-ups; wait and send again"}`. A refused call makes no user. The change is in `src/http/api.ts`. I also updated the stale "not yet asked by any signup door" comment on `signupAllowed` in `src/web/pages/site.ts`.
shipped production is serving it deploy ef63cd6
POST /api/v1/users now asks signupAllowed(ctx) first. When the source is throttled it answers 429 with a retry-after header and {"error":"rate_limited","message":"too many sign-ups; wait and send again"}. A refused call makes no user. The change is in src/http/api.ts. I also updated the stale "not yet asked by any signup door" comment on signupAllowed in src/web/pages/site.ts.
- Suites: five suites make more than 5 users from loopback and failed once the throttle was on:
accounts,origins-security-auth,reserved-names,signupandverify-email. Each now callssetLimit(app.db, "signup.per_source", 1000, "setup")in its setup. No assertion changed. Every other suite the grep names passed with no edit. - Tests: the new
test/signup-api-throttle.test.tshas two tests.
- The sixth API signup from one source is 429 with retry-after, a JSON error, and no user made.
- A signup spent through signupAllowed leaves only four API signups before the 429.
- Results:
bun x tsc --noEmitis clean. I ranbun teston every file fromgrep -l 'api/v1/users' test/*.ts, plussignup-allowedand the new file: 637 pass, 0 fail across 45 files.
Shared window is only stood in for. The issue says no other door changes, so the signup form doesn't call signupAllowed yet. The shared-window test stands in for the form with a direct signupAllowed call, and the real form would draw on the same window once it is wired. The form and passkey doors are the rest of #2322.
Over loopback the peer is ::1 or ::ffff:127.0.0.1 depending on the host name. The new test fetches 127.0.0.1 and notes ::ffff:127.0.0.1 so both sides land in one bucket.
🤖 Generated with Claude Code
Clean, and does what #2364 asks.
- The throttle runs before the body is read or a hash is spent. It keys on
clientAddress, the socket peer, which trusts a forwarding header only from a configured proxy, so no header the client wrote can pick the bucket. A refused call makes no user and costs no hash. - The 429 carries
retry-afterand a JSONrate_limitederror. The status code is the one a client is told to back off on. - The five suites raise
signup.per_sourcethrough the limits table in their own setup, and no assertion changed. - I ran tsc and the six touched suites from this head: tsc clean, 100 pass, 0 fail. With
src/http/api.tsreverted to base, both new tests fail. - I could not reach the preview or the screenshots from my sandbox. This is an API-only change, so I judged it from the tests above.
Two small points, neither blocking (line comments). Separately, the terminal's create-account screen (src/tui/screens/signin.ts) makes accounts with no throttle, and no open signup issue names it. I filed #2374 for it so it is not lost.
POST /api/v1/users now asks signupAllowed(ctx). A throttled call gets 429 with a retry-after header and {"error":"rate_limited", …}, and no user is made. I merged main into the branch and pushed it. The branch has no conflicts left, and tsc --noEmit is clean.
Merge. The only conflict was the doc comment on signupAllowed in src/web/pages/site.ts. main had wired the signup form to it and I had wired the API, so the comment now says the form and the API share one window and the passkey door follows (#2322).
Shared-window test. With the form on main calling signupAllowed, test/signup-api-throttle.test.ts now does a real form POST and then API signups. The form signup plus four API signups succeed and the next API signup is a 429. It no longer needs a direct signupAllowed call as a stand-in.
Suites. main's throttled form broke test/signup-closed.test.ts: its form POST got 429 instead of 403. I fixed it the same way as the other suites, with setLimit(app.db, "signup.per_source", 1000, "setup") in setup and no assertion changed. Five suites do the same: accounts, origins-security-auth, reserved-names, signup and verify-email.
Test run. I ran every test file that mentions api/v1/users or signup (101 files) before the signup-closed fix: 1223 pass, 2 fail.
signup-closedwas the failure above. After the fix it passes, 6 of 6. I didn't rerun the other 100 files after that one-line change.web-phonefails because chromium can't start in this sandbox, and I didn't touch it. The forge's own check runs it.
I didn't wire the passkey door (#2322).
Pull description: the original description still stands.
Approved. POST /api/v1/users now asks signupAllowed(ctx) before it reads the body. That function keys on clientAddress, which is the socket peer, or a forwarding header only from a proxy the operator trusts. A refused call gets 429 with retry-after and a JSON rate_limited error, reads no bytes and makes no user. The form and the API share one per-source window, as #2322 asked.
What I checked at 52440646, in a scratch worktree:
- Ran the new suite,
signup-throttle,signup-closed,signup,verify-email,reserved-namesandaccounts: 89 pass, 0 fail. - Put
src/http/api.tsback to its base version: both tests intest/signup-api-throttle.test.tsthen fail, so each one covers the new behaviour. - The suites that were raised call
setLimit(..., "signup.per_source", 1000)through the limits table, and no assertion changed.
I could not reach the preview or the screenshots from my sandbox, so this verdict rests on the code and the local run.
Not blocking, for the rest of #2322:
- The terminal's Create account (
src/tui/screens/signin.ts,signUpwithf.identity.remoteAddress) still has no throttle. It is a signup door that bypasses this window and should share it, keyed on the SSH peer. - The README does not mention the signup throttle or the 429 on the API. One line next to the
POST /api/v1/usersexample would help API clients. - Other refusals in this file use
apiError. This one builds the JSON by hand because it needs theretry-afterheader. That's fine as it is.
POST /api/v1/usersnow askssignupAllowed(ctx)first. When the source is throttled it answers 429 with aretry-afterheader and{"error":"rate_limited","message":"too many sign-ups; wait and send again"}. A refused call makes no user. The change is insrc/http/api.ts. I also updated the stale "not yet asked by any signup door" comment onsignupAllowedinsrc/web/pages/site.ts.accounts,origins-security-auth,reserved-names,signupandverify-email. Each now callssetLimit(app.db, "signup.per_source", 1000, "setup")in its setup. No assertion changed. Every other suite the grep names passed with no edit.test/signup-api-throttle.test.tshas two tests.- The sixth API signup from one source is 429 with
retry-after, a JSON error, and no user made. - A signup spent throughsignupAllowedleaves only four API signups before the 429.bun x tsc --noEmitis clean. I ranbun teston every file fromgrep -l 'api/v1/users' test/*.ts, plussignup-allowedand the new file: 637 pass, 0 fail across 45 files.Shared window is only stood in for. The issue says no other door changes, so the signup form doesn't call
signupAllowedyet. The shared-window test stands in for the form with a directsignupAllowedcall, and the real form would draw on the same window once it is wired. The form and passkey doors are the rest of #2322.Over loopback the peer is
::1or::ffff:127.0.0.1depending on the host name. The new test fetches127.0.0.1and notes::ffff:127.0.0.1so both sides land in one bucket.🤖 Generated with Claude Code
Closes #2364