@tuturuuu/utils 0.0.2 → 0.6.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.
Files changed (186) hide show
  1. package/CHANGELOG.md +305 -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 +122 -3
  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,192 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const mockRpc = vi.fn();
4
+ const mockCreateAdminClient = vi.fn(() => ({
5
+ rpc: mockRpc,
6
+ }));
7
+
8
+ vi.mock('@tuturuuu/supabase/next/server', () => ({
9
+ createAdminClient: () => mockCreateAdminClient(),
10
+ }));
11
+
12
+ import {
13
+ buildAbuseRiskSubjects,
14
+ resolveAbuseRiskDecision,
15
+ } from '../reputation';
16
+
17
+ const browserHeaders = {
18
+ cookie: 'sb-test-auth-token=stable-session-token',
19
+ 'user-agent':
20
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36',
21
+ };
22
+
23
+ describe('adaptive abuse reputation', () => {
24
+ beforeEach(() => {
25
+ vi.clearAllMocks();
26
+ vi.useFakeTimers();
27
+ vi.setSystemTime(new Date('2026-05-17T00:00:00.000Z'));
28
+ mockRpc.mockResolvedValue({ data: [], error: null });
29
+ });
30
+
31
+ it('does not earn trust from old account and browser shape alone', async () => {
32
+ const decision = await resolveAbuseRiskDecision({
33
+ authKind: 'session',
34
+ headers: browserHeaders,
35
+ ipAddress: '203.0.113.10',
36
+ isRead: true,
37
+ method: 'GET',
38
+ route: '/api/v1/workspaces/ws-1/tasks',
39
+ userCreatedAt: '2025-01-01T00:00:00.000Z',
40
+ userId: 'user-1',
41
+ });
42
+
43
+ expect(decision.tier).toBe('standard');
44
+ expect(decision.trustMultiplier).toBe(1);
45
+ expect(decision.reasons).toContain('established_account');
46
+ });
47
+
48
+ it('allows clean long-lived reputation to receive trusted limits', async () => {
49
+ mockRpc.mockResolvedValue({
50
+ data: [
51
+ {
52
+ decision_source: 'reputation',
53
+ subject_key: 'user:user-1',
54
+ tier: 'trusted',
55
+ trust_multiplier: 3,
56
+ },
57
+ ],
58
+ error: null,
59
+ });
60
+
61
+ const decision = await resolveAbuseRiskDecision({
62
+ authKind: 'session',
63
+ headers: browserHeaders,
64
+ ipAddress: '203.0.113.10',
65
+ isRead: true,
66
+ method: 'GET',
67
+ route: '/api/v1/workspaces/ws-1/tasks',
68
+ userCreatedAt: '2025-01-01T00:00:00.000Z',
69
+ userId: 'user-1',
70
+ });
71
+
72
+ expect(decision.tier).toBe('trusted');
73
+ expect(decision.trustMultiplier).toBe(3);
74
+ expect(decision.reasons).toContain('server_reputation_trusted');
75
+ });
76
+
77
+ it('suppresses trusted reputation when current browser mutation is scripted', async () => {
78
+ mockRpc.mockResolvedValue({
79
+ data: [
80
+ {
81
+ decision_source: 'reputation',
82
+ subject_key: 'user:user-1',
83
+ tier: 'trusted',
84
+ trust_multiplier: 3,
85
+ },
86
+ ],
87
+ error: null,
88
+ });
89
+
90
+ const decision = await resolveAbuseRiskDecision({
91
+ authKind: 'session',
92
+ headers: {
93
+ 'user-agent': 'curl/8.7.1',
94
+ },
95
+ ipAddress: '203.0.113.10',
96
+ isRead: false,
97
+ method: 'POST',
98
+ route: '/api/v1/workspaces/ws-1/tasks',
99
+ userCreatedAt: '2025-01-01T00:00:00.000Z',
100
+ userId: 'user-1',
101
+ });
102
+
103
+ expect(decision.tier).toBe('challenge_required');
104
+ expect(decision.trustMultiplier).toBe(1);
105
+ expect(decision.reasons).toContain('scripted_http_client');
106
+ expect(decision.reasons).toContain('suspicious_browser_mutation');
107
+ });
108
+
109
+ it('keeps recent abuse restrictions stronger than account age', async () => {
110
+ mockRpc.mockResolvedValue({
111
+ data: [
112
+ {
113
+ decision_source: 'reputation',
114
+ subject_key: 'user:user-1',
115
+ tier: 'restricted',
116
+ trust_multiplier: 0.35,
117
+ },
118
+ ],
119
+ error: null,
120
+ });
121
+
122
+ const decision = await resolveAbuseRiskDecision({
123
+ authKind: 'session',
124
+ headers: browserHeaders,
125
+ ipAddress: '203.0.113.10',
126
+ isRead: true,
127
+ method: 'GET',
128
+ route: '/api/v1/workspaces/ws-1/tasks',
129
+ userCreatedAt: '2025-01-01T00:00:00.000Z',
130
+ userId: 'user-1',
131
+ });
132
+
133
+ expect(decision.tier).toBe('restricted');
134
+ expect(decision.trustMultiplier).toBe(0.35);
135
+ expect(decision.reasons).toContain('server_reputation_restricted');
136
+ });
137
+
138
+ it('builds API-key reputation subjects without browser challenge semantics', async () => {
139
+ mockRpc.mockResolvedValue({
140
+ data: [
141
+ {
142
+ decision_source: 'reputation',
143
+ subject_key: 'api-key:key-1',
144
+ tier: 'trusted',
145
+ trust_multiplier: 2,
146
+ },
147
+ ],
148
+ error: null,
149
+ });
150
+
151
+ const decision = await resolveAbuseRiskDecision({
152
+ apiKeyId: 'key-1',
153
+ authKind: 'api-key',
154
+ headers: {
155
+ 'user-agent': 'Dart/3.9 (dart:io)',
156
+ },
157
+ ipAddress: '203.0.113.10',
158
+ isRead: false,
159
+ method: 'POST',
160
+ route: '/api/v1/workspaces/ws-1/tasks',
161
+ workspaceId: 'ws-1',
162
+ });
163
+
164
+ expect(decision.tier).toBe('trusted');
165
+ expect(decision.trustMultiplier).toBe(2);
166
+ expect(decision.subjects).toContainEqual({
167
+ subject_key: 'api-key:key-1',
168
+ subject_type: 'api_key',
169
+ });
170
+ });
171
+
172
+ it('tracks user, session, IP, CIDR, and user-location subjects together', () => {
173
+ expect(
174
+ buildAbuseRiskSubjects({
175
+ headers: browserHeaders,
176
+ ipAddress: '203.0.113.10',
177
+ userId: 'user-1',
178
+ })
179
+ ).toEqual(
180
+ expect.arrayContaining([
181
+ { subject_key: 'user:user-1', subject_type: 'user' },
182
+ { subject_key: 'ip:203.0.113.10', subject_type: 'ip' },
183
+ { subject_key: 'cidr:203.0.113.0/24', subject_type: 'cidr' },
184
+ {
185
+ subject_key: 'user-location:user-1:203.0.113.10',
186
+ subject_type: 'user_location',
187
+ },
188
+ expect.objectContaining({ subject_type: 'session' }),
189
+ ])
190
+ );
191
+ });
192
+ });
@@ -0,0 +1,44 @@
1
+ import { blockIPEdge } from './edge';
2
+ import type { BlockInfo } from './types';
3
+
4
+ type BackendRateLimitSource = 'auth' | 'database';
5
+
6
+ interface BackendRateLimitEscalationOptions {
7
+ endpoint?: string;
8
+ ipAddress?: string | null;
9
+ source: BackendRateLimitSource;
10
+ /**
11
+ * Deprecated/no-op. Backend 429s are shared availability signals, not enough
12
+ * evidence to create or extend a user suspension.
13
+ */
14
+ userId?: string | null;
15
+ }
16
+
17
+ interface BackendRateLimitErrorLike {
18
+ code?: string;
19
+ message?: string;
20
+ status?: number;
21
+ }
22
+
23
+ export function isBackendRateLimitError(
24
+ error: unknown
25
+ ): error is BackendRateLimitErrorLike {
26
+ if (!error || typeof error !== 'object') {
27
+ return false;
28
+ }
29
+
30
+ const candidate = error as BackendRateLimitErrorLike;
31
+ return (
32
+ candidate.status === 429 ||
33
+ candidate.code === 'over_request_rate_limit' ||
34
+ (typeof candidate.message === 'string' &&
35
+ /request rate limit reached/i.test(candidate.message))
36
+ );
37
+ }
38
+
39
+ export async function cascadeBackendRateLimitToProxyBan({
40
+ ipAddress,
41
+ }: BackendRateLimitEscalationOptions): Promise<BlockInfo | null> {
42
+ const shouldBlockIp = ipAddress && ipAddress !== 'unknown';
43
+ return shouldBlockIp ? blockIPEdge(ipAddress, 'api_abuse') : null;
44
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Configuration constants for OTP Abuse Protection System
3
+ */
4
+
5
+ /**
6
+ * Rate limiting thresholds for different operations
7
+ */
8
+ export const ABUSE_THRESHOLDS = {
9
+ // OTP Send limits
10
+ OTP_SEND_PER_MINUTE: 30,
11
+ OTP_SEND_PER_HOUR: 180,
12
+ OTP_SEND_PER_DAY: 300,
13
+ OTP_SEND_EMAIL_COOLDOWN_WINDOW_MS: 15 * 60 * 1000, // 15 minutes
14
+ OTP_SEND_EMAIL_PER_HOUR: 2,
15
+ OTP_SEND_EMAIL_PER_DAY: 4,
16
+
17
+ // OTP Verify limits (per IP)
18
+ OTP_VERIFY_FAILED_WINDOW_MS: 5 * 60 * 1000, // 5 minutes
19
+ OTP_VERIFY_FAILED_MAX: 5,
20
+
21
+ // OTP Verify limits (per email - distributed attack protection)
22
+ OTP_VERIFY_FAILED_EMAIL_WINDOW_MS: 15 * 60 * 1000, // 15 minutes
23
+ OTP_VERIFY_FAILED_EMAIL_MAX: 10,
24
+
25
+ // MFA limits
26
+ MFA_CHALLENGE_PER_MINUTE: 5,
27
+ MFA_VERIFY_FAILED_WINDOW_MS: 5 * 60 * 1000, // 5 minutes
28
+ MFA_VERIFY_FAILED_MAX: 5,
29
+
30
+ // Reauth limits
31
+ REAUTH_SEND_PER_MINUTE: 3,
32
+ REAUTH_VERIFY_FAILED_WINDOW_MS: 5 * 60 * 1000, // 5 minutes
33
+ REAUTH_VERIFY_FAILED_MAX: 5,
34
+
35
+ // Password login limits
36
+ PASSWORD_LOGIN_FAILED_WINDOW_MS: 5 * 60 * 1000, // 5 minutes
37
+ PASSWORD_LOGIN_FAILED_MAX: 10,
38
+
39
+ // API auth failure limits (session-auth routes)
40
+ API_AUTH_FAILED_WINDOW_MS: 5 * 60 * 1000, // 5 minutes
41
+ API_AUTH_FAILED_MAX: 20, // 20 failed auths in 5 min → auto-block IP
42
+
43
+ // Malformed session cookie abuse at the proxy/edge layer
44
+ MALFORMED_AUTH_COOKIE_WINDOW_MS: 60 * 1000, // 1 minute
45
+ MALFORMED_AUTH_COOKIE_MAX: 3, // 3 malformed-cookie hits in 1 min → block IP
46
+
47
+ // Generic suspicious anonymous API traffic at the proxy/edge layer
48
+ SUSPICIOUS_API_WINDOW_MS: 60 * 1000, // 1 minute
49
+ SUSPICIOUS_API_MAX: 3, // 3 suspicious proxy hits in 1 min → block IP
50
+ } as const;
51
+
52
+ /**
53
+ * Progressive block durations in seconds
54
+ * Level increases with repeated offenses within 24 hours
55
+ */
56
+ export const BLOCK_DURATIONS: Record<1 | 2 | 3 | 4, number> = {
57
+ 1: 5 * 60, // Level 1: 5 minutes
58
+ 2: 15 * 60, // Level 2: 15 minutes
59
+ 3: 60 * 60, // Level 3: 1 hour
60
+ 4: 24 * 60 * 60, // Level 4: 24 hours
61
+ } as const;
62
+
63
+ /**
64
+ * Maximum block level
65
+ */
66
+ export const MAX_BLOCK_LEVEL = 4;
67
+
68
+ /**
69
+ * Redis key prefixes for rate limiting
70
+ */
71
+ export const REDIS_KEYS = {
72
+ // OTP Send attempts per IP (sliding window)
73
+ OTP_SEND: (ip: string) => `otp:send:${ip}`,
74
+ OTP_SEND_HOURLY: (ip: string) => `otp:send:hourly:${ip}`,
75
+ OTP_SEND_DAILY: (ip: string) => `otp:send:daily:${ip}`,
76
+ OTP_SEND_EMAIL_COOLDOWN: (emailHash: string) =>
77
+ `otp:send:email:cooldown:${emailHash}`,
78
+ OTP_SEND_EMAIL_HOURLY: (emailHash: string) =>
79
+ `otp:send:email:hourly:${emailHash}`,
80
+ OTP_SEND_EMAIL_DAILY: (emailHash: string) =>
81
+ `otp:send:email:daily:${emailHash}`,
82
+
83
+ // OTP Verify failed attempts
84
+ OTP_VERIFY_FAILED: (ip: string) => `otp:verify:failed:${ip}`,
85
+ OTP_VERIFY_FAILED_EMAIL: (emailHash: string) =>
86
+ `otp:verify:failed:email:${emailHash}`,
87
+
88
+ // MFA attempts
89
+ MFA_CHALLENGE: (ip: string) => `mfa:challenge:${ip}`,
90
+ MFA_VERIFY_FAILED: (ip: string) => `mfa:verify:failed:${ip}`,
91
+
92
+ // Reauth attempts
93
+ REAUTH_SEND: (ip: string) => `reauth:send:${ip}`,
94
+ REAUTH_VERIFY_FAILED: (ip: string) => `reauth:verify:failed:${ip}`,
95
+
96
+ // Password login attempts
97
+ PASSWORD_LOGIN_FAILED: (ip: string) => `password:login:failed:${ip}`,
98
+
99
+ // API auth failure attempts (session-auth routes)
100
+ API_AUTH_FAILED: (ip: string) => `api:auth:failed:${ip}`,
101
+ API_MALFORMED_AUTH_COOKIE: (ip: string) => `api:auth:malformed-cookie:${ip}`,
102
+ API_SUSPICIOUS: (ip: string) => `api:suspicious:${ip}`,
103
+
104
+ // IP block cache
105
+ IP_BLOCKED: (ip: string) => `ip:blocked:${ip}`,
106
+ IP_BLOCK_LEVEL: (ip: string) => `ip:block:level:${ip}`,
107
+ } as const;
108
+
109
+ /**
110
+ * Window durations in milliseconds for different operations
111
+ */
112
+ export const WINDOW_MS = {
113
+ ONE_MINUTE: 60 * 1000,
114
+ TEN_MINUTES: 10 * 60 * 1000,
115
+ ONE_HOUR: 60 * 60 * 1000,
116
+ TWENTY_FOUR_HOURS: 24 * 60 * 60 * 1000,
117
+ } as const;
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Edge-compatible abuse protection utilities.
3
+ *
4
+ * Unlike the main index.ts (which uses node:crypto), this module only
5
+ * relies on the @upstash/redis REST client and is safe to run in
6
+ * Vercel Edge Runtime or Next.js proxy/middleware.
7
+ */
8
+
9
+ import {
10
+ getUpstashRestRedisClient,
11
+ type UpstashRestRedisClient,
12
+ } from '../upstash-rest';
13
+ import {
14
+ ABUSE_THRESHOLDS,
15
+ BLOCK_DURATIONS,
16
+ REDIS_KEYS,
17
+ WINDOW_MS,
18
+ } from './constants';
19
+ import type { BlockInfo } from './types';
20
+
21
+ let edgeRedisClient: UpstashRestRedisClient | null = null;
22
+ let edgeRedisInitialized = false;
23
+
24
+ function parsePositiveIntEnv(name: string, fallback: number): number {
25
+ const rawValue = process.env[name];
26
+ if (!rawValue) {
27
+ return fallback;
28
+ }
29
+
30
+ const parsed = Number.parseInt(rawValue, 10);
31
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
32
+ }
33
+
34
+ async function getEdgeRedisClient() {
35
+ if (edgeRedisInitialized) return edgeRedisClient;
36
+
37
+ try {
38
+ edgeRedisClient = await getUpstashRestRedisClient();
39
+ edgeRedisInitialized = true;
40
+ return edgeRedisClient;
41
+ } catch {
42
+ edgeRedisInitialized = true;
43
+ return null;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Check if an IP is blocked using Redis cache only (no DB fallback).
49
+ * Designed for Edge Runtime where speed > completeness.
50
+ * The serverless layer provides full DB-backed check as backup.
51
+ */
52
+ export async function isIPBlockedEdge(
53
+ ipAddress: string
54
+ ): Promise<BlockInfo | null> {
55
+ try {
56
+ const redis = await getEdgeRedisClient();
57
+ if (!redis) return null;
58
+
59
+ const cached = await redis.get<string>(REDIS_KEYS.IP_BLOCKED(ipAddress));
60
+ if (!cached) return null;
61
+
62
+ const blockInfo = typeof cached === 'string' ? JSON.parse(cached) : cached;
63
+ if (new Date(blockInfo.expiresAt) <= new Date()) return null;
64
+
65
+ return {
66
+ id: blockInfo.id,
67
+ blockLevel: blockInfo.level,
68
+ reason: blockInfo.reason,
69
+ expiresAt: new Date(blockInfo.expiresAt),
70
+ blockedAt: new Date(blockInfo.blockedAt),
71
+ };
72
+ } catch {
73
+ // Fail-open: if Redis is unavailable, allow request through
74
+ return null;
75
+ }
76
+ }
77
+
78
+ export async function blockIPEdge(
79
+ ipAddress: string,
80
+ reason: BlockInfo['reason']
81
+ ): Promise<BlockInfo | null> {
82
+ try {
83
+ const redis = await getEdgeRedisClient();
84
+ if (!redis) return null;
85
+
86
+ const currentLevel =
87
+ (await redis.get<number>(REDIS_KEYS.IP_BLOCK_LEVEL(ipAddress))) || 0;
88
+ const newLevel = Math.min(currentLevel + 1, 4) as 1 | 2 | 3 | 4;
89
+ const blockDuration = BLOCK_DURATIONS[newLevel];
90
+ const blockedAt = new Date();
91
+ const expiresAt = new Date(blockedAt.getTime() + blockDuration * 1000);
92
+ const blockInfo = {
93
+ id: `edge:${ipAddress}:${blockedAt.getTime()}`,
94
+ blockLevel: newLevel,
95
+ reason,
96
+ blockedAt,
97
+ expiresAt,
98
+ } satisfies BlockInfo;
99
+
100
+ await Promise.all([
101
+ redis.set(
102
+ REDIS_KEYS.IP_BLOCKED(ipAddress),
103
+ JSON.stringify({
104
+ id: blockInfo.id,
105
+ level: blockInfo.blockLevel,
106
+ reason: blockInfo.reason,
107
+ expiresAt: blockInfo.expiresAt.toISOString(),
108
+ blockedAt: blockInfo.blockedAt.toISOString(),
109
+ }),
110
+ { ex: blockDuration }
111
+ ),
112
+ redis.set(REDIS_KEYS.IP_BLOCK_LEVEL(ipAddress), newLevel, {
113
+ ex: WINDOW_MS.TWENTY_FOUR_HOURS / 1000,
114
+ }),
115
+ ]);
116
+
117
+ return blockInfo;
118
+ } catch {
119
+ return null;
120
+ }
121
+ }
122
+
123
+ async function recordEdgeAbuseSignal(
124
+ ipAddress: string,
125
+ redisKey: string,
126
+ {
127
+ maxAttempts,
128
+ windowMs,
129
+ }: {
130
+ maxAttempts: number;
131
+ windowMs: number;
132
+ }
133
+ ): Promise<BlockInfo | null> {
134
+ try {
135
+ const redis = await getEdgeRedisClient();
136
+ if (!redis) return null;
137
+
138
+ const attempts = await redis.incr(redisKey);
139
+
140
+ if (attempts === 1) {
141
+ await redis.expire(redisKey, Math.ceil(windowMs / 1000));
142
+ }
143
+
144
+ if (attempts < maxAttempts) {
145
+ return null;
146
+ }
147
+
148
+ return blockIPEdge(ipAddress, 'api_abuse');
149
+ } catch {
150
+ return null;
151
+ }
152
+ }
153
+
154
+ export async function recordMalformedAuthCookieEdge(
155
+ ipAddress: string
156
+ ): Promise<BlockInfo | null> {
157
+ return recordEdgeAbuseSignal(
158
+ ipAddress,
159
+ REDIS_KEYS.API_MALFORMED_AUTH_COOKIE(ipAddress),
160
+ {
161
+ maxAttempts: parsePositiveIntEnv(
162
+ 'EDGE_MALFORMED_AUTH_COOKIE_MAX',
163
+ ABUSE_THRESHOLDS.MALFORMED_AUTH_COOKIE_MAX
164
+ ),
165
+ windowMs: parsePositiveIntEnv(
166
+ 'EDGE_MALFORMED_AUTH_COOKIE_WINDOW_MS',
167
+ ABUSE_THRESHOLDS.MALFORMED_AUTH_COOKIE_WINDOW_MS
168
+ ),
169
+ }
170
+ );
171
+ }
172
+
173
+ export async function recordSuspiciousApiRequestEdge(
174
+ ipAddress: string
175
+ ): Promise<BlockInfo | null> {
176
+ return recordEdgeAbuseSignal(
177
+ ipAddress,
178
+ REDIS_KEYS.API_SUSPICIOUS(ipAddress),
179
+ {
180
+ maxAttempts: parsePositiveIntEnv(
181
+ 'EDGE_SUSPICIOUS_API_MAX',
182
+ ABUSE_THRESHOLDS.SUSPICIOUS_API_MAX
183
+ ),
184
+ windowMs: parsePositiveIntEnv(
185
+ 'EDGE_SUSPICIOUS_API_WINDOW_MS',
186
+ ABUSE_THRESHOLDS.SUSPICIOUS_API_WINDOW_MS
187
+ ),
188
+ }
189
+ );
190
+ }
191
+
192
+ /**
193
+ * Lightweight IP extraction for Edge Runtime.
194
+ * Checks standard proxy headers in priority order.
195
+ */
196
+ export function extractIPFromRequest(headers: Headers): string {
197
+ // cf-connecting-ip (Cloudflare)
198
+ const cfIP = headers.get('cf-connecting-ip');
199
+ if (cfIP && isValidIPEdge(cfIP)) return cfIP;
200
+
201
+ // true-client-ip (some Cloudflare/enterprise proxy setups)
202
+ const trueClientIP = headers.get('true-client-ip');
203
+ if (trueClientIP && isValidIPEdge(trueClientIP)) return trueClientIP;
204
+
205
+ // x-forwarded-for (most common generic proxy header)
206
+ const forwardedFor = headers.get('x-forwarded-for');
207
+ if (forwardedFor) {
208
+ const firstIP = forwardedFor.split(',')[0]?.trim();
209
+ if (firstIP && isValidIPEdge(firstIP)) return firstIP;
210
+ }
211
+
212
+ // x-real-ip (Nginx)
213
+ const realIP = headers.get('x-real-ip');
214
+ if (realIP && isValidIPEdge(realIP)) return realIP;
215
+
216
+ return 'unknown';
217
+ }
218
+
219
+ function isValidIPEdge(ip: string): boolean {
220
+ const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
221
+ const ipv6Regex = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/;
222
+ return ipv4Regex.test(ip) || ipv6Regex.test(ip);
223
+ }