@tuturuuu/utils 0.8.0 → 0.10.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.
@@ -21,6 +21,7 @@ import {
21
21
  } from '../upstash-rest';
22
22
 
23
23
  const TRUST_CACHE_PREFIX = 'trust:mult';
24
+ const APPEAL_RELIEF_CACHE_PREFIX = 'trust:appeal-relief';
24
25
 
25
26
  // Mirrors the DB CHECK constraint on trust_multiplier (> 0 AND <= 1000).
26
27
  const MIN_TRUST_MULTIPLIER = 0.35;
@@ -39,9 +40,12 @@ export interface CachedTrustEntry {
39
40
  mode?: 'absolute' | 'unlimited';
40
41
  /** For mode=absolute: explicit per-window READ limits. */
41
42
  abs?: { minute?: number; hour?: number; day?: number };
43
+ /** Server-verified session marker. Does not increase limits by itself. */
44
+ verified?: boolean;
42
45
  }
43
46
 
44
47
  const DEFAULT_TRUST_CACHE_TTL_SECONDS = 3600; // 1 hour
48
+ const DEFAULT_APPEAL_RELIEF_TTL_SECONDS = 15 * 60; // 15 minutes
45
49
 
46
50
  let trustRedisClient: UpstashRestRedisClient | null = null;
47
51
  let trustRedisInitialized = false;
@@ -63,6 +67,13 @@ export function getTrustCacheTtlSeconds(): number {
63
67
  );
64
68
  }
65
69
 
70
+ export function getAppealReliefTtlSeconds(): number {
71
+ return parsePositiveIntEnv(
72
+ 'RATE_LIMIT_APPEAL_RELIEF_TTL_SECONDS',
73
+ DEFAULT_APPEAL_RELIEF_TTL_SECONDS
74
+ );
75
+ }
76
+
66
77
  async function getTrustRedisClient() {
67
78
  if (trustRedisInitialized) return trustRedisClient;
68
79
 
@@ -84,6 +95,13 @@ export function buildTrustCacheKey(subjectKey: string): string {
84
95
  return `${TRUST_CACHE_PREFIX}:${subjectKey}`;
85
96
  }
86
97
 
98
+ export function buildIpBlockAppealReliefKey(
99
+ sessionKey: string,
100
+ ipKey: string
101
+ ): string {
102
+ return `${APPEAL_RELIEF_CACHE_PREFIX}:${sessionKey}:${ipKey}`;
103
+ }
104
+
87
105
  function clampMultiplier(value: number): number {
88
106
  if (!Number.isFinite(value) || value <= 0) {
89
107
  return NEUTRAL_TRUST_MULTIPLIER;
@@ -156,14 +174,25 @@ function parseEntry(value: unknown): CachedTrustEntry | null {
156
174
  ? record.mode
157
175
  : undefined;
158
176
  const abs = parseAbsLimits(record.abs);
177
+ const verified = record.verified === true;
159
178
 
160
- return { abs, m, mode };
179
+ const entry: CachedTrustEntry = { m };
180
+ if (mode) {
181
+ entry.mode = mode;
182
+ }
183
+ if (abs) {
184
+ entry.abs = abs;
185
+ }
186
+ if (verified) {
187
+ entry.verified = true;
188
+ }
189
+ return entry;
161
190
  }
162
191
 
163
192
  /** Serializes an entry for storage: plain number when multiplier-only. */
164
193
  function serializeEntry(entry: CachedTrustEntry): number | CachedTrustEntry {
165
194
  const m = clampMultiplier(entry.m);
166
- if (!entry.mode && !entry.abs) {
195
+ if (!entry.mode && !entry.abs && !entry.verified) {
167
196
  return m;
168
197
  }
169
198
 
@@ -182,20 +211,28 @@ function serializeEntry(entry: CachedTrustEntry): number | CachedTrustEntry {
182
211
  serialized.abs = abs;
183
212
  }
184
213
  }
214
+ if (entry.verified) {
215
+ serialized.verified = true;
216
+ }
185
217
  return serialized;
186
218
  }
187
219
 
188
220
  function isActiveEntry(entry: CachedTrustEntry): boolean {
189
- return entry.mode != null || entry.m > NEUTRAL_TRUST_MULTIPLIER;
221
+ return (
222
+ entry.mode != null ||
223
+ entry.verified === true ||
224
+ entry.m > NEUTRAL_TRUST_MULTIPLIER
225
+ );
190
226
  }
191
227
 
192
228
  /**
193
229
  * Resolves the cached trust multiplier for each provided subject key.
194
230
  *
195
231
  * Returns a map of subject key -> clamped multiplier for keys that have a cache
196
- * entry above the neutral baseline. Missing keys are omitted. Returns an empty
197
- * map on any Redis error (fail-open). Lets callers distinguish account trust
198
- * (session key) from location trust (cidr/ip keys).
232
+ * entry above the neutral baseline, admin-mode entries, or neutral verified
233
+ * session entries. Missing keys are omitted. Returns an empty map on any Redis
234
+ * error (fail-open). Lets callers distinguish account trust (session key) from
235
+ * location trust (cidr/ip keys).
199
236
  */
200
237
  export async function getCachedTrustEntries(
201
238
  subjectKeys: string[]
@@ -342,3 +379,101 @@ export async function writeTrustCacheForSubjects(
342
379
  // Cache writes must never block the protected request path.
343
380
  }
344
381
  }
382
+
383
+ /**
384
+ * Write-through a neutral marker for server-verified browser/app sessions.
385
+ * This only proves the session cookie was successfully validated downstream; it
386
+ * does not grant a read-limit multiplier or admin override.
387
+ */
388
+ export async function writeVerifiedSessionCacheForSubjects(
389
+ subjectKeys: string[],
390
+ ttlSeconds: number = getTrustCacheTtlSeconds()
391
+ ): Promise<void> {
392
+ const sessionKeys = subjectKeys.filter((subjectKey) =>
393
+ subjectKey.startsWith('session:')
394
+ );
395
+ if (sessionKeys.length === 0) {
396
+ return;
397
+ }
398
+
399
+ try {
400
+ const redis = await getTrustRedisClient();
401
+ if (!redis) {
402
+ return;
403
+ }
404
+
405
+ const existingEntries = await getCachedTrustEntries(sessionKeys);
406
+ const verifiedEntry = serializeEntry({
407
+ m: NEUTRAL_TRUST_MULTIPLIER,
408
+ verified: true,
409
+ });
410
+ await Promise.all(
411
+ sessionKeys.map((subjectKey) => {
412
+ const existing = existingEntries.get(subjectKey);
413
+ if (
414
+ existing &&
415
+ (existing.mode != null || existing.abs != null || existing.m > 1)
416
+ ) {
417
+ return Promise.resolve();
418
+ }
419
+
420
+ return redis.set(buildTrustCacheKey(subjectKey), verifiedEntry, {
421
+ ex: ttlSeconds,
422
+ });
423
+ })
424
+ );
425
+ } catch {
426
+ // Cache writes must never block the protected request path.
427
+ }
428
+ }
429
+
430
+ /**
431
+ * Grants a short, session+IP-scoped bypass for the hard IP-block check. This is
432
+ * used only after an authenticated user passes Turnstile and submits an appeal;
433
+ * it does not clear the global IP block or elevate unrelated sessions.
434
+ */
435
+ export async function setCachedIpBlockAppealRelief(
436
+ sessionKey: string,
437
+ ipKey: string,
438
+ ttlSeconds: number = getAppealReliefTtlSeconds()
439
+ ): Promise<void> {
440
+ if (!sessionKey || !ipKey) {
441
+ return;
442
+ }
443
+
444
+ try {
445
+ const redis = await getTrustRedisClient();
446
+ if (!redis) {
447
+ return;
448
+ }
449
+
450
+ await redis.set(buildIpBlockAppealReliefKey(sessionKey, ipKey), '1', {
451
+ ex: ttlSeconds,
452
+ });
453
+ } catch {
454
+ // Cache writes must never block the appeal path.
455
+ }
456
+ }
457
+
458
+ export async function hasCachedIpBlockAppealRelief(
459
+ sessionKey: string,
460
+ ipKey: string
461
+ ): Promise<boolean> {
462
+ if (!sessionKey || !ipKey) {
463
+ return false;
464
+ }
465
+
466
+ try {
467
+ const redis = await getTrustRedisClient();
468
+ if (!redis) {
469
+ return false;
470
+ }
471
+
472
+ return Boolean(
473
+ await redis.get<string>(buildIpBlockAppealReliefKey(sessionKey, ipKey))
474
+ );
475
+ } catch {
476
+ // Fail closed for the hard-block bypass.
477
+ return false;
478
+ }
479
+ }
@@ -34,6 +34,18 @@ export * from './user-agent';
34
34
  // In-memory fallback store
35
35
  const memoryStore = new Map<string, { count: number; expiresAt: number }>();
36
36
 
37
+ const SHARED_IP_THROTTLE_ONLY_BLOCK_REASONS = new Set<AbuseEventType>([
38
+ 'otp_send',
39
+ 'otp_verify_failed',
40
+ 'mfa_verify_failed',
41
+ 'reauth_verify_failed',
42
+ 'password_login_failed',
43
+ ]);
44
+
45
+ export function isSharedIpThrottleOnlyBlockReason(reason: AbuseEventType) {
46
+ return SHARED_IP_THROTTLE_ONLY_BLOCK_REASONS.has(reason);
47
+ }
48
+
37
49
  // Redis client singleton (lazy initialized)
38
50
  let redisClient: RedisClient | null = null;
39
51
  let redisInitialized = false;
@@ -396,6 +408,10 @@ export async function blockIP(
396
408
  reason: AbuseEventType,
397
409
  metadata?: Record<string, unknown>
398
410
  ): Promise<void> {
411
+ if (isSharedIpThrottleOnlyBlockReason(reason)) {
412
+ return;
413
+ }
414
+
399
415
  try {
400
416
  const sbAdmin = await getSupabaseAdmin();
401
417
  if (!sbAdmin) return;
@@ -900,18 +916,15 @@ export async function checkOTPSendAllowed(
900
916
  const ipLimits = getOTPSendIpLimits();
901
917
 
902
918
  if (minuteState.count > ipLimits.perMinute) {
903
- // Log and potentially block
904
919
  void logAbuseEvent(ipAddress, 'otp_send', {
905
920
  email,
906
921
  success: false,
907
- metadata: { trigger: 'minute_limit' },
922
+ metadata: {
923
+ trigger: 'minute_limit',
924
+ hard_block_suppressed: minuteState.count >= ipLimits.perMinute * 2,
925
+ },
908
926
  });
909
927
 
910
- if (minuteState.count >= ipLimits.perMinute * 2) {
911
- // Aggressive abuse - block IP
912
- void blockIP(ipAddress, 'otp_send', { trigger: 'rate_limit_exceeded' });
913
- }
914
-
915
928
  return {
916
929
  allowed: false,
917
930
  reason: 'Too many OTP requests. Please try again later.',
@@ -924,11 +937,12 @@ export async function checkOTPSendAllowed(
924
937
  void logAbuseEvent(ipAddress, 'otp_send', {
925
938
  email,
926
939
  success: false,
927
- metadata: { trigger: 'hourly_rate_limit' },
940
+ metadata: {
941
+ trigger: 'hourly_rate_limit',
942
+ hard_block_suppressed: true,
943
+ },
928
944
  });
929
945
 
930
- void blockIP(ipAddress, 'otp_send', { trigger: 'hourly_rate_limit' });
931
-
932
946
  return {
933
947
  allowed: false,
934
948
  reason: 'Hourly OTP limit reached. Please try again later.',
@@ -941,9 +955,11 @@ export async function checkOTPSendAllowed(
941
955
  void logAbuseEvent(ipAddress, 'otp_send', {
942
956
  email,
943
957
  success: false,
944
- metadata: { trigger: 'ip_daily_limit' },
958
+ metadata: {
959
+ trigger: 'ip_daily_limit',
960
+ hard_block_suppressed: true,
961
+ },
945
962
  });
946
- void blockIP(ipAddress, 'otp_send', { trigger: 'ip_daily_limit' });
947
963
 
948
964
  return {
949
965
  allowed: false,
@@ -1114,16 +1130,35 @@ export async function recordOTPVerifyFailure(
1114
1130
  ),
1115
1131
  ]);
1116
1132
 
1117
- // Log the failure
1118
- void logAbuseEvent(ipAddress, 'otp_verify_failed', { email, success: false });
1133
+ void logAbuseEvent(ipAddress, 'otp_verify_failed', {
1134
+ email,
1135
+ success: false,
1136
+ metadata: createHumanAuthRateLimitMetadata(
1137
+ ipCount,
1138
+ ABUSE_THRESHOLDS.OTP_VERIFY_FAILED_MAX
1139
+ ),
1140
+ });
1141
+ }
1119
1142
 
1120
- // Block if threshold exceeded
1121
- if (ipCount >= ABUSE_THRESHOLDS.OTP_VERIFY_FAILED_MAX) {
1122
- void blockIP(ipAddress, 'otp_verify_failed', {
1123
- trigger: 'max_failures_exceeded',
1124
- failedCount: ipCount,
1125
- });
1143
+ /**
1144
+ * Shared NATs can represent a whole classroom, university, or enterprise.
1145
+ * Human-auth counters still throttle the noisy flow, but they must not write a
1146
+ * global IP block that prevents unrelated people on the same public address
1147
+ * from signing in.
1148
+ */
1149
+ function createHumanAuthRateLimitMetadata(
1150
+ failedCount: number,
1151
+ maxFailures: number
1152
+ ): Record<string, unknown> | undefined {
1153
+ if (failedCount < maxFailures) {
1154
+ return undefined;
1126
1155
  }
1156
+
1157
+ return {
1158
+ trigger: 'max_failures_exceeded',
1159
+ failedCount,
1160
+ hard_block_suppressed: true,
1161
+ };
1127
1162
  }
1128
1163
 
1129
1164
  /**
@@ -1221,14 +1256,13 @@ export async function recordMFAVerifyFailure(ipAddress: string): Promise<void> {
1221
1256
  ABUSE_THRESHOLDS.MFA_VERIFY_FAILED_WINDOW_MS
1222
1257
  );
1223
1258
 
1224
- void logAbuseEvent(ipAddress, 'mfa_verify_failed', { success: false });
1225
-
1226
- if (count >= ABUSE_THRESHOLDS.MFA_VERIFY_FAILED_MAX) {
1227
- void blockIP(ipAddress, 'mfa_verify_failed', {
1228
- trigger: 'max_failures_exceeded',
1229
- failedCount: count,
1230
- });
1231
- }
1259
+ void logAbuseEvent(ipAddress, 'mfa_verify_failed', {
1260
+ success: false,
1261
+ metadata: createHumanAuthRateLimitMetadata(
1262
+ count,
1263
+ ABUSE_THRESHOLDS.MFA_VERIFY_FAILED_MAX
1264
+ ),
1265
+ });
1232
1266
  }
1233
1267
 
1234
1268
  /**
@@ -1322,11 +1356,13 @@ export async function recordReauthVerifyFailure(
1322
1356
  ABUSE_THRESHOLDS.REAUTH_VERIFY_FAILED_WINDOW_MS
1323
1357
  );
1324
1358
 
1325
- void logAbuseEvent(ipAddress, 'reauth_verify_failed', { success: false });
1326
-
1327
- if (count >= ABUSE_THRESHOLDS.REAUTH_VERIFY_FAILED_MAX) {
1328
- void blockIP(ipAddress, 'reauth_verify_failed');
1329
- }
1359
+ void logAbuseEvent(ipAddress, 'reauth_verify_failed', {
1360
+ success: false,
1361
+ metadata: createHumanAuthRateLimitMetadata(
1362
+ count,
1363
+ ABUSE_THRESHOLDS.REAUTH_VERIFY_FAILED_MAX
1364
+ ),
1365
+ });
1330
1366
  }
1331
1367
 
1332
1368
  /**
@@ -1431,14 +1467,11 @@ export async function recordPasswordLoginFailure(
1431
1467
  void logAbuseEvent(ipAddress, 'password_login_failed', {
1432
1468
  email: normalizedEmail ?? undefined,
1433
1469
  success: false,
1470
+ metadata: createHumanAuthRateLimitMetadata(
1471
+ count,
1472
+ ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_MAX
1473
+ ),
1434
1474
  });
1435
-
1436
- if (count >= ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_MAX) {
1437
- void blockIP(ipAddress, 'password_login_failed', {
1438
- trigger: 'max_failures_exceeded',
1439
- failedCount: count,
1440
- });
1441
- }
1442
1475
  }
1443
1476
 
1444
1477
  /**