@tuturuuu/utils 0.6.1 → 0.8.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.
@@ -239,44 +239,6 @@ async function getCounter(key: string): Promise<number> {
239
239
  return existing.count;
240
240
  }
241
241
 
242
- async function getCounterWithTTL(
243
- key: string
244
- ): Promise<{ count: number; ttl: number }> {
245
- const redis = await getRedisClient();
246
-
247
- if (redis) {
248
- try {
249
- const [count, ttl] = await Promise.all([
250
- redis.get<number>(key),
251
- redis.ttl(key),
252
- ]);
253
-
254
- return {
255
- count: count || 0,
256
- ttl: ttl > 0 ? ttl : 0,
257
- };
258
- } catch {
259
- // Fall through to memory
260
- }
261
- }
262
-
263
- const existing = memoryStore.get(key);
264
- if (!existing) {
265
- return { count: 0, ttl: 0 };
266
- }
267
-
268
- const now = Date.now();
269
- if (now > existing.expiresAt) {
270
- memoryStore.delete(key);
271
- return { count: 0, ttl: 0 };
272
- }
273
-
274
- return {
275
- count: existing.count,
276
- ttl: Math.ceil((existing.expiresAt - now) / 1000),
277
- };
278
- }
279
-
280
242
  /**
281
243
  * Delete keys from Redis or memory
282
244
  */
@@ -902,7 +864,10 @@ export async function resetOtpLimitsForEmail({
902
864
  }
903
865
 
904
866
  /**
905
- * Check and track OTP send attempts
867
+ * Check and reserve OTP send attempts.
868
+ *
869
+ * Accepted sends consume quota before provider delivery to prevent parallel
870
+ * requests from all observing the same pre-send counter state.
906
871
  */
907
872
  export async function checkOTPSendAllowed(
908
873
  ipAddress: string,
@@ -928,13 +893,13 @@ export async function checkOTPSendAllowed(
928
893
  const dailyKey = REDIS_KEYS.OTP_SEND_DAILY(ipAddress);
929
894
 
930
895
  const [minuteState, hourlyState, dailyState] = await Promise.all([
931
- getCounterWithTTL(minuteKey),
932
- getCounterWithTTL(hourlyKey),
933
- getCounterWithTTL(dailyKey),
896
+ incrementCounter(minuteKey, WINDOW_MS.ONE_MINUTE),
897
+ incrementCounter(hourlyKey, WINDOW_MS.ONE_HOUR),
898
+ incrementCounter(dailyKey, WINDOW_MS.TWENTY_FOUR_HOURS),
934
899
  ]);
935
900
  const ipLimits = getOTPSendIpLimits();
936
901
 
937
- if (minuteState.count >= ipLimits.perMinute) {
902
+ if (minuteState.count > ipLimits.perMinute) {
938
903
  // Log and potentially block
939
904
  void logAbuseEvent(ipAddress, 'otp_send', {
940
905
  email,
@@ -955,7 +920,7 @@ export async function checkOTPSendAllowed(
955
920
  };
956
921
  }
957
922
 
958
- if (hourlyState.count >= ipLimits.perHour) {
923
+ if (hourlyState.count > ipLimits.perHour) {
959
924
  void logAbuseEvent(ipAddress, 'otp_send', {
960
925
  email,
961
926
  success: false,
@@ -972,7 +937,7 @@ export async function checkOTPSendAllowed(
972
937
  };
973
938
  }
974
939
 
975
- if (dailyState.count >= ipLimits.perDay) {
940
+ if (dailyState.count > ipLimits.perDay) {
976
941
  void logAbuseEvent(ipAddress, 'otp_send', {
977
942
  email,
978
943
  success: false,
@@ -995,14 +960,11 @@ export async function checkOTPSendAllowed(
995
960
  const hourlyEmailKey = REDIS_KEYS.OTP_SEND_EMAIL_HOURLY(emailHash);
996
961
  const dailyEmailKey = REDIS_KEYS.OTP_SEND_EMAIL_DAILY(emailHash);
997
962
 
998
- const [cooldownState, hourlyEmailState, dailyEmailState] =
999
- await Promise.all([
1000
- getCounterWithTTL(cooldownKey),
1001
- getCounterWithTTL(hourlyEmailKey),
1002
- getCounterWithTTL(dailyEmailKey),
1003
- ]);
1004
-
1005
- if (cooldownState.count >= 1) {
963
+ const cooldownState = await incrementCounter(
964
+ cooldownKey,
965
+ ABUSE_THRESHOLDS.OTP_SEND_EMAIL_COOLDOWN_WINDOW_MS
966
+ );
967
+ if (cooldownState.count > 1) {
1006
968
  void logAbuseEvent(ipAddress, 'otp_send', {
1007
969
  email,
1008
970
  success: false,
@@ -1017,7 +979,12 @@ export async function checkOTPSendAllowed(
1017
979
  };
1018
980
  }
1019
981
 
1020
- if (hourlyEmailState.count >= ABUSE_THRESHOLDS.OTP_SEND_EMAIL_PER_HOUR) {
982
+ const [hourlyEmailState, dailyEmailState] = await Promise.all([
983
+ incrementCounter(hourlyEmailKey, WINDOW_MS.ONE_HOUR),
984
+ incrementCounter(dailyEmailKey, WINDOW_MS.TWENTY_FOUR_HOURS),
985
+ ]);
986
+
987
+ if (hourlyEmailState.count > ABUSE_THRESHOLDS.OTP_SEND_EMAIL_PER_HOUR) {
1021
988
  void logAbuseEvent(ipAddress, 'otp_send', {
1022
989
  email,
1023
990
  success: false,
@@ -1032,7 +999,7 @@ export async function checkOTPSendAllowed(
1032
999
  };
1033
1000
  }
1034
1001
 
1035
- if (dailyEmailState.count >= ABUSE_THRESHOLDS.OTP_SEND_EMAIL_PER_DAY) {
1002
+ if (dailyEmailState.count > ABUSE_THRESHOLDS.OTP_SEND_EMAIL_PER_DAY) {
1036
1003
  void logAbuseEvent(ipAddress, 'otp_send', {
1037
1004
  email,
1038
1005
  success: false,
@@ -1050,10 +1017,7 @@ export async function checkOTPSendAllowed(
1050
1017
 
1051
1018
  return {
1052
1019
  allowed: true,
1053
- remainingAttempts: Math.max(
1054
- 0,
1055
- ipLimits.perMinute - (minuteState.count + 1)
1056
- ),
1020
+ remainingAttempts: Math.max(0, ipLimits.perMinute - minuteState.count),
1057
1021
  };
1058
1022
  }
1059
1023
 
@@ -1061,31 +1025,6 @@ export async function recordOTPSendSuccess(
1061
1025
  ipAddress: string,
1062
1026
  email?: string
1063
1027
  ): Promise<void> {
1064
- await Promise.all([
1065
- incrementCounter(REDIS_KEYS.OTP_SEND(ipAddress), WINDOW_MS.ONE_MINUTE),
1066
- incrementCounter(REDIS_KEYS.OTP_SEND_HOURLY(ipAddress), WINDOW_MS.ONE_HOUR),
1067
- incrementCounter(
1068
- REDIS_KEYS.OTP_SEND_DAILY(ipAddress),
1069
- WINDOW_MS.TWENTY_FOUR_HOURS
1070
- ),
1071
- ...(email
1072
- ? [
1073
- incrementCounter(
1074
- REDIS_KEYS.OTP_SEND_EMAIL_COOLDOWN(hashEmail(email)),
1075
- ABUSE_THRESHOLDS.OTP_SEND_EMAIL_COOLDOWN_WINDOW_MS
1076
- ),
1077
- incrementCounter(
1078
- REDIS_KEYS.OTP_SEND_EMAIL_HOURLY(hashEmail(email)),
1079
- WINDOW_MS.ONE_HOUR
1080
- ),
1081
- incrementCounter(
1082
- REDIS_KEYS.OTP_SEND_EMAIL_DAILY(hashEmail(email)),
1083
- WINDOW_MS.TWENTY_FOUR_HOURS
1084
- ),
1085
- ]
1086
- : []),
1087
- ]);
1088
-
1089
1028
  void logAbuseEvent(ipAddress, 'otp_send', { email, success: true });
1090
1029
  }
1091
1030
 
@@ -1404,9 +1343,16 @@ export async function clearReauthVerifyFailures(
1404
1343
  */
1405
1344
  export async function checkPasswordLoginLimit(
1406
1345
  ipAddress: string,
1346
+ emailOrContext?: string | AbuseProtectionLogContext,
1407
1347
  context?: AbuseProtectionLogContext
1408
1348
  ): Promise<AbuseCheckResult> {
1409
- const blockInfo = await isIPBlocked(ipAddress, context);
1349
+ const email =
1350
+ typeof emailOrContext === 'string'
1351
+ ? normalizeAbuseEventEmail(emailOrContext)
1352
+ : null;
1353
+ const logContext =
1354
+ typeof emailOrContext === 'string' ? context : emailOrContext;
1355
+ const blockInfo = await isIPBlocked(ipAddress, logContext);
1410
1356
  if (blockInfo) {
1411
1357
  return {
1412
1358
  allowed: false,
@@ -1418,10 +1364,16 @@ export async function checkPasswordLoginLimit(
1418
1364
  };
1419
1365
  }
1420
1366
 
1421
- const key = REDIS_KEYS.PASSWORD_LOGIN_FAILED(ipAddress);
1422
- const count = await getCounter(key);
1367
+ const ipKey = REDIS_KEYS.PASSWORD_LOGIN_FAILED(ipAddress);
1368
+ const emailKey = email
1369
+ ? REDIS_KEYS.PASSWORD_LOGIN_FAILED_EMAIL(hashEmail(email))
1370
+ : null;
1371
+ const [ipCount, emailCount] = await Promise.all([
1372
+ getCounter(ipKey),
1373
+ emailKey ? getCounter(emailKey) : Promise.resolve(0),
1374
+ ]);
1423
1375
 
1424
- if (count >= ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_MAX) {
1376
+ if (ipCount >= ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_MAX) {
1425
1377
  return {
1426
1378
  allowed: false,
1427
1379
  reason: 'Too many failed login attempts',
@@ -1432,9 +1384,25 @@ export async function checkPasswordLoginLimit(
1432
1384
  };
1433
1385
  }
1434
1386
 
1387
+ if (email && emailCount >= ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_EMAIL_MAX) {
1388
+ return {
1389
+ allowed: false,
1390
+ reason: 'Too many failed login attempts for this email',
1391
+ retryAfter: Math.ceil(
1392
+ ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_EMAIL_WINDOW_MS / 1000
1393
+ ),
1394
+ remainingAttempts: 0,
1395
+ };
1396
+ }
1397
+
1435
1398
  return {
1436
1399
  allowed: true,
1437
- remainingAttempts: ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_MAX - count,
1400
+ remainingAttempts: Math.min(
1401
+ ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_MAX - ipCount,
1402
+ email
1403
+ ? ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_EMAIL_MAX - emailCount
1404
+ : ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_MAX - ipCount
1405
+ ),
1438
1406
  };
1439
1407
  }
1440
1408
 
@@ -1445,14 +1413,23 @@ export async function recordPasswordLoginFailure(
1445
1413
  ipAddress: string,
1446
1414
  email?: string
1447
1415
  ): Promise<void> {
1448
- const key = REDIS_KEYS.PASSWORD_LOGIN_FAILED(ipAddress);
1449
- const { count } = await incrementCounter(
1450
- key,
1451
- ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_WINDOW_MS
1452
- );
1416
+ const normalizedEmail = normalizeAbuseEventEmail(email);
1417
+ const ipKey = REDIS_KEYS.PASSWORD_LOGIN_FAILED(ipAddress);
1418
+ const emailKey = normalizedEmail
1419
+ ? REDIS_KEYS.PASSWORD_LOGIN_FAILED_EMAIL(hashEmail(normalizedEmail))
1420
+ : null;
1421
+ const [{ count }] = await Promise.all([
1422
+ incrementCounter(ipKey, ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_WINDOW_MS),
1423
+ emailKey
1424
+ ? incrementCounter(
1425
+ emailKey,
1426
+ ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_EMAIL_WINDOW_MS
1427
+ )
1428
+ : Promise.resolve({ count: 0, ttl: 0 }),
1429
+ ]);
1453
1430
 
1454
1431
  void logAbuseEvent(ipAddress, 'password_login_failed', {
1455
- email,
1432
+ email: normalizedEmail ?? undefined,
1456
1433
  success: false,
1457
1434
  });
1458
1435
 
@@ -1465,12 +1442,22 @@ export async function recordPasswordLoginFailure(
1465
1442
  }
1466
1443
 
1467
1444
  /**
1468
- * Clear password login failures on success
1445
+ * Clear password login failures for the successful account only.
1446
+ * The IP-wide failure counter intentionally remains in place so a successful
1447
+ * login for another account cannot reset brute-force pressure from the IP.
1469
1448
  */
1470
1449
  export async function clearPasswordLoginFailures(
1471
- ipAddress: string
1450
+ _ipAddress: string,
1451
+ email?: string
1472
1452
  ): Promise<void> {
1473
- await deleteKeys(REDIS_KEYS.PASSWORD_LOGIN_FAILED(ipAddress));
1453
+ const normalizedEmail = normalizeAbuseEventEmail(email);
1454
+ if (!normalizedEmail) {
1455
+ return;
1456
+ }
1457
+
1458
+ await deleteKeys(
1459
+ REDIS_KEYS.PASSWORD_LOGIN_FAILED_EMAIL(hashEmail(normalizedEmail))
1460
+ );
1474
1461
  }
1475
1462
 
1476
1463
  /**
@@ -23,11 +23,21 @@ export const ABUSE_REPUTATION_SUBJECT_TYPES = [
23
23
  'ip',
24
24
  'cidr',
25
25
  'user_location',
26
+ 'workspace',
26
27
  ] as const;
27
28
 
28
29
  export type AbuseReputationSubjectType =
29
30
  (typeof ABUSE_REPUTATION_SUBJECT_TYPES)[number];
30
31
 
32
+ export const RATE_LIMIT_MODES = [
33
+ 'inherit_multiplier',
34
+ 'absolute',
35
+ 'unlimited',
36
+ 'blocked',
37
+ ] as const;
38
+
39
+ export type RateLimitMode = (typeof RATE_LIMIT_MODES)[number];
40
+
31
41
  export type AbuseSignalType =
32
42
  | 'organic_activity'
33
43
  | 'automation_client'