import { NextRequest, NextResponse } from 'next/server'; import { z, ZodSchema } from 'zod'; import { checkRateLimit, rateLimiters, rateLimitHeaders, type RateLimiterName, } from '@/lib/rate-limit'; /** * Parses URL search params against a Zod schema. * Throws a ZodError on validation failure (caught by `errorResponse`). */ export function parseQuery(req: NextRequest, schema: T): z.infer { const params = Object.fromEntries(req.nextUrl.searchParams.entries()); return schema.parse(params); } /** * Parses the JSON request body against a Zod schema. * Throws a ZodError on validation failure (caught by `errorResponse`). * * H-14: tolerates empty request bodies (content-length 0 or req.json() * throwing on an empty stream) by substituting `{}` so DELETE/PATCH * routes whose schemas have all-optional fields don't crash with a * 500 — the schema's own optionality decides whether the empty object * is a valid input. */ export async function parseBody( req: NextRequest, schema: T, ): Promise> { const body = await req.json().catch(() => ({})); return schema.parse(body ?? {}); } /** * Best-effort client IP from forwarded headers. The trusted proxy is * nginx (which sets `x-forwarded-for` from `$proxy_add_x_forwarded_for`), * so the leftmost token is the original client. Falls back to a literal * `unknown` so the per-IP key still exists when running outside the * proxy (dev, tests). */ export function clientIp(req: NextRequest): string { const xff = req.headers.get('x-forwarded-for'); if (xff) { const first = xff.split(',')[0]?.trim(); if (first) return first; } return req.headers.get('x-real-ip') ?? 'unknown'; } /** * Wraps an unauthenticated route handler with a per-IP (or per-key) rate * limit. Used for portal/auth endpoints that have no session yet — the * `withRateLimit` helper in api/helpers.ts is keyed on `ctx.userId` and * cannot apply here. * * If `keySuffix` is provided, it's appended to the IP so a single client * IP can't exhaust an unrelated user's bucket (e.g. for sign-in we key * on `${ip}:${email}` so per-account brute force is the bottleneck and * a noisy NAT IP doesn't deny everyone). */ export async function enforcePublicRateLimit( req: NextRequest, name: RateLimiterName, keySuffix?: string, ): Promise { const config = rateLimiters[name]; const identifier = keySuffix ? `${clientIp(req)}:${keySuffix}` : clientIp(req); const result = await checkRateLimit(identifier, config); if (result.allowed) return null; const retryAfterSec = Math.max(1, Math.ceil((result.resetAt - Date.now()) / 1000)); return NextResponse.json( { error: 'Too many requests. Please try again shortly.', retryAfter: retryAfterSec }, { status: 429, headers: { ...rateLimitHeaders(result), 'Retry-After': retryAfterSec.toString(), }, }, ); }