@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,587 @@
1
+ import { createHash } from 'node:crypto';
2
+ import type { SupabaseClient } from '@tuturuuu/supabase';
3
+ import type { Database, Json } from '@tuturuuu/types';
4
+ import {
5
+ classifyPotentialSpamUserAgent,
6
+ extractUserAgentFromHeaders,
7
+ } from './user-agent';
8
+
9
+ export const ABUSE_RISK_TIERS = [
10
+ 'trusted',
11
+ 'standard',
12
+ 'watch',
13
+ 'challenge_required',
14
+ 'restricted',
15
+ ] as const;
16
+
17
+ export type AbuseRiskTier = (typeof ABUSE_RISK_TIERS)[number];
18
+
19
+ export const ABUSE_REPUTATION_SUBJECT_TYPES = [
20
+ 'user',
21
+ 'session',
22
+ 'api_key',
23
+ 'ip',
24
+ 'cidr',
25
+ 'user_location',
26
+ ] as const;
27
+
28
+ export type AbuseReputationSubjectType =
29
+ (typeof ABUSE_REPUTATION_SUBJECT_TYPES)[number];
30
+
31
+ export type AbuseSignalType =
32
+ | 'organic_activity'
33
+ | 'automation_client'
34
+ | 'scripted_client'
35
+ | 'missing_user_agent'
36
+ | 'auth_failure'
37
+ | 'rate_limit_hit'
38
+ | 'client_error'
39
+ | 'payload_abuse'
40
+ | 'challenge_issued'
41
+ | 'challenge_passed'
42
+ | 'challenge_failed'
43
+ | 'manual_override';
44
+
45
+ export interface AbuseRiskSubject {
46
+ subject_key: string;
47
+ subject_type: AbuseReputationSubjectType;
48
+ }
49
+
50
+ export interface AbuseRiskDecision {
51
+ confidenceScore: number;
52
+ decisionSource: 'default' | 'heuristic' | 'override' | 'reputation';
53
+ reasons: string[];
54
+ riskScore: number;
55
+ subjectKey: string | null;
56
+ subjects: AbuseRiskSubject[];
57
+ tier: AbuseRiskTier;
58
+ trustMultiplier: number;
59
+ }
60
+
61
+ export interface RateLimitDecision extends AbuseRiskDecision {
62
+ adjustedMaxRequests: number;
63
+ }
64
+
65
+ export interface ResolveAbuseRiskDecisionInput {
66
+ apiKeyId?: string | null;
67
+ authKind: 'api-key' | 'app-session' | 'session' | 'temp';
68
+ headers: Headers | Map<string, string> | Record<string, string | null>;
69
+ ipAddress?: string | null;
70
+ isRead: boolean;
71
+ method: string;
72
+ route: string;
73
+ userCreatedAt?: string | null;
74
+ userId?: string | null;
75
+ workspaceId?: string | null;
76
+ }
77
+
78
+ export interface RecordAbuseActivitySignalInput {
79
+ apiKeyId?: string | null;
80
+ confidenceDelta?: number;
81
+ headers?: Headers | Map<string, string> | Record<string, string | null>;
82
+ ipAddress?: string | null;
83
+ metadata?: Record<string, unknown>;
84
+ method?: string | null;
85
+ reasonCode?: string | null;
86
+ riskTier?: AbuseRiskTier;
87
+ route?: string | null;
88
+ scoreDelta?: number;
89
+ signalType: AbuseSignalType;
90
+ subjects: AbuseRiskSubject[];
91
+ userId?: string | null;
92
+ workspaceId?: string | null;
93
+ }
94
+
95
+ export interface RecordAbuseStepUpChallengeInput {
96
+ ipAddress?: string | null;
97
+ metadata?: Record<string, unknown>;
98
+ riskTier?: AbuseRiskTier;
99
+ route?: string | null;
100
+ status: 'expired' | 'failed' | 'issued' | 'passed';
101
+ subjectKey: string;
102
+ userId?: string | null;
103
+ }
104
+
105
+ type TrustDecisionRow = {
106
+ decision_source: string | null;
107
+ subject_key: string | null;
108
+ tier: AbuseRiskTier | null;
109
+ trust_multiplier: number | string | null;
110
+ };
111
+
112
+ const HASH_PREFIX_LENGTH = 24;
113
+ const DEFAULT_DECISION: Omit<AbuseRiskDecision, 'subjects'> = {
114
+ confidenceScore: 0,
115
+ decisionSource: 'default',
116
+ reasons: [],
117
+ riskScore: 50,
118
+ subjectKey: null,
119
+ tier: 'standard',
120
+ trustMultiplier: 1,
121
+ };
122
+
123
+ function clampScore(value: number) {
124
+ if (!Number.isFinite(value)) {
125
+ return 0;
126
+ }
127
+
128
+ return Math.min(100, Math.max(0, Math.round(value)));
129
+ }
130
+
131
+ function normalizeIpAddress(value?: string | null) {
132
+ const normalized = value?.trim();
133
+ return normalized && normalized !== 'unknown' ? normalized : null;
134
+ }
135
+
136
+ function hashStableSubject(value: string) {
137
+ return createHash('sha256')
138
+ .update(value)
139
+ .digest('hex')
140
+ .slice(0, HASH_PREFIX_LENGTH);
141
+ }
142
+
143
+ function parseCookieHeader(cookieHeader: string | null | undefined) {
144
+ if (!cookieHeader) {
145
+ return [];
146
+ }
147
+
148
+ return cookieHeader
149
+ .split(';')
150
+ .map((entry) => {
151
+ const separatorIndex = entry.indexOf('=');
152
+ if (separatorIndex < 0) {
153
+ return null;
154
+ }
155
+
156
+ return {
157
+ name: entry.slice(0, separatorIndex).trim(),
158
+ value: entry.slice(separatorIndex + 1).trim(),
159
+ };
160
+ })
161
+ .filter(
162
+ (entry): entry is { name: string; value: string } =>
163
+ !!entry?.name && !!entry.value
164
+ );
165
+ }
166
+
167
+ function readHeader(
168
+ headers: Headers | Map<string, string> | Record<string, string | null>,
169
+ name: string
170
+ ) {
171
+ if (headers instanceof Headers) {
172
+ return headers.get(name) ?? headers.get(name.toLowerCase()) ?? null;
173
+ }
174
+
175
+ if (headers instanceof Map) {
176
+ return headers.get(name) ?? headers.get(name.toLowerCase()) ?? null;
177
+ }
178
+
179
+ return headers[name] ?? headers[name.toLowerCase()] ?? null;
180
+ }
181
+
182
+ function getSessionSubjectKey(
183
+ headers: Headers | Map<string, string> | Record<string, string | null>
184
+ ) {
185
+ const cookies = parseCookieHeader(readHeader(headers, 'cookie'));
186
+ const authCookie = cookies.find(
187
+ (cookie) =>
188
+ cookie.name === 'tuturuuu_app_session' ||
189
+ /^sb-[a-z0-9-]+-auth-token(?:\.\d+)?$/i.test(cookie.name)
190
+ );
191
+
192
+ if (!authCookie) {
193
+ return null;
194
+ }
195
+
196
+ return `session:${hashStableSubject(`${authCookie.name}:${authCookie.value}`)}`;
197
+ }
198
+
199
+ function getCidrSubjectKey(ipAddress: string | null) {
200
+ if (!ipAddress) {
201
+ return null;
202
+ }
203
+
204
+ const ipv4Parts = ipAddress.split('.');
205
+ if (
206
+ ipv4Parts.length === 4 &&
207
+ ipv4Parts.every((part) => /^\d{1,3}$/.test(part))
208
+ ) {
209
+ return `cidr:${ipv4Parts.slice(0, 3).join('.')}.0/24`;
210
+ }
211
+
212
+ const ipv6Parts = ipAddress.split(':').filter(Boolean);
213
+ if (ipv6Parts.length >= 4) {
214
+ return `cidr:${ipv6Parts.slice(0, 4).join(':')}::/64`;
215
+ }
216
+
217
+ return null;
218
+ }
219
+
220
+ function isLikelyBrowserUserAgent(userAgent: string | null) {
221
+ return (
222
+ !!userAgent &&
223
+ /\b(?:mozilla\/5\.0|applewebkit|chrome\/|firefox\/|safari\/|edg\/)\b/i.test(
224
+ userAgent
225
+ )
226
+ );
227
+ }
228
+
229
+ function getAccountAgeDays(userCreatedAt?: string | null) {
230
+ if (!userCreatedAt) {
231
+ return null;
232
+ }
233
+
234
+ const createdAt = new Date(userCreatedAt).getTime();
235
+ if (!Number.isFinite(createdAt)) {
236
+ return null;
237
+ }
238
+
239
+ return Math.max(0, Math.floor((Date.now() - createdAt) / 86_400_000));
240
+ }
241
+
242
+ function normalizeTrustDecisionRow(row?: TrustDecisionRow | null) {
243
+ if (!row?.tier) {
244
+ return null;
245
+ }
246
+
247
+ const multiplier =
248
+ typeof row.trust_multiplier === 'string'
249
+ ? Number.parseFloat(row.trust_multiplier)
250
+ : row.trust_multiplier;
251
+
252
+ return {
253
+ decisionSource:
254
+ row.decision_source === 'override' || row.decision_source === 'reputation'
255
+ ? row.decision_source
256
+ : 'default',
257
+ subjectKey: row.subject_key ?? null,
258
+ tier: row.tier,
259
+ trustMultiplier:
260
+ Number.isFinite(multiplier) && multiplier && multiplier > 0
261
+ ? multiplier
262
+ : 1,
263
+ } as const;
264
+ }
265
+
266
+ async function getSupabaseAdmin(): Promise<SupabaseClient<Database> | null> {
267
+ try {
268
+ const { createAdminClient } = await import(
269
+ '@tuturuuu/supabase/next/server'
270
+ );
271
+ return (await createAdminClient({
272
+ noCookie: true,
273
+ })) as SupabaseClient<Database>;
274
+ } catch {
275
+ return null;
276
+ }
277
+ }
278
+
279
+ export function buildAbuseRiskSubjects({
280
+ apiKeyId,
281
+ headers,
282
+ ipAddress,
283
+ userId,
284
+ }: Pick<
285
+ ResolveAbuseRiskDecisionInput,
286
+ 'apiKeyId' | 'headers' | 'ipAddress' | 'userId'
287
+ >): AbuseRiskSubject[] {
288
+ const subjects: AbuseRiskSubject[] = [];
289
+ const normalizedIp = normalizeIpAddress(ipAddress);
290
+ const sessionKey = getSessionSubjectKey(headers);
291
+ const cidrKey = getCidrSubjectKey(normalizedIp);
292
+
293
+ if (userId) {
294
+ subjects.push({ subject_type: 'user', subject_key: `user:${userId}` });
295
+ }
296
+
297
+ if (sessionKey) {
298
+ subjects.push({ subject_type: 'session', subject_key: sessionKey });
299
+ }
300
+
301
+ if (apiKeyId) {
302
+ subjects.push({
303
+ subject_type: 'api_key',
304
+ subject_key: `api-key:${apiKeyId}`,
305
+ });
306
+ }
307
+
308
+ if (normalizedIp) {
309
+ subjects.push({ subject_type: 'ip', subject_key: `ip:${normalizedIp}` });
310
+
311
+ if (userId) {
312
+ subjects.push({
313
+ subject_type: 'user_location',
314
+ subject_key: `user-location:${userId}:${normalizedIp}`,
315
+ });
316
+ }
317
+ }
318
+
319
+ if (cidrKey) {
320
+ subjects.push({ subject_type: 'cidr', subject_key: cidrKey });
321
+ }
322
+
323
+ return subjects;
324
+ }
325
+
326
+ async function loadServerTrustDecision({
327
+ apiKeyId,
328
+ ipAddress,
329
+ userId,
330
+ }: Pick<ResolveAbuseRiskDecisionInput, 'apiKeyId' | 'ipAddress' | 'userId'>) {
331
+ const supabase = await getSupabaseAdmin();
332
+ if (!supabase) {
333
+ return null;
334
+ }
335
+
336
+ try {
337
+ const { data, error } = await supabase.rpc(
338
+ 'get_rate_limit_trust_decision',
339
+ {
340
+ p_api_key_id: apiKeyId ?? undefined,
341
+ p_ip_address: normalizeIpAddress(ipAddress) ?? undefined,
342
+ p_user_id: userId ?? undefined,
343
+ }
344
+ );
345
+
346
+ if (error) {
347
+ return null;
348
+ }
349
+
350
+ return normalizeTrustDecisionRow(
351
+ Array.isArray(data) ? (data[0] as TrustDecisionRow | undefined) : null
352
+ );
353
+ } catch {
354
+ return null;
355
+ }
356
+ }
357
+
358
+ export async function resolveAbuseRiskDecision(
359
+ input: ResolveAbuseRiskDecisionInput
360
+ ): Promise<AbuseRiskDecision> {
361
+ const subjects = buildAbuseRiskSubjects(input);
362
+ const userAgent = extractUserAgentFromHeaders(input.headers);
363
+ const userAgentClassification = classifyPotentialSpamUserAgent(userAgent, {
364
+ allowNativeAppUserAgents: input.authKind === 'api-key',
365
+ });
366
+ const serverDecision = await loadServerTrustDecision(input);
367
+ const accountAgeDays = getAccountAgeDays(input.userCreatedAt);
368
+ const reasons: string[] = [];
369
+ let riskScore = 50;
370
+ let confidenceScore = 10;
371
+ let tier = serverDecision?.tier ?? DEFAULT_DECISION.tier;
372
+ let trustMultiplier =
373
+ serverDecision?.trustMultiplier ?? DEFAULT_DECISION.trustMultiplier;
374
+ let decisionSource: AbuseRiskDecision['decisionSource'] =
375
+ serverDecision?.decisionSource ?? DEFAULT_DECISION.decisionSource;
376
+ let subjectKey = serverDecision?.subjectKey ?? DEFAULT_DECISION.subjectKey;
377
+
378
+ if (serverDecision) {
379
+ confidenceScore += 25;
380
+ if (serverDecision.tier === 'trusted') {
381
+ riskScore += 30;
382
+ reasons.push('server_reputation_trusted');
383
+ } else if (serverDecision.tier === 'restricted') {
384
+ riskScore -= 40;
385
+ reasons.push('server_reputation_restricted');
386
+ } else if (serverDecision.tier === 'challenge_required') {
387
+ riskScore -= 25;
388
+ reasons.push('server_reputation_challenge_required');
389
+ } else if (serverDecision.tier === 'watch') {
390
+ riskScore -= 10;
391
+ reasons.push('server_reputation_watch');
392
+ }
393
+ }
394
+
395
+ if (!userAgent) {
396
+ riskScore -= 18;
397
+ confidenceScore += 12;
398
+ reasons.push('missing_user_agent');
399
+ } else if (userAgentClassification.riskLevel === 'block') {
400
+ const reason = userAgentClassification.reason ?? 'suspicious_user_agent';
401
+ riskScore -= input.authKind === 'api-key' ? 8 : 28;
402
+ confidenceScore += input.authKind === 'api-key' ? 8 : 20;
403
+ reasons.push(reason);
404
+ }
405
+
406
+ if (accountAgeDays != null) {
407
+ if (accountAgeDays >= 60) {
408
+ riskScore += 12;
409
+ confidenceScore += 12;
410
+ reasons.push('established_account');
411
+ } else if (accountAgeDays <= 1) {
412
+ riskScore -= 8;
413
+ confidenceScore += 6;
414
+ reasons.push('new_account');
415
+ }
416
+ }
417
+
418
+ const likelyBrowser = isLikelyBrowserUserAgent(userAgent);
419
+ const suspiciousBrowserMutation =
420
+ !input.isRead &&
421
+ input.authKind !== 'api-key' &&
422
+ (!likelyBrowser || userAgentClassification.riskLevel === 'block');
423
+
424
+ if (suspiciousBrowserMutation && tier !== 'restricted') {
425
+ tier = 'challenge_required';
426
+ trustMultiplier = 1;
427
+ decisionSource = 'heuristic';
428
+ subjectKey ??= subjects[0]?.subject_key ?? null;
429
+ reasons.push('suspicious_browser_mutation');
430
+ } else if (
431
+ tier === 'trusted' &&
432
+ userAgentClassification.riskLevel === 'block'
433
+ ) {
434
+ tier = 'standard';
435
+ trustMultiplier = 1;
436
+ decisionSource = 'heuristic';
437
+ reasons.push('trusted_tier_suppressed_by_current_signal');
438
+ } else if (!serverDecision) {
439
+ if (riskScore <= 15 && confidenceScore >= 20) {
440
+ tier = 'restricted';
441
+ trustMultiplier = 0.35;
442
+ decisionSource = 'heuristic';
443
+ } else if (riskScore <= 30 && confidenceScore >= 20) {
444
+ tier = 'challenge_required';
445
+ trustMultiplier = 1;
446
+ decisionSource = 'heuristic';
447
+ } else if (riskScore <= 45 && confidenceScore >= 10) {
448
+ tier = 'watch';
449
+ trustMultiplier = 0.75;
450
+ decisionSource = 'heuristic';
451
+ }
452
+ }
453
+
454
+ return {
455
+ confidenceScore: clampScore(confidenceScore),
456
+ decisionSource,
457
+ reasons,
458
+ riskScore: clampScore(riskScore),
459
+ subjectKey,
460
+ subjects,
461
+ tier,
462
+ trustMultiplier,
463
+ };
464
+ }
465
+
466
+ export function applyRateLimitDecision(
467
+ maxRequests: number,
468
+ decision: Pick<AbuseRiskDecision, 'trustMultiplier'>
469
+ ): RateLimitDecision['adjustedMaxRequests'] {
470
+ return Math.max(1, Math.floor(maxRequests * decision.trustMultiplier));
471
+ }
472
+
473
+ export function buildRateLimitDecision(
474
+ maxRequests: number,
475
+ decision: AbuseRiskDecision
476
+ ): RateLimitDecision {
477
+ return {
478
+ ...decision,
479
+ adjustedMaxRequests: applyRateLimitDecision(maxRequests, decision),
480
+ };
481
+ }
482
+
483
+ export async function recordAbuseActivitySignal({
484
+ apiKeyId,
485
+ confidenceDelta = 0,
486
+ ipAddress,
487
+ metadata,
488
+ method,
489
+ reasonCode,
490
+ riskTier = 'standard',
491
+ route,
492
+ scoreDelta = 0,
493
+ signalType,
494
+ subjects,
495
+ userId,
496
+ workspaceId,
497
+ }: RecordAbuseActivitySignalInput): Promise<void> {
498
+ if (subjects.length === 0) {
499
+ return;
500
+ }
501
+
502
+ const supabase = await getSupabaseAdmin();
503
+ if (!supabase) {
504
+ return;
505
+ }
506
+
507
+ try {
508
+ await supabase.rpc('record_abuse_activity_signal', {
509
+ p_api_key_id: apiKeyId ?? undefined,
510
+ p_confidence_delta: confidenceDelta,
511
+ p_ip_address: normalizeIpAddress(ipAddress) ?? undefined,
512
+ p_metadata: (metadata ?? {}) as Json,
513
+ p_method: method ?? undefined,
514
+ p_reason_code: reasonCode ?? undefined,
515
+ p_risk_tier: riskTier,
516
+ p_route: route ?? undefined,
517
+ p_score_delta: scoreDelta,
518
+ p_signal_type: signalType,
519
+ p_subjects: subjects as unknown as Json,
520
+ p_user_id: userId ?? undefined,
521
+ p_workspace_id: workspaceId ?? undefined,
522
+ });
523
+ } catch {
524
+ // Reputation logging must never block the protected request path.
525
+ }
526
+ }
527
+
528
+ export async function recordAbuseStepUpChallenge({
529
+ ipAddress,
530
+ metadata,
531
+ riskTier = 'challenge_required',
532
+ route,
533
+ status,
534
+ subjectKey,
535
+ userId,
536
+ }: RecordAbuseStepUpChallengeInput): Promise<void> {
537
+ const supabase = await getSupabaseAdmin();
538
+ if (!supabase) {
539
+ return;
540
+ }
541
+
542
+ try {
543
+ await supabase.from('abuse_step_up_challenges').insert({
544
+ completed_at:
545
+ status === 'passed' || status === 'failed'
546
+ ? new Date().toISOString()
547
+ : null,
548
+ ip_address: normalizeIpAddress(ipAddress),
549
+ metadata: (metadata ?? {}) as Json,
550
+ risk_tier: riskTier,
551
+ route: route ?? null,
552
+ status,
553
+ subject_key: subjectKey,
554
+ user_id: userId ?? null,
555
+ });
556
+ } catch {
557
+ // Challenge audit logging must never block the protected request path.
558
+ }
559
+ }
560
+
561
+ export function getSignalForResponseStatus(status: number): {
562
+ confidenceDelta: number;
563
+ scoreDelta: number;
564
+ signalType: AbuseSignalType;
565
+ } {
566
+ if (status === 429) {
567
+ return {
568
+ confidenceDelta: 10,
569
+ scoreDelta: -16,
570
+ signalType: 'rate_limit_hit',
571
+ };
572
+ }
573
+
574
+ if (status >= 400 && status < 500) {
575
+ return {
576
+ confidenceDelta: 4,
577
+ scoreDelta: -4,
578
+ signalType: 'client_error',
579
+ };
580
+ }
581
+
582
+ return {
583
+ confidenceDelta: 1,
584
+ scoreDelta: 1,
585
+ signalType: 'organic_activity',
586
+ };
587
+ }
@@ -0,0 +1,97 @@
1
+ import type { UpstashRestRedisClient } from '../upstash-rest';
2
+
3
+ /**
4
+ * Types for OTP Abuse Protection System
5
+ */
6
+
7
+ export type AbuseEventType =
8
+ | 'otp_send'
9
+ | 'otp_limit_reset'
10
+ | 'otp_verify_failed'
11
+ | 'mfa_challenge'
12
+ | 'mfa_verify_failed'
13
+ | 'reauth_send'
14
+ | 'reauth_verify_failed'
15
+ | 'password_login_failed'
16
+ | 'api_auth_failed'
17
+ | 'api_rate_limited'
18
+ | 'api_abuse'
19
+ | 'manual';
20
+
21
+ export type IPBlockStatus = 'active' | 'expired' | 'manually_unblocked';
22
+
23
+ export interface AbuseCheckResult {
24
+ allowed: boolean;
25
+ blocked?: boolean;
26
+ reason?: string;
27
+ retryAfter?: number; // seconds until retry allowed
28
+ remainingAttempts?: number;
29
+ }
30
+
31
+ export interface BlockInfo {
32
+ id: string;
33
+ blockLevel: number;
34
+ reason: AbuseEventType;
35
+ expiresAt: Date;
36
+ blockedAt: Date;
37
+ }
38
+
39
+ export interface AbuseProtectionLogContext {
40
+ route?: string | null;
41
+ source?: string | null;
42
+ }
43
+
44
+ export interface RateLimitConfig {
45
+ windowMs: number;
46
+ maxAttempts: number;
47
+ }
48
+
49
+ export interface LogAbuseEventOptions {
50
+ email?: string;
51
+ userAgent?: string;
52
+ endpoint?: string;
53
+ success?: boolean;
54
+ metadata?: Record<string, unknown>;
55
+ }
56
+
57
+ export interface BlockedIP {
58
+ id: string;
59
+ ip_address: string;
60
+ reason: AbuseEventType;
61
+ block_level: number;
62
+ status: IPBlockStatus;
63
+ blocked_at: string;
64
+ expires_at: string;
65
+ unblocked_at?: string;
66
+ unblocked_by?: string;
67
+ unblock_reason?: string;
68
+ metadata: Record<string, unknown>;
69
+ created_at: string;
70
+ updated_at: string;
71
+ }
72
+
73
+ export interface AbuseEvent {
74
+ id: string;
75
+ ip_address: string;
76
+ event_type: AbuseEventType;
77
+ email?: string;
78
+ email_hash?: string;
79
+ user_agent?: string;
80
+ endpoint?: string;
81
+ success: boolean;
82
+ metadata: Record<string, unknown>;
83
+ created_at: string;
84
+ }
85
+
86
+ export type RedisClient = Pick<
87
+ UpstashRestRedisClient,
88
+ 'get' | 'set' | 'incr' | 'expire' | 'ttl' | 'del'
89
+ >;
90
+
91
+ export interface AbuseProtectionConfig {
92
+ redis?: RedisClient;
93
+ supabaseAdmin?: {
94
+ from: (table: string) => unknown;
95
+ rpc: (fn: string, params: Record<string, unknown>) => Promise<unknown>;
96
+ };
97
+ }