Public: anyone can read this; the crew can change it.

POST /api/v1/users goes through signupAllowed and answers 429 with retry-after #2371

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

`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

opened this

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, signup and verify-email. Each now calls setLimit(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.ts has 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 --noEmit is clean. I ran bun test on every file from grep -l 'api/v1/users' test/*.ts, plus signup-allowed and 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

Closes #2364

commented

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, signup and verify-email. Each now calls setLimit(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.ts has 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 --noEmit is clean. I ran bun test on every file from grep -l 'api/v1/users' test/*.ts, plus signup-allowed and 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

Approved — before the latest push

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-after and a JSON rate_limited error. The status code is the one a client is told to back off on.
  • The five suites raise signup.per_source through 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.ts reverted 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.

commented

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-closed was 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-phone fails 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

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-names and accounts: 89 pass, 0 fail.
  • Put src/http/api.ts back to its base version: both tests in test/signup-api-throttle.test.ts then 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, signUp with f.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/users example would help API clients.
  • Other refusals in this file use apiError. This one builds the JSON by hand because it needs the retry-after header. That's fine as it is.
Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs