From 7388048bbe2f8611cc3ec9f5c947e289d83c4aa5 Mon Sep 17 00:00:00 2001 From: Isaac S Date: Fri, 29 May 2026 01:48:30 -0400 Subject: [PATCH] Add rate limiting to v5 API routes Port the in-memory sliding-window rate limiter (with optional Upstash Redis backend, failing open on Redis errors) from v4 into v5/src/lib/rate-limit.ts, and guard the chat and upload-notion routes. - chat: 20 req/min per IP (key chat:) - upload-notion: 15 req/min per IP (key upload:) Both guards run at the top of POST before any Notion fetch or model call and return 429 with a Retry-After header when exceeded. Document the optional UPSTASH_REDIS_REST_URL / UPSTASH_REDIS_REST_TOKEN env vars in v5/.env.example (falls back to in-memory without them). Co-Authored-By: Claude Opus 4.8 (1M context) --- v5/.env.example | 28 ++++++++ v5/src/app/api/chat/route.ts | 14 ++++ v5/src/app/api/upload-notion/route.ts | 14 ++++ v5/src/lib/rate-limit.ts | 93 +++++++++++++++++++++++++++ 4 files changed, 149 insertions(+) create mode 100644 v5/.env.example create mode 100644 v5/src/lib/rate-limit.ts diff --git a/v5/.env.example b/v5/.env.example new file mode 100644 index 0000000..f13a1c4 --- /dev/null +++ b/v5/.env.example @@ -0,0 +1,28 @@ +# ── Notion ─────────────────────────────────────────────────────────── +# Internal integration token from https://www.notion.so/my-integrations +NOTION_API_KEY=ntn_YourTokenHere + +# Notion database IDs (required — share each database with the integration) +NOTION_DB_TOOLS=YourDatabaseIdHere +NOTION_DB_CATEGORIES=YourDatabaseIdHere +NOTION_DB_LOCATIONS=YourDatabaseIdHere +NOTION_DB_UNITS=YourDatabaseIdHere +NOTION_DB_RESOURCES=YourDatabaseIdHere +NOTION_DB_MAINTENANCE_LOGS=YourDatabaseIdHere +NOTION_DB_FLAGS=YourDatabaseIdHere + +# ── AI APIs ────────────────────────────────────────────────────────── +# Claude API key for the chat assistant (Vercel AI SDK) +ANTHROPIC_API_KEY=sk-ant-api03-YourKeyHere + +# ── Admin ──────────────────────────────────────────────────────────── +# Shared secret guarding POST /api/admin/revalidate +ADMIN_REVALIDATE_SECRET=YourSecretHere + +# ── Rate limiting (optional) ───────────────────────────────────────── +# Upstash Redis backs the API rate limiter. When both are set, limits are +# enforced across all serverless instances. Without them, the limiter falls +# back to an in-memory store (resets on cold start; fine for basic abuse +# prevention). +UPSTASH_REDIS_REST_URL= +UPSTASH_REDIS_REST_TOKEN= diff --git a/v5/src/app/api/chat/route.ts b/v5/src/app/api/chat/route.ts index 6bec752..e94102c 100644 --- a/v5/src/app/api/chat/route.ts +++ b/v5/src/app/api/chat/route.ts @@ -22,6 +22,7 @@ import { import type { MakerLabTool, MakerLabUnit } from "../../../components/catalog-types"; import type { ResourceRecord } from "../../../lib/types"; import { languageNameForLocale } from "../../../i18n/config"; +import { getClientIp, rateLimitAsync } from "../../../lib/rate-limit"; const MAX_PDFS_PER_CHAT = 3; const MAX_PDF_BYTES = 10 * 1024 * 1024; // 10MB ceiling @@ -45,6 +46,19 @@ interface ChatRequest { const PRIORITIES = ["Critical", "High", "Medium", "Low"] as const; export async function POST(req: Request) { + // Rate limit before any expensive work (Notion fetch / model call). + const ip = getClientIp(req); + const { allowed } = await rateLimitAsync(`chat:${ip}`, { + limit: 20, + windowMs: 60_000, + }); + if (!allowed) { + return Response.json( + { error: "Too many requests. Please slow down." }, + { status: 429, headers: { "Retry-After": "60" } } + ); + } + const { messages, toolId, locale }: ChatRequest = await req.json(); const tools = await getCatalogTools(); const focused = toolId ? await getCatalogTool(toolId) : null; diff --git a/v5/src/app/api/upload-notion/route.ts b/v5/src/app/api/upload-notion/route.ts index c28a3ec..707c7d8 100644 --- a/v5/src/app/api/upload-notion/route.ts +++ b/v5/src/app/api/upload-notion/route.ts @@ -1,4 +1,5 @@ import { NextRequest } from "next/server"; +import { getClientIp, rateLimitAsync } from "../../../lib/rate-limit"; const NOTION_API = "https://api.notion.com/v1"; // file_uploads requires a newer Notion-Version than the catalog reads use. @@ -16,6 +17,19 @@ interface CreateFileUploadResponse { } export async function POST(req: NextRequest) { + // Rate limit before any expensive work (Notion upload session / byte transfer). + const ip = getClientIp(req); + const { allowed } = await rateLimitAsync(`upload:${ip}`, { + limit: 15, + windowMs: 60_000, + }); + if (!allowed) { + return Response.json( + { error: "Too many requests. Please slow down." }, + { status: 429, headers: { "Retry-After": "60" } } + ); + } + const token = process.env.NOTION_API_KEY; if (!token) { return Response.json({ error: "Server misconfigured" }, { status: 500 }); diff --git a/v5/src/lib/rate-limit.ts b/v5/src/lib/rate-limit.ts new file mode 100644 index 0000000..2d0d764 --- /dev/null +++ b/v5/src/lib/rate-limit.ts @@ -0,0 +1,93 @@ +import "server-only"; + +interface RateLimitEntry { + count: number; + resetAt: number; +} + +const store = new Map(); +const UPSTASH_URL = process.env.UPSTASH_REDIS_REST_URL || ""; +const UPSTASH_TOKEN = process.env.UPSTASH_REDIS_REST_TOKEN || ""; +const useUpstash = Boolean(UPSTASH_URL && UPSTASH_TOKEN); + +// Clean up expired entries periodically (every 60s) +let lastCleanup = Date.now(); +function cleanup() { + const now = Date.now(); + if (now - lastCleanup < 60_000) return; + lastCleanup = now; + for (const [key, entry] of store) { + if (now > entry.resetAt) store.delete(key); + } +} + +/** + * Simple in-memory sliding window rate limiter. + * Resets on serverless cold start — good enough for abuse prevention. + */ +export function rateLimit( + key: string, + { limit, windowMs }: { limit: number; windowMs: number } +): { allowed: boolean; remaining: number } { + // Keep sync behavior for callers. If Upstash is configured, callers should use rateLimitAsync. + if (useUpstash) { + throw new Error("rateLimitAsync must be used when Upstash Redis is configured"); + } + cleanup(); + + const now = Date.now(); + const entry = store.get(key); + + if (!entry || now > entry.resetAt) { + store.set(key, { count: 1, resetAt: now + windowMs }); + return { allowed: true, remaining: limit - 1 }; + } + + entry.count++; + const allowed = entry.count <= limit; + return { allowed, remaining: Math.max(0, limit - entry.count) }; +} + +export async function rateLimitAsync( + key: string, + { limit, windowMs }: { limit: number; windowMs: number } +): Promise<{ allowed: boolean; remaining: number }> { + if (!useUpstash) { + return rateLimit(key, { limit, windowMs }); + } + + const redisKey = `rl:${key}`; + const ttlSec = Math.max(1, Math.ceil(windowMs / 1000)); + const url = `${UPSTASH_URL}/pipeline`; + const body = JSON.stringify([ + ["INCR", redisKey], + ["EXPIRE", redisKey, ttlSec, "NX"], + ]); + + const res = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${UPSTASH_TOKEN}`, + "Content-Type": "application/json", + }, + body, + cache: "no-store", + }); + + if (!res.ok) { + // Fail open to avoid downtime on transient Redis issues. + return { allowed: true, remaining: limit - 1 }; + } + + const parsed = (await res.json()) as Array<{ result?: number }>; + const count = Number(parsed?.[0]?.result || 0); + const allowed = count <= limit; + return { allowed, remaining: Math.max(0, limit - count) }; +} + +/** Extract client IP from request headers (works on Vercel) */ +export function getClientIp(req: Request): string { + const forwarded = req.headers.get("x-forwarded-for"); + if (forwarded) return forwarded.split(",")[0].trim(); + return req.headers.get("x-real-ip") || "unknown"; +}