Skip to content

Commit e0beaea

Browse files
Merge branch 'main' into fixes-issue-#726
2 parents 266a7ce + e8d256d commit e0beaea

127 files changed

Lines changed: 6824 additions & 1223 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ jobs:
9393
- name: Run Tests
9494
shell: bash
9595
run: |
96-
if timeout 30s pnpm run test; then
96+
if timeout 30s pnpm vitest run --coverage; then
9797
echo "Tests completed within the 30-second limit."
9898
else
9999
status=$?

.husky/pre-push

100644100755
Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,35 @@
11
#!/bin/sh
2-
command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'pre-push' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; }
3-
git lfs pre-push "$@"
2+
. "$(dirname "$0")/_/husky.sh"
3+
4+
# Git hooks run with a minimal PATH; ensure pnpm is available.
5+
if ! command -v pnpm >/dev/null 2>&1; then
6+
if [ -n "$PNPM_HOME" ]; then
7+
PATH="$PNPM_HOME:$PATH"
8+
fi
9+
PATH="$HOME/.local/share/pnpm:/opt/homebrew/bin:/usr/local/bin:$PATH"
10+
export PATH
11+
fi
12+
13+
if ! command -v pnpm >/dev/null 2>&1; then
14+
printf >&2 "\n❌ Pre-push blocked: pnpm not found on PATH.\nInstall pnpm or add it to PATH, then try pushing again.\n\n"
15+
exit 1
16+
fi
17+
18+
command -v git-lfs >/dev/null 2>&1 || {
19+
printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'pre-push' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."
20+
exit 2
21+
}
22+
23+
git lfs pre-push "$@" || exit $?
24+
25+
echo "Running type-check before push..."
26+
pnpm run type-check || {
27+
printf >&2 "\n❌ Pre-push blocked: TypeScript type-check failed.\nFix the errors above, then try pushing again.\n\n"
28+
exit 1
29+
}
30+
31+
echo "Running tests before push..."
32+
pnpm run test || {
33+
printf >&2 "\n❌ Pre-push blocked: Tests failed.\nFix the failing tests above, then try pushing again.\n\n"
34+
exit 1
35+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"specId": "6c84e8d5-a68c-431b-90ee-558702173e54", "workflowType": "requirements-first", "specType": "feature"}

.kiro/specs/distributed-rate-limiting/design.md

Lines changed: 367 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# Requirements Document
2+
3+
## Introduction
4+
5+
The current rate limiter in `src/lib/ratelimit.ts` uses a module-level in-memory `Map` as its counter store. In a multi-instance deployment (e.g., multiple Next.js server pods or Vercel edge nodes), each instance maintains independent counters, allowing a client to bypass the AUTH rate limit (5 req/min) by spreading requests across instances.
6+
7+
This feature replaces the single-instance in-memory store with a shared [Upstash Redis](https://upstash.com/) backend in production, while preserving the existing in-memory behavior for development and test environments. All 20+ `withRateLimit()` call sites must continue to work without modification — the change is a pure internal implementation swap.
8+
9+
## Glossary
10+
11+
- **RateLimiter**: The abstraction that performs counter reads and writes. Either `InMemoryRateLimiter` (dev/test) or `UpstashRateLimiter` (production).
12+
- **InMemoryRateLimiter**: The existing `Map`-backed sliding window implementation used in development and test.
13+
- **UpstashRateLimiter**: A Redis-backed sliding window implementation that uses Upstash REST API calls to maintain shared counters.
14+
- **RateLimit_Store**: The backing storage for rate-limit counters (either the in-memory `Map` or the Upstash Redis instance).
15+
- **Sliding_Window**: The rate-limiting algorithm that counts requests within a rolling time window and resets the counter after `windowMs` milliseconds.
16+
- **Tier**: A named rate-limit configuration (`AUTH`, `WRITE`, `READ`) with a `limit` and `windowMs`.
17+
- **Identifier**: A string key combining client IP and tier (e.g., `"1.2.3.4:AUTH"`) used to namespace counters in the RateLimit_Store.
18+
- **UPSTASH_REDIS_REST_URL**: Environment variable holding the Upstash Redis REST endpoint URL.
19+
- **UPSTASH_REDIS_REST_TOKEN**: Environment variable holding the Upstash Redis REST API token.
20+
- **NODE_ENV**: Node.js environment variable (`"production"`, `"development"`, or `"test"`).
21+
22+
---
23+
24+
## Requirements
25+
26+
### Requirement 1: Shared Counter Backend in Production
27+
28+
**User Story:** As a platform operator, I want rate-limit counters to be stored in a shared Redis backend in production, so that rate limits are enforced consistently across all server instances and edge nodes.
29+
30+
#### Acceptance Criteria
31+
32+
1. WHEN `NODE_ENV` is `"production"` AND `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` are set, THE `RateLimiter` SHALL use the `UpstashRateLimiter` as its `RateLimit_Store`.
33+
2. WHEN the `UpstashRateLimiter` is active, THE `RateLimiter` SHALL increment and read counters via the Upstash Redis REST API for every call to `slidingWindowRateLimit`.
34+
3. WHEN two separate server instances call `slidingWindowRateLimit` with the same `Identifier`, THE `UpstashRateLimiter` SHALL reflect a combined request count that is the sum of both instances' requests within the same `Sliding_Window`.
35+
4. WHEN a client's combined request count across all instances reaches the configured `limit` for a `Tier`, THE `RateLimiter` SHALL return `success: false` for subsequent requests within the same `Sliding_Window`.
36+
37+
---
38+
39+
### Requirement 2: In-Memory Fallback for Development and Test
40+
41+
**User Story:** As a developer, I want the rate limiter to work without Redis configured in development and test, so that local development and CI test runs are not blocked by external service dependencies.
42+
43+
#### Acceptance Criteria
44+
45+
1. WHEN `NODE_ENV` is `"development"` OR `NODE_ENV` is `"test"`, THE `RateLimiter` SHALL use the `InMemoryRateLimiter` as its `RateLimit_Store`, regardless of whether Upstash environment variables are set.
46+
2. WHEN `UPSTASH_REDIS_REST_URL` or `UPSTASH_REDIS_REST_TOKEN` is absent AND `NODE_ENV` is `"production"`, THE `RateLimiter` SHALL fall back to the `InMemoryRateLimiter` and log a warning at startup indicating that distributed rate limiting is disabled.
47+
3. WHILE the `InMemoryRateLimiter` is active, THE `RateLimiter` SHALL preserve all existing `Sliding_Window` semantics: allowing requests up to `limit`, blocking further requests until `windowMs` elapses, and returning correct `remaining` and `reset` values. THE `remaining` value SHALL reflect `limit - count` and MAY exceed the configured `limit` in edge cases where the `limit` is dynamically reduced after counters are initialized.
48+
49+
---
50+
51+
### Requirement 3: Backward-Compatible Public API
52+
53+
**User Story:** As a developer maintaining route handlers, I want the `withRateLimit()` function signature and return shape to remain unchanged, so that no call sites require modification after the refactor.
54+
55+
#### Acceptance Criteria
56+
57+
1. THE `RateLimiter` SHALL export `withRateLimit(request, tier)` with the same signature: accepting a `Request` and a `RateLimitTier`, returning `{ addHeaders, rateLimitResponse }`.
58+
2. THE `RateLimiter` SHALL export `slidingWindowRateLimit(identifier, config)` with the same signature: accepting a `string` and `RateLimitConfig`, returning `RateLimitResult`.
59+
3. THE `RateLimiter` SHALL export `RATE_LIMIT_TIERS`, `RateLimitTier`, `RateLimitConfig`, `RateLimitResult`, `RateLimitInfo`, and `createRateLimitResponse` with unchanged types and values.
60+
4. WHEN `withRateLimit` is called from any existing route handler, THE `RateLimiter` SHALL return a `rateLimitResponse` of `null` for allowed requests and a `NextResponse` with status `429` for blocked requests, identical to the current behavior. THE `RateLimiter` SHALL never return a non-null `rateLimitResponse` for a request that was allowed by the `RateLimit_Store`.
61+
62+
---
63+
64+
### Requirement 4: Sliding Window Algorithm Correctness
65+
66+
**User Story:** As a platform operator, I want the sliding window algorithm to behave identically whether backed by in-memory or Upstash storage, so that rate-limit decisions are consistent and predictable.
67+
68+
#### Acceptance Criteria
69+
70+
1. WHEN a new `Identifier` is first seen within a `Sliding_Window`, THE `RateLimiter` SHALL initialize a counter at `1` and set `resetAt` to `now + windowMs`.
71+
2. WHEN an existing `Identifier`'s counter is below `limit`, THE `RateLimiter` SHALL increment the counter by `1` and return `success: true` with `remaining` equal to `limit - count`.
72+
3. WHEN an existing `Identifier`'s counter equals or exceeds `limit`, THE `RateLimiter` SHALL return `success: false` with `remaining` equal to `0` and `retryAfter` equal to the ceiling of `(resetAt - now) / 1000` seconds.
73+
4. WHEN the current time exceeds `resetAt` for an `Identifier`, THE `RateLimiter` SHALL reset the counter to `1` and set a new `resetAt` to `now + windowMs`, treating the request as the first in a new window.
74+
5. FOR ALL valid `(identifier, config)` pairs, the sequence `[allow × limit, block × 1, advance time by windowMs, allow × 1]` SHALL hold for both `InMemoryRateLimiter` and `UpstashRateLimiter` implementations.
75+
76+
---
77+
78+
### Requirement 5: AUTH Tier Bypass Prevention
79+
80+
**User Story:** As a security engineer, I want the AUTH rate limit (5 req/min) to be enforced globally, so that an attacker cannot bypass it by distributing requests across multiple server instances.
81+
82+
#### Acceptance Criteria
83+
84+
1. WHEN `NODE_ENV` is `"production"` AND Upstash credentials are configured, THE `UpstashRateLimiter` SHALL enforce the `AUTH` tier limit of `5` requests per `60,000 ms` window as a single shared counter across all instances.
85+
2. WHEN the shared request count across all instances reaches exactly the `AUTH` tier `limit` of `5` within the same `Sliding_Window`, THE `UpstashRateLimiter` SHALL block the request that reaches the limit and all subsequent requests with `success: false`, without permitting an additional request beyond the limit.
86+
3. IF the Upstash REST API call fails during an AUTH tier check, THEN THE `RateLimiter` SHALL fail open (allow the request) and log the error, to prevent service disruption due to Redis unavailability.
87+
88+
---
89+
90+
### Requirement 6: Upstash Package and Environment Configuration
91+
92+
**User Story:** As a developer onboarding to the project, I want all required packages installed and environment variables documented, so that I can configure distributed rate limiting without guessing at dependencies or config keys.
93+
94+
#### Acceptance Criteria
95+
96+
1. THE project SHALL declare `@upstash/ratelimit` and `@upstash/redis` as production dependencies in `package.json`.
97+
2. THE `.env.example` file SHALL document `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` with placeholder values and a comment explaining their purpose.
98+
3. WHEN `UPSTASH_REDIS_REST_URL` or `UPSTASH_REDIS_REST_TOKEN` contains an invalid value at startup in production, THE `RateLimiter` SHALL log a descriptive error, fall back to `InMemoryRateLimiter` immediately, and prevent any attempt to initialize the `UpstashRateLimiter` for the remainder of the process lifetime.
99+
100+
---
101+
102+
### Requirement 7: Existing Tests Pass with In-Memory Backend
103+
104+
**User Story:** As a developer, I want the existing rate-limit test suite in `src/app/api/tutorials/__tests__/ratelimit.test.ts` to continue passing after the refactor, so that the behavioral contract of `slidingWindowRateLimit` and `withRateLimit` is preserved.
105+
106+
#### Acceptance Criteria
107+
108+
1. WHEN the test suite runs with `NODE_ENV` set to `"test"`, THE `RateLimiter` SHALL use the `InMemoryRateLimiter` so that no Upstash credentials or network calls are required.
109+
2. THE `InMemoryRateLimiter` SHALL pass all existing test cases for `slidingWindowRateLimit`, including: allowing requests within the limit, blocking requests that exceed the limit, resetting the window after `windowMs` elapses, and returning correct `remaining` counts.
110+
3. THE `InMemoryRateLimiter` SHALL pass all existing test cases for `withRateLimit`, including: `READ` and `WRITE` tier enforcement, independent tracking per IP and tier, and `addHeaders` injecting correct `X-RateLimit-*` headers.
111+
4. WHEN `vi.useFakeTimers()` is active in a test, THE `InMemoryRateLimiter` SHOULD respect the mocked `Date.now()` value so that time-advance tests (`vi.advanceTimersByTime`) behave as expected. IF the limiter uses real time, time-advance tests SHALL still pass by relying on sufficiently long real time elapsed or by configuring real window durations.

0 commit comments

Comments
 (0)