AUTH4D-336 password lockout keyed on address and IP

POST /t/{tenantId}/password/sign-in and POST /t/{tenantId}/password/change (AUTH4D-144 (repository file docs/api-spec/auth4d-144.md)) used to lock an address after 10 wrong attempts in 15 minutes, counted on the address alone. Anyone who could open a hosted-login flow could keep a chosen player locked out of password sign-in. The lockout is now counted per address and IP address, with a much larger per-address ceiling as a backstop. Request and response shapes do not change.

Budgets

Every attempt spends these budgets in order, before any hashing. The first one that is spent answers 429 {"error": "rate_limited"} with Retry-After in seconds, and the budgets after it are not charged.

Budget Key Limit
Per IP tenant, client IP 30 attempts an hour
Lockout tenant, normalised address and IP 10 attempts in 15 minutes
Address ceiling tenant, normalised address 100 attempts in 15 minutes

The client IP is the one the API resolves for every request (clientIp, from the platform’s connecting-IP header). Keys are HMACs under AUTH_PRIVACY_KEY, so no address or IP address reaches the Durable Object. The values are PASSWORD_BUDGETS in apps/api/src/modules/password/index.ts.

Lifting a lockout

Both address budgets lift on their own when their window ends. A successful password sign-in, a successful password change or a successful email-code reset (POST /t/{tenantId}/password/reset/confirm) also lifts them at once: it deletes the address ceiling and the lockout for the IP address the request came from. Lockouts held by other IP addresses stay until they lift on their own. The rate-limit store gains an optional resetBudget operation for this (AtomicStateStore in packages/contracts, implemented by packages/storage-do).

Enumeration resistance

The address budgets are keyed on the address, not the account, so an unknown address, an address with no password and an existing account get the same answers, in the same order, with the same Retry-After. A lockout is lifted only after the password or the email code is proven, so lifting reveals nothing that the successful response did not already reveal.

Source: docs/api-spec/auth4d-336.md