Skip to content

Commit f06cc94

Browse files
committed
feat: add rate limiting to newsletter endpoint + tests
Add IP-based rate limiting (3 requests per 10 min) to the newsletter subscribe endpoint. Includes periodic cleanup to prevent memory leaks. Add 15 tests covering: successful subscription, input validation, Resend API errors, missing env vars, and rate limiting behavior.
1 parent f5f9f8e commit f06cc94

File tree

2 files changed

+473
-1
lines changed

2 files changed

+473
-1
lines changed

src/contract/newsletter/index.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,36 @@
11
import { z } from "zod";
22
import { base } from "../shared/os.ts";
33

4+
// Simple in-memory rate limiter: max 3 requests per IP per 10 minutes
5+
const rateLimitMap = new Map<string, number[]>();
6+
const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000;
7+
const RATE_LIMIT_MAX = 3;
8+
9+
function isRateLimited(ip: string): boolean {
10+
const now = Date.now();
11+
const timestamps = rateLimitMap.get(ip) ?? [];
12+
const recent = timestamps.filter((t) => now - t < RATE_LIMIT_WINDOW_MS);
13+
14+
if (recent.length >= RATE_LIMIT_MAX) {
15+
rateLimitMap.set(ip, recent);
16+
return true;
17+
}
18+
19+
recent.push(now);
20+
rateLimitMap.set(ip, recent);
21+
return false;
22+
}
23+
24+
// Periodic cleanup to prevent memory leak
25+
setInterval(() => {
26+
const now = Date.now();
27+
for (const [ip, timestamps] of rateLimitMap) {
28+
const recent = timestamps.filter((t) => now - t < RATE_LIMIT_WINDOW_MS);
29+
if (recent.length === 0) rateLimitMap.delete(ip);
30+
else rateLimitMap.set(ip, recent);
31+
}
32+
}, 60 * 1000);
33+
434
export const subscribe = base
535
.input(
636
z.object({
@@ -17,7 +47,15 @@ export const subscribe = base
1747
message: "Failed to subscribe",
1848
},
1949
})
20-
.handler(async ({ input, errors }) => {
50+
.handler(async ({ input, context, errors }) => {
51+
// Rate limit by IP
52+
const forwarded = (context.headers as Headers).get?.("x-forwarded-for");
53+
const ip = forwarded?.split(",")[0]?.trim() || "unknown";
54+
55+
if (isRateLimited(ip)) {
56+
throw errors.RATE_LIMITED({ data: { retryAfter: 600 } });
57+
}
58+
2159
const apiKey = process.env.RESEND_API_KEY;
2260
const segmentId = process.env.RESEND_SEGMENT_ID;
2361

0 commit comments

Comments
 (0)