@upstash/ratelimit 1.2.0-canary → 1.2.1-canary

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.
package/dist/index.d.mts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Aggregate } from '@upstash/core-analytics';
2
+ import { Pipeline } from '@upstash/redis';
2
3
 
3
4
  /**
4
5
  * EphemeralCache is used to block certain identifiers right away in case they have already exceeded the ratelimit.
@@ -83,7 +84,7 @@ type RatelimitResponse = {
83
84
  /**
84
85
  * The value which was in the deny list if reason: "denyList"
85
86
  */
86
- deniedValue?: string;
87
+ deniedValue?: DeniedValue;
87
88
  };
88
89
  type Algorithm<TContext> = () => {
89
90
  limit: (ctx: TContext, identifier: string, rate?: number, opts?: {
@@ -93,6 +94,7 @@ type Algorithm<TContext> = () => {
93
94
  resetTokens: (ctx: TContext, identifier: string) => Promise<void>;
94
95
  };
95
96
  type IsDenied = 0 | 1;
97
+ type DeniedValue = string | undefined;
96
98
  type LimitOptions = {
97
99
  geo?: Geo;
98
100
  rate?: number;
@@ -112,6 +114,7 @@ interface Redis {
112
114
  evalsha: <TArgs extends unknown[], TData = unknown>(...args: [sha1: string, keys: string[], args: TArgs]) => Promise<TData>;
113
115
  scriptLoad: (...args: [script: string]) => Promise<string>;
114
116
  smismember: (key: string, members: string[]) => Promise<IsDenied[]>;
117
+ multi: () => Pipeline;
115
118
  }
116
119
 
117
120
  type Geo = {
@@ -237,6 +240,7 @@ type RatelimitConfig<TContext> = {
237
240
  * @default false
238
241
  */
239
242
  enableProtection?: boolean;
243
+ denyListThreshold?: number;
240
244
  };
241
245
  /**
242
246
  * Ratelimiter using serverless redis from https://upstash.com/
@@ -261,6 +265,7 @@ declare abstract class Ratelimit<TContext extends Context> {
261
265
  protected readonly primaryRedis: Redis;
262
266
  protected readonly analytics?: Analytics;
263
267
  protected readonly enableProtection: boolean;
268
+ protected readonly denyListThreshold: number;
264
269
  constructor(config: RatelimitConfig<TContext>);
265
270
  /**
266
271
  * Determine if a request should pass or be rejected based on the identifier and previously chosen ratelimit.
@@ -561,6 +566,10 @@ type RegionRatelimitConfig = {
561
566
  * @default false
562
567
  */
563
568
  enableProtection?: boolean;
569
+ /**
570
+ * @default 6
571
+ */
572
+ denyListThreshold?: number;
564
573
  };
565
574
  /**
566
575
  * Ratelimiter using serverless redis from https://upstash.com/
@@ -699,4 +708,51 @@ declare class RegionRatelimit extends Ratelimit<RegionContext> {
699
708
  window: Duration): Algorithm<RegionContext>;
700
709
  }
701
710
 
702
- export { Algorithm, Analytics, AnalyticsConfig, MultiRegionRatelimit, MultiRegionRatelimitConfig, RegionRatelimit as Ratelimit, RegionRatelimitConfig as RatelimitConfig };
711
+ declare class ThresholdError extends Error {
712
+ constructor(threshold: number);
713
+ }
714
+ /**
715
+ * Gets the list of ips from the github source which are not in the
716
+ * deny list already
717
+ *
718
+ * First, gets the ip list from github using the threshold. Then, calls redis with
719
+ * a transaction which does the following:
720
+ * - subtract the current ip deny list from all
721
+ * - delete current ip deny list
722
+ * - recreate ip deny list with the ips from github. Ips already in the users own lists
723
+ * are excluded.
724
+ * - status key is set to valid with ttl until next 2 AM UTC, which is a bit later than
725
+ * when the list is updated on github.
726
+ *
727
+ * @param redis redis instance
728
+ * @param prefix ratelimit prefix
729
+ * @param threshold ips with less than or equal to the threshold are not included
730
+ * @param ttl time to live in milliseconds for the status flag. Optional. If not
731
+ * passed, ttl is infferred from current time.
732
+ * @returns list of ips which are not in the deny list
733
+ */
734
+ declare const updateIpDenyList: (redis: Redis, prefix: string, threshold: number, ttl?: number) => Promise<unknown[]>;
735
+ /**
736
+ * Disables the ip deny list by removing the ip deny list from the all
737
+ * set and removing the ip deny list. Also sets the status key to disabled
738
+ * with no ttl.
739
+ *
740
+ * @param redis redis instance
741
+ * @param prefix ratelimit prefix
742
+ * @returns
743
+ */
744
+ declare const disableIpDenyList: (redis: Redis, prefix: string) => Promise<unknown[]>;
745
+
746
+ type ipDenyList_ThresholdError = ThresholdError;
747
+ declare const ipDenyList_ThresholdError: typeof ThresholdError;
748
+ declare const ipDenyList_disableIpDenyList: typeof disableIpDenyList;
749
+ declare const ipDenyList_updateIpDenyList: typeof updateIpDenyList;
750
+ declare namespace ipDenyList {
751
+ export {
752
+ ipDenyList_ThresholdError as ThresholdError,
753
+ ipDenyList_disableIpDenyList as disableIpDenyList,
754
+ ipDenyList_updateIpDenyList as updateIpDenyList,
755
+ };
756
+ }
757
+
758
+ export { Algorithm, Analytics, AnalyticsConfig, ipDenyList as IpDenyList, MultiRegionRatelimit, MultiRegionRatelimitConfig, RegionRatelimit as Ratelimit, RegionRatelimitConfig as RatelimitConfig };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Aggregate } from '@upstash/core-analytics';
2
+ import { Pipeline } from '@upstash/redis';
2
3
 
3
4
  /**
4
5
  * EphemeralCache is used to block certain identifiers right away in case they have already exceeded the ratelimit.
@@ -83,7 +84,7 @@ type RatelimitResponse = {
83
84
  /**
84
85
  * The value which was in the deny list if reason: "denyList"
85
86
  */
86
- deniedValue?: string;
87
+ deniedValue?: DeniedValue;
87
88
  };
88
89
  type Algorithm<TContext> = () => {
89
90
  limit: (ctx: TContext, identifier: string, rate?: number, opts?: {
@@ -93,6 +94,7 @@ type Algorithm<TContext> = () => {
93
94
  resetTokens: (ctx: TContext, identifier: string) => Promise<void>;
94
95
  };
95
96
  type IsDenied = 0 | 1;
97
+ type DeniedValue = string | undefined;
96
98
  type LimitOptions = {
97
99
  geo?: Geo;
98
100
  rate?: number;
@@ -112,6 +114,7 @@ interface Redis {
112
114
  evalsha: <TArgs extends unknown[], TData = unknown>(...args: [sha1: string, keys: string[], args: TArgs]) => Promise<TData>;
113
115
  scriptLoad: (...args: [script: string]) => Promise<string>;
114
116
  smismember: (key: string, members: string[]) => Promise<IsDenied[]>;
117
+ multi: () => Pipeline;
115
118
  }
116
119
 
117
120
  type Geo = {
@@ -237,6 +240,7 @@ type RatelimitConfig<TContext> = {
237
240
  * @default false
238
241
  */
239
242
  enableProtection?: boolean;
243
+ denyListThreshold?: number;
240
244
  };
241
245
  /**
242
246
  * Ratelimiter using serverless redis from https://upstash.com/
@@ -261,6 +265,7 @@ declare abstract class Ratelimit<TContext extends Context> {
261
265
  protected readonly primaryRedis: Redis;
262
266
  protected readonly analytics?: Analytics;
263
267
  protected readonly enableProtection: boolean;
268
+ protected readonly denyListThreshold: number;
264
269
  constructor(config: RatelimitConfig<TContext>);
265
270
  /**
266
271
  * Determine if a request should pass or be rejected based on the identifier and previously chosen ratelimit.
@@ -561,6 +566,10 @@ type RegionRatelimitConfig = {
561
566
  * @default false
562
567
  */
563
568
  enableProtection?: boolean;
569
+ /**
570
+ * @default 6
571
+ */
572
+ denyListThreshold?: number;
564
573
  };
565
574
  /**
566
575
  * Ratelimiter using serverless redis from https://upstash.com/
@@ -699,4 +708,51 @@ declare class RegionRatelimit extends Ratelimit<RegionContext> {
699
708
  window: Duration): Algorithm<RegionContext>;
700
709
  }
701
710
 
702
- export { Algorithm, Analytics, AnalyticsConfig, MultiRegionRatelimit, MultiRegionRatelimitConfig, RegionRatelimit as Ratelimit, RegionRatelimitConfig as RatelimitConfig };
711
+ declare class ThresholdError extends Error {
712
+ constructor(threshold: number);
713
+ }
714
+ /**
715
+ * Gets the list of ips from the github source which are not in the
716
+ * deny list already
717
+ *
718
+ * First, gets the ip list from github using the threshold. Then, calls redis with
719
+ * a transaction which does the following:
720
+ * - subtract the current ip deny list from all
721
+ * - delete current ip deny list
722
+ * - recreate ip deny list with the ips from github. Ips already in the users own lists
723
+ * are excluded.
724
+ * - status key is set to valid with ttl until next 2 AM UTC, which is a bit later than
725
+ * when the list is updated on github.
726
+ *
727
+ * @param redis redis instance
728
+ * @param prefix ratelimit prefix
729
+ * @param threshold ips with less than or equal to the threshold are not included
730
+ * @param ttl time to live in milliseconds for the status flag. Optional. If not
731
+ * passed, ttl is infferred from current time.
732
+ * @returns list of ips which are not in the deny list
733
+ */
734
+ declare const updateIpDenyList: (redis: Redis, prefix: string, threshold: number, ttl?: number) => Promise<unknown[]>;
735
+ /**
736
+ * Disables the ip deny list by removing the ip deny list from the all
737
+ * set and removing the ip deny list. Also sets the status key to disabled
738
+ * with no ttl.
739
+ *
740
+ * @param redis redis instance
741
+ * @param prefix ratelimit prefix
742
+ * @returns
743
+ */
744
+ declare const disableIpDenyList: (redis: Redis, prefix: string) => Promise<unknown[]>;
745
+
746
+ type ipDenyList_ThresholdError = ThresholdError;
747
+ declare const ipDenyList_ThresholdError: typeof ThresholdError;
748
+ declare const ipDenyList_disableIpDenyList: typeof disableIpDenyList;
749
+ declare const ipDenyList_updateIpDenyList: typeof updateIpDenyList;
750
+ declare namespace ipDenyList {
751
+ export {
752
+ ipDenyList_ThresholdError as ThresholdError,
753
+ ipDenyList_disableIpDenyList as disableIpDenyList,
754
+ ipDenyList_updateIpDenyList as updateIpDenyList,
755
+ };
756
+ }
757
+
758
+ export { Algorithm, Analytics, AnalyticsConfig, ipDenyList as IpDenyList, MultiRegionRatelimit, MultiRegionRatelimitConfig, RegionRatelimit as Ratelimit, RegionRatelimitConfig as RatelimitConfig };
package/dist/index.js CHANGED
@@ -21,6 +21,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var src_exports = {};
22
22
  __export(src_exports, {
23
23
  Analytics: () => Analytics,
24
+ IpDenyList: () => ip_deny_list_exports,
24
25
  MultiRegionRatelimit: () => MultiRegionRatelimit,
25
26
  Ratelimit: () => RegionRatelimit
26
27
  });
@@ -294,7 +295,107 @@ var resetScript = `
294
295
  until cursor == "0"
295
296
  `;
296
297
 
297
- // src/deny-list.ts
298
+ // src/types.ts
299
+ var DenyListExtension = "denyList";
300
+ var IpDenyListKey = "ipDenyList";
301
+ var IpDenyListStatusKey = "ipDenyListStatus";
302
+
303
+ // src/deny-list/scripts.ts
304
+ var checkDenyListScript = `
305
+ -- Checks if values provideed in ARGV are present in the deny lists.
306
+ -- This is done using the allDenyListsKey below.
307
+
308
+ -- Additionally, checks the status of the ip deny list using the
309
+ -- ipDenyListStatusKey below. Here are the possible states of the
310
+ -- ipDenyListStatusKey key:
311
+ -- * status == -1: set to "disabled" with no TTL
312
+ -- * status == -2: not set, meaning that is was set before but expired
313
+ -- * status > 0: set to "valid", with a TTL
314
+ --
315
+ -- In the case of status == -2, we set the status to "pending" with
316
+ -- 30 second ttl. During this time, the process which got status == -2
317
+ -- will update the ip deny list.
318
+
319
+ local allDenyListsKey = KEYS[1]
320
+ local ipDenyListStatusKey = KEYS[2]
321
+
322
+ local results = redis.call('SMISMEMBER', allDenyListsKey, unpack(ARGV))
323
+ local status = redis.call('TTL', ipDenyListStatusKey)
324
+ if status == -2 then
325
+ redis.call('SETEX', ipDenyListStatusKey, 30, "pending")
326
+ end
327
+
328
+ return { results, status }
329
+ `;
330
+
331
+ // src/deny-list/ip-deny-list.ts
332
+ var ip_deny_list_exports = {};
333
+ __export(ip_deny_list_exports, {
334
+ ThresholdError: () => ThresholdError,
335
+ disableIpDenyList: () => disableIpDenyList,
336
+ updateIpDenyList: () => updateIpDenyList
337
+ });
338
+
339
+ // src/deny-list/time.ts
340
+ var MILLISECONDS_IN_HOUR = 60 * 60 * 1e3;
341
+ var MILLISECONDS_IN_DAY = 24 * MILLISECONDS_IN_HOUR;
342
+ var MILLISECONDS_TO_2AM = 2 * MILLISECONDS_IN_HOUR;
343
+ var getIpListTTL = (time) => {
344
+ const now = time || Date.now();
345
+ const timeSinceLast2AM = (now - MILLISECONDS_TO_2AM) % MILLISECONDS_IN_DAY;
346
+ return MILLISECONDS_IN_DAY - timeSinceLast2AM;
347
+ };
348
+
349
+ // src/deny-list/ip-deny-list.ts
350
+ var baseUrl = "https://raw.githubusercontent.com/stamparm/ipsum/master/levels";
351
+ var ThresholdError = class extends Error {
352
+ constructor(threshold) {
353
+ super(`Allowed threshold values are from 1 to 8, 1 and 8 included. Received: ${threshold}`);
354
+ this.name = "ThresholdError";
355
+ }
356
+ };
357
+ var getIpDenyList = async (threshold) => {
358
+ if (typeof threshold !== "number" || threshold < 1 || threshold > 8) {
359
+ throw new ThresholdError(threshold);
360
+ }
361
+ try {
362
+ const response = await fetch(`${baseUrl}/${threshold}.txt`);
363
+ if (!response.ok) {
364
+ throw new Error(`Error fetching data: ${response.statusText}`);
365
+ }
366
+ const data = await response.text();
367
+ const lines = data.split("\n");
368
+ return lines.filter((value) => value.length > 0);
369
+ } catch (error) {
370
+ throw new Error(`Failed to fetch ip deny list: ${error}`);
371
+ }
372
+ };
373
+ var updateIpDenyList = async (redis, prefix, threshold, ttl) => {
374
+ const allIps = await getIpDenyList(threshold);
375
+ const allDenyLists = [prefix, DenyListExtension, "all"].join(":");
376
+ const ipDenyList = [prefix, DenyListExtension, IpDenyListKey].join(":");
377
+ const statusKey = [prefix, IpDenyListStatusKey].join(":");
378
+ const transaction = redis.multi();
379
+ transaction.sdiffstore(allDenyLists, allDenyLists, ipDenyList);
380
+ transaction.del(ipDenyList);
381
+ transaction.sadd(ipDenyList, ...allIps);
382
+ transaction.sdiffstore(ipDenyList, ipDenyList, allDenyLists);
383
+ transaction.sunionstore(allDenyLists, allDenyLists, ipDenyList);
384
+ transaction.set(statusKey, "valid", { px: ttl ?? getIpListTTL() });
385
+ return await transaction.exec();
386
+ };
387
+ var disableIpDenyList = async (redis, prefix) => {
388
+ const allDenyListsKey = [prefix, DenyListExtension, "all"].join(":");
389
+ const ipDenyListKey = [prefix, DenyListExtension, IpDenyListKey].join(":");
390
+ const statusKey = [prefix, IpDenyListStatusKey].join(":");
391
+ const transaction = redis.multi();
392
+ transaction.sdiffstore(allDenyListsKey, allDenyListsKey, ipDenyListKey);
393
+ transaction.del(ipDenyListKey);
394
+ transaction.set(statusKey, "disabled");
395
+ return await transaction.exec();
396
+ };
397
+
398
+ // src/deny-list/deny-list.ts
298
399
  var denyListCache = new Cache(/* @__PURE__ */ new Map());
299
400
  var checkDenyListCache = (members) => {
300
401
  return members.find(
@@ -307,25 +408,39 @@ var blockMember = (member) => {
307
408
  denyListCache.blockUntil(member, Date.now() + 6e4);
308
409
  };
309
410
  var checkDenyList = async (redis, prefix, members) => {
310
- const deniedMembers = await redis.smismember(
311
- [prefix, "denyList", "all"].join(":"),
411
+ const [deniedValues, ipDenyListStatus] = await redis.eval(
412
+ checkDenyListScript,
413
+ [
414
+ [prefix, DenyListExtension, "all"].join(":"),
415
+ [prefix, IpDenyListStatusKey].join(":")
416
+ ],
312
417
  members
313
418
  );
314
- let deniedMember = void 0;
315
- deniedMembers.map((memberDenied, index) => {
419
+ let deniedValue = void 0;
420
+ deniedValues.map((memberDenied, index) => {
316
421
  if (memberDenied) {
317
422
  blockMember(members[index]);
318
- deniedMember = members[index];
423
+ deniedValue = members[index];
319
424
  }
320
425
  });
321
- return deniedMember;
426
+ return {
427
+ deniedValue,
428
+ invalidIpDenyList: ipDenyListStatus === -2
429
+ };
322
430
  };
323
- var resolveResponses = ([ratelimitResponse, denyListResponse]) => {
324
- if (denyListResponse) {
431
+ var resolveLimitPayload = (redis, prefix, [ratelimitResponse, denyListResponse], threshold) => {
432
+ if (denyListResponse.deniedValue) {
325
433
  ratelimitResponse.success = false;
326
434
  ratelimitResponse.remaining = 0;
327
435
  ratelimitResponse.reason = "denyList";
328
- ratelimitResponse.deniedValue = denyListResponse;
436
+ ratelimitResponse.deniedValue = denyListResponse.deniedValue;
437
+ }
438
+ if (denyListResponse.invalidIpDenyList) {
439
+ const updatePromise = updateIpDenyList(redis, prefix, threshold);
440
+ ratelimitResponse.pending = Promise.all([
441
+ ratelimitResponse.pending,
442
+ updatePromise
443
+ ]);
329
444
  }
330
445
  return ratelimitResponse;
331
446
  };
@@ -350,12 +465,14 @@ var Ratelimit = class {
350
465
  primaryRedis;
351
466
  analytics;
352
467
  enableProtection;
468
+ denyListThreshold;
353
469
  constructor(config) {
354
470
  this.ctx = config.ctx;
355
471
  this.limiter = config.limiter;
356
472
  this.timeout = config.timeout ?? 5e3;
357
473
  this.prefix = config.prefix ?? "@upstash/ratelimit";
358
474
  this.enableProtection = config.enableProtection ?? false;
475
+ this.denyListThreshold = config.denyListThreshold ?? 6;
359
476
  this.primaryRedis = "redis" in this.ctx ? this.ctx.redis : this.ctx.regionContexts[0].redis;
360
477
  this.analytics = config.analytics ? new Analytics({
361
478
  redis: this.primaryRedis,
@@ -485,21 +602,17 @@ var Ratelimit = class {
485
602
  getRatelimitResponse = async (identifier, req) => {
486
603
  const key = this.getKey(identifier);
487
604
  const definedMembers = this.getDefinedMembers(identifier, req);
488
- const deniedMember = checkDenyListCache(definedMembers);
605
+ const deniedValue = checkDenyListCache(definedMembers);
489
606
  let result;
490
- if (deniedMember) {
491
- result = [defaultDeniedResponse(deniedMember), deniedMember];
607
+ if (deniedValue) {
608
+ result = [defaultDeniedResponse(deniedValue), { deniedValue, invalidIpDenyList: false }];
492
609
  } else {
493
610
  result = await Promise.all([
494
611
  this.limiter().limit(this.ctx, key, req?.rate),
495
- checkDenyList(
496
- this.primaryRedis,
497
- this.prefix,
498
- definedMembers
499
- )
612
+ this.enableProtection ? checkDenyList(this.primaryRedis, this.prefix, definedMembers) : { deniedValue: void 0, invalidIpDenyList: false }
500
613
  ]);
501
614
  }
502
- return resolveResponses(result);
615
+ return resolveLimitPayload(this.primaryRedis, this.prefix, result, this.denyListThreshold);
503
616
  };
504
617
  /**
505
618
  * Creates an array with the original response promise and a timeout promise
@@ -1109,7 +1222,8 @@ var RegionRatelimit = class extends Ratelimit {
1109
1222
  cacheScripts: config.cacheScripts ?? true
1110
1223
  },
1111
1224
  ephemeralCache: config.ephemeralCache,
1112
- enableProtection: config.enableProtection
1225
+ enableProtection: config.enableProtection,
1226
+ denyListThreshold: config.denyListThreshold
1113
1227
  });
1114
1228
  }
1115
1229
  /**
@@ -1476,6 +1590,7 @@ var RegionRatelimit = class extends Ratelimit {
1476
1590
  // Annotate the CommonJS export names for ESM import in node:
1477
1591
  0 && (module.exports = {
1478
1592
  Analytics,
1593
+ IpDenyList,
1479
1594
  MultiRegionRatelimit,
1480
1595
  Ratelimit
1481
1596
  });