From 3feb336bb0f0073e8ffd7859a628f1ceb5c60f99 Mon Sep 17 00:00:00 2001 From: Anthony G <1@anthony.sh> Date: Mon, 10 Aug 2026 14:28:13 +0200 Subject: [PATCH] feat: add IP-based rate limiter (swc/jest replaces ts-jest for TS7 compat) --- package.json | 7 ++++- src/lib/__tests__/rate-limit.test.ts | 41 ++++++++++++++++++++++++++++ src/lib/rate-limit.ts | 19 +++++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 src/lib/__tests__/rate-limit.test.ts create mode 100644 src/lib/rate-limit.ts diff --git a/package.json b/package.json index 7800915..cee9467 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "jest": { "testEnvironment": "node", "transform": { - "^.+\\.tsx?$": "ts-jest" + "^.+\\.tsx?$": "@swc/jest" }, "moduleNameMapper": { "^@/(.*)$": "/src/$1" @@ -34,13 +34,18 @@ "license": "ISC", "type": "commonjs", "dependencies": { + "@prisma/adapter-mariadb": "^7.9.1", "@prisma/client": "^7.9.1", + "dotenv": "^17.4.2", + "mariadb": "^3.5.3", "next": "^16.3.0", "prisma": "^7.9.1", "react": "^19.2.8", "react-dom": "^19.2.8" }, "devDependencies": { + "@swc/core": "^1.15.47", + "@swc/jest": "^0.2.39", "@types/jest": "^30.0.0", "@types/node": "^26.2.0", "@types/react": "^19.2.18", diff --git a/src/lib/__tests__/rate-limit.test.ts b/src/lib/__tests__/rate-limit.test.ts new file mode 100644 index 0000000..8089c2e --- /dev/null +++ b/src/lib/__tests__/rate-limit.test.ts @@ -0,0 +1,41 @@ +import { isRateLimited } from '../rate-limit' + +beforeEach(() => { + jest.useFakeTimers() + jest.resetModules() +}) + +afterEach(() => { + jest.useRealTimers() +}) + +describe('isRateLimited', () => { + it('allows requests under the limit', () => { + const ip = '1.2.3.4' + for (let i = 0; i < 5; i++) { + expect(isRateLimited(ip)).toBe(false) + } + }) + + it('blocks the 6th request within the window', () => { + const ip = '10.0.0.1' + for (let i = 0; i < 5; i++) isRateLimited(ip) + expect(isRateLimited(ip)).toBe(true) + }) + + it('resets after the window expires', () => { + const ip = '10.0.0.2' + for (let i = 0; i < 5; i++) isRateLimited(ip) + expect(isRateLimited(ip)).toBe(true) + jest.advanceTimersByTime(3_600_001) + expect(isRateLimited(ip)).toBe(false) + }) + + it('tracks different IPs independently', () => { + const ipA = '192.168.1.1' + const ipB = '192.168.1.2' + for (let i = 0; i < 5; i++) isRateLimited(ipA) + expect(isRateLimited(ipA)).toBe(true) + expect(isRateLimited(ipB)).toBe(false) + }) +}) diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts new file mode 100644 index 0000000..0dc17e6 --- /dev/null +++ b/src/lib/rate-limit.ts @@ -0,0 +1,19 @@ +import { config } from '../../config/app.config' + +type Entry = { count: number; resetAt: number } +const store = new Map() + +export function isRateLimited(ip: string): boolean { + const now = Date.now() + const entry = store.get(ip) + + if (!entry || now > entry.resetAt) { + store.set(ip, { count: 1, resetAt: now + config.RATE_LIMIT_WINDOW_MS }) + return false + } + + if (entry.count >= config.RATE_LIMIT_MAX) return true + + entry.count++ + return false +}