@tuturuuu/utils 0.8.0 → 0.9.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.
@@ -2,8 +2,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
2
 
3
3
  const mocks = vi.hoisted(() => ({
4
4
  blockIPEdge: vi.fn(),
5
- checkUserSuspension: vi.fn(),
6
- suspendUser: vi.fn(),
7
5
  }));
8
6
 
9
7
  vi.mock('../edge', () => ({
@@ -11,14 +9,6 @@ vi.mock('../edge', () => ({
11
9
  mocks.blockIPEdge(...args),
12
10
  }));
13
11
 
14
- vi.mock('../user-suspension', () => ({
15
- checkUserSuspension: (
16
- ...args: Parameters<typeof mocks.checkUserSuspension>
17
- ) => mocks.checkUserSuspension(...args),
18
- suspendUser: (...args: Parameters<typeof mocks.suspendUser>) =>
19
- mocks.suspendUser(...args),
20
- }));
21
-
22
12
  import {
23
13
  cascadeBackendRateLimitToProxyBan,
24
14
  isBackendRateLimitError,
@@ -28,8 +18,6 @@ describe('backend-rate-limit', () => {
28
18
  beforeEach(() => {
29
19
  vi.clearAllMocks();
30
20
  mocks.blockIPEdge.mockResolvedValue(null);
31
- mocks.checkUserSuspension.mockResolvedValue({ suspended: false });
32
- mocks.suspendUser.mockResolvedValue(true);
33
21
  });
34
22
 
35
23
  describe('isBackendRateLimitError', () => {
@@ -51,16 +39,7 @@ describe('backend-rate-limit', () => {
51
39
  });
52
40
 
53
41
  describe('cascadeBackendRateLimitToProxyBan', () => {
54
- it('blocks the IP without suspending an identified user', async () => {
55
- const blockInfo = {
56
- id: 'block-1',
57
- blockLevel: 1,
58
- reason: 'api_abuse',
59
- blockedAt: new Date(),
60
- expiresAt: new Date(Date.now() + 300_000),
61
- };
62
- mocks.blockIPEdge.mockResolvedValue(blockInfo);
63
-
42
+ it('does not hard-ban a known client IP for a backend rate-limit signal', async () => {
64
43
  const result = await cascadeBackendRateLimitToProxyBan({
65
44
  endpoint: '/api/ai/chat/new',
66
45
  ipAddress: '203.0.113.10',
@@ -68,46 +47,32 @@ describe('backend-rate-limit', () => {
68
47
  userId: 'user-1',
69
48
  });
70
49
 
71
- expect(result).toBe(blockInfo);
72
- expect(mocks.blockIPEdge).toHaveBeenCalledWith(
73
- '203.0.113.10',
74
- 'api_abuse'
75
- );
76
- expect(mocks.checkUserSuspension).not.toHaveBeenCalled();
77
- expect(mocks.suspendUser).not.toHaveBeenCalled();
50
+ expect(result).toBeNull();
51
+ expect(mocks.blockIPEdge).not.toHaveBeenCalled();
78
52
  });
79
53
 
80
- it('does not inspect existing user suspensions', async () => {
81
- mocks.checkUserSuspension.mockResolvedValue({
82
- suspended: true,
83
- reason: 'already suspended',
84
- });
85
-
86
- await cascadeBackendRateLimitToProxyBan({
54
+ it('does not hard-ban auth backend rate-limit signals', async () => {
55
+ const result = await cascadeBackendRateLimitToProxyBan({
87
56
  endpoint: '/api/ai/chat/new',
88
57
  ipAddress: '203.0.113.10',
89
58
  source: 'auth',
90
59
  userId: 'user-1',
91
60
  });
92
61
 
93
- expect(mocks.suspendUser).not.toHaveBeenCalled();
94
- expect(mocks.blockIPEdge).toHaveBeenCalledWith(
95
- '203.0.113.10',
96
- 'api_abuse'
97
- );
98
- expect(mocks.checkUserSuspension).not.toHaveBeenCalled();
62
+ expect(result).toBeNull();
63
+ expect(mocks.blockIPEdge).not.toHaveBeenCalled();
99
64
  });
100
65
 
101
- it('does not suspend the user when the client IP is unavailable', async () => {
102
- await cascadeBackendRateLimitToProxyBan({
66
+ it('returns null when the client IP is unavailable', async () => {
67
+ const result = await cascadeBackendRateLimitToProxyBan({
103
68
  endpoint: '/api/ai/chat/new',
104
69
  ipAddress: 'unknown',
105
70
  source: 'database',
106
71
  userId: 'user-1',
107
72
  });
108
73
 
74
+ expect(result).toBeNull();
109
75
  expect(mocks.blockIPEdge).not.toHaveBeenCalled();
110
- expect(mocks.suspendUser).not.toHaveBeenCalled();
111
76
  });
112
77
  });
113
78
  });
@@ -80,6 +80,17 @@ describe('edge-trust cache', () => {
80
80
  expect(await getCachedTrustMultiplier(['session:aaa'])).toBe(2.5);
81
81
  });
82
82
 
83
+ it('returns neutral verified session entries without treating plain neutral entries as active', async () => {
84
+ mockRedisClient();
85
+ mocks.mget.mockResolvedValue([{ m: 1, verified: true }, 1]);
86
+
87
+ const { getCachedTrustEntries } = await import('../edge-trust.js');
88
+ const entries = await getCachedTrustEntries(['session:aaa', 'session:bbb']);
89
+
90
+ expect(entries.get('session:aaa')).toEqual({ m: 1, verified: true });
91
+ expect(entries.has('session:bbb')).toBe(false);
92
+ });
93
+
83
94
  it('fails open to a neutral multiplier when Redis errors', async () => {
84
95
  mockRedisClient();
85
96
  mocks.mget.mockRejectedValue(new Error('redis down'));
@@ -139,6 +150,53 @@ describe('edge-trust cache', () => {
139
150
  });
140
151
  });
141
152
 
153
+ it('writes neutral verified session markers without writing non-session subjects', async () => {
154
+ mockRedisClient();
155
+ mocks.mget.mockResolvedValue([null]);
156
+ mocks.set.mockResolvedValue('OK');
157
+
158
+ const { writeVerifiedSessionCacheForSubjects } = await import(
159
+ '../edge-trust.js'
160
+ );
161
+ await writeVerifiedSessionCacheForSubjects(
162
+ ['session:aaa', 'cidr:1.2.3.0/24'],
163
+ 600
164
+ );
165
+
166
+ expect(mocks.set).toHaveBeenCalledTimes(1);
167
+ expect(mocks.set).toHaveBeenCalledWith(
168
+ 'trust:mult:session:aaa',
169
+ { m: 1, verified: true },
170
+ { ex: 600 }
171
+ );
172
+ });
173
+
174
+ it('does not downgrade elevated trust entries when writing verified session markers', async () => {
175
+ mockRedisClient();
176
+ mocks.mget.mockResolvedValue([3, null]);
177
+ mocks.set.mockResolvedValue('OK');
178
+
179
+ const { writeVerifiedSessionCacheForSubjects } = await import(
180
+ '../edge-trust.js'
181
+ );
182
+ await writeVerifiedSessionCacheForSubjects(
183
+ ['session:elevated', 'session:neutral'],
184
+ 600
185
+ );
186
+
187
+ expect(mocks.set).toHaveBeenCalledTimes(1);
188
+ expect(mocks.set).toHaveBeenCalledWith(
189
+ 'trust:mult:session:neutral',
190
+ { m: 1, verified: true },
191
+ { ex: 600 }
192
+ );
193
+ expect(mocks.set).not.toHaveBeenCalledWith(
194
+ 'trust:mult:session:elevated',
195
+ expect.anything(),
196
+ expect.anything()
197
+ );
198
+ });
199
+
142
200
  it('parses legacy plain numbers and rich JSON entries together', async () => {
143
201
  mockRedisClient();
144
202
  mocks.mget.mockResolvedValue([
@@ -3,19 +3,27 @@
3
3
  */
4
4
 
5
5
  import { afterEach, describe, expect, it, vi } from 'vitest';
6
+
6
7
  import {
7
8
  ABUSE_THRESHOLDS,
8
9
  BLOCK_DURATIONS,
10
+ checkMFAVerifyLimit,
9
11
  checkOTPSendAllowed,
12
+ checkOTPVerifyLimit,
10
13
  checkPasswordLoginLimit,
14
+ checkReauthVerifyLimit,
11
15
  classifyPotentialSpamUserAgent,
12
16
  clearPasswordLoginFailures,
13
17
  extractIPFromHeaders,
14
18
  hashEmail,
19
+ isSharedIpThrottleOnlyBlockReason,
15
20
  MAX_BLOCK_LEVEL,
16
21
  REDIS_KEYS,
22
+ recordMFAVerifyFailure,
17
23
  recordOTPSendSuccess,
24
+ recordOTPVerifyFailure,
18
25
  recordPasswordLoginFailure,
26
+ recordReauthVerifyFailure,
19
27
  resetOtpLimitsForEmail,
20
28
  WINDOW_MS,
21
29
  } from '../index';
@@ -251,6 +259,23 @@ describe('abuse-protection', () => {
251
259
  });
252
260
  });
253
261
 
262
+ describe('shared-IP hard block suppression', () => {
263
+ it('keeps human-auth failure reasons throttle-only', () => {
264
+ expect(isSharedIpThrottleOnlyBlockReason('otp_send')).toBe(true);
265
+ expect(isSharedIpThrottleOnlyBlockReason('otp_verify_failed')).toBe(true);
266
+ expect(isSharedIpThrottleOnlyBlockReason('mfa_verify_failed')).toBe(true);
267
+ expect(isSharedIpThrottleOnlyBlockReason('reauth_verify_failed')).toBe(
268
+ true
269
+ );
270
+ expect(isSharedIpThrottleOnlyBlockReason('password_login_failed')).toBe(
271
+ true
272
+ );
273
+ expect(isSharedIpThrottleOnlyBlockReason('api_auth_failed')).toBe(false);
274
+ expect(isSharedIpThrottleOnlyBlockReason('api_abuse')).toBe(false);
275
+ expect(isSharedIpThrottleOnlyBlockReason('manual')).toBe(false);
276
+ });
277
+ });
278
+
254
279
  describe('password login failure limits', () => {
255
280
  it('keeps the IP-wide failure counter after another account succeeds', async () => {
256
281
  const ipAddress = '203.0.113.41';
@@ -293,6 +318,33 @@ describe('abuse-protection', () => {
293
318
  remainingAttempts: 10,
294
319
  });
295
320
  });
321
+
322
+ it('throttles shared-IP password failures without writing a hard IP block', async () => {
323
+ const ipAddress = '203.0.113.42';
324
+
325
+ for (
326
+ let attempt = 0;
327
+ attempt < ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_MAX;
328
+ attempt += 1
329
+ ) {
330
+ await recordPasswordLoginFailure(
331
+ ipAddress,
332
+ `password-shared-ip-${attempt}@example.com`
333
+ );
334
+ }
335
+
336
+ const result = await checkPasswordLoginLimit(
337
+ ipAddress,
338
+ 'another-user@example.com'
339
+ );
340
+
341
+ expect(result).toMatchObject({
342
+ allowed: false,
343
+ reason: 'Too many failed login attempts',
344
+ remainingAttempts: 0,
345
+ });
346
+ expect(result.blocked).not.toBe(true);
347
+ });
296
348
  });
297
349
 
298
350
  describe('BLOCK_DURATIONS constants', () => {
@@ -469,6 +521,90 @@ describe('abuse-protection', () => {
469
521
  expect(blockedAttempt.retryAfter).toBeGreaterThan(0);
470
522
  });
471
523
 
524
+ it('throttles aggressive shared-IP OTP sends without writing a hard IP block', async () => {
525
+ vi.stubEnv('ABUSE_OTP_SEND_IP_LIMIT_MINUTE', '1');
526
+ const ipAddress = '198.51.100.12';
527
+
528
+ await expect(
529
+ checkOTPSendAllowed(ipAddress, 'otp-shared-ip-1@example.com')
530
+ ).resolves.toMatchObject({ allowed: true });
531
+
532
+ const blockedAttempt = await checkOTPSendAllowed(
533
+ ipAddress,
534
+ 'otp-shared-ip-2@example.com'
535
+ );
536
+
537
+ expect(blockedAttempt).toMatchObject({
538
+ allowed: false,
539
+ remainingAttempts: 0,
540
+ });
541
+ expect(blockedAttempt.blocked).not.toBe(true);
542
+ });
543
+
544
+ it('throttles shared-IP OTP verify failures without writing a hard IP block', async () => {
545
+ const ipAddress = '198.51.100.13';
546
+
547
+ for (
548
+ let attempt = 0;
549
+ attempt < ABUSE_THRESHOLDS.OTP_VERIFY_FAILED_MAX;
550
+ attempt += 1
551
+ ) {
552
+ await recordOTPVerifyFailure(
553
+ ipAddress,
554
+ `otp-verify-shared-ip-${attempt}@example.com`
555
+ );
556
+ }
557
+
558
+ const result = await checkOTPVerifyLimit(
559
+ ipAddress,
560
+ 'legit-teacher@example.com'
561
+ );
562
+
563
+ expect(result).toMatchObject({
564
+ allowed: false,
565
+ reason: 'Too many failed verification attempts from this IP',
566
+ remainingAttempts: 0,
567
+ });
568
+ expect(result.blocked).not.toBe(true);
569
+ });
570
+
571
+ it('throttles shared-IP MFA and reauth failures without writing a hard IP block', async () => {
572
+ const mfaIpAddress = '198.51.100.14';
573
+ const reauthIpAddress = '198.51.100.15';
574
+
575
+ for (
576
+ let attempt = 0;
577
+ attempt < ABUSE_THRESHOLDS.MFA_VERIFY_FAILED_MAX;
578
+ attempt += 1
579
+ ) {
580
+ await recordMFAVerifyFailure(mfaIpAddress);
581
+ }
582
+
583
+ for (
584
+ let attempt = 0;
585
+ attempt < ABUSE_THRESHOLDS.REAUTH_VERIFY_FAILED_MAX;
586
+ attempt += 1
587
+ ) {
588
+ await recordReauthVerifyFailure(reauthIpAddress);
589
+ }
590
+
591
+ const mfaResult = await checkMFAVerifyLimit(mfaIpAddress);
592
+ const reauthResult = await checkReauthVerifyLimit(reauthIpAddress);
593
+
594
+ expect(mfaResult).toMatchObject({
595
+ allowed: false,
596
+ reason: 'Too many failed MFA verification attempts',
597
+ remainingAttempts: 0,
598
+ });
599
+ expect(reauthResult).toMatchObject({
600
+ allowed: false,
601
+ reason: 'Too many failed reauthentication attempts',
602
+ remainingAttempts: 0,
603
+ });
604
+ expect(mfaResult.blocked).not.toBe(true);
605
+ expect(reauthResult.blocked).not.toBe(true);
606
+ });
607
+
472
608
  it('caps successful sends for the same email across distributed IPs within an hour', async () => {
473
609
  vi.useFakeTimers();
474
610
  vi.setSystemTime(new Date('2026-03-12T00:00:00.000Z'));
@@ -1,4 +1,3 @@
1
- import { blockIPEdge } from './edge';
2
1
  import type { BlockInfo } from './types';
3
2
 
4
3
  type BackendRateLimitSource = 'auth' | 'database';
@@ -37,8 +36,10 @@ export function isBackendRateLimitError(
37
36
  }
38
37
 
39
38
  export async function cascadeBackendRateLimitToProxyBan({
40
- ipAddress,
39
+ ipAddress: _ipAddress,
41
40
  }: BackendRateLimitEscalationOptions): Promise<BlockInfo | null> {
42
- const shouldBlockIp = ipAddress && ipAddress !== 'unknown';
43
- return shouldBlockIp ? blockIPEdge(ipAddress, 'api_abuse') : null;
41
+ // A backend 429 is an availability/backpressure signal. It is not enough
42
+ // evidence to hard-ban a public NAT IP that may represent a classroom,
43
+ // university, or enterprise office.
44
+ return null;
44
45
  }
@@ -39,6 +39,8 @@ export interface CachedTrustEntry {
39
39
  mode?: 'absolute' | 'unlimited';
40
40
  /** For mode=absolute: explicit per-window READ limits. */
41
41
  abs?: { minute?: number; hour?: number; day?: number };
42
+ /** Server-verified session marker. Does not increase limits by itself. */
43
+ verified?: boolean;
42
44
  }
43
45
 
44
46
  const DEFAULT_TRUST_CACHE_TTL_SECONDS = 3600; // 1 hour
@@ -156,14 +158,25 @@ function parseEntry(value: unknown): CachedTrustEntry | null {
156
158
  ? record.mode
157
159
  : undefined;
158
160
  const abs = parseAbsLimits(record.abs);
161
+ const verified = record.verified === true;
159
162
 
160
- return { abs, m, mode };
163
+ const entry: CachedTrustEntry = { m };
164
+ if (mode) {
165
+ entry.mode = mode;
166
+ }
167
+ if (abs) {
168
+ entry.abs = abs;
169
+ }
170
+ if (verified) {
171
+ entry.verified = true;
172
+ }
173
+ return entry;
161
174
  }
162
175
 
163
176
  /** Serializes an entry for storage: plain number when multiplier-only. */
164
177
  function serializeEntry(entry: CachedTrustEntry): number | CachedTrustEntry {
165
178
  const m = clampMultiplier(entry.m);
166
- if (!entry.mode && !entry.abs) {
179
+ if (!entry.mode && !entry.abs && !entry.verified) {
167
180
  return m;
168
181
  }
169
182
 
@@ -182,20 +195,28 @@ function serializeEntry(entry: CachedTrustEntry): number | CachedTrustEntry {
182
195
  serialized.abs = abs;
183
196
  }
184
197
  }
198
+ if (entry.verified) {
199
+ serialized.verified = true;
200
+ }
185
201
  return serialized;
186
202
  }
187
203
 
188
204
  function isActiveEntry(entry: CachedTrustEntry): boolean {
189
- return entry.mode != null || entry.m > NEUTRAL_TRUST_MULTIPLIER;
205
+ return (
206
+ entry.mode != null ||
207
+ entry.verified === true ||
208
+ entry.m > NEUTRAL_TRUST_MULTIPLIER
209
+ );
190
210
  }
191
211
 
192
212
  /**
193
213
  * Resolves the cached trust multiplier for each provided subject key.
194
214
  *
195
215
  * 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).
216
+ * entry above the neutral baseline, admin-mode entries, or neutral verified
217
+ * session entries. Missing keys are omitted. Returns an empty map on any Redis
218
+ * error (fail-open). Lets callers distinguish account trust (session key) from
219
+ * location trust (cidr/ip keys).
199
220
  */
200
221
  export async function getCachedTrustEntries(
201
222
  subjectKeys: string[]
@@ -342,3 +363,50 @@ export async function writeTrustCacheForSubjects(
342
363
  // Cache writes must never block the protected request path.
343
364
  }
344
365
  }
366
+
367
+ /**
368
+ * Write-through a neutral marker for server-verified browser/app sessions.
369
+ * This only proves the session cookie was successfully validated downstream; it
370
+ * does not grant a read-limit multiplier or admin override.
371
+ */
372
+ export async function writeVerifiedSessionCacheForSubjects(
373
+ subjectKeys: string[],
374
+ ttlSeconds: number = getTrustCacheTtlSeconds()
375
+ ): Promise<void> {
376
+ const sessionKeys = subjectKeys.filter((subjectKey) =>
377
+ subjectKey.startsWith('session:')
378
+ );
379
+ if (sessionKeys.length === 0) {
380
+ return;
381
+ }
382
+
383
+ try {
384
+ const redis = await getTrustRedisClient();
385
+ if (!redis) {
386
+ return;
387
+ }
388
+
389
+ const existingEntries = await getCachedTrustEntries(sessionKeys);
390
+ const verifiedEntry = serializeEntry({
391
+ m: NEUTRAL_TRUST_MULTIPLIER,
392
+ verified: true,
393
+ });
394
+ await Promise.all(
395
+ sessionKeys.map((subjectKey) => {
396
+ const existing = existingEntries.get(subjectKey);
397
+ if (
398
+ existing &&
399
+ (existing.mode != null || existing.abs != null || existing.m > 1)
400
+ ) {
401
+ return Promise.resolve();
402
+ }
403
+
404
+ return redis.set(buildTrustCacheKey(subjectKey), verifiedEntry, {
405
+ ex: ttlSeconds,
406
+ });
407
+ })
408
+ );
409
+ } catch {
410
+ // Cache writes must never block the protected request path.
411
+ }
412
+ }
@@ -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
  /**