Describe the bug
The `/api/ai-insights` endpoint enforces a 5-requests-per-hour rate limit using a module-level `Map`:
```ts
// src/app/api/ai-insights/route.ts
const aiInsightsRateLimit = new Map<
string,
{ count: number; resetTime: number }
();
```
In serverless environments (Vercel, AWS Lambda), each function invocation may run in a fresh container. The `Map` is reset on every cold start. A user can exhaust their 5-request quota, wait 30–60 seconds for the function container to expire, and then make 5 more requests — repeating indefinitely. The rate limit provides no real protection.
Why this matters
The AI Insights endpoint calls either the Anthropic API (`ANTHROPIC_API_KEY`) or the Groq API (`GROQ_API_KEY`). Both are metered services billed per token. Unbounded access through rate-limit bypass translates directly into unexpected API costs for the project.
Contrast with other rate-limited endpoints
Every other rate-limited endpoint in the codebase uses the Upstash Redis client with atomic Lua scripts, which is durable across cold starts:
- `src/lib/auth-rate-limit.ts` — auth endpoints
- `src/middleware.ts` — metrics endpoints (60 req/min per user)
- `src/lib/contact-rate-limit.ts` — contact form (3 req/hour per IP)
The AI Insights endpoint is the only one that uses an in-memory `Map` as its primary (and only) rate-limiting mechanism.
Files affected
- `src/app/api/ai-insights/route.ts` (lines 12–16, 97–120) — in-memory rate limiter definition and usage
Expected behaviour
The rate limit should persist across serverless function instances. A user who makes 5 requests should be blocked for the remainder of the hour regardless of cold starts or concurrent instances.
Suggested fix
Replace the in-memory `Map` with the Upstash Redis rate limiter already integrated in `src/lib/rate-limit.ts`. The fix is a drop-in replacement following the exact pattern used by existing rate-limited routes:
```ts
// Before the AI generation logic, replace the Map-based check with:
import { rateLimit } from "@/lib/rate-limit";
const rateLimitResult = await rateLimit(ai-insights:${userId}, {
max: 5,
windowSeconds: 60 * 60, // 1 hour
});
if (!rateLimitResult.success) {
return NextResponse.json(
{ error: "Rate limit exceeded. Try again later." },
{
status: 429,
headers: { "Retry-After": String(rateLimitResult.retryAfter) },
}
);
}
```
The module-level `aiInsightsRateLimit` Map and all related `existing.count` logic can then be removed entirely.
Describe the bug
The `/api/ai-insights` endpoint enforces a 5-requests-per-hour rate limit using a module-level `Map`:
```ts
// src/app/api/ai-insights/route.ts
const aiInsightsRateLimit = new Map<
string,
{ count: number; resetTime: number }
In serverless environments (Vercel, AWS Lambda), each function invocation may run in a fresh container. The `Map` is reset on every cold start. A user can exhaust their 5-request quota, wait 30–60 seconds for the function container to expire, and then make 5 more requests — repeating indefinitely. The rate limit provides no real protection.
Why this matters
The AI Insights endpoint calls either the Anthropic API (`ANTHROPIC_API_KEY`) or the Groq API (`GROQ_API_KEY`). Both are metered services billed per token. Unbounded access through rate-limit bypass translates directly into unexpected API costs for the project.
Contrast with other rate-limited endpoints
Every other rate-limited endpoint in the codebase uses the Upstash Redis client with atomic Lua scripts, which is durable across cold starts:
The AI Insights endpoint is the only one that uses an in-memory `Map` as its primary (and only) rate-limiting mechanism.
Files affected
Expected behaviour
The rate limit should persist across serverless function instances. A user who makes 5 requests should be blocked for the remainder of the hour regardless of cold starts or concurrent instances.
Suggested fix
Replace the in-memory `Map` with the Upstash Redis rate limiter already integrated in `src/lib/rate-limit.ts`. The fix is a drop-in replacement following the exact pattern used by existing rate-limited routes:
```ts
// Before the AI generation logic, replace the Map-based check with:
import { rateLimit } from "@/lib/rate-limit";
const rateLimitResult = await rateLimit(
ai-insights:${userId}, {max: 5,
windowSeconds: 60 * 60, // 1 hour
});
if (!rateLimitResult.success) {
return NextResponse.json(
{ error: "Rate limit exceeded. Try again later." },
{
status: 429,
headers: { "Retry-After": String(rateLimitResult.retryAfter) },
}
);
}
```
The module-level `aiInsightsRateLimit` Map and all related `existing.count` logic can then be removed entirely.