|
| 1 | +import { Ratelimit } from "@upstash/ratelimit"; |
| 2 | +import { Redis } from "@upstash/redis"; |
| 3 | + |
| 4 | +/** |
| 5 | + * Per-IP rate limiting via Upstash, for the public "Run pipeline" endpoint. |
| 6 | + * Configured for 8 runs per 10 minutes — enough to explore every seeded scenario |
| 7 | + * (clean / price / quantity / duplicate, plus an approve/reject) without leaving |
| 8 | + * the door open for a bot to drain the Anthropic budget. |
| 9 | + * |
| 10 | + * Design choice (mirrors the sibling ai-invoice-parser repo): if the Upstash env |
| 11 | + * vars are absent (e.g. local dev without a Redis instance), we FAIL OPEN — the |
| 12 | + * limiter is disabled and a one-time warning is logged. The app stays fully |
| 13 | + * functional; only the abuse guard is off. In production on Vercel you set the |
| 14 | + * two env vars and the guard engages. |
| 15 | + */ |
| 16 | + |
| 17 | +const WINDOW = "10 m" as const; |
| 18 | +const LIMIT = 8; |
| 19 | + |
| 20 | +export type RateVerdict = |
| 21 | + | { ok: true; remaining: number | null } |
| 22 | + | { ok: false; limit: number; reset: number; retryAfterSeconds: number }; |
| 23 | + |
| 24 | +let limiter: Ratelimit | null = null; |
| 25 | +let initialized = false; |
| 26 | + |
| 27 | +function getLimiter(): Ratelimit | null { |
| 28 | + if (initialized) return limiter; |
| 29 | + initialized = true; |
| 30 | + |
| 31 | + // Accept either naming convention so it works however you provision Redis: |
| 32 | + // • Upstash directly → UPSTASH_REDIS_REST_URL / _TOKEN |
| 33 | + // • Vercel Marketplace → KV_REST_API_URL / _TOKEN (Vercel's Upstash add-on, |
| 34 | + // which keeps the legacy "KV" prefix) |
| 35 | + const url = process.env.UPSTASH_REDIS_REST_URL ?? process.env.KV_REST_API_URL; |
| 36 | + const token = |
| 37 | + process.env.UPSTASH_REDIS_REST_TOKEN ?? process.env.KV_REST_API_TOKEN; |
| 38 | + |
| 39 | + if (!url || !token) { |
| 40 | + console.warn( |
| 41 | + "[ratelimit] No Redis credentials found " + |
| 42 | + "(UPSTASH_REDIS_REST_URL/_TOKEN or KV_REST_API_URL/_TOKEN) — " + |
| 43 | + "rate limiting is DISABLED (failing open).", |
| 44 | + ); |
| 45 | + limiter = null; |
| 46 | + return limiter; |
| 47 | + } |
| 48 | + |
| 49 | + limiter = new Ratelimit({ |
| 50 | + redis: new Redis({ url, token }), |
| 51 | + limiter: Ratelimit.slidingWindow(LIMIT, WINDOW), |
| 52 | + prefix: "ledgerloop", |
| 53 | + analytics: false, |
| 54 | + }); |
| 55 | + return limiter; |
| 56 | +} |
| 57 | + |
| 58 | +/** Check (and consume) one unit of the rate budget for the given IP. */ |
| 59 | +export async function checkRateLimit(ip: string): Promise<RateVerdict> { |
| 60 | + const rl = getLimiter(); |
| 61 | + if (!rl) { |
| 62 | + // Failing open: always allow. |
| 63 | + return { ok: true, remaining: null }; |
| 64 | + } |
| 65 | + |
| 66 | + try { |
| 67 | + const { success, limit, reset, remaining } = await rl.limit(ip); |
| 68 | + if (success) { |
| 69 | + return { ok: true, remaining }; |
| 70 | + } |
| 71 | + const retryAfterSeconds = Math.max(0, Math.ceil((reset - Date.now()) / 1000)); |
| 72 | + return { ok: false, limit, reset, retryAfterSeconds }; |
| 73 | + } catch (err) { |
| 74 | + // If Redis itself errors, don't take the whole endpoint down — fail open but |
| 75 | + // log it so the operator notices. |
| 76 | + console.error("[ratelimit] Upstash error, failing open:", err); |
| 77 | + return { ok: true, remaining: null }; |
| 78 | + } |
| 79 | +} |
| 80 | + |
| 81 | +/** Best-effort client IP extraction from proxy headers (Vercel-friendly). */ |
| 82 | +export function clientIpFrom(headers: Headers): string { |
| 83 | + const forwarded = headers.get("x-forwarded-for"); |
| 84 | + if (forwarded) { |
| 85 | + const first = forwarded.split(",")[0]?.trim(); |
| 86 | + if (first) return first; |
| 87 | + } |
| 88 | + return headers.get("x-real-ip") ?? "anonymous"; |
| 89 | +} |
0 commit comments