Difficulty: Expert
Type: refactor (architecture/scalability)
Background
lib/rate-limit.ts's own doc comment already flags its scaling limitation explicitly: "The bucket state lives in process memory... If you run more than one instance behind a load balancer, each instance keeps its own counters and the effective limit is multiplied by the instance count. For multi-instance or serverless (edge) deployments, replace the Map store with a shared backend (e.g. Redis via @upstash/ratelimit, or a DB-backed counter) — see docs/deployment.md 'Production rate-limiting' for the upgrade path." Today, getBucket()/take() operate directly on the module-level Map, with no seam for swapping in a different backing store.
Problem
The current design hard-codes the storage mechanism into the rate-limiting algorithm itself, so implementing the documented "upgrade path" to a shared backend today would require rewriting lib/rate-limit.ts's core logic rather than just plugging in a new adapter. This also makes the token-bucket algorithm itself hard to unit-test independently of its storage, and blocks any future multi-instance/edge deployment of this app without a disruptive rewrite at that time.
Expected Outcome
The token-bucket algorithm in lib/rate-limit.ts is decoupled from its storage via a small, explicit RateLimitStore interface. The default export continues to use an in-memory Map-based implementation (bundling in the eviction behavior from the related Advanced issue in this set, if it has landed — otherwise, that's fine to layer in independently) with zero behavior change for existing callers, but a second store implementation can be added later (e.g., Redis-backed) purely by implementing the interface, without touching rateLimitRequest()'s calling code.
Suggested Implementation
- Define a
RateLimitStore interface in lib/rate-limit.ts (or a new lib/rate-limit/store.ts) with the minimal operations the algorithm needs — something like:
interface RateLimitStore {
getBucket(key: string): Promise<Bucket | undefined> // or sync, if keeping in-memory synchronous
setBucket(key: string, bucket: Bucket): Promise<void>
}
Consider carefully whether to make the interface async from the start (a Redis-backed store necessarily is) even though the in-memory implementation doesn't need to be — doing so now avoids a second breaking interface change later when a real remote store is added, but changes today's callers (app/api/integration/*/route.ts) to await rateLimitRequest(...). Justify the chosen approach in the PR description.
- Extract today's
Map-based logic into an InMemoryRateLimitStore implementing the interface, preserving its exact current refill/token-bucket math.
- Refactor
getBucket()/take()/rateLimitRequest() to operate against an injected RateLimitStore instance (defaulting to InMemoryRateLimitStore), rather than the module-level Map directly.
- Keep
rateLimitRequest(req, address)'s public signature and behavior backward-compatible for existing callers — this refactor should be invisible to app/api/integration/*/route.ts beyond the async/await change decided in step 1, if any.
- Add a documented (but not necessarily implemented) example of how a second store — e.g., a stub
RedisRateLimitStore sketch — would plug into the interface, either as a code comment, a .example.ts file, or an update to docs/deployment.md's "Production rate-limiting" section, so the documented upgrade path is now concretely actionable rather than just described in prose.
- Update
test/rate-limit.test.ts to test the algorithm against the RateLimitStore interface (e.g., with an injectable fake store for deterministic tests) in addition to (or instead of) only exercising the default in-memory store end to end.
Acceptance Criteria
Likely Affected Files/Directories
lib/rate-limit.ts
lib/rate-limit/store.ts (new, if split out)
app/api/integration/membership/route.ts
app/api/integration/verify/route.ts
test/rate-limit.test.ts
docs/deployment.md
Difficulty: Expert
Type: refactor (architecture/scalability)
Background
lib/rate-limit.ts's own doc comment already flags its scaling limitation explicitly: "The bucket state lives in process memory... If you run more than one instance behind a load balancer, each instance keeps its own counters and the effective limit is multiplied by the instance count. For multi-instance or serverless (edge) deployments, replace theMapstore with a shared backend (e.g. Redis via@upstash/ratelimit, or a DB-backed counter) — seedocs/deployment.md'Production rate-limiting' for the upgrade path." Today,getBucket()/take()operate directly on the module-levelMap, with no seam for swapping in a different backing store.Problem
The current design hard-codes the storage mechanism into the rate-limiting algorithm itself, so implementing the documented "upgrade path" to a shared backend today would require rewriting
lib/rate-limit.ts's core logic rather than just plugging in a new adapter. This also makes the token-bucket algorithm itself hard to unit-test independently of its storage, and blocks any future multi-instance/edge deployment of this app without a disruptive rewrite at that time.Expected Outcome
The token-bucket algorithm in
lib/rate-limit.tsis decoupled from its storage via a small, explicitRateLimitStoreinterface. The default export continues to use an in-memoryMap-based implementation (bundling in the eviction behavior from the related Advanced issue in this set, if it has landed — otherwise, that's fine to layer in independently) with zero behavior change for existing callers, but a second store implementation can be added later (e.g., Redis-backed) purely by implementing the interface, without touchingrateLimitRequest()'s calling code.Suggested Implementation
RateLimitStoreinterface inlib/rate-limit.ts(or a newlib/rate-limit/store.ts) with the minimal operations the algorithm needs — something like:asyncfrom the start (a Redis-backed store necessarily is) even though the in-memory implementation doesn't need to be — doing so now avoids a second breaking interface change later when a real remote store is added, but changes today's callers (app/api/integration/*/route.ts) toawait rateLimitRequest(...). Justify the chosen approach in the PR description.Map-based logic into anInMemoryRateLimitStoreimplementing the interface, preserving its exact current refill/token-bucket math.getBucket()/take()/rateLimitRequest()to operate against an injectedRateLimitStoreinstance (defaulting toInMemoryRateLimitStore), rather than the module-levelMapdirectly.rateLimitRequest(req, address)'s public signature and behavior backward-compatible for existing callers — this refactor should be invisible toapp/api/integration/*/route.tsbeyond theasync/awaitchange decided in step 1, if any.RedisRateLimitStoresketch — would plug into the interface, either as a code comment, a.example.tsfile, or an update todocs/deployment.md's "Production rate-limiting" section, so the documented upgrade path is now concretely actionable rather than just described in prose.test/rate-limit.test.tsto test the algorithm against theRateLimitStoreinterface (e.g., with an injectable fake store for deterministic tests) in addition to (or instead of) only exercising the default in-memory store end to end.Acceptance Criteria
lib/rate-limit.tsexposes aRateLimitStoreinterface and anInMemoryRateLimitStoredefault implementation.limited/retryAfter/remainingsemantics) is unchanged and covered by the existingtest/rate-limit.test.tssuite (updated only as needed for the interface change, not the algorithm).app/api/integration/*/route.tscontinue to work correctly against the refactored module.docs/deployment.md(or an equivalent doc) shows concretely how a second store implementation would be wired in, fulfilling the "upgrade path" the code comment already promises.npm run typecheck,npm run lint,npm testpass.Likely Affected Files/Directories
lib/rate-limit.tslib/rate-limit/store.ts(new, if split out)app/api/integration/membership/route.tsapp/api/integration/verify/route.tstest/rate-limit.test.tsdocs/deployment.md