@tuturuuu/utils 0.0.3 → 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.
- package/CHANGELOG.md +305 -0
- package/biome.json +5 -0
- package/jsr.json +8 -8
- package/package.json +63 -32
- package/src/__tests__/ai-temp-auth.test.ts +309 -0
- package/src/__tests__/api-proxy-guard.test.ts +1451 -0
- package/src/__tests__/app-url.test.ts +270 -0
- package/src/__tests__/avatar-url.test.ts +97 -0
- package/src/__tests__/color-helper.test.ts +179 -0
- package/src/__tests__/constants.test.ts +351 -0
- package/src/__tests__/crypto.test.ts +107 -0
- package/src/__tests__/date-helper.test.ts +408 -0
- package/src/__tests__/fixtures/task-description-full-featured.json +456 -0
- package/src/__tests__/format.test.ts +317 -0
- package/src/__tests__/html-sanitizer.test.ts +360 -0
- package/src/__tests__/interest-calculator.test.ts +336 -0
- package/src/__tests__/interest-detector.test.ts +222 -0
- package/src/__tests__/label-colors.test.ts +241 -0
- package/src/__tests__/name-helper.test.ts +158 -0
- package/src/__tests__/node-diff.test.ts +576 -0
- package/src/__tests__/notification-service.test.ts +210 -0
- package/src/__tests__/onboarding-helper.test.ts +331 -0
- package/src/__tests__/path-helper.test.ts +152 -0
- package/src/__tests__/permissions.test.tsx +81 -0
- package/src/__tests__/request-emoji-limit.test.ts +172 -0
- package/src/__tests__/search-helper.test.ts +51 -0
- package/src/__tests__/storage-display-name.test.ts +37 -0
- package/src/__tests__/storage-path.test.ts +238 -0
- package/src/__tests__/tag-utils.test.ts +205 -0
- package/src/__tests__/task-description-yjs-state.test.ts +581 -0
- package/src/__tests__/task-helper-board-api-routing.test.ts +94 -0
- package/src/__tests__/task-helper-create-task.test.ts +129 -0
- package/src/__tests__/task-helpers.test.ts +464 -0
- package/src/__tests__/task-overrides.test.ts +305 -0
- package/src/__tests__/task-reorder-cache.test.ts +74 -0
- package/src/__tests__/task-sort-keys.test.ts +36 -0
- package/src/__tests__/task-transformers.test.ts +62 -0
- package/src/__tests__/text-helper.test.ts +776 -0
- package/src/__tests__/time-helper.test.ts +70 -0
- package/src/__tests__/time-tracker-period.test.ts +55 -0
- package/src/__tests__/timezone.test.ts +117 -0
- package/src/__tests__/upstash-rest.test.ts +77 -0
- package/src/__tests__/uuid-helper.test.ts +133 -0
- package/src/__tests__/workspace-helper.test.ts +859 -0
- package/src/__tests__/workspace-limits.test.ts +255 -0
- package/src/__tests__/yjs-helper.test.ts +581 -0
- package/src/abuse-protection/__tests__/backend-rate-limit.test.ts +113 -0
- package/src/abuse-protection/__tests__/edge.test.ts +136 -0
- package/src/abuse-protection/__tests__/index.test.ts +562 -0
- package/src/abuse-protection/__tests__/reputation.test.ts +192 -0
- package/src/abuse-protection/backend-rate-limit.ts +44 -0
- package/src/abuse-protection/constants.ts +117 -0
- package/src/abuse-protection/edge.ts +223 -0
- package/src/abuse-protection/index.ts +1545 -0
- package/src/abuse-protection/reputation.ts +587 -0
- package/src/abuse-protection/types.ts +97 -0
- package/src/abuse-protection/user-agent.ts +124 -0
- package/src/abuse-protection/user-suspension.ts +231 -0
- package/src/ai-temp-auth.ts +315 -0
- package/src/api-proxy-guard.ts +965 -0
- package/src/app-url.ts +96 -0
- package/src/avatar-url.ts +64 -0
- package/src/break-duration.ts +84 -0
- package/src/calendar-auth-token.test.ts +37 -0
- package/src/calendar-auth-token.ts +19 -0
- package/src/calendar-sync-coordination.md +197 -0
- package/src/calendar-utils.test.ts +169 -0
- package/src/calendar-utils.ts +91 -0
- package/src/color-helper.ts +110 -0
- package/src/common/nextjs.tsx +99 -0
- package/src/common/scan.tsx +15 -0
- package/src/configs/reports.ts +160 -0
- package/src/constants.ts +85 -0
- package/src/crypto.ts +21 -0
- package/src/currencies.ts +97 -0
- package/src/date-helper.ts +313 -0
- package/src/editor/convert-to-task.ts +264 -0
- package/src/editor/index.ts +5 -0
- package/src/email/__tests__/client.test.ts +141 -0
- package/src/email/__tests__/validation.test.ts +46 -0
- package/src/email/client.ts +92 -0
- package/src/email/server.ts +128 -0
- package/src/email/validation.ts +11 -0
- package/src/encryption/__tests__/calendar-events.test.ts +411 -0
- package/src/encryption/__tests__/configuration.test.ts +114 -0
- package/src/encryption/__tests__/field-encryption.test.ts +232 -0
- package/src/encryption/__tests__/key-generation.test.ts +30 -0
- package/src/encryption/__tests__/performance-edge-cases.test.ts +187 -0
- package/src/encryption/__tests__/test-helpers.ts +22 -0
- package/src/encryption/__tests__/workspace-key-encryption.test.ts +129 -0
- package/src/encryption/encryption-service.ts +343 -0
- package/src/encryption/index.ts +25 -0
- package/src/encryption/types.ts +57 -0
- package/src/exchange-rates.ts +49 -0
- package/src/feature-flags/__tests__/feature-flags.test.ts +302 -0
- package/src/feature-flags/core.ts +322 -0
- package/src/feature-flags/data.ts +16 -0
- package/src/feature-flags/default.ts +18 -0
- package/src/feature-flags/index.ts +7 -0
- package/src/feature-flags/requestable-features.ts +79 -0
- package/src/feature-flags/types.ts +4 -0
- package/src/fetcher.ts +2 -0
- package/src/finance/index.ts +4 -0
- package/src/finance/interest-calculator.ts +456 -0
- package/src/finance/interest-detector.ts +141 -0
- package/src/finance/transform-invoice-results.ts +219 -0
- package/src/finance/wallet-permissions.test.ts +169 -0
- package/src/finance/wallet-permissions.ts +82 -0
- package/src/format.ts +120 -1
- package/src/generated/platform-build-metadata.ts +11 -0
- package/src/hooks/use-platform.ts +64 -0
- package/src/html-sanitizer.ts +155 -0
- package/src/internal-domains.ts +497 -0
- package/src/keyboard-preset.ts +109 -0
- package/src/label-colors.ts +213 -0
- package/src/launchable-apps.test.ts +126 -0
- package/src/launchable-apps.ts +490 -0
- package/src/name-helper.ts +269 -0
- package/src/next-config.test.ts +234 -0
- package/src/next-config.ts +203 -0
- package/src/node-diff.ts +375 -0
- package/src/notification-service.ts +379 -0
- package/src/nova/scores/__tests__/calculate.test.ts +254 -0
- package/src/nova/scores/calculate.ts +132 -0
- package/src/nova/submissions/check-permission.ts +132 -0
- package/src/onboarding-helper.ts +213 -0
- package/src/path-helper.ts +93 -0
- package/src/permissions.tsx +1170 -0
- package/src/plan-helpers.test.ts +188 -0
- package/src/plan-helpers.ts +80 -0
- package/src/platform-release.test.ts +74 -0
- package/src/platform-release.ts +155 -0
- package/src/portless.ts +124 -0
- package/src/priority-styles.ts +42 -0
- package/src/request-emoji-limit.ts +335 -0
- package/src/search-helper.ts +18 -0
- package/src/search.test.ts +89 -0
- package/src/search.ts +355 -0
- package/src/storage-display-name.ts +30 -0
- package/src/storage-path.ts +147 -0
- package/src/tag-utils.ts +159 -0
- package/src/task/reorder.ts +245 -0
- package/src/task/transformers.ts +149 -0
- package/src/task-date-timezone.ts +133 -0
- package/src/task-description-content.ts +240 -0
- package/src/task-helper/board.ts +193 -0
- package/src/task-helper/bulk-actions.ts +564 -0
- package/src/task-helper/personal-external-staging.ts +21 -0
- package/src/task-helper/recycle-bin.ts +202 -0
- package/src/task-helper/relationships.ts +346 -0
- package/src/task-helper/shared.ts +109 -0
- package/src/task-helper/sort-keys.ts +337 -0
- package/src/task-helper/task-hooks-basic.ts +342 -0
- package/src/task-helper/task-hooks-move.ts +264 -0
- package/src/task-helper/task-operations.ts +278 -0
- package/src/task-helper.ts +12 -0
- package/src/task-helpers.ts +241 -0
- package/src/task-list-status.ts +62 -0
- package/src/task-overrides.ts +82 -0
- package/src/task-snapshot.ts +374 -0
- package/src/text-diff.ts +81 -0
- package/src/text-helper.ts +537 -0
- package/src/time-helper.ts +63 -0
- package/src/time-tracker-period.ts +73 -0
- package/src/timeblock-helper.ts +418 -0
- package/src/timezone.ts +190 -0
- package/src/timezones.json +1271 -0
- package/src/upstash-rest.ts +56 -0
- package/src/user-helper.ts +296 -0
- package/src/uuid-helper.ts +11 -0
- package/src/workspace-handle.ts +10 -0
- package/src/workspace-helper.ts +1408 -0
- package/src/workspace-limits.ts +68 -0
- package/src/yjs-helper.ts +217 -0
- package/src/yjs-task-description.ts +81 -0
- package/tsconfig.json +3 -5
- package/tsconfig.typecheck.json +33 -0
- package/vitest.config.ts +36 -0
- package/dist/index.d.ts +0 -8
- package/dist/index.js +0 -2
- package/dist/index.js.map +0 -1
- package/dist/index.mjs +0 -2
- package/dist/index.mjs.map +0 -1
- package/eslint.config.mjs +0 -20
- package/rollup.config.js +0 -41
- 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
|
+
}
|