56 lines
1.4 KiB
JavaScript
56 lines
1.4 KiB
JavaScript
/**
|
|
* @fileoverview Simple in-memory per-tenant rate limiter.
|
|
* @module middleware/rateLimit
|
|
*/
|
|
|
|
import { AppError, asyncHandler } from '../errors.js';
|
|
import { getTenant, resolveLimits } from '../store/tenants.js';
|
|
import { createDebugger } from '../debug/logger.js';
|
|
|
|
const log = createDebugger('billing');
|
|
|
|
/** @type {Map<string, { windowStart: number, count: number }>} */
|
|
const windows = new Map();
|
|
|
|
/**
|
|
* Reset rate limit windows (tests).
|
|
* @returns {void}
|
|
*/
|
|
export function resetRateLimitWindows() {
|
|
windows.clear();
|
|
}
|
|
|
|
/**
|
|
* Enforce plan requestsPerMinute using a 60s sliding fixed window.
|
|
*/
|
|
export const rateLimit = asyncHandler(async (req, res, next) => {
|
|
const tenantId = req.auth?.tenantId;
|
|
if (!tenantId) return next();
|
|
|
|
const tenant = getTenant(tenantId);
|
|
const limits = tenant
|
|
? resolveLimits(tenant)
|
|
: { requestsPerMinute: 30 };
|
|
|
|
const rpm = limits.requestsPerMinute ?? 30;
|
|
const now = Date.now();
|
|
const windowMs = 60_000;
|
|
|
|
let entry = windows.get(tenantId);
|
|
if (!entry || now - entry.windowStart >= windowMs) {
|
|
entry = { windowStart: now, count: 0 };
|
|
windows.set(tenantId, entry);
|
|
}
|
|
|
|
entry.count += 1;
|
|
if (entry.count > rpm) {
|
|
log.debug('rate limited', { tenantId, count: entry.count, rpm });
|
|
throw new AppError('Rate limit exceeded', {
|
|
status: 429,
|
|
code: 'RATE_LIMITED',
|
|
details: { requestsPerMinute: rpm },
|
|
});
|
|
}
|
|
|
|
next();
|
|
});
|