diff --git a/src/backend/core/http/middleware/rateLimit.js b/src/backend/core/http/middleware/rateLimit.js index c7c0a901f..7be35cba0 100644 --- a/src/backend/core/http/middleware/rateLimit.js +++ b/src/backend/core/http/middleware/rateLimit.js @@ -722,8 +722,15 @@ export async function checkRateLimit(key, limit, windowMs, backend) { * budget. This resolves the key and the per-subscription limit exactly as the * gate does — same spec in, same bucket out — so a handler can charge a second * scope conditionally. Returns true if allowed; fails open on backend error. + * Takes the array form too; windows charge in order, no refund on refusal. */ export async function consumeRouteRateLimit(req, spec) { + if (Array.isArray(spec)) { + for (const window of spec) { + if (!(await consumeRouteRateLimit(req, window))) return false; + } + return true; + } const { window: windowMs, key: strategy = 'fingerprint', diff --git a/src/backend/core/http/middleware/rateLimit.test.js b/src/backend/core/http/middleware/rateLimit.test.js index 6a530f165..2f063b383 100644 --- a/src/backend/core/http/middleware/rateLimit.test.js +++ b/src/backend/core/http/middleware/rateLimit.test.js @@ -1506,6 +1506,20 @@ describe('consumeRouteRateLimit', () => { expect(await consumeRouteRateLimit(reqFor(2), opts)).toBe(true); }); + it('charges every window of an array spec, and any refusal refuses', async () => { + // The array form routes use (burst + sustained); the tightest bites. + const specs = [ + { limit: 5, window: 60_000, key: 'user', scope: uniqueScope() }, + { limit: 2, window: 60_000, key: 'user', scope: uniqueScope() }, + ]; + const req = { actor: { user: { id: 9 } }, headers: {} }; + expect(await consumeRouteRateLimit(req, specs)).toBe(true); + expect(await consumeRouteRateLimit(req, specs)).toBe(true); + expect(await consumeRouteRateLimit(req, specs)).toBe(false); + // The refusing window really was the second one, not the first. + expect(await consumeRouteRateLimit(req, specs[0])).toBe(true); + }); + it('applies bySubscription overrides via the wired metering service', async () => { configureRateLimit({ metering: {