Skip to content

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

platform/apps/app/src/lib/rate-limit.ts
/**
* 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'}`;
}