@tuturuuu/utils 0.8.0 → 0.10.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 +21 -0
- package/biome.json +1 -1
- package/package.json +17 -15
- package/src/__tests__/api-proxy-guard.test.ts +728 -71
- package/src/__tests__/app-url.test.ts +2 -0
- package/src/__tests__/task-description-codec.test.ts +110 -0
- package/src/abuse-protection/__tests__/backend-rate-limit.test.ts +10 -45
- package/src/abuse-protection/__tests__/edge-trust.test.ts +99 -0
- package/src/abuse-protection/__tests__/index.test.ts +136 -0
- package/src/abuse-protection/backend-rate-limit.ts +5 -4
- package/src/abuse-protection/edge-trust.ts +141 -6
- package/src/abuse-protection/index.ts +73 -40
- package/src/api-proxy-guard.ts +202 -43
- package/src/currencies.test.ts +33 -0
- package/src/currencies.ts +28 -5
- package/src/effect.test.ts +247 -0
- package/src/effect.ts +259 -0
- package/src/internal-domains.ts +24 -0
- package/src/platform-release.test.ts +1 -1
- package/src/platform-release.ts +1 -1
- package/src/portless.ts +2 -0
- package/src/task-description-codec.ts +457 -0
- package/src/task-helper/task-operations.ts +3 -0
- package/src/yjs-task-description.ts +13 -21
package/src/api-proxy-guard.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { extractIPFromRequest, isIPBlockedEdge } from './abuse-protection/edge';
|
|
|
5
5
|
import {
|
|
6
6
|
type CachedTrustEntry,
|
|
7
7
|
getCachedTrustEntries,
|
|
8
|
+
hasCachedIpBlockAppealRelief,
|
|
8
9
|
} from './abuse-protection/edge-trust';
|
|
9
10
|
import { DEV_MODE, MAX_PAYLOAD_SIZE } from './constants';
|
|
10
11
|
import { validateRequestEmojiLimit } from './request-emoji-limit';
|
|
@@ -31,10 +32,10 @@ function isEnvFlagEnabled(name: string, defaultOn = true): boolean {
|
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
/**
|
|
34
|
-
* Whether trusted accounts/locations get scaled read limits (and
|
|
35
|
+
* Whether trusted accounts/locations get scaled read limits (and verified
|
|
35
36
|
* sessions get their own per-session bucket) at the edge. Default on; set
|
|
36
37
|
* `API_PROXY_EDGE_TRUST_ENABLED=0` to disable trust uplift and fall back to
|
|
37
|
-
* legacy per-IP
|
|
38
|
+
* legacy per-IP keying without a deploy.
|
|
38
39
|
*/
|
|
39
40
|
function edgeTrustEnabled(): boolean {
|
|
40
41
|
return isEnvFlagEnabled('API_PROXY_EDGE_TRUST_ENABLED');
|
|
@@ -187,18 +188,39 @@ const DEFAULT_ANONYMOUS_MUTATE_RATE_LIMITS: RateLimitConfig[] = [
|
|
|
187
188
|
createConfig('day', '1 d', 600, 'API_PROXY_ANON_MUTATE_LIMIT_DAY'),
|
|
188
189
|
];
|
|
189
190
|
|
|
191
|
+
const DEFAULT_AUTHENTICATED_READ_RATE_LIMITS: RateLimitConfig[] = [
|
|
192
|
+
createConfig('minute', '1 m', 600, 'API_PROXY_AUTH_READ_LIMIT_MINUTE'),
|
|
193
|
+
createConfig('hour', '1 h', 6000, 'API_PROXY_AUTH_READ_LIMIT_HOUR'),
|
|
194
|
+
createConfig('day', '1 d', 40_000, 'API_PROXY_AUTH_READ_LIMIT_DAY'),
|
|
195
|
+
];
|
|
196
|
+
|
|
190
197
|
const DEFAULT_MUTATE_RATE_LIMITS: RateLimitConfig[] = [
|
|
191
198
|
{ window: 'minute', limit: 60, duration: '1 m' },
|
|
192
199
|
{ window: 'hour', limit: 300, duration: '1 h' },
|
|
193
200
|
{ window: 'day', limit: 2000, duration: '1 d' },
|
|
194
201
|
];
|
|
195
202
|
|
|
203
|
+
const USERS_ME_READ_RATE_LIMITS: RateLimitConfig[] = [
|
|
204
|
+
createConfig('minute', '1 m', 600, 'API_PROXY_USERS_ME_READ_LIMIT_MINUTE'),
|
|
205
|
+
createConfig('hour', '1 h', 6000, 'API_PROXY_USERS_ME_READ_LIMIT_HOUR'),
|
|
206
|
+
createConfig('day', '1 d', 40_000, 'API_PROXY_USERS_ME_READ_LIMIT_DAY'),
|
|
207
|
+
];
|
|
208
|
+
|
|
196
209
|
const USERS_ME_MUTATE_RATE_LIMITS: RateLimitConfig[] = [
|
|
197
210
|
{ window: 'minute', limit: 60, duration: '1 m' },
|
|
198
211
|
{ window: 'hour', limit: 200, duration: '1 h' },
|
|
199
212
|
{ window: 'day', limit: 1200, duration: '1 d' },
|
|
200
213
|
];
|
|
201
214
|
|
|
215
|
+
const WORKSPACE_DASHBOARD_READ_RATE_LIMITS: RateLimitProfile = {
|
|
216
|
+
get: [
|
|
217
|
+
createConfig('minute', '1 m', 600, 'API_PROXY_WORKSPACE_READ_LIMIT_MINUTE'),
|
|
218
|
+
createConfig('hour', '1 h', 6000, 'API_PROXY_WORKSPACE_READ_LIMIT_HOUR'),
|
|
219
|
+
createConfig('day', '1 d', 40_000, 'API_PROXY_WORKSPACE_READ_LIMIT_DAY'),
|
|
220
|
+
],
|
|
221
|
+
mutate: DEFAULT_MUTATE_RATE_LIMITS,
|
|
222
|
+
};
|
|
223
|
+
|
|
202
224
|
const AUTH_RATE_LIMITS: RateLimitProfile = {
|
|
203
225
|
get: NO_READ_RATE_LIMITS,
|
|
204
226
|
mutate: [
|
|
@@ -235,6 +257,20 @@ const OTP_VERIFY_RATE_LIMITS: RateLimitProfile = {
|
|
|
235
257
|
],
|
|
236
258
|
};
|
|
237
259
|
|
|
260
|
+
const RATE_LIMIT_APPEAL_RATE_LIMITS: RateLimitProfile = {
|
|
261
|
+
get: NO_READ_RATE_LIMITS,
|
|
262
|
+
mutate: [
|
|
263
|
+
createConfig(
|
|
264
|
+
'minute',
|
|
265
|
+
'1 m',
|
|
266
|
+
1,
|
|
267
|
+
'API_PROXY_RATE_LIMIT_APPEAL_LIMIT_MINUTE'
|
|
268
|
+
),
|
|
269
|
+
createConfig('hour', '1 h', 3, 'API_PROXY_RATE_LIMIT_APPEAL_LIMIT_HOUR'),
|
|
270
|
+
createConfig('day', '1 d', 10, 'API_PROXY_RATE_LIMIT_APPEAL_LIMIT_DAY'),
|
|
271
|
+
],
|
|
272
|
+
};
|
|
273
|
+
|
|
238
274
|
const CRON_RATE_LIMITS: RateLimitProfile = {
|
|
239
275
|
get: [
|
|
240
276
|
createConfig('minute', '1 m', 10, 'API_PROXY_CRON_READ_LIMIT_MINUTE'),
|
|
@@ -312,6 +348,25 @@ const USERS_DATABASE_READ_OVER_POST_RATE_LIMITS: RateLimitProfile = {
|
|
|
312
348
|
],
|
|
313
349
|
};
|
|
314
350
|
|
|
351
|
+
const USERS_ADMIN_READ_RATE_LIMITS: RateLimitProfile = {
|
|
352
|
+
get: [
|
|
353
|
+
createConfig(
|
|
354
|
+
'minute',
|
|
355
|
+
'1 m',
|
|
356
|
+
1200,
|
|
357
|
+
'API_PROXY_USERS_ADMIN_READ_LIMIT_MINUTE'
|
|
358
|
+
),
|
|
359
|
+
createConfig(
|
|
360
|
+
'hour',
|
|
361
|
+
'1 h',
|
|
362
|
+
12_000,
|
|
363
|
+
'API_PROXY_USERS_ADMIN_READ_LIMIT_HOUR'
|
|
364
|
+
),
|
|
365
|
+
createConfig('day', '1 d', 80_000, 'API_PROXY_USERS_ADMIN_READ_LIMIT_DAY'),
|
|
366
|
+
],
|
|
367
|
+
mutate: DEFAULT_MUTATE_RATE_LIMITS,
|
|
368
|
+
};
|
|
369
|
+
|
|
315
370
|
const FINANCE_READ_RATE_LIMITS: RateLimitProfile = {
|
|
316
371
|
get: [
|
|
317
372
|
createConfig('minute', '1 m', 1200, 'API_PROXY_FINANCE_READ_LIMIT_MINUTE', [
|
|
@@ -362,7 +417,46 @@ function isUsersDatabaseReadOverPost(req: NextRequest) {
|
|
|
362
417
|
);
|
|
363
418
|
}
|
|
364
419
|
|
|
420
|
+
function isUsersAdminRead(req: NextRequest) {
|
|
421
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const pathname = req.nextUrl.pathname;
|
|
426
|
+
|
|
427
|
+
return (
|
|
428
|
+
/^\/api\/v1\/workspaces\/[^/]+\/users(?:\/(?:database|groups|feedbacks|attendance\/export|[^/]+\/(?:attendance|emails|referrals|linked-promotions|referral-discounts|user-groups)))?\/?$/u.test(
|
|
429
|
+
pathname
|
|
430
|
+
) ||
|
|
431
|
+
/^\/api\/v1\/workspaces\/[^/]+\/user-groups\/[^/]+(?:\/(?:attendance|members(?:\/[^/]+(?:\/feedbacks)?)?|posts(?:\/[^/]+(?:\/status)?)?|linked-products|storage|indicators(?:\/.*)?))?\/?$/u.test(
|
|
432
|
+
pathname
|
|
433
|
+
)
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function isWorkspaceDashboardRead(req: NextRequest) {
|
|
438
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return /^\/api(?:\/v1)?\/workspaces\/[^/]+(?:\/|$)/u.test(
|
|
443
|
+
req.nextUrl.pathname
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function isRateLimitAppealSubmission(req: NextRequest) {
|
|
448
|
+
return (
|
|
449
|
+
req.method === 'POST' &&
|
|
450
|
+
/^\/api\/v1\/rate-limit-appeals\/?$/u.test(req.nextUrl.pathname)
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
365
454
|
const DEFAULT_ROUTE_POLICIES: ProxyRoutePolicy[] = [
|
|
455
|
+
{
|
|
456
|
+
key: 'rate-limit-appeal',
|
|
457
|
+
matches: isRateLimitAppealSubmission,
|
|
458
|
+
rateLimits: RATE_LIMIT_APPEAL_RATE_LIMITS,
|
|
459
|
+
},
|
|
366
460
|
{
|
|
367
461
|
key: 'cron',
|
|
368
462
|
matches: (req) =>
|
|
@@ -441,6 +535,16 @@ const DEFAULT_ROUTE_POLICIES: ProxyRoutePolicy[] = [
|
|
|
441
535
|
matches: isFinanceInvoiceCreateSupportRead,
|
|
442
536
|
rateLimits: FINANCE_READ_RATE_LIMITS,
|
|
443
537
|
},
|
|
538
|
+
{
|
|
539
|
+
key: 'users-admin-read',
|
|
540
|
+
matches: isUsersAdminRead,
|
|
541
|
+
rateLimits: USERS_ADMIN_READ_RATE_LIMITS,
|
|
542
|
+
},
|
|
543
|
+
{
|
|
544
|
+
key: 'workspace-dashboard-read',
|
|
545
|
+
matches: isWorkspaceDashboardRead,
|
|
546
|
+
rateLimits: WORKSPACE_DASHBOARD_READ_RATE_LIMITS,
|
|
547
|
+
},
|
|
444
548
|
{
|
|
445
549
|
key: 'high-fanout',
|
|
446
550
|
matches: (req) =>
|
|
@@ -467,7 +571,7 @@ const DEFAULT_ROUTE_POLICIES: ProxyRoutePolicy[] = [
|
|
|
467
571
|
key: 'users-me',
|
|
468
572
|
matches: (req) => req.nextUrl.pathname.startsWith('/api/v1/users/me'),
|
|
469
573
|
rateLimits: {
|
|
470
|
-
get:
|
|
574
|
+
get: USERS_ME_READ_RATE_LIMITS,
|
|
471
575
|
mutate: USERS_ME_MUTATE_RATE_LIMITS,
|
|
472
576
|
},
|
|
473
577
|
},
|
|
@@ -813,9 +917,10 @@ function getCallerClass(req: NextRequest): CallerClass {
|
|
|
813
917
|
// Intentionally always 'anonymous'. Auth headers/cookies are forgeable at the
|
|
814
918
|
// edge (we can't verify signatures cheaply here), so presence alone must never
|
|
815
919
|
// grant an elevated proxy budget — real per-user enforcement happens
|
|
816
|
-
// downstream.
|
|
817
|
-
//
|
|
818
|
-
//
|
|
920
|
+
// downstream. Per-session proxy buckets and higher read throughput for
|
|
921
|
+
// genuinely trusted accounts/locations are granted instead via the
|
|
922
|
+
// server-written trust cache (see resolveProxyIdentity +
|
|
923
|
+
// getCachedTrustEntries), which cannot be forged.
|
|
819
924
|
void req;
|
|
820
925
|
return 'anonymous';
|
|
821
926
|
}
|
|
@@ -837,6 +942,13 @@ async function hashStableSubjectEdge(value: string): Promise<string> {
|
|
|
837
942
|
.slice(0, 24);
|
|
838
943
|
}
|
|
839
944
|
|
|
945
|
+
export async function buildProxySessionSubjectKey(
|
|
946
|
+
cookieName: string,
|
|
947
|
+
cookieValue: string
|
|
948
|
+
): Promise<string> {
|
|
949
|
+
return `session:${await hashStableSubjectEdge(`${cookieName}:${cookieValue}`)}`;
|
|
950
|
+
}
|
|
951
|
+
|
|
840
952
|
function parseCookieHeaderEdge(
|
|
841
953
|
cookieHeader: string | null
|
|
842
954
|
): Array<{ name: string; value: string }> {
|
|
@@ -876,6 +988,19 @@ function getSessionAuthCookie(
|
|
|
876
988
|
);
|
|
877
989
|
}
|
|
878
990
|
|
|
991
|
+
export async function getProxySessionSubjectKeyFromCookieHeader(
|
|
992
|
+
cookieHeader: string | null
|
|
993
|
+
): Promise<string | null> {
|
|
994
|
+
const cookie =
|
|
995
|
+
parseCookieHeaderEdge(cookieHeader).find(
|
|
996
|
+
(entry) =>
|
|
997
|
+
entry.name === 'tuturuuu_app_session' ||
|
|
998
|
+
GENERIC_SUPABASE_AUTH_COOKIE_NAME_PATTERN.test(entry.name)
|
|
999
|
+
) ?? null;
|
|
1000
|
+
|
|
1001
|
+
return cookie ? buildProxySessionSubjectKey(cookie.name, cookie.value) : null;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
879
1004
|
/**
|
|
880
1005
|
* Mirrors `reputation.ts getCidrSubjectKey` so the edge cidr subject key matches
|
|
881
1006
|
* the DB-side trusted-location key.
|
|
@@ -935,7 +1060,7 @@ async function resolveProxyIdentity(
|
|
|
935
1060
|
const normalizedIp = ip && ip !== 'unknown' ? ip : null;
|
|
936
1061
|
const sessionCookie = getSessionAuthCookie(req);
|
|
937
1062
|
const sessionKey = sessionCookie
|
|
938
|
-
?
|
|
1063
|
+
? await buildProxySessionSubjectKey(sessionCookie.name, sessionCookie.value)
|
|
939
1064
|
: null;
|
|
940
1065
|
|
|
941
1066
|
return {
|
|
@@ -1050,6 +1175,13 @@ function getEffectiveRateLimits(
|
|
|
1050
1175
|
callerClass: CallerClass
|
|
1051
1176
|
): RateLimitProfile {
|
|
1052
1177
|
if (callerClass === 'authenticated') {
|
|
1178
|
+
if (routePolicy.key === 'default') {
|
|
1179
|
+
return {
|
|
1180
|
+
get: DEFAULT_AUTHENTICATED_READ_RATE_LIMITS,
|
|
1181
|
+
mutate: DEFAULT_MUTATE_RATE_LIMITS,
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1053
1185
|
return routePolicy.rateLimits;
|
|
1054
1186
|
}
|
|
1055
1187
|
|
|
@@ -1161,75 +1293,102 @@ export async function guardApiProxyRequest(
|
|
|
1161
1293
|
const callerClass = getCallerClass(req);
|
|
1162
1294
|
const ip = extractIPFromRequest(req.headers);
|
|
1163
1295
|
const identity = await resolveProxyIdentity(req, ip);
|
|
1296
|
+
const routePolicy = getRoutePolicy(req, routePolicies);
|
|
1164
1297
|
|
|
1165
1298
|
if (ip !== 'unknown') {
|
|
1166
1299
|
const blockInfo = await isIPBlockedEdge(ip);
|
|
1167
1300
|
if (blockInfo) {
|
|
1168
|
-
const
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1301
|
+
const hasTemporaryRelief =
|
|
1302
|
+
routePolicy.key === 'rate-limit-appeal' ||
|
|
1303
|
+
(identity.sessionKey && identity.ipKey
|
|
1304
|
+
? await hasCachedIpBlockAppealRelief(
|
|
1305
|
+
identity.sessionKey,
|
|
1306
|
+
identity.ipKey
|
|
1307
|
+
)
|
|
1308
|
+
: false);
|
|
1309
|
+
|
|
1310
|
+
if (hasTemporaryRelief) {
|
|
1311
|
+
// Keep processing: appeal submissions and matching temporary relief
|
|
1312
|
+
// still go through the route limiter below.
|
|
1313
|
+
} else {
|
|
1314
|
+
const retryAfter = Math.max(
|
|
1315
|
+
1,
|
|
1316
|
+
Math.ceil((blockInfo.expiresAt.getTime() - Date.now()) / 1000)
|
|
1317
|
+
);
|
|
1318
|
+
|
|
1319
|
+
return buildRateLimitResponse(429, retryAfter, {
|
|
1320
|
+
'X-Proxy-Block-Reason': 'ip-already-blocked',
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1176
1323
|
}
|
|
1177
1324
|
}
|
|
1178
1325
|
|
|
1179
|
-
const routePolicy = getRoutePolicy(req, routePolicies);
|
|
1180
1326
|
const isRead = req.method === 'GET' || req.method === 'HEAD';
|
|
1181
1327
|
|
|
1182
|
-
// Resolve
|
|
1328
|
+
// Resolve server-verified session keying for reads and mutations, but apply
|
|
1329
|
+
// trust uplift to read limits only. Account trust (session key) and location
|
|
1183
1330
|
// trust (cidr/ip keys) are distinguished so that:
|
|
1184
|
-
// * a
|
|
1185
|
-
// hash — unforgeable) gets its OWN per-session bucket, so
|
|
1331
|
+
// * a server-verified SESSION (cache entry keyed on the real cookie's
|
|
1332
|
+
// hash — unforgeable) gets its OWN per-session bucket, so signed-in
|
|
1186
1333
|
// teammates behind one NAT no longer collide; and
|
|
1187
1334
|
// * a trusted LOCATION (office cidr/ip) uplifts the shared per-IP bucket
|
|
1188
1335
|
// for the whole team without any per-request credential.
|
|
1189
1336
|
// Untrusted traffic keeps legacy per-IP keying, preserving single-IP abuse
|
|
1190
|
-
// caps. A forged cookie hashes to a key with no cache entry
|
|
1191
|
-
//
|
|
1337
|
+
// caps. A forged cookie hashes to a key with no cache entry, so it cannot
|
|
1338
|
+
// escape the shared IP bucket.
|
|
1192
1339
|
let trustEntry: CachedTrustEntry | null = null;
|
|
1193
|
-
let
|
|
1194
|
-
if (
|
|
1340
|
+
let sessionScoped = false;
|
|
1341
|
+
if (edgeTrustEnabled()) {
|
|
1195
1342
|
const lookupKeys = [
|
|
1196
1343
|
identity.sessionKey,
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1344
|
+
...(isRead
|
|
1345
|
+
? [identity.cidrKey, identity.ipKey, identity.workspaceKey]
|
|
1346
|
+
: []),
|
|
1200
1347
|
].filter((key): key is string => key != null);
|
|
1201
1348
|
const entries = await getCachedTrustEntries(lookupKeys);
|
|
1202
1349
|
|
|
1203
1350
|
const sessionEntry = identity.sessionKey
|
|
1204
1351
|
? entries.get(identity.sessionKey)
|
|
1205
1352
|
: undefined;
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1353
|
+
sessionScoped = !!identity.sessionKey && !!sessionEntry;
|
|
1354
|
+
|
|
1355
|
+
if (isRead) {
|
|
1356
|
+
// Location/workspace trust applies to the shared per-IP bucket for the
|
|
1357
|
+
// whole team; session trust applies to the per-session bucket.
|
|
1358
|
+
const locationEntry = combineTrustEntries([
|
|
1359
|
+
identity.cidrKey ? entries.get(identity.cidrKey) : undefined,
|
|
1360
|
+
identity.ipKey ? entries.get(identity.ipKey) : undefined,
|
|
1361
|
+
identity.workspaceKey
|
|
1362
|
+
? entries.get(identity.workspaceKey)
|
|
1363
|
+
: undefined,
|
|
1364
|
+
]);
|
|
1365
|
+
|
|
1366
|
+
trustEntry = combineTrustEntries([sessionEntry, locationEntry]);
|
|
1367
|
+
}
|
|
1216
1368
|
}
|
|
1217
1369
|
|
|
1218
|
-
//
|
|
1370
|
+
// Server-verified sessions key per-session; everyone else keys per-IP.
|
|
1219
1371
|
const limiterId =
|
|
1220
|
-
|
|
1372
|
+
sessionScoped && identity.sessionKey
|
|
1221
1373
|
? identity.sessionKey
|
|
1222
1374
|
: identity.ipKey;
|
|
1223
1375
|
|
|
1224
1376
|
if (limiterId) {
|
|
1225
|
-
const
|
|
1226
|
-
|
|
1227
|
-
|
|
1377
|
+
const effectiveCallerClass: CallerClass = sessionScoped
|
|
1378
|
+
? 'authenticated'
|
|
1379
|
+
: callerClass;
|
|
1380
|
+
const baseRateLimits = getEffectiveRateLimits(
|
|
1381
|
+
routePolicy,
|
|
1382
|
+
effectiveCallerClass
|
|
1383
|
+
);
|
|
1384
|
+
const { profile: rateLimits, suffix: trustSuffix } = isRead
|
|
1385
|
+
? applyTrustEntryToReads(baseRateLimits, trustEntry)
|
|
1386
|
+
: { profile: baseRateLimits, suffix: '' };
|
|
1228
1387
|
|
|
1229
1388
|
const limiterPrefix = getRateLimitPrefix(
|
|
1230
1389
|
options.prefixBase,
|
|
1231
1390
|
routePolicy,
|
|
1232
|
-
|
|
1391
|
+
effectiveCallerClass,
|
|
1233
1392
|
trustSuffix
|
|
1234
1393
|
);
|
|
1235
1394
|
const limiterCacheKey = `${limiterPrefix}:${JSON.stringify(rateLimits)}`;
|
|
@@ -1283,7 +1442,7 @@ export async function guardApiProxyRequest(
|
|
|
1283
1442
|
'X-RateLimit-Limit': `${limit}`,
|
|
1284
1443
|
'X-RateLimit-Remaining': `${remaining}`,
|
|
1285
1444
|
'X-RateLimit-Reset': `${Math.ceil(reset / 1000)}`,
|
|
1286
|
-
'X-RateLimit-Caller-Class':
|
|
1445
|
+
'X-RateLimit-Caller-Class': effectiveCallerClass,
|
|
1287
1446
|
'X-RateLimit-Window': window,
|
|
1288
1447
|
'X-RateLimit-Policy': routePolicy.key,
|
|
1289
1448
|
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
getCurrencyConfig,
|
|
4
|
+
getCurrencyLocale,
|
|
5
|
+
isSupportedCurrency,
|
|
6
|
+
resolveSupportedCurrency,
|
|
7
|
+
} from './currencies';
|
|
8
|
+
|
|
9
|
+
describe('resolveSupportedCurrency', () => {
|
|
10
|
+
it('normalizes supported currency codes', () => {
|
|
11
|
+
expect(resolveSupportedCurrency(' sgd ')).toBe('SGD');
|
|
12
|
+
expect(resolveSupportedCurrency('eur')).toBe('EUR');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('uses a supported fallback for missing or unsupported values', () => {
|
|
16
|
+
expect(resolveSupportedCurrency(undefined, 'VND')).toBe('VND');
|
|
17
|
+
expect(resolveSupportedCurrency(null, 'EUR')).toBe('EUR');
|
|
18
|
+
expect(resolveSupportedCurrency('XYZ', 'SGD')).toBe('SGD');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('falls back to USD when the fallback is unsupported', () => {
|
|
22
|
+
expect(resolveSupportedCurrency('', 'XYZ')).toBe('USD');
|
|
23
|
+
expect(resolveSupportedCurrency('ZZZ', 'XYZ')).toBe('USD');
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('currency lookup normalization', () => {
|
|
28
|
+
it('trims currency codes before support and locale checks', () => {
|
|
29
|
+
expect(isSupportedCurrency(' vnd ')).toBe(true);
|
|
30
|
+
expect(getCurrencyLocale(' php ')).toBe('en-PH');
|
|
31
|
+
expect(getCurrencyConfig(' eur ')?.code).toBe('EUR');
|
|
32
|
+
});
|
|
33
|
+
});
|
package/src/currencies.ts
CHANGED
|
@@ -66,9 +66,8 @@ export type CurrencyConfig = (typeof SUPPORTED_CURRENCIES)[number];
|
|
|
66
66
|
* @returns The BCP 47 locale string for the currency
|
|
67
67
|
*/
|
|
68
68
|
export function getCurrencyLocale(currency = 'VND'): string {
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
);
|
|
69
|
+
const code = currency.trim().toUpperCase();
|
|
70
|
+
const found = SUPPORTED_CURRENCIES.find((c) => c.code === code);
|
|
72
71
|
return found?.locale ?? 'en-US';
|
|
73
72
|
}
|
|
74
73
|
|
|
@@ -81,7 +80,30 @@ export function getCurrencyLocale(currency = 'VND'): string {
|
|
|
81
80
|
export function isSupportedCurrency(
|
|
82
81
|
currency: string
|
|
83
82
|
): currency is SupportedCurrency {
|
|
84
|
-
|
|
83
|
+
const code = currency.trim().toUpperCase();
|
|
84
|
+
return SUPPORTED_CURRENCIES.some((c) => c.code === code);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Normalize a currency code to the supported uppercase code set.
|
|
89
|
+
*
|
|
90
|
+
* @param value - The candidate currency code
|
|
91
|
+
* @param fallback - The fallback currency code when value is missing or unsupported
|
|
92
|
+
* @returns A supported currency code
|
|
93
|
+
*/
|
|
94
|
+
export function resolveSupportedCurrency(
|
|
95
|
+
value: string | null | undefined,
|
|
96
|
+
fallback: string = 'USD'
|
|
97
|
+
): SupportedCurrency {
|
|
98
|
+
const normalizedFallback = fallback.trim().toUpperCase();
|
|
99
|
+
const safeFallback = isSupportedCurrency(normalizedFallback)
|
|
100
|
+
? normalizedFallback
|
|
101
|
+
: 'USD';
|
|
102
|
+
const normalizedValue = value?.trim().toUpperCase();
|
|
103
|
+
|
|
104
|
+
return normalizedValue && isSupportedCurrency(normalizedValue)
|
|
105
|
+
? normalizedValue
|
|
106
|
+
: safeFallback;
|
|
85
107
|
}
|
|
86
108
|
|
|
87
109
|
/**
|
|
@@ -93,5 +115,6 @@ export function isSupportedCurrency(
|
|
|
93
115
|
export function getCurrencyConfig(
|
|
94
116
|
currency: string
|
|
95
117
|
): CurrencyConfig | undefined {
|
|
96
|
-
|
|
118
|
+
const code = currency.trim().toUpperCase();
|
|
119
|
+
return SUPPORTED_CURRENCIES.find((c) => c.code === code);
|
|
97
120
|
}
|