export const DEFAULT_ACCESS_CODE_RATE_LIMIT_MAX_ATTEMPTS = 10; export const DEFAULT_ACCESS_CODE_RATE_LIMIT_WINDOW_SECONDS = 15 * 60; export const DEFAULT_ACCESS_CODE_RATE_LIMIT_MAX_CLIENTS = 10_000; export interface AccessCodeRateLimitConfig { maxAttempts: number; windowMs: number; maxClients: number; } export interface AccessCodeRateLimitDecision { allowed: boolean; remaining: number; retryAfterSeconds: number; } interface AttemptBucket { count: number; resetAtMs: number; } function positiveInteger(value: string | undefined, fallback: number, maximum: number): number { if (!value) return fallback; const parsed = Number(value); return Number.isSafeInteger(parsed) && parsed > 0 && parsed <= maximum ? parsed : fallback; } export function resolveAccessCodeRateLimitConfig( env: Record = process.env, ): AccessCodeRateLimitConfig { return { maxAttempts: positiveInteger( env.ACCESS_CODE_RATE_LIMIT_MAX_ATTEMPTS, DEFAULT_ACCESS_CODE_RATE_LIMIT_MAX_ATTEMPTS, 10_000, ), windowMs: positiveInteger( env.ACCESS_CODE_RATE_LIMIT_WINDOW_SECONDS, DEFAULT_ACCESS_CODE_RATE_LIMIT_WINDOW_SECONDS, 24 * 60 * 60, ) * 1000, maxClients: positiveInteger( env.ACCESS_CODE_RATE_LIMIT_MAX_CLIENTS, DEFAULT_ACCESS_CODE_RATE_LIMIT_MAX_CLIENTS, 100_000, ), }; } export function shouldTrustAccessCodeProxyHeaders( value = process.env.ACCESS_CODE_TRUST_PROXY_HEADERS, ): boolean { return value === 'true' || value === '1'; } function firstForwardedAddress(value: string | null): string | null { const address = value?.split(',')[0]?.trim(); if (!address) return null; return address.slice(0, 128); } /** * NextRequest no longer exposes a transport-level remote address. Proxy IP * headers are therefore used only through an explicit trust opt-in. Without * it, all attempts share one conservative process-local bucket. */ export function resolveAccessCodeRateLimitKey( request: Request, trustProxyHeaders = shouldTrustAccessCodeProxyHeaders(), ): string { if (trustProxyHeaders) { const address = firstForwardedAddress(request.headers.get('x-forwarded-for')) ?? firstForwardedAddress(request.headers.get('x-real-ip')); if (address) return `ip:${address}`; } return 'shared:unknown-client'; } /** Bounded, single-process fixed-window limiter for ACCESS_CODE login attempts. */ export class AccessCodeRateLimiter { private readonly buckets = new Map(); consume( key: string, config: AccessCodeRateLimitConfig, nowMs = Date.now(), ): AccessCodeRateLimitDecision { this.pruneExpired(nowMs); const current = this.buckets.get(key); if (current && current.resetAtMs > nowMs) { if (current.count >= config.maxAttempts) { return { allowed: false, remaining: 0, retryAfterSeconds: Math.max(1, Math.ceil((current.resetAtMs - nowMs) / 1000)), }; } current.count += 1; this.touch(key, current); return { allowed: true, remaining: Math.max(0, config.maxAttempts - current.count), retryAfterSeconds: 0, }; } this.ensureCapacity(config.maxClients); this.buckets.set(key, { count: 1, resetAtMs: nowMs + config.windowMs }); return { allowed: true, remaining: Math.max(0, config.maxAttempts - 1), retryAfterSeconds: 0, }; } reset(key: string): void { this.buckets.delete(key); } clear(): void { this.buckets.clear(); } get size(): number { return this.buckets.size; } private touch(key: string, bucket: AttemptBucket): void { this.buckets.delete(key); this.buckets.set(key, bucket); } private pruneExpired(nowMs: number): void { for (const [key, bucket] of this.buckets) { if (bucket.resetAtMs <= nowMs) this.buckets.delete(key); } } private ensureCapacity(maxClients: number): void { while (this.buckets.size >= maxClients) { const oldestKey = this.buckets.keys().next().value as string | undefined; if (!oldestKey) return; this.buckets.delete(oldestKey); } } } export const accessCodeRateLimiter = new AccessCodeRateLimiter();