@tuturuuu/utils 0.0.3 → 0.6.1

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.
Files changed (186) hide show
  1. package/CHANGELOG.md +313 -0
  2. package/biome.json +5 -0
  3. package/jsr.json +8 -8
  4. package/package.json +63 -32
  5. package/src/__tests__/ai-temp-auth.test.ts +309 -0
  6. package/src/__tests__/api-proxy-guard.test.ts +1451 -0
  7. package/src/__tests__/app-url.test.ts +270 -0
  8. package/src/__tests__/avatar-url.test.ts +97 -0
  9. package/src/__tests__/color-helper.test.ts +179 -0
  10. package/src/__tests__/constants.test.ts +351 -0
  11. package/src/__tests__/crypto.test.ts +107 -0
  12. package/src/__tests__/date-helper.test.ts +408 -0
  13. package/src/__tests__/fixtures/task-description-full-featured.json +456 -0
  14. package/src/__tests__/format.test.ts +317 -0
  15. package/src/__tests__/html-sanitizer.test.ts +360 -0
  16. package/src/__tests__/interest-calculator.test.ts +336 -0
  17. package/src/__tests__/interest-detector.test.ts +222 -0
  18. package/src/__tests__/label-colors.test.ts +241 -0
  19. package/src/__tests__/name-helper.test.ts +158 -0
  20. package/src/__tests__/node-diff.test.ts +576 -0
  21. package/src/__tests__/notification-service.test.ts +210 -0
  22. package/src/__tests__/onboarding-helper.test.ts +331 -0
  23. package/src/__tests__/path-helper.test.ts +152 -0
  24. package/src/__tests__/permissions.test.tsx +81 -0
  25. package/src/__tests__/request-emoji-limit.test.ts +172 -0
  26. package/src/__tests__/search-helper.test.ts +51 -0
  27. package/src/__tests__/storage-display-name.test.ts +37 -0
  28. package/src/__tests__/storage-path.test.ts +238 -0
  29. package/src/__tests__/tag-utils.test.ts +205 -0
  30. package/src/__tests__/task-description-yjs-state.test.ts +581 -0
  31. package/src/__tests__/task-helper-board-api-routing.test.ts +94 -0
  32. package/src/__tests__/task-helper-create-task.test.ts +129 -0
  33. package/src/__tests__/task-helpers.test.ts +464 -0
  34. package/src/__tests__/task-overrides.test.ts +305 -0
  35. package/src/__tests__/task-reorder-cache.test.ts +74 -0
  36. package/src/__tests__/task-sort-keys.test.ts +36 -0
  37. package/src/__tests__/task-transformers.test.ts +62 -0
  38. package/src/__tests__/text-helper.test.ts +776 -0
  39. package/src/__tests__/time-helper.test.ts +70 -0
  40. package/src/__tests__/time-tracker-period.test.ts +55 -0
  41. package/src/__tests__/timezone.test.ts +117 -0
  42. package/src/__tests__/upstash-rest.test.ts +77 -0
  43. package/src/__tests__/uuid-helper.test.ts +133 -0
  44. package/src/__tests__/workspace-helper.test.ts +859 -0
  45. package/src/__tests__/workspace-limits.test.ts +255 -0
  46. package/src/__tests__/yjs-helper.test.ts +581 -0
  47. package/src/abuse-protection/__tests__/backend-rate-limit.test.ts +113 -0
  48. package/src/abuse-protection/__tests__/edge.test.ts +136 -0
  49. package/src/abuse-protection/__tests__/index.test.ts +562 -0
  50. package/src/abuse-protection/__tests__/reputation.test.ts +192 -0
  51. package/src/abuse-protection/backend-rate-limit.ts +44 -0
  52. package/src/abuse-protection/constants.ts +117 -0
  53. package/src/abuse-protection/edge.ts +223 -0
  54. package/src/abuse-protection/index.ts +1545 -0
  55. package/src/abuse-protection/reputation.ts +587 -0
  56. package/src/abuse-protection/types.ts +97 -0
  57. package/src/abuse-protection/user-agent.ts +124 -0
  58. package/src/abuse-protection/user-suspension.ts +231 -0
  59. package/src/ai-temp-auth.ts +315 -0
  60. package/src/api-proxy-guard.ts +965 -0
  61. package/src/app-url.ts +96 -0
  62. package/src/avatar-url.ts +64 -0
  63. package/src/break-duration.ts +84 -0
  64. package/src/calendar-auth-token.test.ts +37 -0
  65. package/src/calendar-auth-token.ts +19 -0
  66. package/src/calendar-sync-coordination.md +197 -0
  67. package/src/calendar-utils.test.ts +169 -0
  68. package/src/calendar-utils.ts +91 -0
  69. package/src/color-helper.ts +110 -0
  70. package/src/common/nextjs.tsx +99 -0
  71. package/src/common/scan.tsx +15 -0
  72. package/src/configs/reports.ts +160 -0
  73. package/src/constants.ts +85 -0
  74. package/src/crypto.ts +21 -0
  75. package/src/currencies.ts +97 -0
  76. package/src/date-helper.ts +313 -0
  77. package/src/editor/convert-to-task.ts +264 -0
  78. package/src/editor/index.ts +5 -0
  79. package/src/email/__tests__/client.test.ts +141 -0
  80. package/src/email/__tests__/validation.test.ts +46 -0
  81. package/src/email/client.ts +92 -0
  82. package/src/email/server.ts +128 -0
  83. package/src/email/validation.ts +11 -0
  84. package/src/encryption/__tests__/calendar-events.test.ts +411 -0
  85. package/src/encryption/__tests__/configuration.test.ts +114 -0
  86. package/src/encryption/__tests__/field-encryption.test.ts +232 -0
  87. package/src/encryption/__tests__/key-generation.test.ts +30 -0
  88. package/src/encryption/__tests__/performance-edge-cases.test.ts +187 -0
  89. package/src/encryption/__tests__/test-helpers.ts +22 -0
  90. package/src/encryption/__tests__/workspace-key-encryption.test.ts +129 -0
  91. package/src/encryption/encryption-service.ts +343 -0
  92. package/src/encryption/index.ts +25 -0
  93. package/src/encryption/types.ts +57 -0
  94. package/src/exchange-rates.ts +49 -0
  95. package/src/feature-flags/__tests__/feature-flags.test.ts +302 -0
  96. package/src/feature-flags/core.ts +322 -0
  97. package/src/feature-flags/data.ts +16 -0
  98. package/src/feature-flags/default.ts +18 -0
  99. package/src/feature-flags/index.ts +7 -0
  100. package/src/feature-flags/requestable-features.ts +79 -0
  101. package/src/feature-flags/types.ts +4 -0
  102. package/src/fetcher.ts +2 -0
  103. package/src/finance/index.ts +4 -0
  104. package/src/finance/interest-calculator.ts +456 -0
  105. package/src/finance/interest-detector.ts +141 -0
  106. package/src/finance/transform-invoice-results.ts +219 -0
  107. package/src/finance/wallet-permissions.test.ts +169 -0
  108. package/src/finance/wallet-permissions.ts +82 -0
  109. package/src/format.ts +120 -1
  110. package/src/generated/platform-build-metadata.ts +11 -0
  111. package/src/hooks/use-platform.ts +64 -0
  112. package/src/html-sanitizer.ts +155 -0
  113. package/src/internal-domains.ts +497 -0
  114. package/src/keyboard-preset.ts +109 -0
  115. package/src/label-colors.ts +213 -0
  116. package/src/launchable-apps.test.ts +126 -0
  117. package/src/launchable-apps.ts +490 -0
  118. package/src/name-helper.ts +269 -0
  119. package/src/next-config.test.ts +234 -0
  120. package/src/next-config.ts +203 -0
  121. package/src/node-diff.ts +375 -0
  122. package/src/notification-service.ts +379 -0
  123. package/src/nova/scores/__tests__/calculate.test.ts +254 -0
  124. package/src/nova/scores/calculate.ts +132 -0
  125. package/src/nova/submissions/check-permission.ts +132 -0
  126. package/src/onboarding-helper.ts +213 -0
  127. package/src/path-helper.ts +93 -0
  128. package/src/permissions.tsx +1170 -0
  129. package/src/plan-helpers.test.ts +188 -0
  130. package/src/plan-helpers.ts +80 -0
  131. package/src/platform-release.test.ts +74 -0
  132. package/src/platform-release.ts +155 -0
  133. package/src/portless.ts +124 -0
  134. package/src/priority-styles.ts +42 -0
  135. package/src/request-emoji-limit.ts +335 -0
  136. package/src/search-helper.ts +18 -0
  137. package/src/search.test.ts +89 -0
  138. package/src/search.ts +355 -0
  139. package/src/storage-display-name.ts +30 -0
  140. package/src/storage-path.ts +147 -0
  141. package/src/tag-utils.ts +159 -0
  142. package/src/task/reorder.ts +245 -0
  143. package/src/task/transformers.ts +149 -0
  144. package/src/task-date-timezone.ts +133 -0
  145. package/src/task-description-content.ts +240 -0
  146. package/src/task-helper/board.ts +193 -0
  147. package/src/task-helper/bulk-actions.ts +564 -0
  148. package/src/task-helper/personal-external-staging.ts +21 -0
  149. package/src/task-helper/recycle-bin.ts +202 -0
  150. package/src/task-helper/relationships.ts +346 -0
  151. package/src/task-helper/shared.ts +109 -0
  152. package/src/task-helper/sort-keys.ts +337 -0
  153. package/src/task-helper/task-hooks-basic.ts +342 -0
  154. package/src/task-helper/task-hooks-move.ts +264 -0
  155. package/src/task-helper/task-operations.ts +278 -0
  156. package/src/task-helper.ts +12 -0
  157. package/src/task-helpers.ts +241 -0
  158. package/src/task-list-status.ts +62 -0
  159. package/src/task-overrides.ts +82 -0
  160. package/src/task-snapshot.ts +374 -0
  161. package/src/text-diff.ts +81 -0
  162. package/src/text-helper.ts +537 -0
  163. package/src/time-helper.ts +63 -0
  164. package/src/time-tracker-period.ts +73 -0
  165. package/src/timeblock-helper.ts +418 -0
  166. package/src/timezone.ts +190 -0
  167. package/src/timezones.json +1271 -0
  168. package/src/upstash-rest.ts +56 -0
  169. package/src/user-helper.ts +296 -0
  170. package/src/uuid-helper.ts +11 -0
  171. package/src/workspace-handle.ts +10 -0
  172. package/src/workspace-helper.ts +1408 -0
  173. package/src/workspace-limits.ts +68 -0
  174. package/src/yjs-helper.ts +217 -0
  175. package/src/yjs-task-description.ts +81 -0
  176. package/tsconfig.json +3 -5
  177. package/tsconfig.typecheck.json +33 -0
  178. package/vitest.config.ts +36 -0
  179. package/dist/index.d.ts +0 -8
  180. package/dist/index.js +0 -2
  181. package/dist/index.js.map +0 -1
  182. package/dist/index.mjs +0 -2
  183. package/dist/index.mjs.map +0 -1
  184. package/eslint.config.mjs +0 -20
  185. package/rollup.config.js +0 -41
  186. package/src/index.ts +0 -1
@@ -0,0 +1,1545 @@
1
+ /**
2
+ * OTP Abuse Protection System
3
+ *
4
+ * Provides rate limiting and IP blocking for OTP-related operations
5
+ * to prevent brute force attacks and enumeration.
6
+ */
7
+
8
+ import { createHash } from 'node:crypto';
9
+ import type { SupabaseClient } from '@tuturuuu/supabase';
10
+ import type { Database, Json } from '@tuturuuu/types';
11
+ import { getUpstashRestRedisClient, hasUpstashRestEnv } from '../upstash-rest';
12
+ import {
13
+ ABUSE_THRESHOLDS,
14
+ BLOCK_DURATIONS,
15
+ MAX_BLOCK_LEVEL,
16
+ REDIS_KEYS,
17
+ WINDOW_MS,
18
+ } from './constants';
19
+ import type {
20
+ AbuseCheckResult,
21
+ AbuseEventType,
22
+ AbuseProtectionLogContext,
23
+ BlockInfo,
24
+ LogAbuseEventOptions,
25
+ RedisClient,
26
+ } from './types';
27
+
28
+ // Re-export types and constants
29
+ export * from './constants';
30
+ export * from './reputation';
31
+ export * from './types';
32
+ export * from './user-agent';
33
+
34
+ // In-memory fallback store
35
+ const memoryStore = new Map<string, { count: number; expiresAt: number }>();
36
+
37
+ // Redis client singleton (lazy initialized)
38
+ let redisClient: RedisClient | null = null;
39
+ let redisInitialized = false;
40
+
41
+ function parsePositiveIntEnv(name: string, fallback: number): number {
42
+ const rawValue = process.env[name];
43
+ if (!rawValue) {
44
+ return fallback;
45
+ }
46
+
47
+ const parsed = Number.parseInt(rawValue, 10);
48
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
49
+ }
50
+
51
+ function getOTPSendIpLimits() {
52
+ return {
53
+ perDay: parsePositiveIntEnv(
54
+ 'ABUSE_OTP_SEND_IP_LIMIT_DAY',
55
+ ABUSE_THRESHOLDS.OTP_SEND_PER_DAY
56
+ ),
57
+ perHour: parsePositiveIntEnv(
58
+ 'ABUSE_OTP_SEND_IP_LIMIT_HOUR',
59
+ ABUSE_THRESHOLDS.OTP_SEND_PER_HOUR
60
+ ),
61
+ perMinute: parsePositiveIntEnv(
62
+ 'ABUSE_OTP_SEND_IP_LIMIT_MINUTE',
63
+ ABUSE_THRESHOLDS.OTP_SEND_PER_MINUTE
64
+ ),
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Initialize Redis client from Upstash environment variables
70
+ */
71
+ async function getRedisClient(): Promise<RedisClient | null> {
72
+ if (redisInitialized) return redisClient;
73
+
74
+ try {
75
+ if (!hasUpstashRestEnv()) {
76
+ console.warn(
77
+ '[Abuse Protection] Redis not configured - falling back to memory'
78
+ );
79
+ redisInitialized = true;
80
+ return null;
81
+ }
82
+
83
+ redisClient = await getUpstashRestRedisClient();
84
+ redisInitialized = true;
85
+ return redisClient;
86
+ } catch (error) {
87
+ console.warn('[Abuse Protection] Redis unavailable:', error);
88
+ redisInitialized = true;
89
+ return null;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Validate IP address format
95
+ */
96
+ function isValidIP(ip: string): boolean {
97
+ const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
98
+ const ipv6Regex = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/;
99
+ return ipv4Regex.test(ip) || ipv6Regex.test(ip);
100
+ }
101
+
102
+ /**
103
+ * Extract client IP address from request headers
104
+ * Works with Next.js headers() in Server Actions
105
+ */
106
+ export function extractIPFromHeaders(
107
+ headers: Headers | Map<string, string> | Record<string, string | null>
108
+ ): string {
109
+ const getHeader = (name: string): string | null => {
110
+ if (headers instanceof Headers) {
111
+ return headers.get(name);
112
+ }
113
+ if (headers instanceof Map) {
114
+ return headers.get(name) || null;
115
+ }
116
+ return headers[name] || null;
117
+ };
118
+
119
+ // Check cf-connecting-ip (Cloudflare)
120
+ const cfIP = getHeader('cf-connecting-ip');
121
+ if (cfIP && isValidIP(cfIP)) {
122
+ return cfIP;
123
+ }
124
+
125
+ // Check true-client-ip (some Cloudflare/enterprise proxy setups)
126
+ const trueClientIP = getHeader('true-client-ip');
127
+ if (trueClientIP && isValidIP(trueClientIP)) {
128
+ return trueClientIP;
129
+ }
130
+
131
+ // Check x-forwarded-for after explicit client IP headers
132
+ const forwardedFor = getHeader('x-forwarded-for');
133
+ if (forwardedFor) {
134
+ const firstIP = forwardedFor.split(',')[0]?.trim();
135
+ if (firstIP && isValidIP(firstIP)) {
136
+ return firstIP;
137
+ }
138
+ }
139
+
140
+ // Check x-real-ip (Nginx)
141
+ const realIP = getHeader('x-real-ip');
142
+ if (realIP && isValidIP(realIP)) {
143
+ return realIP;
144
+ }
145
+
146
+ return 'unknown';
147
+ }
148
+
149
+ /**
150
+ * Hash email for privacy when storing in logs
151
+ */
152
+ export function hashEmail(email: string): string {
153
+ return createHash('sha256')
154
+ .update(email.trim().toLowerCase())
155
+ .digest('hex')
156
+ .substring(0, 16);
157
+ }
158
+
159
+ function normalizeAbuseEventEmail(email?: string): string | null {
160
+ const normalized = email?.trim().toLowerCase();
161
+ return normalized ? normalized : null;
162
+ }
163
+
164
+ function sanitizeAbuseLogValue(value: string | null | undefined) {
165
+ const normalized = value?.trim();
166
+ return normalized ? normalized.slice(0, 256) : null;
167
+ }
168
+
169
+ function createAbuseLogContext(
170
+ ipAddress: string,
171
+ context?: AbuseProtectionLogContext
172
+ ) {
173
+ return {
174
+ ipAddress,
175
+ operation: 'is_ip_blocked',
176
+ route: sanitizeAbuseLogValue(context?.route),
177
+ source: sanitizeAbuseLogValue(context?.source),
178
+ };
179
+ }
180
+
181
+ /**
182
+ * Increment a counter in Redis or memory with automatic expiration
183
+ */
184
+ async function incrementCounter(
185
+ key: string,
186
+ windowMs: number
187
+ ): Promise<{ count: number; ttl: number }> {
188
+ const redis = await getRedisClient();
189
+
190
+ if (redis) {
191
+ try {
192
+ const count = await redis.incr(key);
193
+ if (count === 1) {
194
+ await redis.expire(key, Math.ceil(windowMs / 1000));
195
+ }
196
+ const ttl = await redis.ttl(key);
197
+ return { count, ttl: ttl > 0 ? ttl : Math.ceil(windowMs / 1000) };
198
+ } catch (error) {
199
+ console.error('[Abuse Protection] Redis error:', error);
200
+ // Fall through to memory
201
+ }
202
+ }
203
+
204
+ // Memory fallback
205
+ const now = Date.now();
206
+ const existing = memoryStore.get(key);
207
+
208
+ if (!existing || now > existing.expiresAt) {
209
+ memoryStore.set(key, { count: 1, expiresAt: now + windowMs });
210
+ return { count: 1, ttl: Math.ceil(windowMs / 1000) };
211
+ }
212
+
213
+ existing.count++;
214
+ return {
215
+ count: existing.count,
216
+ ttl: Math.ceil((existing.expiresAt - now) / 1000),
217
+ };
218
+ }
219
+
220
+ /**
221
+ * Get counter value from Redis or memory
222
+ */
223
+ async function getCounter(key: string): Promise<number> {
224
+ const redis = await getRedisClient();
225
+
226
+ if (redis) {
227
+ try {
228
+ const count = await redis.get<number>(key);
229
+ return count || 0;
230
+ } catch {
231
+ // Fall through to memory
232
+ }
233
+ }
234
+
235
+ const existing = memoryStore.get(key);
236
+ if (!existing || Date.now() > existing.expiresAt) {
237
+ return 0;
238
+ }
239
+ return existing.count;
240
+ }
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
+ /**
281
+ * Delete keys from Redis or memory
282
+ */
283
+ async function deleteKeys(...keys: string[]): Promise<void> {
284
+ const redis = await getRedisClient();
285
+
286
+ if (redis) {
287
+ try {
288
+ await redis.del(...keys);
289
+ return;
290
+ } catch {
291
+ // Fall through to memory
292
+ }
293
+ }
294
+
295
+ for (const key of keys) {
296
+ memoryStore.delete(key);
297
+ }
298
+ }
299
+
300
+ async function deleteKeysWithCount(...keys: string[]): Promise<number> {
301
+ if (keys.length === 0) {
302
+ return 0;
303
+ }
304
+
305
+ const redis = await getRedisClient();
306
+
307
+ if (redis) {
308
+ try {
309
+ const deleted = await redis.del(...keys);
310
+ return typeof deleted === 'number' ? deleted : 0;
311
+ } catch {
312
+ // Fall through to memory
313
+ }
314
+ }
315
+
316
+ let deleted = 0;
317
+ for (const key of keys) {
318
+ if (memoryStore.delete(key)) {
319
+ deleted++;
320
+ }
321
+ }
322
+
323
+ return deleted;
324
+ }
325
+
326
+ /**
327
+ * Create Supabase admin client for database operations
328
+ */
329
+ async function getSupabaseAdmin(): Promise<SupabaseClient<Database> | null> {
330
+ try {
331
+ const { createAdminClient } = await import(
332
+ '@tuturuuu/supabase/next/server'
333
+ );
334
+ return (await createAdminClient()) as SupabaseClient<Database>;
335
+ } catch (error) {
336
+ console.error(
337
+ '[Abuse Protection] Failed to create Supabase client:',
338
+ error
339
+ );
340
+ return null;
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Check if an IP is currently blocked
346
+ */
347
+ export async function isIPBlocked(
348
+ ipAddress: string,
349
+ context?: AbuseProtectionLogContext
350
+ ): Promise<BlockInfo | null> {
351
+ const redis = await getRedisClient();
352
+
353
+ // Check Redis cache first
354
+ if (redis) {
355
+ try {
356
+ const cached = await redis.get<string>(REDIS_KEYS.IP_BLOCKED(ipAddress));
357
+ if (cached) {
358
+ const blockInfo =
359
+ typeof cached === 'string' ? JSON.parse(cached) : cached;
360
+ if (new Date(blockInfo.expiresAt) > new Date()) {
361
+ return {
362
+ id: blockInfo.id,
363
+ blockLevel: blockInfo.level,
364
+ reason: blockInfo.reason,
365
+ expiresAt: new Date(blockInfo.expiresAt),
366
+ blockedAt: new Date(blockInfo.blockedAt),
367
+ };
368
+ }
369
+ }
370
+ } catch (error) {
371
+ console.error(
372
+ '[Abuse Protection] Redis cache error:',
373
+ error,
374
+ createAbuseLogContext(ipAddress, context)
375
+ );
376
+ }
377
+ }
378
+
379
+ // Check database
380
+ try {
381
+ const sbAdmin = await getSupabaseAdmin();
382
+ if (!sbAdmin) return null;
383
+
384
+ const { data, error } = await sbAdmin.rpc('get_active_ip_block', {
385
+ p_ip_address: ipAddress,
386
+ });
387
+
388
+ if (error || !data || (Array.isArray(data) && data.length === 0)) {
389
+ return null;
390
+ }
391
+
392
+ const block = Array.isArray(data) ? data[0] : data;
393
+ if (!block) {
394
+ return null;
395
+ }
396
+ const blockInfo: BlockInfo = {
397
+ id: block.id,
398
+ blockLevel: block.block_level,
399
+ reason: block.reason,
400
+ expiresAt: new Date(block.expires_at),
401
+ blockedAt: new Date(block.blocked_at),
402
+ };
403
+
404
+ // Cache in Redis
405
+ if (redis && blockInfo.expiresAt > new Date()) {
406
+ const ttl = Math.ceil(
407
+ (blockInfo.expiresAt.getTime() - Date.now()) / 1000
408
+ );
409
+ await redis.set(
410
+ REDIS_KEYS.IP_BLOCKED(ipAddress),
411
+ JSON.stringify({
412
+ id: blockInfo.id,
413
+ level: blockInfo.blockLevel,
414
+ reason: blockInfo.reason,
415
+ expiresAt: blockInfo.expiresAt.toISOString(),
416
+ blockedAt: blockInfo.blockedAt.toISOString(),
417
+ }),
418
+ { ex: ttl }
419
+ );
420
+ }
421
+
422
+ return blockInfo;
423
+ } catch (error) {
424
+ console.error('[Abuse Protection] DB error checking block:', error);
425
+ return null;
426
+ }
427
+ }
428
+
429
+ /**
430
+ * Block an IP address with progressive duration
431
+ */
432
+ export async function blockIP(
433
+ ipAddress: string,
434
+ reason: AbuseEventType,
435
+ metadata?: Record<string, unknown>
436
+ ): Promise<void> {
437
+ try {
438
+ const sbAdmin = await getSupabaseAdmin();
439
+ if (!sbAdmin) return;
440
+
441
+ const redis = await getRedisClient();
442
+
443
+ // Get current block level
444
+ let currentLevel = 0;
445
+ if (redis) {
446
+ try {
447
+ const level = await redis.get<number>(
448
+ REDIS_KEYS.IP_BLOCK_LEVEL(ipAddress)
449
+ );
450
+ currentLevel = level || 0;
451
+ } catch {
452
+ // Check DB fallback
453
+ const { data } = await sbAdmin.rpc('get_ip_block_level', {
454
+ p_ip_address: ipAddress,
455
+ });
456
+ currentLevel = (data as number) || 0;
457
+ }
458
+ } else {
459
+ const { data } = await sbAdmin.rpc('get_ip_block_level', {
460
+ p_ip_address: ipAddress,
461
+ });
462
+ currentLevel = (data as number) || 0;
463
+ }
464
+
465
+ // Calculate new block level (max 4)
466
+ const newLevel = Math.min(currentLevel + 1, MAX_BLOCK_LEVEL) as
467
+ | 1
468
+ | 2
469
+ | 3
470
+ | 4;
471
+ const blockDuration = BLOCK_DURATIONS[newLevel];
472
+ const expiresAt = new Date(Date.now() + blockDuration * 1000);
473
+
474
+ // Insert block record
475
+ const { data: blockRecord, error } = await sbAdmin
476
+ .from('blocked_ips')
477
+ .insert([
478
+ {
479
+ ip_address: ipAddress,
480
+ reason: reason as never,
481
+ block_level: newLevel,
482
+ expires_at: expiresAt.toISOString(),
483
+ metadata: (metadata || {}) as Json,
484
+ },
485
+ ])
486
+ .select('id')
487
+ .single();
488
+
489
+ if (error) {
490
+ console.error('[Abuse Protection] Error creating block:', error);
491
+ return;
492
+ }
493
+
494
+ // Update Redis cache
495
+ if (redis) {
496
+ await Promise.all([
497
+ redis.set(
498
+ REDIS_KEYS.IP_BLOCKED(ipAddress),
499
+ JSON.stringify({
500
+ id: blockRecord.id,
501
+ level: newLevel,
502
+ reason,
503
+ expiresAt: expiresAt.toISOString(),
504
+ blockedAt: new Date().toISOString(),
505
+ }),
506
+ { ex: blockDuration }
507
+ ),
508
+ redis.set(REDIS_KEYS.IP_BLOCK_LEVEL(ipAddress), newLevel, {
509
+ ex: WINDOW_MS.TWENTY_FOUR_HOURS / 1000,
510
+ }),
511
+ ]);
512
+ }
513
+
514
+ console.log(
515
+ `[Abuse Protection] Blocked IP ${ipAddress} at level ${newLevel} for ${blockDuration}s due to ${reason}`
516
+ );
517
+ } catch (error) {
518
+ console.error('[Abuse Protection] Error blocking IP:', error);
519
+ }
520
+ }
521
+
522
+ /**
523
+ * Manually unblock an IP address
524
+ */
525
+ export async function unblockIP(
526
+ ipAddress: string,
527
+ unblockingUserId: string,
528
+ reason?: string
529
+ ): Promise<boolean> {
530
+ try {
531
+ const sbAdmin = await getSupabaseAdmin();
532
+ if (!sbAdmin) return false;
533
+
534
+ const redis = await getRedisClient();
535
+
536
+ // Update all active blocks for this IP
537
+ const { error } = await sbAdmin
538
+ .from('blocked_ips')
539
+ .update({
540
+ status: 'manually_unblocked',
541
+ unblocked_at: new Date().toISOString(),
542
+ unblocked_by: unblockingUserId,
543
+ unblock_reason: reason || 'Manual unblock by admin',
544
+ })
545
+ .eq('ip_address', ipAddress)
546
+ .eq('status', 'active');
547
+
548
+ if (error) {
549
+ console.error('[Abuse Protection] Error unblocking IP:', error);
550
+ return false;
551
+ }
552
+
553
+ // Clear Redis cache
554
+ if (redis) {
555
+ await deleteKeys(
556
+ REDIS_KEYS.IP_BLOCKED(ipAddress),
557
+ REDIS_KEYS.IP_BLOCK_LEVEL(ipAddress)
558
+ );
559
+ }
560
+
561
+ console.log(
562
+ `[Abuse Protection] Unblocked IP ${ipAddress} by user ${unblockingUserId}`
563
+ );
564
+ return true;
565
+ } catch (error) {
566
+ console.error('[Abuse Protection] Error unblocking IP:', error);
567
+ return false;
568
+ }
569
+ }
570
+
571
+ /**
572
+ * Log an abuse event for audit trail
573
+ */
574
+ export async function logAbuseEvent(
575
+ ipAddress: string,
576
+ eventType: AbuseEventType,
577
+ options?: LogAbuseEventOptions
578
+ ): Promise<void> {
579
+ try {
580
+ const sbAdmin = await getSupabaseAdmin();
581
+ if (!sbAdmin) return;
582
+ const normalizedEmail = normalizeAbuseEventEmail(options?.email);
583
+
584
+ await sbAdmin.from('abuse_events').insert([
585
+ {
586
+ ip_address: ipAddress,
587
+ event_type: eventType as never,
588
+ email: normalizedEmail,
589
+ email_hash: normalizedEmail ? hashEmail(normalizedEmail) : null,
590
+ user_agent: options?.userAgent?.substring(0, 500),
591
+ endpoint: options?.endpoint,
592
+ success: options?.success ?? false,
593
+ metadata: (options?.metadata || {}) as Json,
594
+ },
595
+ ]);
596
+ } catch (error) {
597
+ console.error('[Abuse Protection] Error logging event:', error);
598
+ }
599
+ }
600
+
601
+ export interface ResetOtpLimitsForEmailOptions {
602
+ email: string;
603
+ clearEmailScoped: boolean;
604
+ clearRelatedIpCounters: boolean;
605
+ clearRelatedIpBlocks: boolean;
606
+ adminUserId: string;
607
+ reason?: string;
608
+ adminIpAddress?: string;
609
+ }
610
+
611
+ export interface ResetOtpLimitsForEmailResult {
612
+ relatedIps: string[];
613
+ clearedEmailKeys: number;
614
+ clearedIpCounterCount: number;
615
+ unblockedIpCount: number;
616
+ }
617
+
618
+ function isAsciiLetterOrDigit(char: string): boolean {
619
+ const code = char.charCodeAt(0);
620
+
621
+ return (
622
+ (code >= 48 && code <= 57) ||
623
+ (code >= 65 && code <= 90) ||
624
+ (code >= 97 && code <= 122)
625
+ );
626
+ }
627
+
628
+ function isAllowedLocalEmailCharacter(char: string): boolean {
629
+ if (isAsciiLetterOrDigit(char)) {
630
+ return true;
631
+ }
632
+
633
+ switch (char) {
634
+ case '!':
635
+ case '#':
636
+ case '$':
637
+ case '%':
638
+ case '&':
639
+ case "'":
640
+ case '*':
641
+ case '+':
642
+ case '-':
643
+ case '.':
644
+ case '/':
645
+ case '=':
646
+ case '?':
647
+ case '^':
648
+ case '_':
649
+ case '`':
650
+ case '{':
651
+ case '|':
652
+ case '}':
653
+ case '~':
654
+ return true;
655
+ default:
656
+ return false;
657
+ }
658
+ }
659
+
660
+ function isValidEmailDomainLabel(label: string): boolean {
661
+ if (!label || label.startsWith('-') || label.endsWith('-')) {
662
+ return false;
663
+ }
664
+
665
+ for (const char of label) {
666
+ if (!isAsciiLetterOrDigit(char) && char !== '-') {
667
+ return false;
668
+ }
669
+ }
670
+
671
+ return true;
672
+ }
673
+
674
+ function hasWhitespace(text: string): boolean {
675
+ for (const char of text) {
676
+ if (
677
+ char === ' ' ||
678
+ char === '\t' ||
679
+ char === '\n' ||
680
+ char === '\r' ||
681
+ char === '\f' ||
682
+ char === '\v'
683
+ ) {
684
+ return true;
685
+ }
686
+ }
687
+
688
+ return false;
689
+ }
690
+
691
+ function isValidNormalizedEmail(email: string): boolean {
692
+ if (!email || email.length > 254 || hasWhitespace(email)) {
693
+ return false;
694
+ }
695
+
696
+ const atIndex = email.indexOf('@');
697
+ if (atIndex <= 0 || atIndex !== email.lastIndexOf('@')) {
698
+ return false;
699
+ }
700
+
701
+ const localPart = email.slice(0, atIndex);
702
+ const domainPart = email.slice(atIndex + 1);
703
+
704
+ if (
705
+ !localPart ||
706
+ !domainPart ||
707
+ localPart.length > 64 ||
708
+ domainPart.length > 253 ||
709
+ localPart.startsWith('.') ||
710
+ localPart.endsWith('.') ||
711
+ localPart.includes('..') ||
712
+ domainPart.startsWith('.') ||
713
+ domainPart.endsWith('.') ||
714
+ domainPart.includes('..')
715
+ ) {
716
+ return false;
717
+ }
718
+
719
+ for (const char of localPart) {
720
+ if (!isAllowedLocalEmailCharacter(char)) {
721
+ return false;
722
+ }
723
+ }
724
+
725
+ const domainLabels = domainPart.split('.');
726
+ if (domainLabels.length < 2) {
727
+ return false;
728
+ }
729
+
730
+ for (const label of domainLabels) {
731
+ if (!isValidEmailDomainLabel(label)) {
732
+ return false;
733
+ }
734
+ }
735
+
736
+ const topLevelDomain = domainLabels[domainLabels.length - 1];
737
+ if (!topLevelDomain || topLevelDomain.length < 2) {
738
+ return false;
739
+ }
740
+
741
+ return true;
742
+ }
743
+
744
+ function normalizeOtpResetEmail(email: string): string {
745
+ const normalized = email.trim().toLowerCase();
746
+
747
+ if (!isValidNormalizedEmail(normalized)) {
748
+ throw new Error('Email is invalid');
749
+ }
750
+
751
+ return normalized;
752
+ }
753
+
754
+ function getOtpResetScopeKeys(ipAddress: string): string[] {
755
+ return [
756
+ REDIS_KEYS.OTP_SEND(ipAddress),
757
+ REDIS_KEYS.OTP_SEND_HOURLY(ipAddress),
758
+ REDIS_KEYS.OTP_SEND_DAILY(ipAddress),
759
+ REDIS_KEYS.OTP_VERIFY_FAILED(ipAddress),
760
+ REDIS_KEYS.IP_BLOCK_LEVEL(ipAddress),
761
+ ];
762
+ }
763
+
764
+ export async function resetOtpLimitsForEmail({
765
+ email,
766
+ clearEmailScoped,
767
+ clearRelatedIpCounters,
768
+ clearRelatedIpBlocks,
769
+ adminUserId,
770
+ reason,
771
+ adminIpAddress,
772
+ }: ResetOtpLimitsForEmailOptions): Promise<ResetOtpLimitsForEmailResult> {
773
+ if (!clearEmailScoped && !clearRelatedIpCounters && !clearRelatedIpBlocks) {
774
+ throw new Error('At least one OTP reset option is required');
775
+ }
776
+
777
+ const normalizedEmail = normalizeOtpResetEmail(email);
778
+ const emailHash = hashEmail(normalizedEmail);
779
+ const sbAdmin = await getSupabaseAdmin();
780
+
781
+ if (!sbAdmin) {
782
+ throw new Error('Failed to create Supabase client');
783
+ }
784
+
785
+ let relatedIps: string[] = [];
786
+ if (clearRelatedIpCounters || clearRelatedIpBlocks) {
787
+ const sinceIso = new Date(
788
+ Date.now() - WINDOW_MS.TWENTY_FOUR_HOURS
789
+ ).toISOString();
790
+ const { data: relatedEvents, error: relatedEventsError } = await sbAdmin
791
+ .from('abuse_events')
792
+ .select('ip_address')
793
+ .eq('email', normalizedEmail)
794
+ .in('event_type', ['otp_send', 'otp_verify_failed'])
795
+ .gte('created_at', sinceIso);
796
+
797
+ if (relatedEventsError) {
798
+ throw relatedEventsError;
799
+ }
800
+
801
+ relatedIps = Array.from(
802
+ new Set(
803
+ (relatedEvents ?? [])
804
+ .map((event: { ip_address: string | null }) => event.ip_address)
805
+ .filter((value: string | null): value is string => !!value)
806
+ )
807
+ );
808
+ }
809
+
810
+ let clearedEmailKeys = 0;
811
+ if (clearEmailScoped) {
812
+ clearedEmailKeys = await deleteKeysWithCount(
813
+ REDIS_KEYS.OTP_SEND_EMAIL_COOLDOWN(emailHash),
814
+ REDIS_KEYS.OTP_SEND_EMAIL_HOURLY(emailHash),
815
+ REDIS_KEYS.OTP_SEND_EMAIL_DAILY(emailHash),
816
+ REDIS_KEYS.OTP_VERIFY_FAILED_EMAIL(emailHash)
817
+ );
818
+ }
819
+
820
+ let clearedIpCounterCount = 0;
821
+ if (clearRelatedIpCounters && relatedIps.length > 0) {
822
+ const relatedIpKeys = relatedIps.flatMap((ipAddress) =>
823
+ getOtpResetScopeKeys(ipAddress)
824
+ );
825
+ clearedIpCounterCount = await deleteKeysWithCount(...relatedIpKeys);
826
+ }
827
+
828
+ let unblockedIpCount = 0;
829
+ let unblockedIps: string[] = [];
830
+ if (clearRelatedIpBlocks && relatedIps.length > 0) {
831
+ const { data: activeBlocks, error: activeBlocksError } = await sbAdmin
832
+ .from('blocked_ips')
833
+ .select('ip_address')
834
+ .in('ip_address', relatedIps)
835
+ .in('reason', ['otp_send', 'otp_verify_failed'])
836
+ .eq('status', 'active');
837
+
838
+ if (activeBlocksError) {
839
+ throw activeBlocksError;
840
+ }
841
+
842
+ unblockedIps = Array.from(
843
+ new Set(
844
+ (activeBlocks ?? [])
845
+ .map((block: { ip_address: string | null }) => block.ip_address)
846
+ .filter((value: string | null): value is string => !!value)
847
+ )
848
+ );
849
+
850
+ if (unblockedIps.length > 0) {
851
+ const { error: unblockError } = await sbAdmin
852
+ .from('blocked_ips')
853
+ .update({
854
+ status: 'manually_unblocked',
855
+ unblocked_at: new Date().toISOString(),
856
+ unblocked_by: adminUserId,
857
+ unblock_reason:
858
+ reason || 'Manual OTP limit reset unblock by infrastructure admin',
859
+ })
860
+ .in('ip_address', unblockedIps)
861
+ .in('reason', ['otp_send', 'otp_verify_failed'])
862
+ .eq('status', 'active');
863
+
864
+ if (unblockError) {
865
+ throw unblockError;
866
+ }
867
+
868
+ await deleteKeysWithCount(
869
+ ...unblockedIps.flatMap((ipAddress) => [
870
+ REDIS_KEYS.IP_BLOCKED(ipAddress),
871
+ REDIS_KEYS.IP_BLOCK_LEVEL(ipAddress),
872
+ ])
873
+ );
874
+ unblockedIpCount = unblockedIps.length;
875
+ }
876
+ }
877
+
878
+ await logAbuseEvent(adminIpAddress || 'unknown', 'otp_limit_reset', {
879
+ email: normalizedEmail,
880
+ success: true,
881
+ metadata: {
882
+ admin_user_id: adminUserId,
883
+ reason: reason || null,
884
+ clearEmailScoped,
885
+ clearRelatedIpCounters,
886
+ clearRelatedIpBlocks,
887
+ related_ips: relatedIps,
888
+ related_ips_count: relatedIps.length,
889
+ cleared_email_keys: clearedEmailKeys,
890
+ cleared_ip_counter_count: clearedIpCounterCount,
891
+ unblocked_ips: unblockedIps,
892
+ unblocked_ip_count: unblockedIpCount,
893
+ },
894
+ });
895
+
896
+ return {
897
+ relatedIps,
898
+ clearedEmailKeys,
899
+ clearedIpCounterCount,
900
+ unblockedIpCount,
901
+ };
902
+ }
903
+
904
+ /**
905
+ * Check and track OTP send attempts
906
+ */
907
+ export async function checkOTPSendAllowed(
908
+ ipAddress: string,
909
+ email?: string,
910
+ context?: AbuseProtectionLogContext
911
+ ): Promise<AbuseCheckResult> {
912
+ // First check if IP is blocked
913
+ const blockInfo = await isIPBlocked(ipAddress, context);
914
+ if (blockInfo) {
915
+ const retryAfter = Math.ceil(
916
+ (blockInfo.expiresAt.getTime() - Date.now()) / 1000
917
+ );
918
+ return {
919
+ allowed: false,
920
+ blocked: true,
921
+ reason: `IP blocked due to ${blockInfo.reason}. Block level: ${blockInfo.blockLevel}`,
922
+ retryAfter,
923
+ };
924
+ }
925
+
926
+ const minuteKey = REDIS_KEYS.OTP_SEND(ipAddress);
927
+ const hourlyKey = REDIS_KEYS.OTP_SEND_HOURLY(ipAddress);
928
+ const dailyKey = REDIS_KEYS.OTP_SEND_DAILY(ipAddress);
929
+
930
+ const [minuteState, hourlyState, dailyState] = await Promise.all([
931
+ getCounterWithTTL(minuteKey),
932
+ getCounterWithTTL(hourlyKey),
933
+ getCounterWithTTL(dailyKey),
934
+ ]);
935
+ const ipLimits = getOTPSendIpLimits();
936
+
937
+ if (minuteState.count >= ipLimits.perMinute) {
938
+ // Log and potentially block
939
+ void logAbuseEvent(ipAddress, 'otp_send', {
940
+ email,
941
+ success: false,
942
+ metadata: { trigger: 'minute_limit' },
943
+ });
944
+
945
+ if (minuteState.count >= ipLimits.perMinute * 2) {
946
+ // Aggressive abuse - block IP
947
+ void blockIP(ipAddress, 'otp_send', { trigger: 'rate_limit_exceeded' });
948
+ }
949
+
950
+ return {
951
+ allowed: false,
952
+ reason: 'Too many OTP requests. Please try again later.',
953
+ retryAfter: minuteState.ttl,
954
+ remainingAttempts: 0,
955
+ };
956
+ }
957
+
958
+ if (hourlyState.count >= ipLimits.perHour) {
959
+ void logAbuseEvent(ipAddress, 'otp_send', {
960
+ email,
961
+ success: false,
962
+ metadata: { trigger: 'hourly_rate_limit' },
963
+ });
964
+
965
+ void blockIP(ipAddress, 'otp_send', { trigger: 'hourly_rate_limit' });
966
+
967
+ return {
968
+ allowed: false,
969
+ reason: 'Hourly OTP limit reached. Please try again later.',
970
+ retryAfter: hourlyState.ttl,
971
+ remainingAttempts: 0,
972
+ };
973
+ }
974
+
975
+ if (dailyState.count >= ipLimits.perDay) {
976
+ void logAbuseEvent(ipAddress, 'otp_send', {
977
+ email,
978
+ success: false,
979
+ metadata: { trigger: 'ip_daily_limit' },
980
+ });
981
+ void blockIP(ipAddress, 'otp_send', { trigger: 'ip_daily_limit' });
982
+
983
+ return {
984
+ allowed: false,
985
+ reason: 'OTP limit reached. Please try again later.',
986
+ retryAfter: dailyState.ttl,
987
+ remainingAttempts: 0,
988
+ };
989
+ }
990
+
991
+ if (email) {
992
+ const emailHash = hashEmail(email);
993
+
994
+ const cooldownKey = REDIS_KEYS.OTP_SEND_EMAIL_COOLDOWN(emailHash);
995
+ const hourlyEmailKey = REDIS_KEYS.OTP_SEND_EMAIL_HOURLY(emailHash);
996
+ const dailyEmailKey = REDIS_KEYS.OTP_SEND_EMAIL_DAILY(emailHash);
997
+
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) {
1006
+ void logAbuseEvent(ipAddress, 'otp_send', {
1007
+ email,
1008
+ success: false,
1009
+ metadata: { trigger: 'email_cooldown' },
1010
+ });
1011
+
1012
+ return {
1013
+ allowed: false,
1014
+ reason: 'Too many OTP requests. Please try again later.',
1015
+ retryAfter: cooldownState.ttl,
1016
+ remainingAttempts: 0,
1017
+ };
1018
+ }
1019
+
1020
+ if (hourlyEmailState.count >= ABUSE_THRESHOLDS.OTP_SEND_EMAIL_PER_HOUR) {
1021
+ void logAbuseEvent(ipAddress, 'otp_send', {
1022
+ email,
1023
+ success: false,
1024
+ metadata: { trigger: 'email_hourly_limit' },
1025
+ });
1026
+
1027
+ return {
1028
+ allowed: false,
1029
+ reason: 'Hourly OTP limit reached. Please try again later.',
1030
+ retryAfter: hourlyEmailState.ttl,
1031
+ remainingAttempts: 0,
1032
+ };
1033
+ }
1034
+
1035
+ if (dailyEmailState.count >= ABUSE_THRESHOLDS.OTP_SEND_EMAIL_PER_DAY) {
1036
+ void logAbuseEvent(ipAddress, 'otp_send', {
1037
+ email,
1038
+ success: false,
1039
+ metadata: { trigger: 'email_daily_limit' },
1040
+ });
1041
+
1042
+ return {
1043
+ allowed: false,
1044
+ reason: 'OTP limit reached. Please try again later.',
1045
+ retryAfter: dailyEmailState.ttl,
1046
+ remainingAttempts: 0,
1047
+ };
1048
+ }
1049
+ }
1050
+
1051
+ return {
1052
+ allowed: true,
1053
+ remainingAttempts: Math.max(
1054
+ 0,
1055
+ ipLimits.perMinute - (minuteState.count + 1)
1056
+ ),
1057
+ };
1058
+ }
1059
+
1060
+ export async function recordOTPSendSuccess(
1061
+ ipAddress: string,
1062
+ email?: string
1063
+ ): 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
+ void logAbuseEvent(ipAddress, 'otp_send', { email, success: true });
1090
+ }
1091
+
1092
+ // Backward-compatible alias for call sites that still import the old helper.
1093
+ export async function checkOTPSendLimit(
1094
+ ipAddress: string,
1095
+ email?: string,
1096
+ context?: AbuseProtectionLogContext
1097
+ ): Promise<AbuseCheckResult> {
1098
+ return checkOTPSendAllowed(ipAddress, email, context);
1099
+ }
1100
+
1101
+ /**
1102
+ * Check if OTP verification is allowed
1103
+ */
1104
+ export async function checkOTPVerifyLimit(
1105
+ ipAddress: string,
1106
+ email: string,
1107
+ context?: AbuseProtectionLogContext
1108
+ ): Promise<AbuseCheckResult> {
1109
+ // First check if IP is blocked
1110
+ const blockInfo = await isIPBlocked(ipAddress, context);
1111
+ if (blockInfo) {
1112
+ const retryAfter = Math.ceil(
1113
+ (blockInfo.expiresAt.getTime() - Date.now()) / 1000
1114
+ );
1115
+ return {
1116
+ allowed: false,
1117
+ blocked: true,
1118
+ reason: `IP blocked due to ${blockInfo.reason}`,
1119
+ retryAfter,
1120
+ };
1121
+ }
1122
+
1123
+ // Get current counts (don't increment yet - increment on failure)
1124
+ const ipKey = REDIS_KEYS.OTP_VERIFY_FAILED(ipAddress);
1125
+ const emailKey = REDIS_KEYS.OTP_VERIFY_FAILED_EMAIL(hashEmail(email));
1126
+
1127
+ const [ipCount, emailCount] = await Promise.all([
1128
+ getCounter(ipKey),
1129
+ getCounter(emailKey),
1130
+ ]);
1131
+
1132
+ if (ipCount >= ABUSE_THRESHOLDS.OTP_VERIFY_FAILED_MAX) {
1133
+ return {
1134
+ allowed: false,
1135
+ reason: 'Too many failed verification attempts from this IP',
1136
+ retryAfter: Math.ceil(
1137
+ ABUSE_THRESHOLDS.OTP_VERIFY_FAILED_WINDOW_MS / 1000
1138
+ ),
1139
+ remainingAttempts: 0,
1140
+ };
1141
+ }
1142
+
1143
+ if (emailCount >= ABUSE_THRESHOLDS.OTP_VERIFY_FAILED_EMAIL_MAX) {
1144
+ return {
1145
+ allowed: false,
1146
+ reason: 'Too many failed verification attempts for this email',
1147
+ retryAfter: Math.ceil(
1148
+ ABUSE_THRESHOLDS.OTP_VERIFY_FAILED_EMAIL_WINDOW_MS / 1000
1149
+ ),
1150
+ remainingAttempts: 0,
1151
+ };
1152
+ }
1153
+
1154
+ return {
1155
+ allowed: true,
1156
+ remainingAttempts: ABUSE_THRESHOLDS.OTP_VERIFY_FAILED_MAX - ipCount,
1157
+ };
1158
+ }
1159
+
1160
+ /**
1161
+ * Record a failed OTP verification attempt
1162
+ */
1163
+ export async function recordOTPVerifyFailure(
1164
+ ipAddress: string,
1165
+ email: string
1166
+ ): Promise<void> {
1167
+ const ipKey = REDIS_KEYS.OTP_VERIFY_FAILED(ipAddress);
1168
+ const emailKey = REDIS_KEYS.OTP_VERIFY_FAILED_EMAIL(hashEmail(email));
1169
+
1170
+ const [{ count: ipCount }] = await Promise.all([
1171
+ incrementCounter(ipKey, ABUSE_THRESHOLDS.OTP_VERIFY_FAILED_WINDOW_MS),
1172
+ incrementCounter(
1173
+ emailKey,
1174
+ ABUSE_THRESHOLDS.OTP_VERIFY_FAILED_EMAIL_WINDOW_MS
1175
+ ),
1176
+ ]);
1177
+
1178
+ // Log the failure
1179
+ void logAbuseEvent(ipAddress, 'otp_verify_failed', { email, success: false });
1180
+
1181
+ // Block if threshold exceeded
1182
+ if (ipCount >= ABUSE_THRESHOLDS.OTP_VERIFY_FAILED_MAX) {
1183
+ void blockIP(ipAddress, 'otp_verify_failed', {
1184
+ trigger: 'max_failures_exceeded',
1185
+ failedCount: ipCount,
1186
+ });
1187
+ }
1188
+ }
1189
+
1190
+ /**
1191
+ * Clear failed attempts on successful verification
1192
+ */
1193
+ export async function clearOTPVerifyFailures(
1194
+ ipAddress: string,
1195
+ email: string
1196
+ ): Promise<void> {
1197
+ await deleteKeys(
1198
+ REDIS_KEYS.OTP_VERIFY_FAILED(ipAddress),
1199
+ REDIS_KEYS.OTP_VERIFY_FAILED_EMAIL(hashEmail(email))
1200
+ );
1201
+ }
1202
+
1203
+ /**
1204
+ * Check and track MFA challenge attempts
1205
+ */
1206
+ export async function checkMFAChallengeLimit(
1207
+ ipAddress: string
1208
+ ): Promise<AbuseCheckResult> {
1209
+ const blockInfo = await isIPBlocked(ipAddress);
1210
+ if (blockInfo) {
1211
+ return {
1212
+ allowed: false,
1213
+ blocked: true,
1214
+ reason: `IP blocked due to ${blockInfo.reason}`,
1215
+ retryAfter: Math.ceil(
1216
+ (blockInfo.expiresAt.getTime() - Date.now()) / 1000
1217
+ ),
1218
+ };
1219
+ }
1220
+
1221
+ const key = REDIS_KEYS.MFA_CHALLENGE(ipAddress);
1222
+ const { count, ttl } = await incrementCounter(key, WINDOW_MS.ONE_MINUTE);
1223
+
1224
+ if (count > ABUSE_THRESHOLDS.MFA_CHALLENGE_PER_MINUTE) {
1225
+ void logAbuseEvent(ipAddress, 'mfa_challenge', { success: false });
1226
+ return {
1227
+ allowed: false,
1228
+ reason: 'Too many MFA challenge requests',
1229
+ retryAfter: ttl,
1230
+ remainingAttempts: 0,
1231
+ };
1232
+ }
1233
+
1234
+ return { allowed: true };
1235
+ }
1236
+
1237
+ /**
1238
+ * Check if MFA verification is allowed
1239
+ */
1240
+ export async function checkMFAVerifyLimit(
1241
+ ipAddress: string
1242
+ ): Promise<AbuseCheckResult> {
1243
+ const blockInfo = await isIPBlocked(ipAddress);
1244
+ if (blockInfo) {
1245
+ return {
1246
+ allowed: false,
1247
+ blocked: true,
1248
+ reason: `IP blocked due to ${blockInfo.reason}`,
1249
+ retryAfter: Math.ceil(
1250
+ (blockInfo.expiresAt.getTime() - Date.now()) / 1000
1251
+ ),
1252
+ };
1253
+ }
1254
+
1255
+ const key = REDIS_KEYS.MFA_VERIFY_FAILED(ipAddress);
1256
+ const count = await getCounter(key);
1257
+
1258
+ if (count >= ABUSE_THRESHOLDS.MFA_VERIFY_FAILED_MAX) {
1259
+ return {
1260
+ allowed: false,
1261
+ reason: 'Too many failed MFA verification attempts',
1262
+ retryAfter: Math.ceil(
1263
+ ABUSE_THRESHOLDS.MFA_VERIFY_FAILED_WINDOW_MS / 1000
1264
+ ),
1265
+ remainingAttempts: 0,
1266
+ };
1267
+ }
1268
+
1269
+ return {
1270
+ allowed: true,
1271
+ remainingAttempts: ABUSE_THRESHOLDS.MFA_VERIFY_FAILED_MAX - count,
1272
+ };
1273
+ }
1274
+
1275
+ /**
1276
+ * Record a failed MFA verification attempt
1277
+ */
1278
+ export async function recordMFAVerifyFailure(ipAddress: string): Promise<void> {
1279
+ const key = REDIS_KEYS.MFA_VERIFY_FAILED(ipAddress);
1280
+ const { count } = await incrementCounter(
1281
+ key,
1282
+ ABUSE_THRESHOLDS.MFA_VERIFY_FAILED_WINDOW_MS
1283
+ );
1284
+
1285
+ void logAbuseEvent(ipAddress, 'mfa_verify_failed', { success: false });
1286
+
1287
+ if (count >= ABUSE_THRESHOLDS.MFA_VERIFY_FAILED_MAX) {
1288
+ void blockIP(ipAddress, 'mfa_verify_failed', {
1289
+ trigger: 'max_failures_exceeded',
1290
+ failedCount: count,
1291
+ });
1292
+ }
1293
+ }
1294
+
1295
+ /**
1296
+ * Clear MFA failures on success
1297
+ */
1298
+ export async function clearMFAVerifyFailures(ipAddress: string): Promise<void> {
1299
+ await deleteKeys(REDIS_KEYS.MFA_VERIFY_FAILED(ipAddress));
1300
+ }
1301
+
1302
+ /**
1303
+ * Check reauthentication send limits
1304
+ */
1305
+ export async function checkReauthSendLimit(
1306
+ ipAddress: string
1307
+ ): Promise<AbuseCheckResult> {
1308
+ const blockInfo = await isIPBlocked(ipAddress);
1309
+ if (blockInfo) {
1310
+ return {
1311
+ allowed: false,
1312
+ blocked: true,
1313
+ reason: `IP blocked due to ${blockInfo.reason}`,
1314
+ retryAfter: Math.ceil(
1315
+ (blockInfo.expiresAt.getTime() - Date.now()) / 1000
1316
+ ),
1317
+ };
1318
+ }
1319
+
1320
+ const key = REDIS_KEYS.REAUTH_SEND(ipAddress);
1321
+ const { count, ttl } = await incrementCounter(key, WINDOW_MS.ONE_MINUTE);
1322
+
1323
+ if (count > ABUSE_THRESHOLDS.REAUTH_SEND_PER_MINUTE) {
1324
+ void logAbuseEvent(ipAddress, 'reauth_send', { success: false });
1325
+ return {
1326
+ allowed: false,
1327
+ reason: 'Too many reauthentication requests',
1328
+ retryAfter: ttl,
1329
+ remainingAttempts: 0,
1330
+ };
1331
+ }
1332
+
1333
+ return { allowed: true };
1334
+ }
1335
+
1336
+ /**
1337
+ * Check reauthentication verify limits
1338
+ */
1339
+ export async function checkReauthVerifyLimit(
1340
+ ipAddress: string
1341
+ ): Promise<AbuseCheckResult> {
1342
+ const blockInfo = await isIPBlocked(ipAddress);
1343
+ if (blockInfo) {
1344
+ return {
1345
+ allowed: false,
1346
+ blocked: true,
1347
+ reason: `IP blocked due to ${blockInfo.reason}`,
1348
+ retryAfter: Math.ceil(
1349
+ (blockInfo.expiresAt.getTime() - Date.now()) / 1000
1350
+ ),
1351
+ };
1352
+ }
1353
+
1354
+ const key = REDIS_KEYS.REAUTH_VERIFY_FAILED(ipAddress);
1355
+ const count = await getCounter(key);
1356
+
1357
+ if (count >= ABUSE_THRESHOLDS.REAUTH_VERIFY_FAILED_MAX) {
1358
+ return {
1359
+ allowed: false,
1360
+ reason: 'Too many failed reauthentication attempts',
1361
+ retryAfter: Math.ceil(
1362
+ ABUSE_THRESHOLDS.REAUTH_VERIFY_FAILED_WINDOW_MS / 1000
1363
+ ),
1364
+ remainingAttempts: 0,
1365
+ };
1366
+ }
1367
+
1368
+ return {
1369
+ allowed: true,
1370
+ remainingAttempts: ABUSE_THRESHOLDS.REAUTH_VERIFY_FAILED_MAX - count,
1371
+ };
1372
+ }
1373
+
1374
+ /**
1375
+ * Record failed reauthentication
1376
+ */
1377
+ export async function recordReauthVerifyFailure(
1378
+ ipAddress: string
1379
+ ): Promise<void> {
1380
+ const key = REDIS_KEYS.REAUTH_VERIFY_FAILED(ipAddress);
1381
+ const { count } = await incrementCounter(
1382
+ key,
1383
+ ABUSE_THRESHOLDS.REAUTH_VERIFY_FAILED_WINDOW_MS
1384
+ );
1385
+
1386
+ void logAbuseEvent(ipAddress, 'reauth_verify_failed', { success: false });
1387
+
1388
+ if (count >= ABUSE_THRESHOLDS.REAUTH_VERIFY_FAILED_MAX) {
1389
+ void blockIP(ipAddress, 'reauth_verify_failed');
1390
+ }
1391
+ }
1392
+
1393
+ /**
1394
+ * Clear reauth failures on success
1395
+ */
1396
+ export async function clearReauthVerifyFailures(
1397
+ ipAddress: string
1398
+ ): Promise<void> {
1399
+ await deleteKeys(REDIS_KEYS.REAUTH_VERIFY_FAILED(ipAddress));
1400
+ }
1401
+
1402
+ /**
1403
+ * Check password login limits
1404
+ */
1405
+ export async function checkPasswordLoginLimit(
1406
+ ipAddress: string,
1407
+ context?: AbuseProtectionLogContext
1408
+ ): Promise<AbuseCheckResult> {
1409
+ const blockInfo = await isIPBlocked(ipAddress, context);
1410
+ if (blockInfo) {
1411
+ return {
1412
+ allowed: false,
1413
+ blocked: true,
1414
+ reason: `IP blocked due to ${blockInfo.reason}`,
1415
+ retryAfter: Math.ceil(
1416
+ (blockInfo.expiresAt.getTime() - Date.now()) / 1000
1417
+ ),
1418
+ };
1419
+ }
1420
+
1421
+ const key = REDIS_KEYS.PASSWORD_LOGIN_FAILED(ipAddress);
1422
+ const count = await getCounter(key);
1423
+
1424
+ if (count >= ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_MAX) {
1425
+ return {
1426
+ allowed: false,
1427
+ reason: 'Too many failed login attempts',
1428
+ retryAfter: Math.ceil(
1429
+ ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_WINDOW_MS / 1000
1430
+ ),
1431
+ remainingAttempts: 0,
1432
+ };
1433
+ }
1434
+
1435
+ return {
1436
+ allowed: true,
1437
+ remainingAttempts: ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_MAX - count,
1438
+ };
1439
+ }
1440
+
1441
+ /**
1442
+ * Record failed password login
1443
+ */
1444
+ export async function recordPasswordLoginFailure(
1445
+ ipAddress: string,
1446
+ email?: string
1447
+ ): 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
+ );
1453
+
1454
+ void logAbuseEvent(ipAddress, 'password_login_failed', {
1455
+ email,
1456
+ success: false,
1457
+ });
1458
+
1459
+ if (count >= ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_MAX) {
1460
+ void blockIP(ipAddress, 'password_login_failed', {
1461
+ trigger: 'max_failures_exceeded',
1462
+ failedCount: count,
1463
+ });
1464
+ }
1465
+ }
1466
+
1467
+ /**
1468
+ * Clear password login failures on success
1469
+ */
1470
+ export async function clearPasswordLoginFailures(
1471
+ ipAddress: string
1472
+ ): Promise<void> {
1473
+ await deleteKeys(REDIS_KEYS.PASSWORD_LOGIN_FAILED(ipAddress));
1474
+ }
1475
+
1476
+ /**
1477
+ * Check if an IP should be blocked for API auth abuse
1478
+ */
1479
+ export async function checkApiAuthLimit(
1480
+ ipAddress: string,
1481
+ context?: AbuseProtectionLogContext
1482
+ ): Promise<AbuseCheckResult> {
1483
+ const blockInfo = await isIPBlocked(ipAddress, context);
1484
+ if (blockInfo) {
1485
+ return {
1486
+ allowed: false,
1487
+ blocked: true,
1488
+ reason: `IP blocked due to ${blockInfo.reason}`,
1489
+ retryAfter: Math.ceil(
1490
+ (blockInfo.expiresAt.getTime() - Date.now()) / 1000
1491
+ ),
1492
+ };
1493
+ }
1494
+
1495
+ const key = REDIS_KEYS.API_AUTH_FAILED(ipAddress);
1496
+ const count = await getCounter(key);
1497
+
1498
+ if (count >= ABUSE_THRESHOLDS.API_AUTH_FAILED_MAX) {
1499
+ return {
1500
+ allowed: false,
1501
+ reason: 'Too many failed API authentication attempts',
1502
+ retryAfter: Math.ceil(ABUSE_THRESHOLDS.API_AUTH_FAILED_WINDOW_MS / 1000),
1503
+ remainingAttempts: 0,
1504
+ };
1505
+ }
1506
+
1507
+ return {
1508
+ allowed: true,
1509
+ remainingAttempts: ABUSE_THRESHOLDS.API_AUTH_FAILED_MAX - count,
1510
+ };
1511
+ }
1512
+
1513
+ /**
1514
+ * Record a failed API auth attempt. Auto-blocks IP if threshold exceeded.
1515
+ */
1516
+ export async function recordApiAuthFailure(
1517
+ ipAddress: string,
1518
+ endpoint?: string
1519
+ ): Promise<void> {
1520
+ const key = REDIS_KEYS.API_AUTH_FAILED(ipAddress);
1521
+ const { count } = await incrementCounter(
1522
+ key,
1523
+ ABUSE_THRESHOLDS.API_AUTH_FAILED_WINDOW_MS
1524
+ );
1525
+
1526
+ void logAbuseEvent(ipAddress, 'api_auth_failed', {
1527
+ endpoint,
1528
+ success: false,
1529
+ });
1530
+
1531
+ if (count >= ABUSE_THRESHOLDS.API_AUTH_FAILED_MAX) {
1532
+ void blockIP(ipAddress, 'api_auth_failed', {
1533
+ trigger: 'max_failures_exceeded',
1534
+ failedCount: count,
1535
+ endpoint,
1536
+ });
1537
+ }
1538
+ }
1539
+
1540
+ /**
1541
+ * Clear API auth failures (e.g. on successful auth from that IP)
1542
+ */
1543
+ export async function clearApiAuthFailures(ipAddress: string): Promise<void> {
1544
+ await deleteKeys(REDIS_KEYS.API_AUTH_FAILED(ipAddress));
1545
+ }