Rate limits — per visitor + IP backstop
Source: platform/apps/app/src/lib/rate-limit.ts · rendered from main on every deploy — edit in the repo, not here
/** * Layered rate limiting for enterprise chat (ADR-0017 §4a). * * Layer 1 (CF WAF) is configured in wrangler.toml — not in code. * Layer 2 (per-session) is implemented here via KV. * Layer 3 (tenant quota) is enforced inline in chat / answer / messages * route handlers against `usage_counters` (period = YYYY-MM-DD). * Quota-event emission lives in those handlers (ADR-0050 §6). * Layer 4 (cost ceiling) is handled by AI Gateway — not in code. */
import type { Plan } from '@puccha/types';
interface RateLimitResult { allowed: boolean; remaining: number; resetAt: number;}
/** Per-session chat rate limit via KV (Layer 2). */export async function checkSessionRateLimit( kv: KVNamespace, key: string, limit: number, windowSec: number,): Promise<RateLimitResult> { const now = Math.floor(Date.now() / 1000); const windowStart = now - windowSec; const kvKey = `rl:${key}`;
const raw = await kv.get(kvKey); const timestamps: number[] = raw ? JSON.parse(raw) : [];
// Drop expired entries const valid = timestamps.filter((t) => t > windowStart);
if (valid.length >= limit) { return { allowed: false, remaining: 0, resetAt: valid[0] + windowSec, }; }
valid.push(now); await kv.put(kvKey, JSON.stringify(valid), { expirationTtl: windowSec + 60 });
return { allowed: true, remaining: limit - valid.length, resetAt: valid[0] + windowSec, };}
/** * ADR-0185 — anonymous widget traffic is keyed per VISITOR, with a per-IP * backstop. The old single per-IP bucket (15/h) locked out a whole office * behind one NAT address. The visitor id is client-controlled, so its bucket * is a UX limit; the IP bucket is the abuse ceiling. Callers without a * (valid) visitor id — raw API calls, bots, the /api/answer fallback — keep * the old 15/h per IP. */export const ANON_VISITOR_LIMIT = { limit: 40, windowSec: 3600 } as const;export const ANON_IP_BACKSTOP = { limit: 300, windowSec: 3600 } as const;/** Widget ids are UUIDs; anything outside this shape is treated as absent so * a garbage header can't mint arbitrary KV keys. */const VISITOR_ID_RE = /^[A-Za-z0-9_-]{8,64}$/;
export function normalizeVisitorId(raw: string | null | undefined): string | null { if (!raw) return null; const v = raw.trim(); return VISITOR_ID_RE.test(v) ? v : null;}
/** * Layer-2 check for one chat/answer turn. Authenticated users and API keys * keep their single bucket; anonymous callers get the ADR-0185 pair. Returns * the first bucket that refuses, else the (visitor or IP) result that was * charged last. */export async function checkTurnRateLimit( kv: KVNamespace, opts: { tenantId: string; userId: string | null; apiKeyId: string | null; ip: string | null; visitorId?: string | null; },): Promise<RateLimitResult> { const { tenantId, userId, apiKeyId, ip } = opts; if (userId || apiKeyId) { const { limit, windowSec } = getSessionLimit(!!userId, !!apiKeyId); return checkSessionRateLimit( kv, buildRateLimitKey(tenantId, userId, apiKeyId, ip), limit, windowSec, ); } const visitorId = normalizeVisitorId(opts.visitorId); if (!visitorId) { const { limit, windowSec } = getSessionLimit(false, false); return checkSessionRateLimit(kv, buildRateLimitKey(tenantId, null, null, ip), limit, windowSec); } const visitor = await checkSessionRateLimit( kv, `chat:visitor:${tenantId}:${visitorId}`, ANON_VISITOR_LIMIT.limit, ANON_VISITOR_LIMIT.windowSec, ); if (!visitor.allowed) return visitor; const backstop = await checkSessionRateLimit( kv, buildRateLimitKey(tenantId, null, null, ip), ANON_IP_BACKSTOP.limit, ANON_IP_BACKSTOP.windowSec, ); return backstop.allowed ? visitor : backstop;}
/** Per-session limits by auth type (Layer 2). */export function getSessionLimit( isAuthenticated: boolean, hasApiKey: boolean,): { limit: number; windowSec: number } { if (hasApiKey) return { limit: 60, windowSec: 3600 }; // 60/hour (API integrations) if (isAuthenticated) return { limit: 30, windowSec: 3600 }; // 30/hour (logged-in users) return { limit: 15, windowSec: 3600 }; // 15/hour (anonymous widget users)}
/** Daily chat turn quota by plan (Layer 3). */const PLAN_DAILY_LIMITS: Record<Plan, number> = { free: 30, // ~$0.30/day max cost team: 500, business: 5_000, // ADR-0059 §6 — Growth: between Business (5K) and Enterprise (50K). Aligned // with the 12K-resolution monthly quota; daily soft cap is abuse-layer only, // the monthly meter is the billing source of truth. growth: 15_000, enterprise: 50_000,};
export function getDailyLimit(plan: Plan): number { return PLAN_DAILY_LIMITS[plan];}
/** Create a standardized 429 response with Retry-After header. *//** Machine-readable 429 reasons so clients can word the message honestly — * a duplicate question is not "the system is busy" (#516). */export type RateLimitCode = 'rate_limited' | 'duplicate_query' | 'quota_exceeded';
export function rateLimitResponse( message: string, retryAfterSec?: number, code: RateLimitCode = 'rate_limited',): Response { const headers: Record<string, string> = { 'Content-Type': 'application/json' }; if (retryAfterSec !== undefined && retryAfterSec > 0) { headers['Retry-After'] = String(retryAfterSec); headers['X-RateLimit-Remaining'] = '0'; } return new Response(JSON.stringify({ error: message, retryAfter: retryAfterSec, code }), { status: 429, headers, });}
/** Build rate limit key from request context. */export function buildRateLimitKey( tenantId: string, userId: string | null, apiKeyId: string | null, ip: string | null,): string { if (userId) return `chat:user:${tenantId}:${userId}`; if (apiKeyId) return `chat:apikey:${tenantId}:${apiKeyId}`; return `chat:ip:${tenantId}:${ip ?? 'unknown'}`;}