Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion __tests__/api/health.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NextApiRequest, NextApiResponse } from "next";
import type { Mock } from "vitest";
import handler from "@/pages/api/health";
import { _resetRateLimitForTests } from "@/lib/rate-limit";

vi.mock("@/lib/prisma", () => ({
default: {
Expand All @@ -16,10 +17,15 @@ function createMockReqRes(method = "GET"): {
req: NextApiRequest;
res: NextApiResponse;
} {
const req = { method } as NextApiRequest;
const req = {
method,
headers: {},
socket: { remoteAddress: "127.0.0.1" },
} as unknown as NextApiRequest;
const res = {
status: vi.fn().mockReturnThis(),
json: vi.fn().mockReturnThis(),
setHeader: vi.fn(),
} as unknown as NextApiResponse;
return { req, res };
}
Expand All @@ -28,6 +34,7 @@ describe("GET /api/health", () => {
const originalEnv = process.env;

beforeEach(() => {
_resetRateLimitForTests();
process.env = {
...originalEnv,
DATABASE_URL: "postgresql://localhost:5432/test",
Expand Down
3 changes: 3 additions & 0 deletions __tests__/lib/j0di3/j0di3-proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ function createMockReqRes(
method: "POST",
body: { question: "What is React?" },
query: {},
headers: {},
socket: { remoteAddress: "127.0.0.1" },
user: {
id: "user-123",
name: "Test",
Expand All @@ -42,6 +44,7 @@ function createMockReqRes(
const res = {
status: vi.fn().mockReturnThis(),
json: vi.fn().mockReturnThis(),
setHeader: vi.fn(),
} as unknown as NextApiResponse;

return { req, res };
Expand Down
9 changes: 8 additions & 1 deletion __tests__/pages/api/contact.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { NextApiRequest, NextApiResponse } from "next";
import type { Mock } from "vitest";
import { classifyContact } from "@/pages/api/api-helpers/classify-contact";
import handler from "@/pages/api/contact";
import { _resetRateLimitForTests } from "@/lib/rate-limit";

vi.mock("@/pages/api/api-helpers/classify-contact", () => ({
classifyContact: vi.fn(),
Expand All @@ -14,16 +15,22 @@ function createMockReqRes(body: Record<string, unknown>): {
req: NextApiRequest;
res: NextApiResponse;
} {
const req = { body } as NextApiRequest;
const req = {
body,
headers: {},
socket: { remoteAddress: "127.0.0.1" },
} as unknown as NextApiRequest;
const res = {
status: vi.fn().mockReturnThis(),
json: vi.fn().mockReturnThis(),
setHeader: vi.fn(),
} as unknown as NextApiResponse;
return { req, res };
}

describe("POST /api/contact", () => {
beforeEach(() => {
_resetRateLimitForTests();
process.env.CONTACT_WEBHOOK_ID = "T00/B00/xxx";
});

Expand Down
67 changes: 67 additions & 0 deletions docs/RATE_LIMITS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# API rate limits

Every public API route that touches a paid backend, sends a Slack notification,
or is otherwise expensive enforces a per-IP or per-user rate limit. The limiter
lives at `src/lib/rate-limit.ts` and is in-memory (single Vercel instance);
swap the underlying `Map` for Redis/Upstash when we scale out.

## Response headers

Every rate-limited route sets:

- `X-RateLimit-Limit` — max requests in the window
- `X-RateLimit-Remaining` — requests left in the current window
- `X-RateLimit-Reset` — UNIX seconds when the window resets
- `Retry-After` — seconds until the window resets (sent **only on 429**)

The body on `429` is `{ "error": "<human message>" }` so the frontend can
surface the reason directly.

## Per-route limits

| Route | Bucket scope | Key | Limit / window |
| ---------------------------------- | ------------------- | ------------------ | ---------------------- |
| `/api/j0di3/*` | `j0di3` | session user id | **30 / minute** |
| `/api/ai/chat` | `ai-chat` | session user id | **20 / minute** |
| `/api/military-resume/translate` | (default) | IP | **10 / 15 minutes** |
| `/api/military-resume/parse-pdf` | (default) | IP | _per-route, see file_ |
| `/api/military-resume/extract-fields` | (default) | IP | _per-route, see file_ |
| `/api/military-resume/salary-lookup` | (default) | IP | _per-route, see file_ |
| `/api/contact` | `contact` | IP | **5 / 15 minutes** |
| `/api/newsletter` | `newsletter` | IP | **5 / 15 minutes** |
| `/api/apply` | `apply` | IP | **3 / hour** |
| `/api/mentor` | `mentor` | IP | **5 / hour** |
| `/api/mentee` | `mentee` | IP | **5 / hour** |
| `/api/health` | `health` | IP | **60 / minute** |

Limits are intentionally conservative — they should never block normal use but
will catch a buggy client in a tight loop or a casual abuser. If a real user
hits one, raise the cap in code; do not paper over with a longer window.

## Frontend conventions

- Treat `429` as user-facing: show the response body's `error` string verbatim.
- If `Retry-After` is present, disable the affected control for that many
seconds before letting the user retry.
- Do not implement client-side retries on `429` — the user should consciously
retry, otherwise we just amplify the storm.

## Adding a new limit

```ts
import { applyRateLimit } from "@/lib/rate-limit";

const rl = applyRateLimit(req, res, {
scope: "my-feature", // unique per route family
key: session.user.id, // optional — defaults to client IP
max: 10,
windowMs: 60_000,
});
if (!rl.allowed) {
return res.status(429).json({ error: "Slow down a little." });
}
```

`applyRateLimit` sets the headers above automatically — the caller only has to
short-circuit on `!rl.allowed`. Don't pull `checkRateLimit` directly unless you
need a result without writing headers (e.g. for tests).
33 changes: 24 additions & 9 deletions src/lib/j0di3-proxy.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,30 @@
import axios from "axios";
import type { NextApiRequest, NextApiResponse } from "next";
import { requireAuth, type AuthenticatedRequest } from "@/lib/rbac";
import j0di3 from "@/lib/j0di3-client";
import { applyRateLimit } from "@/lib/rate-limit";
import { type AuthenticatedRequest, requireAuth } from "@/lib/rbac";

type Method = "GET" | "POST" | "PATCH" | "DELETE";

export function j0di3Proxy(
method: Method,
path: string | ((req: NextApiRequest) => string)
) {
export function j0di3Proxy(method: Method, path: string | ((req: NextApiRequest) => string)) {
return requireAuth(async (req: AuthenticatedRequest, res: NextApiResponse) => {
const url = typeof path === "function" ? path(req) : path;

// Per-user rate limit. Every call hits a paid AI backend, so a
// misbehaving client or stolen session should not be able to drive
// cost up arbitrarily.
const rl = applyRateLimit(req, res, {
scope: "j0di3",
key: req.user!.id,
max: 30,
windowMs: 60_000,
});
if (!rl.allowed) {
return res.status(429).json({
error: "You're making requests to J0dI3 too quickly. Please wait a moment and try again.",
});
}

const troopId = req.user!.troopId;

if (!troopId) {
Expand All @@ -20,8 +34,7 @@ export function j0di3Proxy(
}

try {
const body =
method !== "GET" ? { ...req.body, troop_id: troopId } : undefined;
const body = method !== "GET" ? { ...req.body, troop_id: troopId } : undefined;

const { data } = await j0di3({
method,
Expand All @@ -30,12 +43,14 @@ export function j0di3Proxy(
params: method === "GET" ? { ...req.query, troop_id: troopId } : undefined,
});


res.json(data);
} catch (error: unknown) {
if (axios.isAxiosError(error) && error.response) {
const status = error.response.status;
const message = error.response.data?.detail || error.response.data?.error || "J0dI3 request failed";
const message =
error.response.data?.detail ||
error.response.data?.error ||
"J0dI3 request failed";
return res.status(status).json({ error: message });
}
console.error("[j0di3-proxy] Unexpected error:", error);
Expand Down
46 changes: 45 additions & 1 deletion src/lib/rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* swap the Map for Redis or similar.
*/

import type { NextApiRequest } from "next";
import type { NextApiRequest, NextApiResponse } from "next";

interface RateLimitEntry {
count: number;
Expand Down Expand Up @@ -70,3 +70,47 @@ export function checkRateLimit(
entry.count++;
return { allowed: true, remaining: maxRequests - entry.count, resetAt: entry.resetAt };
}

export interface ApplyRateLimitOptions {
/** Bucket key. Defaults to client IP. Use a user/session id for per-user limits. */
key?: string;
/** Max requests allowed in the window. */
max: number;
/** Window length in milliseconds. */
windowMs: number;
/** Optional prefix added to the key (avoids collisions across endpoints sharing a key strategy). */
scope?: string;
}

/**
* Apply a rate limit and set the standard headers (X-RateLimit-* and
* Retry-After when blocked). The caller is responsible for short-circuiting
* with `res.status(429).json(...)` when `allowed === false`.
*/
export function applyRateLimit(
req: NextApiRequest,
res: NextApiResponse,
options: ApplyRateLimitOptions
): RateLimitResult {
const rawKey = options.key || getClientIp(req);
const bucketKey = options.scope ? `${options.scope}:${rawKey}` : rawKey;
const limit = checkRateLimit(bucketKey, options.max, options.windowMs);

res.setHeader("X-RateLimit-Limit", options.max);
res.setHeader("X-RateLimit-Remaining", limit.remaining);
res.setHeader("X-RateLimit-Reset", Math.ceil(limit.resetAt / 1000));
if (!limit.allowed) {
const retryAfterSec = Math.max(0, Math.ceil((limit.resetAt - Date.now()) / 1000));
res.setHeader("Retry-After", retryAfterSec);
}
return limit;
}

/**
* Exposed for unit tests — clears the in-memory bucket so test order does not
* accumulate state. Do not call from production code.
*/
export function _resetRateLimitForTests(): void {
ipBuckets.clear();
lastCleanup = Date.now();
}
15 changes: 15 additions & 0 deletions src/pages/api/ai/chat.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { streamText } from "ai";
import { NextApiResponse } from "next";
import { getAIModelWithFallback, JODIE_SYSTEM_PROMPT } from "@/lib/ai-provider";
import { applyRateLimit } from "@/lib/rate-limit";
import { AuthenticatedRequest, requireAuth } from "@/lib/rbac";

export const runtime = "nodejs";
Expand Down Expand Up @@ -47,6 +48,20 @@ export default requireAuth(async (req: AuthenticatedRequest, res: NextApiRespons
return res.status(405).json({ error: "Method not allowed" });
}

// Per-user rate limit on the streaming AI endpoint. Each call hits a paid
// model, so a runaway client should not be able to drain quota.
const rl = applyRateLimit(req, res, {
scope: "ai-chat",
key: req.user!.id,
max: 20,
windowMs: 60_000,
});
if (!rl.allowed) {
return res.status(429).json({
error: "You're sending messages too quickly. Please wait a moment.",
});
}

try {
const { messages, lessonContext } = req.body as ChatRequestBody;

Expand Down
13 changes: 13 additions & 0 deletions src/pages/api/apply.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import axios from "axios";
import { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { applyRateLimit } from "@/lib/rate-limit";
import { checkParams } from "./api-helpers";

// Define the ParsedBody interface to type-check the request body
Expand Down Expand Up @@ -112,6 +114,17 @@ async function postToSlack(text: string): Promise<void> {

export default async function handler(req: Request, res: Response) {
try {
const rl = applyRateLimit(
req as unknown as NextApiRequest,
res as unknown as NextApiResponse,
{ scope: "apply", max: 3, windowMs: 60 * 60 * 1000 }
);
if (!rl.allowed) {
return res.status(429).json({
message: "Too many application attempts. Please try again in an hour.",
});
}

const parsedBody = req.body as ParsedBody;

const hasErrors = validateBody(parsedBody);
Expand Down
10 changes: 10 additions & 0 deletions src/pages/api/contact.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import axios, { AxiosRequestConfig } from "axios";
import { NextApiRequest, NextApiResponse } from "next";
import { applyRateLimit } from "@/lib/rate-limit";
import { checkLength, checkParams, contactErrors } from "./api-helpers";
import { classifyContact } from "./api-helpers/classify-contact";

Expand Down Expand Up @@ -35,6 +36,15 @@ async function postToSlack(parsedBody: ParsedBody): Promise<void> {
}

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
// Per-IP rate limit on the contact form. The Gemini classifier handles
// semantic spam; this caps the volume that classifier even has to score.
const rl = applyRateLimit(req, res, { scope: "contact", max: 5, windowMs: 15 * 60 * 1000 });
if (!rl.allowed) {
return res.status(429).json({
error: "Too many messages sent recently. Please try again in a few minutes.",
});
}

const parsedBody: ParsedBody = req.body as ParsedBody;
const { name, email, message } = parsedBody;
const requiredParams: string[] = ["email", "message"];
Expand Down
9 changes: 9 additions & 0 deletions src/pages/api/health.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "@/lib/prisma";
import { applyRateLimit } from "@/lib/rate-limit";
import { version } from "../../../package.json";

interface CheckResult {
Expand Down Expand Up @@ -64,6 +65,14 @@ export default async function handler(
return res.status(405).json({ error: "Method not allowed" });
}

// Cap /health to prevent tight-loop pings from a misbehaving uptime
// checker. Real uptime probes ping every 30–60s — 60/min per IP is
// generous.
const rl = applyRateLimit(req, res, { scope: "health", max: 60, windowMs: 60_000 });
if (!rl.allowed) {
return res.status(429).json({ error: "Rate limit exceeded" });
}

const dbCheck = await checkDatabase();
const envCheck = checkEnvironment();
const checks = [dbCheck, envCheck];
Expand Down
13 changes: 13 additions & 0 deletions src/pages/api/mentee.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import axios from "axios";
import { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { applyRateLimit } from "@/lib/rate-limit";
import { checkParams } from "./api-helpers";

// Define the ParsedBody type for mentee
Expand All @@ -16,6 +18,17 @@ interface ParsedBody {

export default async function handler(req: Request, res: Response) {
try {
const rl = applyRateLimit(
req as unknown as NextApiRequest,
res as unknown as NextApiResponse,
{ scope: "mentee", max: 5, windowMs: 60 * 60 * 1000 }
);
if (!rl.allowed) {
return res.status(429).json({
message: "Too many mentee signups. Please try again in an hour.",
});
}

const parsedBody = req.body as ParsedBody; // Cast to ensure body matches ParsedBody interface

const requiredParams: (keyof ParsedBody)[] = [
Expand Down
Loading
Loading