@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.
- package/CHANGELOG.md +313 -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,965 @@
|
|
|
1
|
+
import { Ratelimit } from '@upstash/ratelimit';
|
|
2
|
+
import type { NextRequest } from 'next/server';
|
|
3
|
+
import { NextResponse } from 'next/server';
|
|
4
|
+
import { extractIPFromRequest, isIPBlockedEdge } from './abuse-protection/edge';
|
|
5
|
+
import { DEV_MODE, MAX_PAYLOAD_SIZE } from './constants';
|
|
6
|
+
import { validateRequestEmojiLimit } from './request-emoji-limit';
|
|
7
|
+
import {
|
|
8
|
+
getUpstashRatelimitRedisClient,
|
|
9
|
+
type UpstashRatelimitRedisClient,
|
|
10
|
+
} from './upstash-rest';
|
|
11
|
+
|
|
12
|
+
const isDev = process.env.NODE_ENV !== 'production';
|
|
13
|
+
|
|
14
|
+
type CallerClass = 'anonymous' | 'authenticated';
|
|
15
|
+
|
|
16
|
+
export type RateLimitWindow = 'minute' | 'hour' | 'day';
|
|
17
|
+
|
|
18
|
+
export type RateLimitConfig = {
|
|
19
|
+
duration: '1 m' | '1 h' | '1 d';
|
|
20
|
+
limit: number;
|
|
21
|
+
window: RateLimitWindow;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type RateLimitProfile = {
|
|
25
|
+
get: RateLimitConfig[];
|
|
26
|
+
mutate: RateLimitConfig[];
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type ProxyRoutePolicy = {
|
|
30
|
+
key: string;
|
|
31
|
+
matches: (req: NextRequest) => boolean;
|
|
32
|
+
rateLimits: RateLimitProfile;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type TrustedProxyBypassRule = {
|
|
36
|
+
matches: (pathname: string, headers: Headers) => boolean;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type GuardOptions = {
|
|
40
|
+
prefixBase: string;
|
|
41
|
+
routePolicies?: ProxyRoutePolicy[];
|
|
42
|
+
trustedBypassRules?: TrustedProxyBypassRule[];
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
type RateLimitBucket = {
|
|
46
|
+
limiter: RateLimiter;
|
|
47
|
+
window: RateLimitWindow;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type Limiters = {
|
|
51
|
+
get: RateLimitBucket[];
|
|
52
|
+
mutate: RateLimitBucket[];
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
type RateLimitResult = {
|
|
56
|
+
limit: number;
|
|
57
|
+
remaining: number;
|
|
58
|
+
reset: number;
|
|
59
|
+
success: boolean;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
type RateLimiter = {
|
|
63
|
+
limit: (identifier: string) => Promise<RateLimitResult>;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
type LocalRateLimitState = {
|
|
67
|
+
count: number;
|
|
68
|
+
reset: number;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const limiterCache = new Map<string, Limiters>();
|
|
72
|
+
const localLimiterState = new Map<string, LocalRateLimitState>();
|
|
73
|
+
const GENERIC_SUPABASE_AUTH_COOKIE_NAME_PATTERN =
|
|
74
|
+
/^sb-[a-z0-9-]+-auth-token(?:\.\d+)?$/i;
|
|
75
|
+
const UUID_PATH_SEGMENT =
|
|
76
|
+
'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}';
|
|
77
|
+
const FINANCE_INVOICE_CREATE_SUPPORT_READ_PATH_PATTERN = new RegExp(
|
|
78
|
+
`^/api/v1/workspaces/[^/]+/(?:finance/invoices(?:/subscription/context)?|inventory/products|promotions|settings/(?:configs|[^/]+)|user-groups(?:/linked-products|/${UUID_PATH_SEGMENT}/linked-products)|users(?:/${UUID_PATH_SEGMENT}(?:/(?:linked-promotions|referral-discounts|user-groups))?)?|wallets)/?$`,
|
|
79
|
+
'u'
|
|
80
|
+
);
|
|
81
|
+
const FINANCE_INVOICE_TRANSACTION_CATEGORIES_PATH_PATTERN =
|
|
82
|
+
/^\/api\/workspaces\/[^/]+\/transactions\/categories\/?$/u;
|
|
83
|
+
const FINANCE_READ_PATH_PATTERNS = [
|
|
84
|
+
/^\/api\/workspaces\/[^/]+\/finance(?:\/|$)/u,
|
|
85
|
+
/^\/api\/workspaces\/[^/]+\/transactions(?:\/|$)/u,
|
|
86
|
+
/^\/api\/workspaces\/[^/]+\/wallets(?:\/|$)/u,
|
|
87
|
+
/^\/api\/workspaces\/[^/]+\/tags(?:\/|$)/u,
|
|
88
|
+
/^\/api\/v1\/workspaces\/[^/]+\/finance(?:\/|$)/u,
|
|
89
|
+
/^\/api\/v1\/workspaces\/[^/]+\/wallets(?:\/|$)/u,
|
|
90
|
+
] as const;
|
|
91
|
+
|
|
92
|
+
const NO_READ_RATE_LIMITS: RateLimitConfig[] = [];
|
|
93
|
+
|
|
94
|
+
function parsePositiveIntEnv(
|
|
95
|
+
name: string,
|
|
96
|
+
fallback: number,
|
|
97
|
+
aliases: string[] = []
|
|
98
|
+
): number {
|
|
99
|
+
const rawValue = process.env[name];
|
|
100
|
+
if (rawValue) {
|
|
101
|
+
const parsed = Number.parseInt(rawValue, 10);
|
|
102
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
for (const alias of aliases) {
|
|
106
|
+
const aliasValue = process.env[alias];
|
|
107
|
+
if (!aliasValue) {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const parsed = Number.parseInt(aliasValue, 10);
|
|
112
|
+
if (Number.isFinite(parsed) && parsed > 0) {
|
|
113
|
+
return parsed;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return fallback;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function createConfig(
|
|
121
|
+
window: RateLimitWindow,
|
|
122
|
+
duration: '1 m' | '1 h' | '1 d',
|
|
123
|
+
fallback: number,
|
|
124
|
+
envName: string,
|
|
125
|
+
envAliases: string[] = []
|
|
126
|
+
): RateLimitConfig {
|
|
127
|
+
return {
|
|
128
|
+
window,
|
|
129
|
+
duration,
|
|
130
|
+
limit: parsePositiveIntEnv(envName, fallback, envAliases),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const DEFAULT_ANONYMOUS_READ_RATE_LIMITS: RateLimitConfig[] = [
|
|
135
|
+
createConfig('minute', '1 m', 60, 'API_PROXY_ANON_READ_LIMIT_MINUTE'),
|
|
136
|
+
createConfig('hour', '1 h', 240, 'API_PROXY_ANON_READ_LIMIT_HOUR'),
|
|
137
|
+
createConfig('day', '1 d', 1200, 'API_PROXY_ANON_READ_LIMIT_DAY'),
|
|
138
|
+
];
|
|
139
|
+
|
|
140
|
+
const DEFAULT_ANONYMOUS_MUTATE_RATE_LIMITS: RateLimitConfig[] = [
|
|
141
|
+
createConfig('minute', '1 m', 30, 'API_PROXY_ANON_MUTATE_LIMIT_MINUTE'),
|
|
142
|
+
createConfig('hour', '1 h', 120, 'API_PROXY_ANON_MUTATE_LIMIT_HOUR'),
|
|
143
|
+
createConfig('day', '1 d', 600, 'API_PROXY_ANON_MUTATE_LIMIT_DAY'),
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
const DEFAULT_MUTATE_RATE_LIMITS: RateLimitConfig[] = [
|
|
147
|
+
{ window: 'minute', limit: 60, duration: '1 m' },
|
|
148
|
+
{ window: 'hour', limit: 300, duration: '1 h' },
|
|
149
|
+
{ window: 'day', limit: 2000, duration: '1 d' },
|
|
150
|
+
];
|
|
151
|
+
|
|
152
|
+
const USERS_ME_MUTATE_RATE_LIMITS: RateLimitConfig[] = [
|
|
153
|
+
{ window: 'minute', limit: 60, duration: '1 m' },
|
|
154
|
+
{ window: 'hour', limit: 200, duration: '1 h' },
|
|
155
|
+
{ window: 'day', limit: 1200, duration: '1 d' },
|
|
156
|
+
];
|
|
157
|
+
|
|
158
|
+
const AUTH_RATE_LIMITS: RateLimitProfile = {
|
|
159
|
+
get: NO_READ_RATE_LIMITS,
|
|
160
|
+
mutate: [
|
|
161
|
+
{ window: 'minute', limit: 3, duration: '1 m' },
|
|
162
|
+
{ window: 'hour', limit: 12, duration: '1 h' },
|
|
163
|
+
{ window: 'day', limit: 30, duration: '1 d' },
|
|
164
|
+
],
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const PASSWORD_LOGIN_RATE_LIMITS: RateLimitProfile = {
|
|
168
|
+
get: NO_READ_RATE_LIMITS,
|
|
169
|
+
mutate: [
|
|
170
|
+
createConfig('minute', '1 m', 60, 'API_PROXY_PASSWORD_LOGIN_LIMIT_MINUTE'),
|
|
171
|
+
createConfig('hour', '1 h', 600, 'API_PROXY_PASSWORD_LOGIN_LIMIT_HOUR'),
|
|
172
|
+
createConfig('day', '1 d', 4000, 'API_PROXY_PASSWORD_LOGIN_LIMIT_DAY'),
|
|
173
|
+
],
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const OTP_SEND_RATE_LIMITS: RateLimitProfile = {
|
|
177
|
+
get: NO_READ_RATE_LIMITS,
|
|
178
|
+
mutate: [
|
|
179
|
+
createConfig('minute', '1 m', 30, 'API_PROXY_OTP_SEND_LIMIT_MINUTE'),
|
|
180
|
+
createConfig('hour', '1 h', 180, 'API_PROXY_OTP_SEND_LIMIT_HOUR'),
|
|
181
|
+
createConfig('day', '1 d', 300, 'API_PROXY_OTP_SEND_LIMIT_DAY'),
|
|
182
|
+
],
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const OTP_VERIFY_RATE_LIMITS: RateLimitProfile = {
|
|
186
|
+
get: NO_READ_RATE_LIMITS,
|
|
187
|
+
mutate: [
|
|
188
|
+
createConfig('minute', '1 m', 60, 'API_PROXY_OTP_VERIFY_LIMIT_MINUTE'),
|
|
189
|
+
createConfig('hour', '1 h', 600, 'API_PROXY_OTP_VERIFY_LIMIT_HOUR'),
|
|
190
|
+
createConfig('day', '1 d', 4000, 'API_PROXY_OTP_VERIFY_LIMIT_DAY'),
|
|
191
|
+
],
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const CRON_RATE_LIMITS: RateLimitProfile = {
|
|
195
|
+
get: [
|
|
196
|
+
createConfig('minute', '1 m', 10, 'API_PROXY_CRON_READ_LIMIT_MINUTE'),
|
|
197
|
+
createConfig('hour', '1 h', 60, 'API_PROXY_CRON_READ_LIMIT_HOUR'),
|
|
198
|
+
createConfig('day', '1 d', 200, 'API_PROXY_CRON_READ_LIMIT_DAY'),
|
|
199
|
+
],
|
|
200
|
+
mutate: [
|
|
201
|
+
createConfig('minute', '1 m', 10, 'API_PROXY_CRON_MUTATE_LIMIT_MINUTE'),
|
|
202
|
+
createConfig('hour', '1 h', 60, 'API_PROXY_CRON_MUTATE_LIMIT_HOUR'),
|
|
203
|
+
createConfig('day', '1 d', 200, 'API_PROXY_CRON_MUTATE_LIMIT_DAY'),
|
|
204
|
+
],
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const CROSS_APP_RETURN_RATE_LIMITS: RateLimitProfile = {
|
|
208
|
+
get: NO_READ_RATE_LIMITS,
|
|
209
|
+
mutate: [
|
|
210
|
+
createConfig(
|
|
211
|
+
'minute',
|
|
212
|
+
'1 m',
|
|
213
|
+
180,
|
|
214
|
+
'API_PROXY_CROSS_APP_RETURN_LIMIT_MINUTE'
|
|
215
|
+
),
|
|
216
|
+
createConfig('hour', '1 h', 2000, 'API_PROXY_CROSS_APP_RETURN_LIMIT_HOUR'),
|
|
217
|
+
createConfig('day', '1 d', 10_000, 'API_PROXY_CROSS_APP_RETURN_LIMIT_DAY'),
|
|
218
|
+
],
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const HIGH_FANOUT_RATE_LIMITS: RateLimitProfile = {
|
|
222
|
+
get: NO_READ_RATE_LIMITS,
|
|
223
|
+
mutate: [
|
|
224
|
+
{ window: 'minute', limit: 2, duration: '1 m' },
|
|
225
|
+
{ window: 'hour', limit: 20, duration: '1 h' },
|
|
226
|
+
{ window: 'day', limit: 60, duration: '1 d' },
|
|
227
|
+
],
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const TASK_DESCRIPTION_RATE_LIMITS: RateLimitProfile = {
|
|
231
|
+
get: NO_READ_RATE_LIMITS,
|
|
232
|
+
mutate: [
|
|
233
|
+
{ window: 'minute', limit: 60, duration: '1 m' },
|
|
234
|
+
{ window: 'hour', limit: 600, duration: '1 h' },
|
|
235
|
+
{ window: 'day', limit: 4000, duration: '1 d' },
|
|
236
|
+
],
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const FORM_SUBMISSION_RATE_LIMITS: RateLimitProfile = {
|
|
240
|
+
get: NO_READ_RATE_LIMITS,
|
|
241
|
+
mutate: [
|
|
242
|
+
{ window: 'minute', limit: 60, duration: '1 m' },
|
|
243
|
+
{ window: 'hour', limit: 600, duration: '1 h' },
|
|
244
|
+
{ window: 'day', limit: 4000, duration: '1 d' },
|
|
245
|
+
],
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const TASK_BOARD_READ_RATE_LIMITS: RateLimitProfile = {
|
|
249
|
+
get: [
|
|
250
|
+
createConfig(
|
|
251
|
+
'minute',
|
|
252
|
+
'1 m',
|
|
253
|
+
300,
|
|
254
|
+
'API_PROXY_TASK_BOARD_READ_LIMIT_MINUTE'
|
|
255
|
+
),
|
|
256
|
+
createConfig('hour', '1 h', 3000, 'API_PROXY_TASK_BOARD_READ_LIMIT_HOUR'),
|
|
257
|
+
createConfig('day', '1 d', 20_000, 'API_PROXY_TASK_BOARD_READ_LIMIT_DAY'),
|
|
258
|
+
],
|
|
259
|
+
mutate: DEFAULT_MUTATE_RATE_LIMITS,
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const FINANCE_READ_RATE_LIMITS: RateLimitProfile = {
|
|
263
|
+
get: [
|
|
264
|
+
createConfig('minute', '1 m', 1200, 'API_PROXY_FINANCE_READ_LIMIT_MINUTE', [
|
|
265
|
+
'API_PROXY_FINANCE_INVOICE_CREATE_READ_LIMIT_MINUTE',
|
|
266
|
+
]),
|
|
267
|
+
createConfig('hour', '1 h', 12_000, 'API_PROXY_FINANCE_READ_LIMIT_HOUR', [
|
|
268
|
+
'API_PROXY_FINANCE_INVOICE_CREATE_READ_LIMIT_HOUR',
|
|
269
|
+
]),
|
|
270
|
+
createConfig('day', '1 d', 80_000, 'API_PROXY_FINANCE_READ_LIMIT_DAY', [
|
|
271
|
+
'API_PROXY_FINANCE_INVOICE_CREATE_READ_LIMIT_DAY',
|
|
272
|
+
]),
|
|
273
|
+
],
|
|
274
|
+
mutate: DEFAULT_MUTATE_RATE_LIMITS,
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
function isFinanceInvoiceCreateSupportRead(req: NextRequest) {
|
|
278
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return (
|
|
283
|
+
FINANCE_INVOICE_CREATE_SUPPORT_READ_PATH_PATTERN.test(
|
|
284
|
+
req.nextUrl.pathname
|
|
285
|
+
) ||
|
|
286
|
+
FINANCE_INVOICE_TRANSACTION_CATEGORIES_PATH_PATTERN.test(
|
|
287
|
+
req.nextUrl.pathname
|
|
288
|
+
)
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function isFinanceRead(req: NextRequest) {
|
|
293
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return FINANCE_READ_PATH_PATTERNS.some((pattern) =>
|
|
298
|
+
pattern.test(req.nextUrl.pathname)
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const DEFAULT_ROUTE_POLICIES: ProxyRoutePolicy[] = [
|
|
303
|
+
{
|
|
304
|
+
key: 'cron',
|
|
305
|
+
matches: (req) =>
|
|
306
|
+
req.nextUrl.pathname === '/api/cron' ||
|
|
307
|
+
req.nextUrl.pathname.startsWith('/api/cron/'),
|
|
308
|
+
rateLimits: CRON_RATE_LIMITS,
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
key: 'auth',
|
|
312
|
+
matches: (req) =>
|
|
313
|
+
req.nextUrl.pathname.startsWith('/api/auth/mfa/') ||
|
|
314
|
+
/^\/api\/v1\/auth\/otp\/settings(?:\/|$)/.test(req.nextUrl.pathname),
|
|
315
|
+
rateLimits: AUTH_RATE_LIMITS,
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
key: 'password-login',
|
|
319
|
+
matches: (req) =>
|
|
320
|
+
/^\/api\/v1\/auth\/password-login(?:\/|$)/.test(req.nextUrl.pathname) ||
|
|
321
|
+
/^\/api\/v1\/auth\/mobile\/password-login(?:\/|$)/.test(
|
|
322
|
+
req.nextUrl.pathname
|
|
323
|
+
),
|
|
324
|
+
rateLimits: PASSWORD_LOGIN_RATE_LIMITS,
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
key: 'otp-send',
|
|
328
|
+
matches: (req) =>
|
|
329
|
+
/^\/api\/v1\/auth\/otp\/send(?:\/|$)/.test(req.nextUrl.pathname) ||
|
|
330
|
+
/^\/api\/v1\/auth\/mobile\/send-otp(?:\/|$)/.test(req.nextUrl.pathname),
|
|
331
|
+
rateLimits: OTP_SEND_RATE_LIMITS,
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
key: 'otp-verify',
|
|
335
|
+
matches: (req) =>
|
|
336
|
+
/^\/api\/v1\/auth\/otp\/verify(?:\/|$)/.test(req.nextUrl.pathname) ||
|
|
337
|
+
/^\/api\/v1\/auth\/mobile\/verify-otp(?:\/|$)/.test(req.nextUrl.pathname),
|
|
338
|
+
rateLimits: OTP_VERIFY_RATE_LIMITS,
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
key: 'cross-app-return',
|
|
342
|
+
matches: (req) =>
|
|
343
|
+
/^\/api\/v1\/auth\/cross-app-return(?:\/|$)/.test(req.nextUrl.pathname),
|
|
344
|
+
rateLimits: CROSS_APP_RETURN_RATE_LIMITS,
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
key: 'form-submission',
|
|
348
|
+
matches: (req) =>
|
|
349
|
+
req.method === 'POST' &&
|
|
350
|
+
/^\/api\/v1\/shared\/forms\/[^/]+(?:\/|$)/.test(req.nextUrl.pathname),
|
|
351
|
+
rateLimits: FORM_SUBMISSION_RATE_LIMITS,
|
|
352
|
+
},
|
|
353
|
+
{
|
|
354
|
+
key: 'task-board-read',
|
|
355
|
+
matches: (req) =>
|
|
356
|
+
(req.method === 'GET' || req.method === 'HEAD') &&
|
|
357
|
+
(/^\/api\/v1\/workspaces\/[^/]+\/tasks\/?$/u.test(req.nextUrl.pathname) ||
|
|
358
|
+
/^\/api\/v1\/workspaces\/[^/]+\/task-boards\/[^/]+\/?$/u.test(
|
|
359
|
+
req.nextUrl.pathname
|
|
360
|
+
) ||
|
|
361
|
+
/^\/api\/v1\/workspaces\/[^/]+\/task-boards\/[^/]+\/lists\/?$/u.test(
|
|
362
|
+
req.nextUrl.pathname
|
|
363
|
+
)),
|
|
364
|
+
rateLimits: TASK_BOARD_READ_RATE_LIMITS,
|
|
365
|
+
},
|
|
366
|
+
{
|
|
367
|
+
key: 'finance-read',
|
|
368
|
+
matches: isFinanceRead,
|
|
369
|
+
rateLimits: FINANCE_READ_RATE_LIMITS,
|
|
370
|
+
},
|
|
371
|
+
{
|
|
372
|
+
key: 'finance-invoice-create-read',
|
|
373
|
+
matches: isFinanceInvoiceCreateSupportRead,
|
|
374
|
+
rateLimits: FINANCE_READ_RATE_LIMITS,
|
|
375
|
+
},
|
|
376
|
+
{
|
|
377
|
+
key: 'high-fanout',
|
|
378
|
+
matches: (req) =>
|
|
379
|
+
/^\/api\/v1\/workspaces\/[^/]+\/mail\/send(?:\/|$)/.test(
|
|
380
|
+
req.nextUrl.pathname
|
|
381
|
+
) ||
|
|
382
|
+
/^\/api\/v1\/workspaces\/[^/]+\/users\/[^/]+\/follow-up(?:\/|$)/.test(
|
|
383
|
+
req.nextUrl.pathname
|
|
384
|
+
) ||
|
|
385
|
+
/^\/api\/v1\/workspaces\/[^/]+\/user-groups\/[^/]+\/group-checks\/[^/]+\/email(?:\/|$)/.test(
|
|
386
|
+
req.nextUrl.pathname
|
|
387
|
+
),
|
|
388
|
+
rateLimits: HIGH_FANOUT_RATE_LIMITS,
|
|
389
|
+
},
|
|
390
|
+
{
|
|
391
|
+
key: 'task-description',
|
|
392
|
+
matches: (req) =>
|
|
393
|
+
/^\/api\/v1\/workspaces\/[^/]+\/tasks\/[^/]+\/description(?:\/|$)/.test(
|
|
394
|
+
req.nextUrl.pathname
|
|
395
|
+
),
|
|
396
|
+
rateLimits: TASK_DESCRIPTION_RATE_LIMITS,
|
|
397
|
+
},
|
|
398
|
+
{
|
|
399
|
+
key: 'users-me',
|
|
400
|
+
matches: (req) => req.nextUrl.pathname.startsWith('/api/v1/users/me'),
|
|
401
|
+
rateLimits: {
|
|
402
|
+
get: NO_READ_RATE_LIMITS,
|
|
403
|
+
mutate: USERS_ME_MUTATE_RATE_LIMITS,
|
|
404
|
+
},
|
|
405
|
+
},
|
|
406
|
+
{
|
|
407
|
+
key: 'default',
|
|
408
|
+
matches: () => true,
|
|
409
|
+
rateLimits: {
|
|
410
|
+
get: NO_READ_RATE_LIMITS,
|
|
411
|
+
mutate: DEFAULT_MUTATE_RATE_LIMITS,
|
|
412
|
+
},
|
|
413
|
+
},
|
|
414
|
+
];
|
|
415
|
+
|
|
416
|
+
function hasBearerToken(headers: Headers, secrets: Array<string | undefined>) {
|
|
417
|
+
const authHeader = headers.get('authorization');
|
|
418
|
+
if (!authHeader) {
|
|
419
|
+
return false;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return secrets.some(
|
|
423
|
+
(secret) => !!secret && authHeader === `Bearer ${secret}`
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function hasHeaderToken(
|
|
428
|
+
headers: Headers,
|
|
429
|
+
headerName: string,
|
|
430
|
+
secrets: Array<string | undefined>
|
|
431
|
+
) {
|
|
432
|
+
const headerValue = headers.get(headerName);
|
|
433
|
+
if (!headerValue) {
|
|
434
|
+
return false;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return secrets.some((secret) => !!secret && headerValue === secret);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function hasPolarWebhookSignatureHeaders(headers: Headers) {
|
|
441
|
+
return (
|
|
442
|
+
!!headers.get('webhook-id') &&
|
|
443
|
+
!!headers.get('webhook-timestamp') &&
|
|
444
|
+
!!headers.get('webhook-signature')
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const DEFAULT_TRUSTED_BYPASS_RULES: TrustedProxyBypassRule[] = [
|
|
449
|
+
{
|
|
450
|
+
matches: (pathname, headers) =>
|
|
451
|
+
(pathname === '/api/cron' || pathname.startsWith('/api/cron/')) &&
|
|
452
|
+
(hasBearerToken(headers, [
|
|
453
|
+
process.env.CRON_SECRET,
|
|
454
|
+
process.env.VERCEL_CRON_SECRET,
|
|
455
|
+
process.env.SUPABASE_SERVICE_ROLE_KEY,
|
|
456
|
+
]) ||
|
|
457
|
+
hasHeaderToken(headers, 'x-cron-secret', [
|
|
458
|
+
process.env.CRON_SECRET,
|
|
459
|
+
process.env.VERCEL_CRON_SECRET,
|
|
460
|
+
]) ||
|
|
461
|
+
hasHeaderToken(headers, 'x-vercel-cron-secret', [
|
|
462
|
+
process.env.CRON_SECRET,
|
|
463
|
+
process.env.VERCEL_CRON_SECRET,
|
|
464
|
+
])),
|
|
465
|
+
},
|
|
466
|
+
{
|
|
467
|
+
matches: (pathname, headers) =>
|
|
468
|
+
(pathname === '/api/payment/webhooks' ||
|
|
469
|
+
pathname.startsWith('/api/payment/webhooks/')) &&
|
|
470
|
+
!!process.env.POLAR_WEBHOOK_SECRET &&
|
|
471
|
+
hasPolarWebhookSignatureHeaders(headers),
|
|
472
|
+
},
|
|
473
|
+
{
|
|
474
|
+
matches: (pathname, headers) =>
|
|
475
|
+
(pathname === '/api/v1/webhooks' ||
|
|
476
|
+
pathname.startsWith('/api/v1/webhooks/')) &&
|
|
477
|
+
!!process.env.SUPABASE_WEBHOOK_SECRET &&
|
|
478
|
+
headers.get('x-webhook-secret') === process.env.SUPABASE_WEBHOOK_SECRET,
|
|
479
|
+
},
|
|
480
|
+
// Migration APIs: bypass rate limit in DEV_MODE (routes already return 403 in production)
|
|
481
|
+
{
|
|
482
|
+
matches: (pathname, _headers) =>
|
|
483
|
+
pathname.startsWith('/api/v1/infrastructure/migrate/') && DEV_MODE,
|
|
484
|
+
},
|
|
485
|
+
];
|
|
486
|
+
|
|
487
|
+
function createRedisRateLimitBuckets(
|
|
488
|
+
redis: UpstashRatelimitRedisClient,
|
|
489
|
+
prefixBase: string,
|
|
490
|
+
kind: 'get' | 'mutate',
|
|
491
|
+
configs: RateLimitConfig[]
|
|
492
|
+
): RateLimitBucket[] {
|
|
493
|
+
return configs.map((config) => ({
|
|
494
|
+
window: config.window,
|
|
495
|
+
limiter: new Ratelimit({
|
|
496
|
+
redis,
|
|
497
|
+
limiter: Ratelimit.slidingWindow(config.limit, config.duration),
|
|
498
|
+
prefix: `${prefixBase}:${kind}:${config.window}`,
|
|
499
|
+
analytics: false,
|
|
500
|
+
}),
|
|
501
|
+
}));
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function rateLimitDurationMs(duration: RateLimitConfig['duration']) {
|
|
505
|
+
switch (duration) {
|
|
506
|
+
case '1 m':
|
|
507
|
+
return 60_000;
|
|
508
|
+
case '1 h':
|
|
509
|
+
return 60 * 60_000;
|
|
510
|
+
case '1 d':
|
|
511
|
+
return 24 * 60 * 60_000;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function createLocalRateLimiter(
|
|
516
|
+
prefix: string,
|
|
517
|
+
config: RateLimitConfig
|
|
518
|
+
): RateLimiter {
|
|
519
|
+
const durationMs = rateLimitDurationMs(config.duration);
|
|
520
|
+
|
|
521
|
+
return {
|
|
522
|
+
async limit(identifier: string) {
|
|
523
|
+
const now = Date.now();
|
|
524
|
+
const key = `${prefix}:${identifier}`;
|
|
525
|
+
let state = localLimiterState.get(key);
|
|
526
|
+
|
|
527
|
+
if (!state || state.reset <= now) {
|
|
528
|
+
state = {
|
|
529
|
+
count: 0,
|
|
530
|
+
reset: now + durationMs,
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (state.count >= config.limit) {
|
|
535
|
+
localLimiterState.set(key, state);
|
|
536
|
+
return {
|
|
537
|
+
limit: config.limit,
|
|
538
|
+
remaining: 0,
|
|
539
|
+
reset: state.reset,
|
|
540
|
+
success: false,
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
state.count += 1;
|
|
545
|
+
localLimiterState.set(key, state);
|
|
546
|
+
|
|
547
|
+
return {
|
|
548
|
+
limit: config.limit,
|
|
549
|
+
remaining: Math.max(0, config.limit - state.count),
|
|
550
|
+
reset: state.reset,
|
|
551
|
+
success: true,
|
|
552
|
+
};
|
|
553
|
+
},
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function createLocalRateLimitBuckets(
|
|
558
|
+
prefixBase: string,
|
|
559
|
+
kind: 'get' | 'mutate',
|
|
560
|
+
configs: RateLimitConfig[]
|
|
561
|
+
): RateLimitBucket[] {
|
|
562
|
+
return configs.map((config) => ({
|
|
563
|
+
window: config.window,
|
|
564
|
+
limiter: createLocalRateLimiter(
|
|
565
|
+
`${prefixBase}:local:${kind}:${config.window}`,
|
|
566
|
+
config
|
|
567
|
+
),
|
|
568
|
+
}));
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function createLocalRateLimiters(
|
|
572
|
+
prefixBase: string,
|
|
573
|
+
profile: RateLimitProfile
|
|
574
|
+
): Limiters {
|
|
575
|
+
return {
|
|
576
|
+
get: createLocalRateLimitBuckets(prefixBase, 'get', profile.get),
|
|
577
|
+
mutate: createLocalRateLimitBuckets(prefixBase, 'mutate', profile.mutate),
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
async function getRateLimiters(
|
|
582
|
+
prefixBase: string,
|
|
583
|
+
profile: RateLimitProfile
|
|
584
|
+
): Promise<Limiters> {
|
|
585
|
+
const cacheKey = `${prefixBase}:${JSON.stringify(profile)}`;
|
|
586
|
+
const cached = limiterCache.get(cacheKey);
|
|
587
|
+
if (cached) {
|
|
588
|
+
return cached;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const redis = await getUpstashRatelimitRedisClient().catch(() => null);
|
|
592
|
+
if (!redis) {
|
|
593
|
+
const localLimiters = createLocalRateLimiters(prefixBase, profile);
|
|
594
|
+
limiterCache.set(cacheKey, localLimiters);
|
|
595
|
+
return localLimiters;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const limiters = {
|
|
599
|
+
get: createRedisRateLimitBuckets(redis, prefixBase, 'get', profile.get),
|
|
600
|
+
mutate: createRedisRateLimitBuckets(
|
|
601
|
+
redis,
|
|
602
|
+
prefixBase,
|
|
603
|
+
'mutate',
|
|
604
|
+
profile.mutate
|
|
605
|
+
),
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
limiterCache.set(cacheKey, limiters);
|
|
609
|
+
return limiters;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function replaceWithLocalRateLimiters(
|
|
613
|
+
cacheKey: string,
|
|
614
|
+
prefixBase: string,
|
|
615
|
+
profile: RateLimitProfile
|
|
616
|
+
): Limiters {
|
|
617
|
+
const localLimiters = createLocalRateLimiters(prefixBase, profile);
|
|
618
|
+
limiterCache.set(cacheKey, localLimiters);
|
|
619
|
+
return localLimiters;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function getRoutePolicy(
|
|
623
|
+
req: NextRequest,
|
|
624
|
+
routePolicies: ProxyRoutePolicy[]
|
|
625
|
+
): ProxyRoutePolicy {
|
|
626
|
+
return (
|
|
627
|
+
routePolicies.find((routePolicy) => routePolicy.matches(req)) ??
|
|
628
|
+
DEFAULT_ROUTE_POLICIES[DEFAULT_ROUTE_POLICIES.length - 1]!
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function buildRateLimitResponse(
|
|
633
|
+
status: 429,
|
|
634
|
+
retryAfter: number,
|
|
635
|
+
headers?: Record<string, string>
|
|
636
|
+
) {
|
|
637
|
+
return NextResponse.json(
|
|
638
|
+
{ error: 'Too Many Requests', message: 'Rate limit exceeded' },
|
|
639
|
+
{
|
|
640
|
+
status,
|
|
641
|
+
headers: {
|
|
642
|
+
'Cache-Control': 'no-store',
|
|
643
|
+
'Retry-After': `${retryAfter}`,
|
|
644
|
+
...headers,
|
|
645
|
+
},
|
|
646
|
+
}
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function looksLikeSupabaseJwt(token: string): boolean {
|
|
651
|
+
const parts = token.split('.');
|
|
652
|
+
if (parts.length !== 3) {
|
|
653
|
+
return false;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
return parts.every(
|
|
657
|
+
(part) => /^[A-Za-z0-9_-]+$/.test(part) && part.length > 0
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function looksLikeWorkspaceApiKey(token: string): boolean {
|
|
662
|
+
return /^ttr_[A-Za-z0-9_-]+$/.test(token);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function hasAuthenticatedBearerToken(headers: Headers): boolean {
|
|
666
|
+
const authHeader = headers.get('authorization');
|
|
667
|
+
if (!authHeader) {
|
|
668
|
+
return false;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const trimmedHeader = authHeader.trim();
|
|
672
|
+
if (looksLikeWorkspaceApiKey(trimmedHeader)) {
|
|
673
|
+
return true;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
if (!trimmedHeader.toLowerCase().startsWith('bearer ')) {
|
|
677
|
+
return false;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const token = trimmedHeader.slice(7).trim();
|
|
681
|
+
return looksLikeSupabaseJwt(token) || looksLikeWorkspaceApiKey(token);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function getSupabaseAuthStorageKey(url: string | undefined): string | null {
|
|
685
|
+
if (!url) {
|
|
686
|
+
return null;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
try {
|
|
690
|
+
return `sb-${new URL(url).hostname.split('.')[0]}-auth-token`;
|
|
691
|
+
} catch {
|
|
692
|
+
return null;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function isSupabaseAuthCookieName(
|
|
697
|
+
cookieName: string,
|
|
698
|
+
storageKeys: string[]
|
|
699
|
+
): boolean {
|
|
700
|
+
for (const storageKey of storageKeys) {
|
|
701
|
+
if (
|
|
702
|
+
cookieName === storageKey ||
|
|
703
|
+
(/^\d+$/.test(cookieName.slice(storageKey.length + 1)) &&
|
|
704
|
+
cookieName.startsWith(`${storageKey}.`))
|
|
705
|
+
) {
|
|
706
|
+
return true;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
return (
|
|
711
|
+
storageKeys.length === 0 &&
|
|
712
|
+
GENERIC_SUPABASE_AUTH_COOKIE_NAME_PATTERN.test(cookieName)
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function getSupabaseAuthStorageKeys(): string[] {
|
|
717
|
+
const storageKeys = [
|
|
718
|
+
getSupabaseAuthStorageKey(process.env.NEXT_PUBLIC_SUPABASE_URL),
|
|
719
|
+
getSupabaseAuthStorageKey(process.env.SUPABASE_SERVER_URL),
|
|
720
|
+
].filter((value): value is string => Boolean(value));
|
|
721
|
+
|
|
722
|
+
return [...new Set(storageKeys)];
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
export function hasSupabaseSessionCookie(req: NextRequest): boolean {
|
|
726
|
+
const storageKeys = getSupabaseAuthStorageKeys();
|
|
727
|
+
|
|
728
|
+
return req.cookies
|
|
729
|
+
.getAll()
|
|
730
|
+
.some((cookie) => isSupabaseAuthCookieName(cookie.name, storageKeys));
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
export function hasAuthenticatedApiSession(req: NextRequest): boolean {
|
|
734
|
+
if (
|
|
735
|
+
hasAuthenticatedBearerToken(req.headers) ||
|
|
736
|
+
hasSupabaseSessionCookie(req)
|
|
737
|
+
) {
|
|
738
|
+
return true;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
return false;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function getCallerClass(req: NextRequest): CallerClass {
|
|
745
|
+
void req;
|
|
746
|
+
return 'anonymous';
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function getEffectiveRateLimits(
|
|
750
|
+
routePolicy: ProxyRoutePolicy,
|
|
751
|
+
callerClass: CallerClass
|
|
752
|
+
): RateLimitProfile {
|
|
753
|
+
if (callerClass === 'authenticated') {
|
|
754
|
+
return routePolicy.rateLimits;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
if (routePolicy.key === 'default' || routePolicy.key === 'users-me') {
|
|
758
|
+
return {
|
|
759
|
+
get: DEFAULT_ANONYMOUS_READ_RATE_LIMITS,
|
|
760
|
+
mutate: DEFAULT_ANONYMOUS_MUTATE_RATE_LIMITS,
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
return routePolicy.rateLimits;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function shouldScopeRateLimitByPath(routePolicy: ProxyRoutePolicy): boolean {
|
|
768
|
+
return routePolicy.key === 'default' || routePolicy.key === 'users-me';
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function getPathScopedRateLimitPrefix(
|
|
772
|
+
prefixBase: string,
|
|
773
|
+
routePolicy: ProxyRoutePolicy,
|
|
774
|
+
callerClass: CallerClass,
|
|
775
|
+
pathname: string
|
|
776
|
+
): string {
|
|
777
|
+
const scopeSuffix = shouldScopeRateLimitByPath(routePolicy)
|
|
778
|
+
? `:${pathname.replaceAll('/', ':') || ':root'}`
|
|
779
|
+
: '';
|
|
780
|
+
|
|
781
|
+
return `${prefixBase}:${routePolicy.key}:${callerClass}${scopeSuffix}`;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
export function isTrustedProxyBypassRequest(
|
|
785
|
+
pathname: string,
|
|
786
|
+
headers: Headers,
|
|
787
|
+
trustedBypassRules: TrustedProxyBypassRule[] = DEFAULT_TRUSTED_BYPASS_RULES
|
|
788
|
+
): boolean {
|
|
789
|
+
return trustedBypassRules.some((rule) => rule.matches(pathname, headers));
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
export function clearApiProxyGuardLimiterCache() {
|
|
793
|
+
limiterCache.clear();
|
|
794
|
+
localLimiterState.clear();
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
async function requestBodyExceedsLimit(
|
|
798
|
+
req: NextRequest,
|
|
799
|
+
maxBytes: number
|
|
800
|
+
): Promise<boolean> {
|
|
801
|
+
const body = req.clone().body;
|
|
802
|
+
if (!body) {
|
|
803
|
+
return false;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const reader = body.getReader();
|
|
807
|
+
let totalBytes = 0;
|
|
808
|
+
|
|
809
|
+
try {
|
|
810
|
+
while (true) {
|
|
811
|
+
const { done, value } = await reader.read();
|
|
812
|
+
if (done) {
|
|
813
|
+
return false;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
totalBytes += value.byteLength;
|
|
817
|
+
if (totalBytes > maxBytes) {
|
|
818
|
+
void reader.cancel().catch(() => {});
|
|
819
|
+
return true;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
} finally {
|
|
823
|
+
reader.releaseLock();
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
export async function guardApiProxyRequest(
|
|
828
|
+
req: NextRequest,
|
|
829
|
+
options: GuardOptions
|
|
830
|
+
): Promise<NextResponse | null> {
|
|
831
|
+
const contentLength = req.headers.get('content-length');
|
|
832
|
+
if (contentLength) {
|
|
833
|
+
const size = Number.parseInt(contentLength, 10);
|
|
834
|
+
if (!Number.isNaN(size) && size > MAX_PAYLOAD_SIZE) {
|
|
835
|
+
return NextResponse.json(
|
|
836
|
+
{ error: 'Payload Too Large', message: 'Request body exceeds limit' },
|
|
837
|
+
{ status: 413 }
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
if (await requestBodyExceedsLimit(req, MAX_PAYLOAD_SIZE)) {
|
|
843
|
+
return NextResponse.json(
|
|
844
|
+
{ error: 'Payload Too Large', message: 'Request body exceeds limit' },
|
|
845
|
+
{ status: 413 }
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
const routePolicies = options.routePolicies ?? DEFAULT_ROUTE_POLICIES;
|
|
850
|
+
const trustedBypassRules = [
|
|
851
|
+
...DEFAULT_TRUSTED_BYPASS_RULES,
|
|
852
|
+
...(options.trustedBypassRules ?? []),
|
|
853
|
+
];
|
|
854
|
+
|
|
855
|
+
if (
|
|
856
|
+
!isDev &&
|
|
857
|
+
!isTrustedProxyBypassRequest(
|
|
858
|
+
req.nextUrl.pathname,
|
|
859
|
+
req.headers,
|
|
860
|
+
trustedBypassRules
|
|
861
|
+
)
|
|
862
|
+
) {
|
|
863
|
+
const callerClass = getCallerClass(req);
|
|
864
|
+
const ip = extractIPFromRequest(req.headers);
|
|
865
|
+
|
|
866
|
+
if (ip !== 'unknown' && callerClass === 'anonymous') {
|
|
867
|
+
const blockInfo = await isIPBlockedEdge(ip);
|
|
868
|
+
if (blockInfo) {
|
|
869
|
+
const retryAfter = Math.max(
|
|
870
|
+
1,
|
|
871
|
+
Math.ceil((blockInfo.expiresAt.getTime() - Date.now()) / 1000)
|
|
872
|
+
);
|
|
873
|
+
|
|
874
|
+
return buildRateLimitResponse(429, retryAfter, {
|
|
875
|
+
'X-Proxy-Block-Reason': 'ip-already-blocked',
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
const routePolicy = getRoutePolicy(req, routePolicies);
|
|
880
|
+
const isRead = req.method === 'GET' || req.method === 'HEAD';
|
|
881
|
+
const rateLimits = getEffectiveRateLimits(routePolicy, callerClass);
|
|
882
|
+
const limiterPrefix = getPathScopedRateLimitPrefix(
|
|
883
|
+
options.prefixBase,
|
|
884
|
+
routePolicy,
|
|
885
|
+
callerClass,
|
|
886
|
+
req.nextUrl.pathname
|
|
887
|
+
);
|
|
888
|
+
const limiterCacheKey = `${limiterPrefix}:${JSON.stringify(rateLimits)}`;
|
|
889
|
+
const limiters = await getRateLimiters(limiterPrefix, rateLimits);
|
|
890
|
+
let activeLimiters = isRead ? limiters.get : limiters.mutate;
|
|
891
|
+
|
|
892
|
+
for (let index = 0; index < activeLimiters.length; index += 1) {
|
|
893
|
+
const { limiter, window } = activeLimiters[index]!;
|
|
894
|
+
let result: RateLimitResult;
|
|
895
|
+
|
|
896
|
+
try {
|
|
897
|
+
result = await limiter.limit(ip);
|
|
898
|
+
} catch {
|
|
899
|
+
const fallbackLimiters = replaceWithLocalRateLimiters(
|
|
900
|
+
limiterCacheKey,
|
|
901
|
+
limiterPrefix,
|
|
902
|
+
rateLimits
|
|
903
|
+
);
|
|
904
|
+
activeLimiters = isRead
|
|
905
|
+
? fallbackLimiters.get
|
|
906
|
+
: fallbackLimiters.mutate;
|
|
907
|
+
const fallbackBucket =
|
|
908
|
+
activeLimiters[index] ??
|
|
909
|
+
activeLimiters.find((bucket) => bucket.window === window);
|
|
910
|
+
|
|
911
|
+
if (!fallbackBucket) {
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
result = await fallbackBucket.limiter.limit(ip);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const { success, limit, remaining, reset } = result;
|
|
919
|
+
|
|
920
|
+
if (isDev) {
|
|
921
|
+
const consumed = limit - remaining;
|
|
922
|
+
const kind = isRead ? 'read' : 'mutate';
|
|
923
|
+
console.log(
|
|
924
|
+
`[ProxyGuard] ${routePolicy.key}:${kind}:${window} ${consumed}/${limit} | IP: ${ip} | path: ${req.nextUrl.pathname}`
|
|
925
|
+
);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
if (!success) {
|
|
929
|
+
const retryAfter = Math.max(
|
|
930
|
+
1,
|
|
931
|
+
Math.ceil((reset - Date.now()) / 1000)
|
|
932
|
+
);
|
|
933
|
+
|
|
934
|
+
return buildRateLimitResponse(429, retryAfter, {
|
|
935
|
+
'X-Proxy-Block-Reason': 'route-rate-limit',
|
|
936
|
+
'X-RateLimit-Limit': `${limit}`,
|
|
937
|
+
'X-RateLimit-Remaining': `${remaining}`,
|
|
938
|
+
'X-RateLimit-Reset': `${Math.ceil(reset / 1000)}`,
|
|
939
|
+
'X-RateLimit-Caller-Class': callerClass,
|
|
940
|
+
'X-RateLimit-Window': window,
|
|
941
|
+
'X-RateLimit-Policy': routePolicy.key,
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
const allowDescriptionYjsState =
|
|
949
|
+
/\/api\/v1\/workspaces\/[^/]+\/tasks\/[^/]+\/description$/.test(
|
|
950
|
+
req.nextUrl.pathname
|
|
951
|
+
);
|
|
952
|
+
const skipValidationForFields =
|
|
953
|
+
/^\/api\/v1\/workspaces\/[^/]+\/whiteboards\/[^/]+$/.test(
|
|
954
|
+
req.nextUrl.pathname
|
|
955
|
+
)
|
|
956
|
+
? ['snapshot']
|
|
957
|
+
: undefined;
|
|
958
|
+
|
|
959
|
+
return (
|
|
960
|
+
(await validateRequestEmojiLimit(req, {
|
|
961
|
+
allowDescriptionYjsState,
|
|
962
|
+
skipValidationForFields,
|
|
963
|
+
})) ?? null
|
|
964
|
+
);
|
|
965
|
+
}
|