import { Inject, CanActivate, ExecutionContext, Injectable } from "@nestjs/common"; import type { Request, Response } from "express"; import { loadEnv } from "../../config/env.js"; import { ApiError } from "../errors.js"; import { logger } from "../logger.js"; import { RedisService } from "../redis.service.js"; import type { AuthContext } from "../types.js"; /** * Redis sliding-window rate limit guard (per credential × route class). * * Bucket key: `rl::` where subject is the authenticated * user id, the API key id, or the client IP; class is "auth" for * /auth/* and "api" otherwise. Limits come from env: * RATE_LIMIT_AUTH (default 10 / min) and RATE_LIMIT_API (default 100 / min) * for user/IP callers. TASK-0127: an API key gets its OWN window — * the key's explicit `rateLimit` (carried on the auth context), else * RATE_LIMIT_API_KEY (default 600 / min, docs/api/authentication.md §11) — * and its bucket is keyed by the KEY id. * * Responses carry X-RateLimit-Limit/Remaining/Reset; over the limit → * 429 { code: "RATE_LIMITED" } + Retry-After. If Redis is down the limit * degrades OPEN (availability over strictness) and logs once per minute. */ const WINDOW_MS = 60_000; @Injectable() export class RateLimitGuard implements CanActivate { private lastRedisWarn = 0; constructor(@Inject(RedisService) private readonly redis: RedisService) {} async canActivate(context: ExecutionContext): Promise { const req = context.switchToHttp().getRequest(); const res = context.switchToHttp().getResponse(); const auth: AuthContext | undefined = req.auth; // The internal service surface (TASK-0092 slice 2) is trusted + // high-frequency (a flush per dirty room, a hydration per cold room) — // it must not count against the per-client window. The room's own rate // limits already gate the upstream WS clients. if (req.path.startsWith("/internal/")) return true; const cls = req.path.startsWith("/api/v1/auth/") ? "auth" : "api"; // TASK-0127: an API key gets its OWN window, per the published contract // (docs/api/authentication.md §11: "Per API key: 600 req/min, or the // key's rateLimit if set"): the key's explicit `rateLimit` (resolved by // the AuthGuard, carried on the auth context — no second query) wins, // else the key-specific default (RATE_LIMIT_API_KEY, 600). User/IP // callers keep the generic windows (RATE_LIMIT_AUTH / RATE_LIMIT_API). const envLimit = cls === "auth" ? loadEnv().RATE_LIMIT_AUTH : loadEnv().RATE_LIMIT_API; const keyLimit = auth?.kind === "api" ? auth.rateLimit : undefined; const limit = auth?.kind === "api" ? keyLimit ?? loadEnv().RATE_LIMIT_API_KEY : envLimit; const subject = auth?.apiKeyId ?? auth?.userId ?? req.ip ?? "unknown"; const key = `rl:${cls}:${subject}`; let count: number; try { const now = Date.now(); const pipe = this.redis.getRedis().pipeline(); pipe.zremrangebyscore(key, 0, now - WINDOW_MS); pipe.zadd(key, now, `${now}:${Math.random().toString(36).slice(2)}`); pipe.zcard(key); pipe.pexpire(key, WINDOW_MS); const results = await pipe.exec(); const cardResult = results?.[2]?.[1]; count = typeof cardResult === "number" ? cardResult : 1; } catch (err) { if (Date.now() - this.lastRedisWarn > 60_000) { this.lastRedisWarn = Date.now(); logger.warn(`rate limiter degraded (redis unavailable): ${(err as Error).message}`); } // Fail-open, but still advertise the configured limit (docs §11: // every public response carries the headers). res.setHeader("X-RateLimit-Limit", String(limit)); res.setHeader("X-RateLimit-Remaining", "0"); res.setHeader("X-RateLimit-Reset", String(Math.ceil((Date.now() + WINDOW_MS) / 1000))); return true; } const remaining = Math.max(0, limit - count); const reset = Math.ceil((Date.now() + WINDOW_MS) / 1000); res.setHeader("X-RateLimit-Limit", String(limit)); res.setHeader("X-RateLimit-Remaining", String(remaining)); res.setHeader("X-RateLimit-Reset", String(reset)); if (count > limit) { const retryAfter = Math.ceil(WINDOW_MS / 1000); res.setHeader("Retry-After", String(retryAfter)); throw new ApiError("RATE_LIMITED", 429, "Too many requests.", [ { retryAfterSeconds: retryAfter } ]); } return true; } }