@sanskari27/aws-rate-limiter 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1027 -0
  4. package/dist/adapters/express.d.ts +122 -0
  5. package/dist/adapters/express.d.ts.map +1 -0
  6. package/dist/adapters/express.js +190 -0
  7. package/dist/adapters/express.js.map +1 -0
  8. package/dist/adapters/fastify.d.ts +112 -0
  9. package/dist/adapters/fastify.d.ts.map +1 -0
  10. package/dist/adapters/fastify.js +178 -0
  11. package/dist/adapters/fastify.js.map +1 -0
  12. package/dist/adapters/index.d.ts +13 -0
  13. package/dist/adapters/index.d.ts.map +1 -0
  14. package/dist/adapters/index.js +22 -0
  15. package/dist/adapters/index.js.map +1 -0
  16. package/dist/adapters/lambda/decorator.d.ts +120 -0
  17. package/dist/adapters/lambda/decorator.d.ts.map +1 -0
  18. package/dist/adapters/lambda/decorator.js +281 -0
  19. package/dist/adapters/lambda/decorator.js.map +1 -0
  20. package/dist/adapters/lambda/extension.d.ts +178 -0
  21. package/dist/adapters/lambda/extension.d.ts.map +1 -0
  22. package/dist/adapters/lambda/extension.js +445 -0
  23. package/dist/adapters/lambda/extension.js.map +1 -0
  24. package/dist/adapters/lambda/index.d.ts +9 -0
  25. package/dist/adapters/lambda/index.d.ts.map +1 -0
  26. package/dist/adapters/lambda/index.js +16 -0
  27. package/dist/adapters/lambda/index.js.map +1 -0
  28. package/dist/config/index.d.ts +4 -0
  29. package/dist/config/index.d.ts.map +1 -0
  30. package/dist/config/index.js +11 -0
  31. package/dist/config/index.js.map +1 -0
  32. package/dist/config/loader.d.ts +68 -0
  33. package/dist/config/loader.d.ts.map +1 -0
  34. package/dist/config/loader.js +280 -0
  35. package/dist/config/loader.js.map +1 -0
  36. package/dist/config/ssm-watcher.d.ts +103 -0
  37. package/dist/config/ssm-watcher.d.ts.map +1 -0
  38. package/dist/config/ssm-watcher.js +264 -0
  39. package/dist/config/ssm-watcher.js.map +1 -0
  40. package/dist/core/algorithm.d.ts +98 -0
  41. package/dist/core/algorithm.d.ts.map +1 -0
  42. package/dist/core/algorithm.js +127 -0
  43. package/dist/core/algorithm.js.map +1 -0
  44. package/dist/core/index.d.ts +8 -0
  45. package/dist/core/index.d.ts.map +1 -0
  46. package/dist/core/index.js +24 -0
  47. package/dist/core/index.js.map +1 -0
  48. package/dist/core/key-builder.d.ts +103 -0
  49. package/dist/core/key-builder.d.ts.map +1 -0
  50. package/dist/core/key-builder.js +232 -0
  51. package/dist/core/key-builder.js.map +1 -0
  52. package/dist/core/types.d.ts +253 -0
  53. package/dist/core/types.d.ts.map +1 -0
  54. package/dist/core/types.js +72 -0
  55. package/dist/core/types.js.map +1 -0
  56. package/dist/index.d.ts +6 -0
  57. package/dist/index.d.ts.map +1 -0
  58. package/dist/index.js +24 -0
  59. package/dist/index.js.map +1 -0
  60. package/dist/observability/index.d.ts +5 -0
  61. package/dist/observability/index.d.ts.map +1 -0
  62. package/dist/observability/index.js +12 -0
  63. package/dist/observability/index.js.map +1 -0
  64. package/dist/observability/logger.d.ts +136 -0
  65. package/dist/observability/logger.d.ts.map +1 -0
  66. package/dist/observability/logger.js +167 -0
  67. package/dist/observability/logger.js.map +1 -0
  68. package/dist/observability/metrics.d.ts +129 -0
  69. package/dist/observability/metrics.d.ts.map +1 -0
  70. package/dist/observability/metrics.js +137 -0
  71. package/dist/observability/metrics.js.map +1 -0
  72. package/dist/rate-limiter.d.ts +171 -0
  73. package/dist/rate-limiter.d.ts.map +1 -0
  74. package/dist/rate-limiter.js +702 -0
  75. package/dist/rate-limiter.js.map +1 -0
  76. package/dist/redis/circuit-breaker.d.ts +84 -0
  77. package/dist/redis/circuit-breaker.d.ts.map +1 -0
  78. package/dist/redis/circuit-breaker.js +131 -0
  79. package/dist/redis/circuit-breaker.js.map +1 -0
  80. package/dist/redis/client.d.ts +98 -0
  81. package/dist/redis/client.d.ts.map +1 -0
  82. package/dist/redis/client.js +223 -0
  83. package/dist/redis/client.js.map +1 -0
  84. package/dist/redis/index.d.ts +8 -0
  85. package/dist/redis/index.d.ts.map +1 -0
  86. package/dist/redis/index.js +16 -0
  87. package/dist/redis/index.js.map +1 -0
  88. package/dist/redis/script-loader.d.ts +111 -0
  89. package/dist/redis/script-loader.d.ts.map +1 -0
  90. package/dist/redis/script-loader.js +204 -0
  91. package/dist/redis/script-loader.js.map +1 -0
  92. package/dist/reservoir/index.d.ts +6 -0
  93. package/dist/reservoir/index.d.ts.map +1 -0
  94. package/dist/reservoir/index.js +9 -0
  95. package/dist/reservoir/index.js.map +1 -0
  96. package/dist/reservoir/local-reservoir.d.ts +98 -0
  97. package/dist/reservoir/local-reservoir.d.ts.map +1 -0
  98. package/dist/reservoir/local-reservoir.js +148 -0
  99. package/dist/reservoir/local-reservoir.js.map +1 -0
  100. package/package.json +101 -0
@@ -0,0 +1,702 @@
1
+ "use strict";
2
+ /**
3
+ * @fileoverview Main RateLimiter class — the primary entry point for all rate-limit checks.
4
+ *
5
+ * Implements multi-dimensional sliding-window rate limiting backed by Redis ElastiCache,
6
+ * with an in-process token reservoir to reduce Redis round-trips by 100×, a circuit
7
+ * breaker for fault isolation, and configurable failure policies (fail_open / fail_closed
8
+ * / fail_local).
9
+ *
10
+ * Check order (fail-fast):
11
+ * 1. Per-IP
12
+ * 2. Per-route
13
+ * 3. Per-user
14
+ * 4. Per-user+route
15
+ */
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.RateLimiter = void 0;
18
+ exports.findMatchingRule = findMatchingRule;
19
+ exports.buildActiveDimensions = buildActiveDimensions;
20
+ const minimatch_1 = require("minimatch");
21
+ const types_1 = require("./core/types");
22
+ const algorithm_1 = require("./core/algorithm");
23
+ const key_builder_1 = require("./core/key-builder");
24
+ const client_1 = require("./redis/client");
25
+ const script_loader_1 = require("./redis/script-loader");
26
+ const circuit_breaker_1 = require("./redis/circuit-breaker");
27
+ const local_reservoir_1 = require("./reservoir/local-reservoir");
28
+ // ---------------------------------------------------------------------------
29
+ // LocalFallbackLimiter — in-process fixed-window counter for fail_local mode
30
+ // ---------------------------------------------------------------------------
31
+ /**
32
+ * Simple in-process fixed-window rate limiter used as a fallback when Redis
33
+ * is unavailable and the failure policy is `fail_local`.
34
+ */
35
+ class LocalFallbackLimiter {
36
+ counters = new Map();
37
+ static MAX_ENTRIES = 50_000;
38
+ static EVICTION_BATCH = 10_000;
39
+ /**
40
+ * Check whether `cost` additional requests are allowed for `key` within `windowMs`.
41
+ *
42
+ * @param key Identifier key (e.g. normalized IP + route).
43
+ * @param limit Maximum allowed count per window.
44
+ * @param windowMs Window duration in milliseconds.
45
+ * @param cost Request cost weight.
46
+ * @returns `true` if allowed, `false` if denied.
47
+ */
48
+ check(key, limit, windowMs, cost) {
49
+ const now = Date.now();
50
+ const entry = this.counters.get(key);
51
+ if (entry === undefined || now > entry.resetAt) {
52
+ if (this.counters.size >= LocalFallbackLimiter.MAX_ENTRIES) {
53
+ this.evictExpired(now);
54
+ }
55
+ this.counters.set(key, { count: cost, resetAt: now + windowMs });
56
+ return true;
57
+ }
58
+ if (entry.count + cost > limit)
59
+ return false;
60
+ entry.count += cost;
61
+ return true;
62
+ }
63
+ evictExpired(now) {
64
+ let evicted = 0;
65
+ for (const [k, v] of this.counters) {
66
+ if (now > v.resetAt) {
67
+ this.counters.delete(k);
68
+ evicted++;
69
+ if (evicted >= LocalFallbackLimiter.EVICTION_BATCH)
70
+ break;
71
+ }
72
+ }
73
+ // Hard cap: if expired-only eviction didn't free enough space, forcefully
74
+ // remove the oldest entries (Map iteration order = insertion order).
75
+ if (this.counters.size >= LocalFallbackLimiter.MAX_ENTRIES) {
76
+ const target = LocalFallbackLimiter.MAX_ENTRIES - LocalFallbackLimiter.EVICTION_BATCH;
77
+ const iter = this.counters.keys();
78
+ while (this.counters.size > target) {
79
+ const next = iter.next();
80
+ /* istanbul ignore next */
81
+ if (next.done)
82
+ break;
83
+ this.counters.delete(next.value);
84
+ }
85
+ }
86
+ }
87
+ }
88
+ // ---------------------------------------------------------------------------
89
+ // RateLimiter
90
+ // ---------------------------------------------------------------------------
91
+ /**
92
+ * Production-grade multi-dimensional rate limiter backed by Redis ElastiCache.
93
+ *
94
+ * @example
95
+ * ```typescript
96
+ * const limiter = new RateLimiter({
97
+ * redis: { url: 'redis://localhost:6379' },
98
+ * rules: [{
99
+ * name: 'default',
100
+ * limits: { ip: { limit: 100, window: 60 } },
101
+ * }],
102
+ * })
103
+ * await limiter.connect()
104
+ * const result = await limiter.check({ ip: '1.2.3.4', route: '/api/users', method: 'GET' })
105
+ * ```
106
+ */
107
+ class RateLimiter {
108
+ config;
109
+ redisManager;
110
+ scriptLoader;
111
+ circuitBreaker;
112
+ reservoir;
113
+ localFallback;
114
+ connected = false;
115
+ /**
116
+ * @param config Top-level rate limiter configuration.
117
+ * @throws {ConfigurationError} If no rules are provided.
118
+ */
119
+ constructor(config) {
120
+ if (!config.rules || config.rules.length === 0) {
121
+ throw new types_1.ConfigurationError('At least one rule must be configured');
122
+ }
123
+ this.config = config;
124
+ this.redisManager = new client_1.RedisClientManager({ config: config.redis });
125
+ this.scriptLoader = new script_loader_1.ScriptLoader();
126
+ const cbConfig = config.failure?.circuitBreaker;
127
+ this.circuitBreaker = new circuit_breaker_1.CircuitBreaker(cbConfig
128
+ ? { threshold: cbConfig.threshold, recoveryTimeout: cbConfig.recoveryTimeout }
129
+ : undefined);
130
+ const reservoirCfg = config.reservoir;
131
+ if (reservoirCfg?.enabled) {
132
+ this.reservoir = new local_reservoir_1.LocalReservoir({
133
+ batchSize: reservoirCfg.batchSize,
134
+ syncInterval: reservoirCfg.syncInterval,
135
+ });
136
+ }
137
+ else {
138
+ this.reservoir = null;
139
+ }
140
+ this.localFallback = new LocalFallbackLimiter();
141
+ }
142
+ // -------------------------------------------------------------------------
143
+ // Lifecycle
144
+ // -------------------------------------------------------------------------
145
+ /**
146
+ * Connect to Redis and load all Lua scripts via EVALSHA.
147
+ * Must be called before {@link check}, {@link status}, or {@link reset}.
148
+ *
149
+ * @throws {RedisConnectionError} If the Redis connection cannot be established.
150
+ */
151
+ async connect() {
152
+ await this.redisManager.connect();
153
+ // RedisClientManager itself satisfies RedisClientForScripts (has scriptLoad + evalsha).
154
+ await this.scriptLoader.loadAll(this.redisManager);
155
+ this.connected = true;
156
+ }
157
+ /**
158
+ * Gracefully shut down: flush any locally pre-fetched reservoir tokens back
159
+ * to Redis, then disconnect.
160
+ */
161
+ async shutdown() {
162
+ if (this.reservoir !== null) {
163
+ await this.reservoir.flush(async (key, tokens) => {
164
+ // Best-effort: return pre-fetched tokens by decrementing the counter.
165
+ // If this fails, tokens expire naturally (TTL = window_ms * 2).
166
+ try {
167
+ const client = this.redisManager.getClient();
168
+ await client
169
+ .decrby(key, tokens);
170
+ }
171
+ catch {
172
+ // Swallow — tokens will expire via TTL
173
+ }
174
+ });
175
+ }
176
+ await this.redisManager.disconnect();
177
+ this.connected = false;
178
+ }
179
+ /**
180
+ * Returns `true` if the rate limiter is connected to Redis.
181
+ *
182
+ * @returns Boolean connection status.
183
+ */
184
+ isConnected() {
185
+ return this.connected && this.redisManager.isConnected();
186
+ }
187
+ // -------------------------------------------------------------------------
188
+ // Public API
189
+ // -------------------------------------------------------------------------
190
+ /**
191
+ * Check rate limits for an incoming request context.
192
+ *
193
+ * Evaluates all active dimensions (ip, route, user, user-route) in fail-fast
194
+ * order using a single `check_multi.lua` Redis round-trip. Falls back to the
195
+ * configured failure policy if the circuit breaker is open or Redis throws.
196
+ *
197
+ * @param ctx Incoming request context.
198
+ * @returns Rate limit decision including remaining quota, reset time, and source.
199
+ * @throws {ConfigurationError} If the limiter has not been connected yet.
200
+ */
201
+ async check(ctx) {
202
+ this.assertConnected();
203
+ if (!ctx.ip || !ctx.route || !ctx.method) {
204
+ throw new types_1.ConfigurationError('RateLimitContext requires non-empty ip, route, and method fields');
205
+ }
206
+ const nowMs = Date.now();
207
+ const cost = ctx.cost ?? 1;
208
+ const rule = findMatchingRule(ctx, this.config.rules);
209
+ const dimensions = buildActiveDimensions(ctx, rule, nowMs);
210
+ // No applicable limits — allow immediately.
211
+ if (dimensions.length === 0) {
212
+ return buildAllowedResult('none', 0, 0, 0, nowMs, 'redis');
213
+ }
214
+ // ---- Reservoir fast path -----------------------------------------------
215
+ if (this.reservoir !== null) {
216
+ // For the reservoir we use the first dimension's current key as the
217
+ // composite reservoir key (IP is always first and most restrictive).
218
+ const reservoirKey = dimensions[0].currKey;
219
+ const spec = dimensions[0].spec;
220
+ const windowMs = spec.window * 1000;
221
+ const ttlMs = windowMs * 2;
222
+ const fetchFn = async (_key) => {
223
+ const batchSize = rule.reservoir?.batchSize ?? this.config.reservoir?.batchSize ?? 10;
224
+ const currKey = dimensions[0].currKey;
225
+ const prevKey = dimensions[0].prevKey;
226
+ const result = await this.scriptLoader.eval(this.redisManager, 'reservoirFetch', [currKey, prevKey], [spec.limit, windowMs, nowMs, batchSize, ttlMs]);
227
+ return typeof result === 'number' ? result : 0;
228
+ };
229
+ try {
230
+ const allowed = await this.reservoir.consume(reservoirKey, cost, fetchFn);
231
+ const resetAt = (0, algorithm_1.computeResetAt)(nowMs, windowMs);
232
+ if (allowed) {
233
+ return buildAllowedResult('none', 0, spec.limit, spec.limit - cost, resetAt, 'reservoir', spec.window);
234
+ }
235
+ // Reservoir denied — fall through to Redis for precise multi-dim check
236
+ }
237
+ catch {
238
+ // Reservoir error — fall through to Redis
239
+ }
240
+ }
241
+ // ---- Circuit breaker check ---------------------------------------------
242
+ if (!this.circuitBreaker.allowRequest()) {
243
+ return this.applyFailurePolicy(ctx, rule, new Error('Circuit breaker is open'));
244
+ }
245
+ // ---- Redis check_multi round-trip --------------------------------------
246
+ try {
247
+ const result = await this.runCheckMulti(dimensions, nowMs, cost);
248
+ this.circuitBreaker.recordSuccess();
249
+ return this.buildResultFromLua(result, dimensions, nowMs, cost);
250
+ }
251
+ catch (err) {
252
+ this.circuitBreaker.recordFailure();
253
+ const error = err instanceof Error ? err : new Error(String(err));
254
+ return this.applyFailurePolicy(ctx, rule, error);
255
+ }
256
+ }
257
+ /**
258
+ * Get the current rate limit status for a request context without consuming quota.
259
+ *
260
+ * Queries each active dimension using `status.lua` and returns the most
261
+ * constrained dimension (lowest remaining capacity).
262
+ *
263
+ * @param ctx Incoming request context.
264
+ * @returns Current rate limit status without modifying any counters.
265
+ * @throws {ConfigurationError} If the limiter has not been connected yet.
266
+ */
267
+ async status(ctx) {
268
+ this.assertConnected();
269
+ const nowMs = Date.now();
270
+ const cost = ctx.cost ?? 1;
271
+ const rule = findMatchingRule(ctx, this.config.rules);
272
+ const dimensions = buildActiveDimensions(ctx, rule, nowMs);
273
+ if (dimensions.length === 0) {
274
+ return buildAllowedResult('none', 0, 0, 0, nowMs, 'redis');
275
+ }
276
+ let mostConstrained = null;
277
+ for (const dim of dimensions) {
278
+ const windowMs = dim.spec.window * 1000;
279
+ const raw = await this.scriptLoader.eval(this.redisManager, 'status', [dim.currKey, dim.prevKey], [dim.spec.limit, windowMs, nowMs]);
280
+ const parsed = parseLuaStatusResult(raw);
281
+ const resetAt = (0, algorithm_1.computeResetAt)(nowMs, windowMs);
282
+ const remaining = Math.max(0, Math.floor(parsed.remaining));
283
+ const dimResult = {
284
+ allowed: parsed.effective + cost <= dim.spec.limit,
285
+ dimension: dim.name,
286
+ effective: parsed.effective,
287
+ limit: dim.spec.limit,
288
+ remaining,
289
+ resetAt,
290
+ windowSecs: dim.spec.window,
291
+ retryAfter: parsed.effective + cost > dim.spec.limit ? parsed.ttlMs : undefined,
292
+ source: 'redis',
293
+ };
294
+ if (mostConstrained === null ||
295
+ remaining < mostConstrained.remaining) {
296
+ mostConstrained = dimResult;
297
+ }
298
+ }
299
+ return mostConstrained;
300
+ }
301
+ /**
302
+ * Reset rate limit counters for a specific dimension and identifier.
303
+ *
304
+ * Deletes both the current and previous bucket keys using `reset.lua`.
305
+ *
306
+ * @param dimension Which dimension to reset: 'ip' | 'user' | 'route' | 'user-route'.
307
+ * @param identifier The raw identifier (IP address, user ID, route string, etc.).
308
+ * @returns The number of Redis keys deleted.
309
+ * @throws {ConfigurationError} If the limiter has not been connected yet.
310
+ */
311
+ async reset(dimension, identifier) {
312
+ this.assertConnected();
313
+ const nowMs = Date.now();
314
+ // Find a rule that defines this dimension (search all rules, not just the first).
315
+ let spec;
316
+ for (const rule of this.config.rules) {
317
+ spec = getSpecForDimension(rule, dimension);
318
+ if (spec !== undefined)
319
+ break;
320
+ }
321
+ if (spec === undefined) {
322
+ return 0;
323
+ }
324
+ const windowMs = spec.window * 1000;
325
+ const currBucket = (0, algorithm_1.computeBucket)(nowMs, windowMs);
326
+ const prevBucket = currBucket - 1;
327
+ let currKey;
328
+ let prevKey;
329
+ switch (dimension) {
330
+ case 'ip': {
331
+ const normIp = (0, key_builder_1.normalizeIP)(identifier);
332
+ currKey = (0, key_builder_1.buildIPKey)(normIp, currBucket);
333
+ prevKey = (0, key_builder_1.buildIPKey)(normIp, prevBucket);
334
+ break;
335
+ }
336
+ case 'route': {
337
+ // identifier is expected to be a pre-normalized route string
338
+ currKey = (0, key_builder_1.buildRouteKey)(identifier, currBucket);
339
+ prevKey = (0, key_builder_1.buildRouteKey)(identifier, prevBucket);
340
+ break;
341
+ }
342
+ case 'user': {
343
+ const hash = (0, key_builder_1.hashIdentifier)(identifier);
344
+ currKey = (0, key_builder_1.buildUserKey)(hash, currBucket);
345
+ prevKey = (0, key_builder_1.buildUserKey)(hash, prevBucket);
346
+ break;
347
+ }
348
+ case 'user-route': {
349
+ // identifier format: "userId:normalizedRoute"
350
+ const separatorIdx = identifier.indexOf(':');
351
+ const userId = identifier.slice(0, separatorIdx);
352
+ const route = identifier.slice(separatorIdx + 1);
353
+ const hash = (0, key_builder_1.hashIdentifier)(userId);
354
+ currKey = (0, key_builder_1.buildUserRouteKey)(hash, route, currBucket);
355
+ prevKey = (0, key_builder_1.buildUserRouteKey)(hash, route, prevBucket);
356
+ break;
357
+ }
358
+ }
359
+ const result = await this.scriptLoader.eval(this.redisManager, 'reset', [currKey, prevKey], []);
360
+ return typeof result === 'number' ? result : 0;
361
+ }
362
+ // -------------------------------------------------------------------------
363
+ // Private helpers
364
+ // -------------------------------------------------------------------------
365
+ /** Asserts that connect() has been called before operations that need Redis. */
366
+ assertConnected() {
367
+ if (!this.connected) {
368
+ throw new types_1.RedisConnectionError('RateLimiter is not connected. Call connect() before using check/status/reset.');
369
+ }
370
+ }
371
+ /**
372
+ * Executes `check_multi.lua` for the active dimensions.
373
+ *
374
+ * @param dimensions Active dimensions to check.
375
+ * @param nowMs Current Unix epoch milliseconds.
376
+ * @param cost Request cost weight.
377
+ * @returns Parsed Lua check result.
378
+ */
379
+ async runCheckMulti(dimensions, nowMs, cost) {
380
+ const keys = [];
381
+ const argv = [];
382
+ // Use the maximum window as the single TTL (window_ms * 2 covers all dims).
383
+ const maxWindowMs = Math.max(...dimensions.map((d) => d.spec.window * 1000));
384
+ const ttlMs = maxWindowMs * 2;
385
+ argv.push(nowMs, cost, ttlMs);
386
+ for (const dim of dimensions) {
387
+ keys.push(dim.currKey, dim.prevKey);
388
+ const windowMs = dim.spec.window * 1000;
389
+ argv.push(`${dim.name}:${dim.spec.limit}:${windowMs}`);
390
+ }
391
+ const raw = await this.scriptLoader.eval(this.redisManager, 'checkMulti', keys, argv);
392
+ return parseLuaCheckResult(raw);
393
+ }
394
+ /**
395
+ * Translates a parsed Lua check result into a {@link RateLimitResult}.
396
+ *
397
+ * @param luaResult Parsed result from `check_multi.lua`.
398
+ * @param dimensions Active dimensions (used to look up specs on deny).
399
+ * @param nowMs Current Unix epoch milliseconds.
400
+ * @param cost Request cost weight.
401
+ */
402
+ buildResultFromLua(luaResult, dimensions, nowMs, cost) {
403
+ if (luaResult.allowed) {
404
+ const limit = luaResult.limit > 0 ? luaResult.limit : dimensions[0].spec.limit;
405
+ const effective = luaResult.effective;
406
+ const remaining = Math.max(0, Math.floor(limit - effective - cost));
407
+ const windowMs = luaResult.ttlMs > 0
408
+ ? luaResult.ttlMs
409
+ : dimensions[0].spec.window * 1000;
410
+ const resetAt = (0, algorithm_1.computeResetAt)(nowMs, windowMs > 0 ? windowMs : /* istanbul ignore next */ dimensions[0].spec.window * 1000);
411
+ return {
412
+ allowed: true,
413
+ dimension: 'none',
414
+ effective,
415
+ limit,
416
+ remaining,
417
+ resetAt,
418
+ windowSecs: Math.ceil(windowMs / 1000),
419
+ source: 'redis',
420
+ };
421
+ }
422
+ // Denied — find the dimension spec for accurate remaining/resetAt.
423
+ const failedDim = dimensions.find((d) => d.name === luaResult.failedDimension);
424
+ const windowMs = failedDim ? failedDim.spec.window * 1000 : luaResult.ttlMs;
425
+ const resetAt = (0, algorithm_1.computeResetAt)(nowMs, windowMs > 0 ? windowMs : luaResult.ttlMs);
426
+ return {
427
+ allowed: false,
428
+ dimension: luaResult.failedDimension,
429
+ effective: luaResult.effective,
430
+ limit: luaResult.limit,
431
+ remaining: 0,
432
+ resetAt,
433
+ windowSecs: failedDim ? failedDim.spec.window : Math.ceil(luaResult.ttlMs / 1000),
434
+ retryAfter: luaResult.ttlMs > 0 ? luaResult.ttlMs : undefined,
435
+ source: 'redis',
436
+ };
437
+ }
438
+ /**
439
+ * Apply the failure policy when Redis is unavailable.
440
+ *
441
+ * - `open` / `fail_open`: allow all traffic.
442
+ * - `closed` / `fail_closed`: deny all traffic (503-like).
443
+ * - `local` / `fail_local`: use the in-process {@link LocalFallbackLimiter}.
444
+ *
445
+ * @param ctx Incoming request context.
446
+ * @param rule Matched rule (used for limits and failure policy).
447
+ * @param _err The underlying Redis error (currently unused beyond logging).
448
+ * @returns A rate limit result derived from the failure policy.
449
+ */
450
+ applyFailurePolicy(ctx, rule, _err) {
451
+ const policy = rule.failure ?? this.config.failure?.default ?? 'open';
452
+ const nowMs = Date.now();
453
+ switch (policy) {
454
+ case 'open':
455
+ return {
456
+ allowed: true,
457
+ dimension: 'none',
458
+ effective: 0,
459
+ limit: Number.MAX_SAFE_INTEGER,
460
+ remaining: Number.MAX_SAFE_INTEGER,
461
+ resetAt: 0,
462
+ windowSecs: 0,
463
+ source: 'local_fallback',
464
+ };
465
+ case 'closed':
466
+ return {
467
+ allowed: false,
468
+ dimension: 'redis_error',
469
+ effective: 0,
470
+ limit: 0,
471
+ remaining: 0,
472
+ resetAt: 0,
473
+ windowSecs: 0,
474
+ retryAfter: 5000,
475
+ source: 'local_fallback',
476
+ };
477
+ case 'local': {
478
+ const cost = ctx.cost ?? 1;
479
+ const normIp = (0, key_builder_1.normalizeIP)(ctx.ip);
480
+ const normRoute = (0, key_builder_1.normalizeRoute)(ctx.method, ctx.route);
481
+ const fallbackKey = `local:${normIp}:${normRoute}`;
482
+ const spec = rule.limits.ip ?? rule.limits.route ?? { limit: 100, window: 60 };
483
+ const windowMs = spec.window * 1000;
484
+ const allowed = this.localFallback.check(fallbackKey, spec.limit, windowMs, cost);
485
+ const resetAt = (0, algorithm_1.computeResetAt)(nowMs, windowMs);
486
+ return {
487
+ allowed,
488
+ dimension: allowed ? 'none' : 'local_fallback',
489
+ effective: 0,
490
+ limit: spec.limit,
491
+ remaining: allowed ? spec.limit - cost : 0,
492
+ resetAt,
493
+ windowSecs: spec.window,
494
+ retryAfter: allowed ? undefined : windowMs,
495
+ source: 'local_fallback',
496
+ };
497
+ }
498
+ }
499
+ }
500
+ }
501
+ exports.RateLimiter = RateLimiter;
502
+ // ---------------------------------------------------------------------------
503
+ // Module-level pure helpers (exported for testability)
504
+ // ---------------------------------------------------------------------------
505
+ /**
506
+ * Finds the first rule in `rules` whose `match` conditions are satisfied by `ctx`.
507
+ * If no rule matches, returns the last rule (acts as default catch-all).
508
+ *
509
+ * Matching logic:
510
+ * - A rule with no `match` field matches everything.
511
+ * - `match.routes`: glob-match `"METHOD /path"` against each pattern.
512
+ * - `match.userTiers`: exact string match against `ctx.userTier`.
513
+ * - `match.ips`: exact string match against `ctx.ip`.
514
+ * - All specified sub-conditions must pass (AND semantics).
515
+ *
516
+ * @param ctx Incoming request context.
517
+ * @param rules Ordered list of rule configurations.
518
+ * @returns The first matching rule, or the last rule if none match.
519
+ */
520
+ function findMatchingRule(ctx, rules) {
521
+ for (const rule of rules) {
522
+ if (rule.match === undefined)
523
+ return rule;
524
+ const { routes, userTiers, ips } = rule.match;
525
+ if (routes !== undefined && routes.length > 0) {
526
+ const target = `${ctx.method.toUpperCase()} ${ctx.route}`;
527
+ const routeMatch = routes.some((pattern) => (0, minimatch_1.minimatch)(target, pattern));
528
+ if (!routeMatch)
529
+ continue;
530
+ }
531
+ if (userTiers !== undefined && userTiers.length > 0) {
532
+ if (ctx.userTier === undefined || !userTiers.includes(ctx.userTier))
533
+ continue;
534
+ }
535
+ if (ips !== undefined && ips.length > 0) {
536
+ const normalizedCtxIp = (0, key_builder_1.normalizeIP)(ctx.ip);
537
+ const ipMatch = ips.some((ip) => (0, key_builder_1.normalizeIP)(ip) === normalizedCtxIp);
538
+ if (!ipMatch)
539
+ continue;
540
+ }
541
+ return rule;
542
+ }
543
+ // Fallback: prefer a rule with no match criteria (true catch-all),
544
+ // otherwise fall back to the last rule.
545
+ const catchAll = rules.find((r) => r.match === undefined);
546
+ return catchAll ?? rules[rules.length - 1];
547
+ }
548
+ /**
549
+ * Builds the list of active dimensions for a request given a matched rule.
550
+ *
551
+ * Dimensions are only included when the rule has a limit spec for them AND
552
+ * (for user-based dims) a user identifier is present on the context.
553
+ *
554
+ * @param ctx Incoming request context.
555
+ * @param rule Matched rate limit rule.
556
+ * @param nowMs Current Unix epoch milliseconds.
557
+ * @returns Ordered array of active dimensions (ip → route → user → user-route).
558
+ */
559
+ function buildActiveDimensions(ctx, rule, nowMs) {
560
+ const dimensions = [];
561
+ const normIp = (0, key_builder_1.normalizeIP)(ctx.ip);
562
+ const normRoute = (0, key_builder_1.normalizeRoute)(ctx.method, ctx.route);
563
+ const userIdentifier = ctx.userId ?? ctx.apiKey;
564
+ // Defer SHA-256 until actually needed by a user/userRoute dimension.
565
+ let userHash;
566
+ const getUserHash = () => {
567
+ if (userHash === undefined) {
568
+ userHash = userIdentifier ? (0, key_builder_1.hashIdentifier)(userIdentifier) : null;
569
+ }
570
+ return userHash;
571
+ };
572
+ // 1. Per-IP
573
+ if (rule.limits.ip) {
574
+ const windowMs = rule.limits.ip.window * 1000;
575
+ const currBucket = (0, algorithm_1.computeBucket)(nowMs, windowMs);
576
+ dimensions.push({
577
+ name: 'ip',
578
+ currKey: (0, key_builder_1.buildIPKey)(normIp, currBucket),
579
+ prevKey: (0, key_builder_1.buildIPKey)(normIp, currBucket - 1),
580
+ spec: rule.limits.ip,
581
+ });
582
+ }
583
+ // 2. Per-route
584
+ if (rule.limits.route) {
585
+ const windowMs = rule.limits.route.window * 1000;
586
+ const currBucket = (0, algorithm_1.computeBucket)(nowMs, windowMs);
587
+ dimensions.push({
588
+ name: 'route',
589
+ currKey: (0, key_builder_1.buildRouteKey)(normRoute, currBucket),
590
+ prevKey: (0, key_builder_1.buildRouteKey)(normRoute, currBucket - 1),
591
+ spec: rule.limits.route,
592
+ });
593
+ }
594
+ // 3. Per-user
595
+ if (rule.limits.user && getUserHash() !== null) {
596
+ const windowMs = rule.limits.user.window * 1000;
597
+ const currBucket = (0, algorithm_1.computeBucket)(nowMs, windowMs);
598
+ dimensions.push({
599
+ name: 'user',
600
+ currKey: (0, key_builder_1.buildUserKey)(getUserHash(), currBucket),
601
+ prevKey: (0, key_builder_1.buildUserKey)(getUserHash(), currBucket - 1),
602
+ spec: rule.limits.user,
603
+ });
604
+ }
605
+ // 4. Per-user+route
606
+ if (rule.limits.userRoute && getUserHash() !== null) {
607
+ const windowMs = rule.limits.userRoute.window * 1000;
608
+ const currBucket = (0, algorithm_1.computeBucket)(nowMs, windowMs);
609
+ dimensions.push({
610
+ name: 'user-route',
611
+ currKey: (0, key_builder_1.buildUserRouteKey)(getUserHash(), normRoute, currBucket),
612
+ prevKey: (0, key_builder_1.buildUserRouteKey)(getUserHash(), normRoute, currBucket - 1),
613
+ spec: rule.limits.userRoute,
614
+ });
615
+ }
616
+ return dimensions;
617
+ }
618
+ // ---------------------------------------------------------------------------
619
+ // Lua result parsers
620
+ // ---------------------------------------------------------------------------
621
+ /**
622
+ * Parses the raw array returned by `check_multi.lua` into a typed object.
623
+ *
624
+ * Expected Lua return: `{allowed, failed_dimension, effective, limit, ttl_ms}`
625
+ *
626
+ * @param raw Raw value returned by `ScriptLoader.eval`.
627
+ * @returns Typed {@link LuaCheckResult}.
628
+ * @throws {Error} If the raw result is not an array of the expected shape.
629
+ */
630
+ function parseLuaCheckResult(raw) {
631
+ if (!Array.isArray(raw) || raw.length < 5) {
632
+ throw new Error(`Unexpected check_multi result: ${JSON.stringify(raw)}`);
633
+ }
634
+ return {
635
+ allowed: Number(raw[0]) === 1,
636
+ failedDimension: String(raw[1] ?? ''),
637
+ effective: Number(raw[2] ?? 0),
638
+ limit: Number(raw[3] ?? 0),
639
+ ttlMs: Number(raw[4] ?? 0),
640
+ };
641
+ }
642
+ /**
643
+ * Parses the raw array returned by `status.lua` into a typed object.
644
+ *
645
+ * Expected Lua return: `{effective, limit, remaining, ttl_ms}`
646
+ *
647
+ * @param raw Raw value returned by `ScriptLoader.eval`.
648
+ * @returns Typed {@link LuaStatusResult}.
649
+ */
650
+ function parseLuaStatusResult(raw) {
651
+ if (!Array.isArray(raw) || raw.length < 4) {
652
+ return { effective: 0, limit: 0, remaining: 0, ttlMs: 0 };
653
+ }
654
+ return {
655
+ effective: Number(raw[0] ?? 0),
656
+ limit: Number(raw[1] ?? 0),
657
+ remaining: Number(raw[2] ?? 0),
658
+ ttlMs: Number(raw[3] ?? 0),
659
+ };
660
+ }
661
+ // ---------------------------------------------------------------------------
662
+ // Misc helpers
663
+ // ---------------------------------------------------------------------------
664
+ /**
665
+ * Builds a fully-allowed {@link RateLimitResult}.
666
+ *
667
+ * @param dimension Dimension name to report (usually "none").
668
+ * @param effective Effective count to report.
669
+ * @param limit Limit to report.
670
+ * @param remaining Remaining tokens to report.
671
+ * @param resetAt Unix epoch ms for window reset.
672
+ * @param source Decision source.
673
+ */
674
+ function buildAllowedResult(dimension, effective, limit, remaining, resetAt, source, windowSecs) {
675
+ return {
676
+ allowed: true,
677
+ dimension,
678
+ effective,
679
+ limit,
680
+ remaining,
681
+ resetAt,
682
+ windowSecs,
683
+ source,
684
+ };
685
+ }
686
+ /**
687
+ * Returns the {@link LimitSpec} for the named dimension from a rule, or undefined
688
+ * if the rule does not define that dimension.
689
+ *
690
+ * @param rule The rate limit rule.
691
+ * @param dimension Dimension name.
692
+ * @returns The limit spec or `undefined`.
693
+ */
694
+ function getSpecForDimension(rule, dimension) {
695
+ switch (dimension) {
696
+ case 'ip': return rule.limits.ip;
697
+ case 'route': return rule.limits.route;
698
+ case 'user': return rule.limits.user;
699
+ case 'user-route': return rule.limits.userRoute;
700
+ }
701
+ }
702
+ //# sourceMappingURL=rate-limiter.js.map