feat: wire CF rate-limit bindings on Workers deploy#91
Merged
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Task Reference: [MYMR-167]
Wires
CloudflareRateLimitBackendto the Worker'senv.RATE_LIMIT_APIandenv.RATE_LIMIT_AUTHbindings on the Workers deploy so middleware throttling uses CF's per-POP shared, eventually-consistent rate-limit binding instead of per-isolateMemoryRateLimitBackend. Adds a middleware-layer auth-path throttle as defense-in-depth on top of Better-Auth's existing in-memorycustomRules. Self-host keeps the memory backend by absence of bindings. Server actions stay on the per-isolate memory backend via a dedicated"actions"slot so their tight per-action caps are honored exactly.lib/api/rate-limit.ts— addsbindingKey?: 'api' | 'auth'toRateLimitRule, replaces the single_backendwith kind-parameterized_backends.{api,auth,actions}, parameterizesgetBackend(kind)andsetBackend(kind, backend). Prepends two auth rules (/api/auth/sign-in/*,/api/auth/sign-up/*) ordered most-specific-first somatchRuleresolves them before the catch-all/api/*. Documents therule.max == binding.simple.limitinvariant.lib/api/rate-limit-cf.ts—check()wraps the binding RPC in try/catch with a structuredconsole.warnon error. NewfailOpen?: booleanconstructor option (defaultstrue) selects per-binding behavior:truereturnsallowed: trueso a degraded subsystem doesn't take the app offline;falsereturnsallowed: falseso an outage cannot widen a brute-force window. Docstrings explain the composite-key quirk and the advisory-only nature ofremaining/resetIn(the binding API exposes only{success}).lib/actions/rate-limit-action.ts— routescheckActionRateLimittogetBackend("actions"). The actions slot is intentionally never CF-bound; server-action callers (perUserMax3–30) need tighter enforcement than any single CF binding'ssimple.limitprovides, so per-isolate memory honors them exactly.middleware.ts— removes the blanket/api/auth/*skip.matchRule(pathname)now routes auth paths toRATE_LIMIT_AUTHviarule.bindingKey, and the rest of/api/*toRATE_LIMIT_API.worker-cf.ts— wraps the OpenNext handler in a one-shot init that registers both CF backends viasetBackend(kind, ...)on the first request through the Workers entry. AUTH binding wiredfailOpen: false; API binding stays defaultfailOpen: true. Idempotent via_rateLimitInitializedflag, amortized to a single binding-pointer assignment per isolate spawn. Emits a structuredrate_limit_initlog forwrangler tailverification.tests/api/rate-limit-cf.test.ts(new) — covers (1)getBackend()default-kind behavior withinstanceof MemoryRateLimitBackendstrengthening, (2) independentapi/auth/actionsslots aftersetBackend, (3) auth rules taking precedence over the catch-all inmatchRule, (4)CloudflareRateLimitBackend.checkfail-open default on binding RPC error, (5) fail-closed branch whenfailOpen: false.beforeEachresets backend slots so test order is independent.Doc-verified design decisions
~limit × M POPs. Mitigation lives at WAF / Bot Fight, out of scope here.binding.limit({key})returns only{success};simple.limitandsimple.periodare fixed at deploy time inwrangler.jsonc:ratelimits[]. The composite-key${key}:${max}:${windowSeconds}workaround inrate-limit-cf.tsis genuinely required to partition multiple rules within a single binding. Becausesimple.limitis the only enforced cap,rule.maxMUST equal it orRateLimit-Policyheaders misrepresent runtime behavior — enforced as an invariant in theRateLimitRuledocstring, and/api/mcp(100) +/api/auth/sign-up/*(5) now align with their respective bindings.rateLimit.customStorage = {get, set}storing{count: integer, lastRequest: bigint}— structurally incompatible with the CF binding's{success}return, so backing BA directly with the CF binding is not possible. Layered defense-in-depth at the middleware layer ON TOP of BA'scustomRulesis the correct architecture; BA continues to enforce sign-up at 3/60 in-process per isolate even though the AUTH binding allows 5/60.@opennextjs/cloudflareis NOT imported intomiddleware.ts(which runs on both self-host and Workers builds) because the package is the existing.workers.tsruntime-split dependency; importing it directly would break the self-host bundle. The wrap stays inworker-cf.ts./api/auth/sign-in/*and/api/auth/sign-up/*is a deliberate deviation from CF's general guidance against IP keys — there is no other key available before authentication, and brute-force defense by IP is the field standard.Type of change
Testing
bun run dev(typecheck + lint + full test suite below)bun run lint)bun run typecheck)bun test— 499/499 pass across 54 files (5 tests intests/api/rate-limit-cf.test.ts, the existingtests/auth/rate-limit.test.tsstill passes confirming the layered defense doesn't disturb BA's in-process throttle)wrangler devsmoke — defer to operator (HOTL); plan AC feat: update design language #3 expects: unauthenticated/→ 307 redirect to/sign-inBEFORE the rate-limit branch runs;/sign-in→ 200 + CSP header;/api/project/<not-a-uuid>→ 400; 101 rapid authenticated calls to/api/project/<uuid>— last is 429 withRateLimit-Policy: 100;w=60+Retry-After; 6 rapid calls to/api/auth/sign-in/emailfrom the same IP — last is 429.wrangler tail --format json | grep rate_limit_initafter deploy should show a single emission per isolate spawn confirming both bindings registered.Notes for reviewer
/api/auth/sign-in) which would never match Better-Auth's actual endpoints (/api/auth/sign-in/email). Switched to prefix patterns (/api/auth/sign-in/*,/api/auth/sign-up/*) and the test exercises real BA paths plus/api/auth/get-session(which correctly falls through to the API bucket).worker-cf.tsare file-local interfaces (WorkerEnv,WorkerCtx) rather than the auto-generatedCloudflareEnv/ExportedHandlerfromcloudflare-env.d.ts. That file is excluded from typecheck (tsconfig.json:33) and importing@cloudflare/workers-typesdirectly is banned byno-restricted-importsineslint.config.*— its ambient declarations clobber DOMRequest/Responseand break unrelated browser tests. Project convention is file-local stubs, seelib/realtime/broker-do.ts:30-36for the canonical pattern. The local interfaces structurally match what workerd passes; no functional difference, just smaller blast radius. Docstrings on bothWorkerEnvandCloudflareRateLimitBindingnow cite this convention so future readers don't try to ‟fix" it.Post-review changes (latest commit)
Addresses concerns from inline review of the original 5-file diff:
checkActionRateLimitrouted through the CF API binding (simple.limit: 100) and silently relaxed every tight caller (oauth.revoke_all: 3 → 100,joinTeamByCode: 5 → 100,profile.update: 10 → 100,oauth.revoke: 20 → 100). Added the"actions"backend kind, never CF-bound, so actions stay on per-isolate memory with rule limits honored exactly. This is pre-PR enforcement, restored.failOpenctor option; AUTH binding wiredfailOpen: falseso a binding outage cannot become a hole in the brute-force defense. Updated docstring to drop the unsupported "matches CF's documented platform fail-open behavior" claim — CF docs do not specify RPC-error semantics.rule.maxis enforcement-irrelevant under the CF binding (onlysimple.limitfromwrangler.jsoncis enforced); mismatchedmaxonly succeeded in misleading clients via theRateLimit-Policyheader. Aligned/api/mcp60→100 and/api/auth/sign-up/*3→5 to match their bindings. Added explicit invariant to theRateLimitRuledocstring. BA's customRules continue to tighten sign-up to 3/60 in-process; follow-up tracked to add a dedicated 3/60 binding for middleware-layer parity.beforeEachreset of all backend slots (test order was previously load-bearing), strengthened the default-kind test withinstanceof MemoryRateLimitBackend, added coverage for the new fail-closed branch.?? "api"in middleware (the helper's default param covers it); added one-line comment by_rateLimitInitializedclarifying it is binding-pointer state (not request-scoped) so it doesn't trip a "no global request state" Workers-anti-pattern flag.Follow-up tasks worth filing (out of scope here): resolve two-sources-of-truth between
RATE_LIMIT_RULESandwrangler.jsonc:ratelimits[]; declare a dedicated 3/60 binding for sign-up so middleware-layer enforcement matches BA; evaluatebetter-auth-cloudflarefor KV-backed secondary storage; re-evaluateRATE_LIMIT_AUTHbucket sizing once production traffic data lands; revisit cf-binding for actions once per-action sizing is justified.