/** * RateLimiter - Token Bucket en memoria por usuario. * - Límite global por remitente (ID normalizado). * - Capacidad y tasa configurables por entorno: * - RATE_LIMIT_PER_MIN: tokens que se recargan por minuto (default 15) * - RATE_LIMIT_BURST: capacidad del bucket (default = RATE_LIMIT_PER_MIN) * - Desactivación en tests: la integración en server evita usar el limiter con NODE_ENV='test'. */ export class RateLimiter { private static readonly buckets = new Map(); private static readonly lastNotifyAt = new Map(); private static readonly NOTIFY_COOLDOWN_MS = 60_000; // 1 minuto private static get ratePerMin(): number { const v = Number(process.env.RATE_LIMIT_PER_MIN); return Number.isFinite(v) && v > 0 ? v : 15; } private static get capacity(): number { const burst = Number(process.env.RATE_LIMIT_BURST); if (Number.isFinite(burst) && burst > 0) return burst; return this.ratePerMin; // por defecto, burst = rate } private static get refillPerMs(): number { return this.ratePerMin / 60_000; // tokens por ms } static checkAndConsume(userId: string, now: number = Date.now()): boolean { if (!userId) return true; // por seguridad, no bloquear IDs vacíos let bucket = this.buckets.get(userId); if (!bucket) { bucket = { tokens: this.capacity, last: now }; this.buckets.set(userId, bucket); } // Refill lineal en función del tiempo transcurrido const elapsed = Math.max(0, now - bucket.last); if (elapsed > 0) { bucket.tokens = Math.min(this.capacity, bucket.tokens + elapsed * this.refillPerMs); bucket.last = now; } if (bucket.tokens >= 1) { bucket.tokens -= 1; return true; } return false; } static shouldNotify(userId: string, now: number = Date.now()): boolean { const last = this.lastNotifyAt.get(userId); // Si nunca se ha notificado, permitir notificar inmediatamente if (last === undefined) { this.lastNotifyAt.set(userId, now); return true; } if (now - last >= this.NOTIFY_COOLDOWN_MS) { this.lastNotifyAt.set(userId, now); return true; } return false; } // Solo para tests static reset(): void { this.buckets.clear(); this.lastNotifyAt.clear(); } }