@tuturuuu/utils 0.12.1 → 0.15.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.
@@ -31,9 +31,6 @@ export * from './reputation';
31
31
  export * from './types';
32
32
  export * from './user-agent';
33
33
 
34
- // In-memory fallback store
35
- const memoryStore = new Map<string, { count: number; expiresAt: number }>();
36
-
37
34
  const SHARED_IP_THROTTLE_ONLY_BLOCK_REASONS = new Set<AbuseEventType>([
38
35
  'otp_send',
39
36
  'otp_verify_failed',
@@ -50,6 +47,28 @@ export function isSharedIpThrottleOnlyBlockReason(reason: AbuseEventType) {
50
47
  let redisClient: RedisClient | null = null;
51
48
  let redisInitialized = false;
52
49
 
50
+ function markRedisUnavailable(message: string, ...details: unknown[]): void {
51
+ if (details.length > 0) {
52
+ console.warn(message, ...details);
53
+ } else {
54
+ console.warn(message);
55
+ }
56
+
57
+ redisClient = null;
58
+ redisInitialized = true;
59
+ }
60
+
61
+ function markRedisCommandFailed(message: string, ...details: unknown[]): void {
62
+ if (details.length > 0) {
63
+ console.warn(message, ...details);
64
+ } else {
65
+ console.warn(message);
66
+ }
67
+
68
+ redisClient = null;
69
+ redisInitialized = false;
70
+ }
71
+
53
72
  function parsePositiveIntEnv(name: string, fallback: number): number {
54
73
  const rawValue = process.env[name];
55
74
  if (!rawValue) {
@@ -85,19 +104,22 @@ async function getRedisClient(): Promise<RedisClient | null> {
85
104
 
86
105
  try {
87
106
  if (!hasUpstashRestEnv()) {
88
- console.warn(
89
- '[Abuse Protection] Redis not configured - falling back to memory'
107
+ markRedisUnavailable(
108
+ '[Abuse Protection] Redis not configured - failing open'
90
109
  );
91
- redisInitialized = true;
92
110
  return null;
93
111
  }
94
112
 
95
113
  redisClient = await getUpstashRestRedisClient();
114
+ if (!redisClient) {
115
+ markRedisUnavailable(
116
+ '[Abuse Protection] Redis client unavailable - failing open'
117
+ );
118
+ }
96
119
  redisInitialized = true;
97
120
  return redisClient;
98
121
  } catch (error) {
99
- console.warn('[Abuse Protection] Redis unavailable:', error);
100
- redisInitialized = true;
122
+ markRedisUnavailable('[Abuse Protection] Redis unavailable:', error);
101
123
  return null;
102
124
  }
103
125
  }
@@ -191,83 +213,70 @@ function createAbuseLogContext(
191
213
  }
192
214
 
193
215
  /**
194
- * Increment a counter in Redis or memory with automatic expiration
216
+ * Increment a Redis counter with automatic expiration.
217
+ * Fails open when Redis is unavailable so abuse protection never becomes an
218
+ * app availability dependency.
195
219
  */
196
220
  async function incrementCounter(
197
221
  key: string,
198
222
  windowMs: number
199
223
  ): Promise<{ count: number; ttl: number }> {
200
224
  const redis = await getRedisClient();
225
+ const neutral = { count: 0, ttl: Math.ceil(windowMs / 1000) };
201
226
 
202
- if (redis) {
203
- try {
204
- const count = await redis.incr(key);
205
- if (count === 1) {
206
- await redis.expire(key, Math.ceil(windowMs / 1000));
207
- }
208
- const ttl = await redis.ttl(key);
209
- return { count, ttl: ttl > 0 ? ttl : Math.ceil(windowMs / 1000) };
210
- } catch (error) {
211
- console.error('[Abuse Protection] Redis error:', error);
212
- // Fall through to memory
213
- }
227
+ if (!redis) {
228
+ return neutral;
214
229
  }
215
230
 
216
- // Memory fallback
217
- const now = Date.now();
218
- const existing = memoryStore.get(key);
219
-
220
- if (!existing || now > existing.expiresAt) {
221
- memoryStore.set(key, { count: 1, expiresAt: now + windowMs });
222
- return { count: 1, ttl: Math.ceil(windowMs / 1000) };
231
+ try {
232
+ const count = await redis.incr(key);
233
+ if (count === 1) {
234
+ await redis.expire(key, Math.ceil(windowMs / 1000));
235
+ }
236
+ const ttl = await redis.ttl(key);
237
+ return { count, ttl: ttl > 0 ? ttl : Math.ceil(windowMs / 1000) };
238
+ } catch (error) {
239
+ markRedisCommandFailed('[Abuse Protection] Redis counter error:', error);
240
+ return neutral;
223
241
  }
224
-
225
- existing.count++;
226
- return {
227
- count: existing.count,
228
- ttl: Math.ceil((existing.expiresAt - now) / 1000),
229
- };
230
242
  }
231
243
 
232
244
  /**
233
- * Get counter value from Redis or memory
245
+ * Get a counter value from Redis.
234
246
  */
235
247
  async function getCounter(key: string): Promise<number> {
236
248
  const redis = await getRedisClient();
237
249
 
238
- if (redis) {
239
- try {
240
- const count = await redis.get<number>(key);
241
- return count || 0;
242
- } catch {
243
- // Fall through to memory
244
- }
250
+ if (!redis) {
251
+ return 0;
245
252
  }
246
253
 
247
- const existing = memoryStore.get(key);
248
- if (!existing || Date.now() > existing.expiresAt) {
254
+ try {
255
+ const count = await redis.get<number>(key);
256
+ return count || 0;
257
+ } catch (error) {
258
+ markRedisCommandFailed(
259
+ '[Abuse Protection] Redis counter read error:',
260
+ error
261
+ );
249
262
  return 0;
250
263
  }
251
- return existing.count;
252
264
  }
253
265
 
254
266
  /**
255
- * Delete keys from Redis or memory
267
+ * Delete Redis keys when Redis is available.
256
268
  */
257
269
  async function deleteKeys(...keys: string[]): Promise<void> {
258
270
  const redis = await getRedisClient();
259
271
 
260
- if (redis) {
261
- try {
262
- await redis.del(...keys);
263
- return;
264
- } catch {
265
- // Fall through to memory
266
- }
272
+ if (!redis) {
273
+ return;
267
274
  }
268
275
 
269
- for (const key of keys) {
270
- memoryStore.delete(key);
276
+ try {
277
+ await redis.del(...keys);
278
+ } catch (error) {
279
+ markRedisCommandFailed('[Abuse Protection] Redis delete error:', error);
271
280
  }
272
281
  }
273
282
 
@@ -278,23 +287,17 @@ async function deleteKeysWithCount(...keys: string[]): Promise<number> {
278
287
 
279
288
  const redis = await getRedisClient();
280
289
 
281
- if (redis) {
282
- try {
283
- const deleted = await redis.del(...keys);
284
- return typeof deleted === 'number' ? deleted : 0;
285
- } catch {
286
- // Fall through to memory
287
- }
290
+ if (!redis) {
291
+ return 0;
288
292
  }
289
293
 
290
- let deleted = 0;
291
- for (const key of keys) {
292
- if (memoryStore.delete(key)) {
293
- deleted++;
294
- }
294
+ try {
295
+ const deleted = await redis.del(...keys);
296
+ return typeof deleted === 'number' ? deleted : 0;
297
+ } catch (error) {
298
+ markRedisCommandFailed('[Abuse Protection] Redis delete error:', error);
299
+ return 0;
295
300
  }
296
-
297
- return deleted;
298
301
  }
299
302
 
300
303
  /**
@@ -324,30 +327,33 @@ export async function isIPBlocked(
324
327
  ): Promise<BlockInfo | null> {
325
328
  const redis = await getRedisClient();
326
329
 
330
+ if (!redis) {
331
+ return null;
332
+ }
333
+
327
334
  // Check Redis cache first
328
- if (redis) {
329
- try {
330
- const cached = await redis.get<string>(REDIS_KEYS.IP_BLOCKED(ipAddress));
331
- if (cached) {
332
- const blockInfo =
333
- typeof cached === 'string' ? JSON.parse(cached) : cached;
334
- if (new Date(blockInfo.expiresAt) > new Date()) {
335
- return {
336
- id: blockInfo.id,
337
- blockLevel: blockInfo.level,
338
- reason: blockInfo.reason,
339
- expiresAt: new Date(blockInfo.expiresAt),
340
- blockedAt: new Date(blockInfo.blockedAt),
341
- };
342
- }
335
+ try {
336
+ const cached = await redis.get<string>(REDIS_KEYS.IP_BLOCKED(ipAddress));
337
+ if (cached) {
338
+ const blockInfo =
339
+ typeof cached === 'string' ? JSON.parse(cached) : cached;
340
+ if (new Date(blockInfo.expiresAt) > new Date()) {
341
+ return {
342
+ id: blockInfo.id,
343
+ blockLevel: blockInfo.level,
344
+ reason: blockInfo.reason,
345
+ expiresAt: new Date(blockInfo.expiresAt),
346
+ blockedAt: new Date(blockInfo.blockedAt),
347
+ };
343
348
  }
344
- } catch (error) {
345
- console.error(
346
- '[Abuse Protection] Redis cache error:',
347
- error,
348
- createAbuseLogContext(ipAddress, context)
349
- );
350
349
  }
350
+ } catch (error) {
351
+ markRedisCommandFailed(
352
+ '[Abuse Protection] Redis cache error:',
353
+ error,
354
+ createAbuseLogContext(ipAddress, context)
355
+ );
356
+ return null;
351
357
  }
352
358
 
353
359
  // Check database
@@ -376,21 +382,29 @@ export async function isIPBlocked(
376
382
  };
377
383
 
378
384
  // Cache in Redis
379
- if (redis && blockInfo.expiresAt > new Date()) {
385
+ if (blockInfo.expiresAt > new Date()) {
380
386
  const ttl = Math.ceil(
381
387
  (blockInfo.expiresAt.getTime() - Date.now()) / 1000
382
388
  );
383
- await redis.set(
384
- REDIS_KEYS.IP_BLOCKED(ipAddress),
385
- JSON.stringify({
386
- id: blockInfo.id,
387
- level: blockInfo.blockLevel,
388
- reason: blockInfo.reason,
389
- expiresAt: blockInfo.expiresAt.toISOString(),
390
- blockedAt: blockInfo.blockedAt.toISOString(),
391
- }),
392
- { ex: ttl }
393
- );
389
+ try {
390
+ await redis.set(
391
+ REDIS_KEYS.IP_BLOCKED(ipAddress),
392
+ JSON.stringify({
393
+ id: blockInfo.id,
394
+ level: blockInfo.blockLevel,
395
+ reason: blockInfo.reason,
396
+ expiresAt: blockInfo.expiresAt.toISOString(),
397
+ blockedAt: blockInfo.blockedAt.toISOString(),
398
+ }),
399
+ { ex: ttl }
400
+ );
401
+ } catch (error) {
402
+ markRedisCommandFailed(
403
+ '[Abuse Protection] Redis cache set error:',
404
+ error
405
+ );
406
+ return null;
407
+ }
394
408
  }
395
409
 
396
410
  return blockInfo;
@@ -413,31 +427,25 @@ export async function blockIP(
413
427
  }
414
428
 
415
429
  try {
430
+ const redis = await getRedisClient();
431
+ if (!redis) return;
432
+
416
433
  const sbAdmin = await getSupabaseAdmin();
417
434
  if (!sbAdmin) return;
418
435
 
419
- const redis = await getRedisClient();
420
-
421
436
  // Get current block level
422
437
  let currentLevel = 0;
423
- if (redis) {
424
- try {
425
- const level = await redis.get<number>(
426
- REDIS_KEYS.IP_BLOCK_LEVEL(ipAddress)
427
- );
428
- currentLevel = level || 0;
429
- } catch {
430
- // Check DB fallback
431
- const { data } = await sbAdmin.rpc('get_ip_block_level', {
432
- p_ip_address: ipAddress,
433
- });
434
- currentLevel = (data as number) || 0;
435
- }
436
- } else {
437
- const { data } = await sbAdmin.rpc('get_ip_block_level', {
438
- p_ip_address: ipAddress,
439
- });
440
- currentLevel = (data as number) || 0;
438
+ try {
439
+ const level = await redis.get<number>(
440
+ REDIS_KEYS.IP_BLOCK_LEVEL(ipAddress)
441
+ );
442
+ currentLevel = level || 0;
443
+ } catch (error) {
444
+ markRedisCommandFailed(
445
+ '[Abuse Protection] Redis block-level error:',
446
+ error
447
+ );
448
+ return;
441
449
  }
442
450
 
443
451
  // Calculate new block level (max 4)
@@ -470,7 +478,7 @@ export async function blockIP(
470
478
  }
471
479
 
472
480
  // Update Redis cache
473
- if (redis) {
481
+ try {
474
482
  await Promise.all([
475
483
  redis.set(
476
484
  REDIS_KEYS.IP_BLOCKED(ipAddress),
@@ -487,6 +495,11 @@ export async function blockIP(
487
495
  ex: WINDOW_MS.TWENTY_FOUR_HOURS / 1000,
488
496
  }),
489
497
  ]);
498
+ } catch (error) {
499
+ markRedisCommandFailed(
500
+ '[Abuse Protection] Redis block cache error:',
501
+ error
502
+ );
490
503
  }
491
504
 
492
505
  console.log(
@@ -112,13 +112,7 @@ type RateLimiter = {
112
112
  limit: (identifier: string) => Promise<RateLimitResult>;
113
113
  };
114
114
 
115
- type LocalRateLimitState = {
116
- count: number;
117
- reset: number;
118
- };
119
-
120
115
  const limiterCache = new Map<string, Limiters>();
121
- const localLimiterState = new Map<string, LocalRateLimitState>();
122
116
  const GENERIC_SUPABASE_AUTH_COOKIE_NAME_PATTERN =
123
117
  /^sb-[a-z0-9-]+-auth-token(?:\.\d+)?$/i;
124
118
  const UUID_PATH_SEGMENT =
@@ -697,87 +691,10 @@ function createRedisRateLimitBuckets(
697
691
  }));
698
692
  }
699
693
 
700
- function rateLimitDurationMs(duration: RateLimitConfig['duration']) {
701
- switch (duration) {
702
- case '1 m':
703
- return 60_000;
704
- case '1 h':
705
- return 60 * 60_000;
706
- case '1 d':
707
- return 24 * 60 * 60_000;
708
- }
709
- }
710
-
711
- function createLocalRateLimiter(
712
- prefix: string,
713
- config: RateLimitConfig
714
- ): RateLimiter {
715
- const durationMs = rateLimitDurationMs(config.duration);
716
-
717
- return {
718
- async limit(identifier: string) {
719
- const now = Date.now();
720
- const key = `${prefix}:${identifier}`;
721
- let state = localLimiterState.get(key);
722
-
723
- if (!state || state.reset <= now) {
724
- state = {
725
- count: 0,
726
- reset: now + durationMs,
727
- };
728
- }
729
-
730
- if (state.count >= config.limit) {
731
- localLimiterState.set(key, state);
732
- return {
733
- limit: config.limit,
734
- remaining: 0,
735
- reset: state.reset,
736
- success: false,
737
- };
738
- }
739
-
740
- state.count += 1;
741
- localLimiterState.set(key, state);
742
-
743
- return {
744
- limit: config.limit,
745
- remaining: Math.max(0, config.limit - state.count),
746
- reset: state.reset,
747
- success: true,
748
- };
749
- },
750
- };
751
- }
752
-
753
- function createLocalRateLimitBuckets(
754
- prefixBase: string,
755
- kind: 'get' | 'mutate',
756
- configs: RateLimitConfig[]
757
- ): RateLimitBucket[] {
758
- return configs.map((config) => ({
759
- window: config.window,
760
- limiter: createLocalRateLimiter(
761
- `${prefixBase}:local:${kind}:${config.window}`,
762
- config
763
- ),
764
- }));
765
- }
766
-
767
- function createLocalRateLimiters(
768
- prefixBase: string,
769
- profile: RateLimitProfile
770
- ): Limiters {
771
- return {
772
- get: createLocalRateLimitBuckets(prefixBase, 'get', profile.get),
773
- mutate: createLocalRateLimitBuckets(prefixBase, 'mutate', profile.mutate),
774
- };
775
- }
776
-
777
694
  async function getRateLimiters(
778
695
  prefixBase: string,
779
696
  profile: RateLimitProfile
780
- ): Promise<Limiters> {
697
+ ): Promise<Limiters | null> {
781
698
  const cacheKey = `${prefixBase}:${JSON.stringify(profile)}`;
782
699
  const cached = limiterCache.get(cacheKey);
783
700
  if (cached) {
@@ -786,9 +703,7 @@ async function getRateLimiters(
786
703
 
787
704
  const redis = await getUpstashRatelimitRedisClient().catch(() => null);
788
705
  if (!redis) {
789
- const localLimiters = createLocalRateLimiters(prefixBase, profile);
790
- limiterCache.set(cacheKey, localLimiters);
791
- return localLimiters;
706
+ return null;
792
707
  }
793
708
 
794
709
  const limiters = {
@@ -805,16 +720,6 @@ async function getRateLimiters(
805
720
  return limiters;
806
721
  }
807
722
 
808
- function replaceWithLocalRateLimiters(
809
- cacheKey: string,
810
- prefixBase: string,
811
- profile: RateLimitProfile
812
- ): Limiters {
813
- const localLimiters = createLocalRateLimiters(prefixBase, profile);
814
- limiterCache.set(cacheKey, localLimiters);
815
- return localLimiters;
816
- }
817
-
818
723
  function getRoutePolicy(
819
724
  req: NextRequest,
820
725
  routePolicies: ProxyRoutePolicy[]
@@ -1242,7 +1147,6 @@ export function isTrustedProxyBypassRequest(
1242
1147
 
1243
1148
  export function clearApiProxyGuardLimiterCache() {
1244
1149
  limiterCache.clear();
1245
- localLimiterState.clear();
1246
1150
  }
1247
1151
 
1248
1152
  async function requestBodyExceedsLimit(
@@ -1418,32 +1322,26 @@ export async function guardApiProxyRequest(
1418
1322
  );
1419
1323
  const limiterCacheKey = `${limiterPrefix}:${JSON.stringify(rateLimits)}`;
1420
1324
  const limiters = await getRateLimiters(limiterPrefix, rateLimits);
1421
- let activeLimiters = isRead ? limiters.get : limiters.mutate;
1325
+ const activeLimiters = limiters
1326
+ ? isRead
1327
+ ? limiters.get
1328
+ : limiters.mutate
1329
+ : [];
1422
1330
 
1423
1331
  for (let index = 0; index < activeLimiters.length; index += 1) {
1424
1332
  const { limiter, window } = activeLimiters[index]!;
1425
- let result: RateLimitResult;
1333
+ let result: RateLimitResult | null | undefined;
1426
1334
 
1427
1335
  try {
1428
1336
  result = await limiter.limit(limiterId);
1429
1337
  } catch {
1430
- const fallbackLimiters = replaceWithLocalRateLimiters(
1431
- limiterCacheKey,
1432
- limiterPrefix,
1433
- rateLimits
1434
- );
1435
- activeLimiters = isRead
1436
- ? fallbackLimiters.get
1437
- : fallbackLimiters.mutate;
1438
- const fallbackBucket =
1439
- activeLimiters[index] ??
1440
- activeLimiters.find((bucket) => bucket.window === window);
1441
-
1442
- if (!fallbackBucket) {
1443
- continue;
1444
- }
1445
-
1446
- result = await fallbackBucket.limiter.limit(limiterId);
1338
+ limiterCache.delete(limiterCacheKey);
1339
+ break;
1340
+ }
1341
+
1342
+ if (!result) {
1343
+ limiterCache.delete(limiterCacheKey);
1344
+ break;
1447
1345
  }
1448
1346
 
1449
1347
  const { success, limit, remaining, reset } = result;