diff --git a/__tests__/api/health.test.ts b/__tests__/api/health.test.ts index 6c8ff3f51..460fac4e6 100644 --- a/__tests__/api/health.test.ts +++ b/__tests__/api/health.test.ts @@ -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: { @@ -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 }; } @@ -28,6 +34,7 @@ describe("GET /api/health", () => { const originalEnv = process.env; beforeEach(() => { + _resetRateLimitForTests(); process.env = { ...originalEnv, DATABASE_URL: "postgresql://localhost:5432/test", diff --git a/__tests__/lib/j0di3/j0di3-proxy.test.ts b/__tests__/lib/j0di3/j0di3-proxy.test.ts index d08812724..18c2ec433 100644 --- a/__tests__/lib/j0di3/j0di3-proxy.test.ts +++ b/__tests__/lib/j0di3/j0di3-proxy.test.ts @@ -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", @@ -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 }; diff --git a/__tests__/pages/api/contact.test.ts b/__tests__/pages/api/contact.test.ts index be79663af..395708085 100644 --- a/__tests__/pages/api/contact.test.ts +++ b/__tests__/pages/api/contact.test.ts @@ -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(), @@ -14,16 +15,22 @@ function createMockReqRes(body: Record): { 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"; }); diff --git a/docs/RATE_LIMITS.md b/docs/RATE_LIMITS.md new file mode 100644 index 000000000..c1916add4 --- /dev/null +++ b/docs/RATE_LIMITS.md @@ -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": "" }` 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). diff --git a/src/components/jobs/ResumeScorer.tsx b/src/components/jobs/ResumeScorer.tsx index 3b2c093e2..9ea8d4a1b 100644 --- a/src/components/jobs/ResumeScorer.tsx +++ b/src/components/jobs/ResumeScorer.tsx @@ -1,5 +1,6 @@ import { useCallback, useRef, useState } from "react"; import usePdfUpload from "@/hooks/use-pdf-upload"; +import { handleClientError } from "@/utils/handle-client-error"; const TARGET_ROLES = [ "Junior Software Engineer", @@ -111,7 +112,13 @@ function renderText(val: unknown): string { if (val && typeof val === "object") { // Try common field names const obj = val as Record; - return obj.name as string || obj.skill as string || obj.text as string || obj.label as string || JSON.stringify(val); + return ( + (obj.name as string) || + (obj.skill as string) || + (obj.text as string) || + (obj.label as string) || + JSON.stringify(val) + ); } return String(val); } @@ -134,32 +141,50 @@ export default function ResumeScorer() { const [error, setError] = useState(null); const fileInputRef = useRef(null); - const { upload, uploading, error: uploadError, progress, fileName, reset: resetUpload } = usePdfUpload(); + const { + upload, + uploading, + error: uploadError, + progress, + fileName, + reset: resetUpload, + } = usePdfUpload(); - const handleFileUpload = useCallback(async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - try { - const text = await upload(file); - setResumeText(text); - } catch { - // handled by hook - } - }, [upload]); + const handleFileUpload = useCallback( + async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + try { + const text = await upload(file); + setResumeText(text); + } catch (err) { + // usePdfUpload surfaces the error via `uploadError`, which the UI + // renders below. Log so devs see it in the console too. + handleClientError(err, { context: "ResumeScorer:handleFileUpload" }); + } + }, + [upload] + ); const handleTextFileRead = useCallback((file: File) => { const reader = new FileReader(); - reader.onload = (e) => setResumeText(e.target?.result as string || ""); + reader.onload = (e) => setResumeText((e.target?.result as string) || ""); reader.readAsText(file); }, []); - const handleDrop = useCallback((e: React.DragEvent) => { - e.preventDefault(); - const file = e.dataTransfer.files[0]; - if (!file) return; - if (file.type === "text/plain") handleTextFileRead(file); - else upload(file).then(setResumeText).catch(() => {}); - }, [upload, handleTextFileRead]); + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + const file = e.dataTransfer.files[0]; + if (!file) return; + if (file.type === "text/plain") handleTextFileRead(file); + else + upload(file) + .then(setResumeText) + .catch((err) => handleClientError(err, { context: "ResumeScorer:handleDrop" })); + }, + [upload, handleTextFileRead] + ); const handleScore = async () => { if (!resumeText.trim()) return; @@ -262,7 +287,6 @@ export default function ResumeScorer() { URL.revokeObjectURL(url); }; - const dimColor = (score: number) => { if (score >= 80) return "tw-bg-green-500"; if (score >= 60) return "tw-bg-yellow-500"; @@ -278,7 +302,9 @@ export default function ResumeScorer() { }; const passFailColor = (pf: string) => { - return pf === "PASS" ? "tw-bg-green-100 tw-text-green-800" : "tw-bg-red-100 tw-text-red-800"; + return pf === "PASS" + ? "tw-bg-green-100 tw-text-green-800" + : "tw-bg-red-100 tw-text-red-800"; }; const DIMENSION_LABELS: Record = { @@ -295,7 +321,10 @@ export default function ResumeScorer() { text .replace(/harvard\s*format(ting|ted)?/gi, "ATS/recruiter formatting") .replace(/harvard/gi, "ATS/recruiter") - .replace(/Please check formatting\.?/gi, "Try pasting your resume text directly for best results.") + .replace( + /Please check formatting\.?/gi, + "Try pasting your resume text directly for best results." + ) .replace(/Could not be analyzed\.?/gi, "We had trouble reading this resume.") .replace(/Could not analyze/gi, "Unable to evaluate"); @@ -316,43 +345,87 @@ export default function ResumeScorer() { className="tw-mb-3 tw-border-2 tw-border-dashed tw-border-navy/10 tw-rounded-lg tw-p-4 tw-text-center tw-cursor-pointer hover:tw-border-primary tw-transition-colors" onClick={() => fileInputRef.current?.click()} > - + {uploading ? (
-

Parsing {fileName}... {progress}%

+

+ Parsing {fileName}... {progress}% +

-
+
) : fileName && resumeText ? (
-

{fileName} loaded

- +

+ {fileName} loaded +

+
) : (
-

Drop PDF, DOCX, or TXT here — or click to browse

+

+ Drop PDF, DOCX, or TXT here — or click to browse +

Max 5MB

)} - {uploadError &&

{uploadError}

} + {uploadError && ( +

{uploadError}

+ )}
-