dorfteich/apps/api/src/rate-limit/rate-limit.service.ts
Claude Fable 5 31f23c12a2 Add DB-backed rate limiting with guard and decorator
RateLimitService implements fixed-window counters as one atomic
PostgreSQL upsert (race-safe under concurrency, proven by test), with
opportunistic sweeping of expired windows and an explicit reset for
successful-login scenarios. The global RateLimitGuard applies
@RateLimit({scope, limit, windowSeconds}) per client IP and answers
429 with Retry-After; main.ts trusts the single Caddy hop so req.ip
is the real client.

Closes #11

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 00:43:54 +02:00

64 lines
2.2 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
export interface RateLimitResult {
allowed: boolean;
retryAfterSeconds: number;
}
/**
* Fixed-window rate limiting backed by PostgreSQL (ADR 0002: no Redis).
* One atomic upsert per hit keeps concurrent requests race-safe; expired
* rows are swept opportunistically on writes.
*/
@Injectable()
export class RateLimitService {
constructor(private readonly prisma: PrismaService) {}
async hit(
scope: string,
key: string,
limit: number,
windowSeconds: number,
): Promise<RateLimitResult> {
const rowKey = `${scope}:${key}`;
const rows = await this.prisma.$queryRaw<{ count: number; window_start: Date }[]>`
INSERT INTO rate_limits (key, window_start, count)
VALUES (${rowKey}, now(), 1)
ON CONFLICT (key) DO UPDATE SET
count = CASE
WHEN rate_limits.window_start <= now() - make_interval(secs => ${windowSeconds})
THEN 1 ELSE rate_limits.count + 1 END,
window_start = CASE
WHEN rate_limits.window_start <= now() - make_interval(secs => ${windowSeconds})
THEN now() ELSE rate_limits.window_start END
RETURNING count, window_start
`;
const row = rows[0];
if (!row) return { allowed: true, retryAfterSeconds: 0 };
const elapsedMs = Date.now() - row.window_start.getTime();
const retryAfterSeconds = Math.max(1, Math.ceil(windowSeconds - elapsedMs / 1000));
// Opportunistic sweep (~1% of hits) so the table stays small without
// needing a scheduler this early.
if (Math.random() < 0.01) void this.cleanupExpired().catch(() => undefined);
return { allowed: row.count <= limit, retryAfterSeconds };
}
/** Deletes counters whose window ended more than a day ago. */
async cleanupExpired(): Promise<number> {
const result = await this.prisma.rateLimit.deleteMany({
where: { windowStart: { lt: new Date(Date.now() - 24 * 60 * 60 * 1000) } },
});
return result.count;
}
/** Clears one counter — used when an action should reset it (e.g. successful login). */
async reset(scope: string, key: string): Promise<void> {
await this.prisma.rateLimit.deleteMany({ where: { key: `${scope}:${key}` } });
}
}