@tuturuuu/utils 0.7.0 → 0.9.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.
@@ -2,6 +2,10 @@ import { Ratelimit } from '@upstash/ratelimit';
2
2
  import type { NextRequest } from 'next/server';
3
3
  import { NextResponse } from 'next/server';
4
4
  import { extractIPFromRequest, isIPBlockedEdge } from './abuse-protection/edge';
5
+ import {
6
+ type CachedTrustEntry,
7
+ getCachedTrustEntries,
8
+ } from './abuse-protection/edge-trust';
5
9
  import { DEV_MODE, MAX_PAYLOAD_SIZE } from './constants';
6
10
  import { validateRequestEmojiLimit } from './request-emoji-limit';
7
11
  import {
@@ -11,6 +15,45 @@ import {
11
15
 
12
16
  const isDev = process.env.NODE_ENV !== 'production';
13
17
 
18
+ // Quantized trust tiers — the cached multiplier is floored to one of these
19
+ // before scaling read limits, so the limiter prefix (and therefore the Redis
20
+ // sliding-window buckets) stays bounded and stable even as a subject's exact
21
+ // multiplier drifts.
22
+ const TRUST_MULTIPLIER_TIERS = [1, 1.5, 2, 3, 5, 10, 25, 100] as const;
23
+
24
+ function isEnvFlagEnabled(name: string, defaultOn = true): boolean {
25
+ const raw = process.env[name];
26
+ if (raw == null || raw.trim() === '') {
27
+ return defaultOn;
28
+ }
29
+
30
+ return !/^(0|false|off|no)$/i.test(raw.trim());
31
+ }
32
+
33
+ /**
34
+ * Whether trusted accounts/locations get scaled read limits (and verified
35
+ * sessions get their own per-session bucket) at the edge. Default on; set
36
+ * `API_PROXY_EDGE_TRUST_ENABLED=0` to disable trust uplift and fall back to
37
+ * legacy per-IP keying without a deploy.
38
+ */
39
+ function edgeTrustEnabled(): boolean {
40
+ return isEnvFlagEnabled('API_PROXY_EDGE_TRUST_ENABLED');
41
+ }
42
+
43
+ /**
44
+ * Floors a raw trust multiplier to the nearest quantized tier. Never reduces
45
+ * below 1 — trust uplift only ever raises read limits.
46
+ */
47
+ function quantizeTrustMultiplier(multiplier: number): number {
48
+ let tier = 1;
49
+ for (const candidate of TRUST_MULTIPLIER_TIERS) {
50
+ if (multiplier >= candidate) {
51
+ tier = candidate;
52
+ }
53
+ }
54
+ return tier;
55
+ }
56
+
14
57
  type CallerClass = 'anonymous' | 'authenticated';
15
58
 
16
59
  export type RateLimitWindow = 'minute' | 'hour' | 'day';
@@ -37,6 +80,7 @@ export type TrustedProxyBypassRule = {
37
80
  };
38
81
 
39
82
  export type GuardOptions = {
83
+ additionalRoutePolicies?: ProxyRoutePolicy[];
40
84
  prefixBase: string;
41
85
  routePolicies?: ProxyRoutePolicy[];
42
86
  trustedBypassRules?: TrustedProxyBypassRule[];
@@ -143,18 +187,39 @@ const DEFAULT_ANONYMOUS_MUTATE_RATE_LIMITS: RateLimitConfig[] = [
143
187
  createConfig('day', '1 d', 600, 'API_PROXY_ANON_MUTATE_LIMIT_DAY'),
144
188
  ];
145
189
 
190
+ const DEFAULT_AUTHENTICATED_READ_RATE_LIMITS: RateLimitConfig[] = [
191
+ createConfig('minute', '1 m', 600, 'API_PROXY_AUTH_READ_LIMIT_MINUTE'),
192
+ createConfig('hour', '1 h', 6000, 'API_PROXY_AUTH_READ_LIMIT_HOUR'),
193
+ createConfig('day', '1 d', 40_000, 'API_PROXY_AUTH_READ_LIMIT_DAY'),
194
+ ];
195
+
146
196
  const DEFAULT_MUTATE_RATE_LIMITS: RateLimitConfig[] = [
147
197
  { window: 'minute', limit: 60, duration: '1 m' },
148
198
  { window: 'hour', limit: 300, duration: '1 h' },
149
199
  { window: 'day', limit: 2000, duration: '1 d' },
150
200
  ];
151
201
 
202
+ const USERS_ME_READ_RATE_LIMITS: RateLimitConfig[] = [
203
+ createConfig('minute', '1 m', 600, 'API_PROXY_USERS_ME_READ_LIMIT_MINUTE'),
204
+ createConfig('hour', '1 h', 6000, 'API_PROXY_USERS_ME_READ_LIMIT_HOUR'),
205
+ createConfig('day', '1 d', 40_000, 'API_PROXY_USERS_ME_READ_LIMIT_DAY'),
206
+ ];
207
+
152
208
  const USERS_ME_MUTATE_RATE_LIMITS: RateLimitConfig[] = [
153
209
  { window: 'minute', limit: 60, duration: '1 m' },
154
210
  { window: 'hour', limit: 200, duration: '1 h' },
155
211
  { window: 'day', limit: 1200, duration: '1 d' },
156
212
  ];
157
213
 
214
+ const WORKSPACE_DASHBOARD_READ_RATE_LIMITS: RateLimitProfile = {
215
+ get: [
216
+ createConfig('minute', '1 m', 600, 'API_PROXY_WORKSPACE_READ_LIMIT_MINUTE'),
217
+ createConfig('hour', '1 h', 6000, 'API_PROXY_WORKSPACE_READ_LIMIT_HOUR'),
218
+ createConfig('day', '1 d', 40_000, 'API_PROXY_WORKSPACE_READ_LIMIT_DAY'),
219
+ ],
220
+ mutate: DEFAULT_MUTATE_RATE_LIMITS,
221
+ };
222
+
158
223
  const AUTH_RATE_LIMITS: RateLimitProfile = {
159
224
  get: NO_READ_RATE_LIMITS,
160
225
  mutate: [
@@ -250,11 +315,11 @@ const TASK_BOARD_READ_RATE_LIMITS: RateLimitProfile = {
250
315
  createConfig(
251
316
  'minute',
252
317
  '1 m',
253
- 300,
318
+ 600,
254
319
  'API_PROXY_TASK_BOARD_READ_LIMIT_MINUTE'
255
320
  ),
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'),
321
+ createConfig('hour', '1 h', 12_000, 'API_PROXY_TASK_BOARD_READ_LIMIT_HOUR'),
322
+ createConfig('day', '1 d', 80_000, 'API_PROXY_TASK_BOARD_READ_LIMIT_DAY'),
258
323
  ],
259
324
  mutate: DEFAULT_MUTATE_RATE_LIMITS,
260
325
  };
@@ -268,6 +333,25 @@ const USERS_DATABASE_READ_OVER_POST_RATE_LIMITS: RateLimitProfile = {
268
333
  ],
269
334
  };
270
335
 
336
+ const USERS_ADMIN_READ_RATE_LIMITS: RateLimitProfile = {
337
+ get: [
338
+ createConfig(
339
+ 'minute',
340
+ '1 m',
341
+ 1200,
342
+ 'API_PROXY_USERS_ADMIN_READ_LIMIT_MINUTE'
343
+ ),
344
+ createConfig(
345
+ 'hour',
346
+ '1 h',
347
+ 12_000,
348
+ 'API_PROXY_USERS_ADMIN_READ_LIMIT_HOUR'
349
+ ),
350
+ createConfig('day', '1 d', 80_000, 'API_PROXY_USERS_ADMIN_READ_LIMIT_DAY'),
351
+ ],
352
+ mutate: DEFAULT_MUTATE_RATE_LIMITS,
353
+ };
354
+
271
355
  const FINANCE_READ_RATE_LIMITS: RateLimitProfile = {
272
356
  get: [
273
357
  createConfig('minute', '1 m', 1200, 'API_PROXY_FINANCE_READ_LIMIT_MINUTE', [
@@ -318,6 +402,33 @@ function isUsersDatabaseReadOverPost(req: NextRequest) {
318
402
  );
319
403
  }
320
404
 
405
+ function isUsersAdminRead(req: NextRequest) {
406
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
407
+ return false;
408
+ }
409
+
410
+ const pathname = req.nextUrl.pathname;
411
+
412
+ return (
413
+ /^\/api\/v1\/workspaces\/[^/]+\/users(?:\/(?:database|groups|feedbacks|attendance\/export|[^/]+\/(?:attendance|emails|referrals|linked-promotions|referral-discounts|user-groups)))?\/?$/u.test(
414
+ pathname
415
+ ) ||
416
+ /^\/api\/v1\/workspaces\/[^/]+\/user-groups\/[^/]+(?:\/(?:attendance|members(?:\/[^/]+(?:\/feedbacks)?)?|posts(?:\/[^/]+(?:\/status)?)?|linked-products|storage|indicators(?:\/.*)?))?\/?$/u.test(
417
+ pathname
418
+ )
419
+ );
420
+ }
421
+
422
+ function isWorkspaceDashboardRead(req: NextRequest) {
423
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
424
+ return false;
425
+ }
426
+
427
+ return /^\/api(?:\/v1)?\/workspaces\/[^/]+(?:\/|$)/u.test(
428
+ req.nextUrl.pathname
429
+ );
430
+ }
431
+
321
432
  const DEFAULT_ROUTE_POLICIES: ProxyRoutePolicy[] = [
322
433
  {
323
434
  key: 'cron',
@@ -397,6 +508,16 @@ const DEFAULT_ROUTE_POLICIES: ProxyRoutePolicy[] = [
397
508
  matches: isFinanceInvoiceCreateSupportRead,
398
509
  rateLimits: FINANCE_READ_RATE_LIMITS,
399
510
  },
511
+ {
512
+ key: 'users-admin-read',
513
+ matches: isUsersAdminRead,
514
+ rateLimits: USERS_ADMIN_READ_RATE_LIMITS,
515
+ },
516
+ {
517
+ key: 'workspace-dashboard-read',
518
+ matches: isWorkspaceDashboardRead,
519
+ rateLimits: WORKSPACE_DASHBOARD_READ_RATE_LIMITS,
520
+ },
400
521
  {
401
522
  key: 'high-fanout',
402
523
  matches: (req) =>
@@ -423,7 +544,7 @@ const DEFAULT_ROUTE_POLICIES: ProxyRoutePolicy[] = [
423
544
  key: 'users-me',
424
545
  matches: (req) => req.nextUrl.pathname.startsWith('/api/v1/users/me'),
425
546
  rateLimits: {
426
- get: NO_READ_RATE_LIMITS,
547
+ get: USERS_ME_READ_RATE_LIMITS,
427
548
  mutate: USERS_ME_MUTATE_RATE_LIMITS,
428
549
  },
429
550
  },
@@ -766,15 +887,254 @@ export function hasAuthenticatedApiSession(req: NextRequest): boolean {
766
887
  }
767
888
 
768
889
  function getCallerClass(req: NextRequest): CallerClass {
890
+ // Intentionally always 'anonymous'. Auth headers/cookies are forgeable at the
891
+ // edge (we can't verify signatures cheaply here), so presence alone must never
892
+ // grant an elevated proxy budget — real per-user enforcement happens
893
+ // downstream. Per-session proxy buckets and higher read throughput for
894
+ // genuinely trusted accounts/locations are granted instead via the
895
+ // server-written trust cache (see resolveProxyIdentity +
896
+ // getCachedTrustEntries), which cannot be forged.
769
897
  void req;
770
898
  return 'anonymous';
771
899
  }
772
900
 
901
+ /**
902
+ * Edge-safe stable subject hash. MUST stay byte-identical to
903
+ * `reputation.ts hashStableSubject` (sha256 hex, sliced to 24 chars) so that
904
+ * the edge `session:<hash>` subject key matches the DB-side key used by the
905
+ * trust cache.
906
+ */
907
+ async function hashStableSubjectEdge(value: string): Promise<string> {
908
+ const digest = await crypto.subtle.digest(
909
+ 'SHA-256',
910
+ new TextEncoder().encode(value)
911
+ );
912
+ return Array.from(new Uint8Array(digest))
913
+ .map((byte) => byte.toString(16).padStart(2, '0'))
914
+ .join('')
915
+ .slice(0, 24);
916
+ }
917
+
918
+ function parseCookieHeaderEdge(
919
+ cookieHeader: string | null
920
+ ): Array<{ name: string; value: string }> {
921
+ if (!cookieHeader) {
922
+ return [];
923
+ }
924
+
925
+ return cookieHeader
926
+ .split(';')
927
+ .map((entry) => {
928
+ const separatorIndex = entry.indexOf('=');
929
+ if (separatorIndex < 0) {
930
+ return null;
931
+ }
932
+
933
+ return {
934
+ name: entry.slice(0, separatorIndex).trim(),
935
+ value: entry.slice(separatorIndex + 1).trim(),
936
+ };
937
+ })
938
+ .filter(
939
+ (entry): entry is { name: string; value: string } =>
940
+ !!entry?.name && !!entry.value
941
+ );
942
+ }
943
+
944
+ function getSessionAuthCookie(
945
+ req: NextRequest
946
+ ): { name: string; value: string } | null {
947
+ const cookies = parseCookieHeaderEdge(req.headers.get('cookie'));
948
+ return (
949
+ cookies.find(
950
+ (cookie) =>
951
+ cookie.name === 'tuturuuu_app_session' ||
952
+ GENERIC_SUPABASE_AUTH_COOKIE_NAME_PATTERN.test(cookie.name)
953
+ ) ?? null
954
+ );
955
+ }
956
+
957
+ /**
958
+ * Mirrors `reputation.ts getCidrSubjectKey` so the edge cidr subject key matches
959
+ * the DB-side trusted-location key.
960
+ */
961
+ function getCidrSubjectKeyEdge(ipAddress: string | null): string | null {
962
+ if (!ipAddress) {
963
+ return null;
964
+ }
965
+
966
+ const ipv4Parts = ipAddress.split('.');
967
+ if (
968
+ ipv4Parts.length === 4 &&
969
+ ipv4Parts.every((part) => /^\d{1,3}$/.test(part))
970
+ ) {
971
+ return `cidr:${ipv4Parts.slice(0, 3).join('.')}.0/24`;
972
+ }
973
+
974
+ const ipv6Parts = ipAddress.split(':').filter(Boolean);
975
+ if (ipv6Parts.length >= 4) {
976
+ return `cidr:${ipv6Parts.slice(0, 4).join(':')}::/64`;
977
+ }
978
+
979
+ return null;
980
+ }
981
+
982
+ export type ProxyIdentity = {
983
+ /** `ip:<ip>` subject key, or null when the IP is unknown. */
984
+ ipKey: string | null;
985
+ /** `cidr:<network>` subject key for trusted-location lookups, or null. */
986
+ cidrKey: string | null;
987
+ /** `session:<hash>` subject key for cookie sessions, or null. Matches the
988
+ * DB-side session subject key so trusted sessions resolve from the cache. */
989
+ sessionKey: string | null;
990
+ /** `workspace:<wsId>` subject key parsed from a workspace-scoped API path, or
991
+ * null. Lets an admin workspace rule uplift reads for the whole workspace. */
992
+ workspaceKey: string | null;
993
+ };
994
+
995
+ const WORKSPACE_PATH_PATTERN =
996
+ /^\/api(?:\/v1)?\/workspaces\/([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})(?:\/|$)/u;
997
+
998
+ function getWorkspaceSubjectKey(pathname: string): string | null {
999
+ const match = WORKSPACE_PATH_PATTERN.exec(pathname);
1000
+ return match ? `workspace:${match[1]!.toLowerCase()}` : null;
1001
+ }
1002
+
1003
+ /**
1004
+ * Resolves the trust/keying subject keys for a request. Limited to
1005
+ * session/cidr/ip (keys an attacker would have to actually possess or share a
1006
+ * network with) plus a workspace key parsed from the path — so trust uplift and
1007
+ * per-session keying can never be granted by a forged credential.
1008
+ */
1009
+ async function resolveProxyIdentity(
1010
+ req: NextRequest,
1011
+ ip: string
1012
+ ): Promise<ProxyIdentity> {
1013
+ const normalizedIp = ip && ip !== 'unknown' ? ip : null;
1014
+ const sessionCookie = getSessionAuthCookie(req);
1015
+ const sessionKey = sessionCookie
1016
+ ? `session:${await hashStableSubjectEdge(`${sessionCookie.name}:${sessionCookie.value}`)}`
1017
+ : null;
1018
+
1019
+ return {
1020
+ cidrKey: getCidrSubjectKeyEdge(normalizedIp),
1021
+ ipKey: normalizedIp ? `ip:${normalizedIp}` : null,
1022
+ sessionKey,
1023
+ workspaceKey: getWorkspaceSubjectKey(req.nextUrl.pathname),
1024
+ };
1025
+ }
1026
+
1027
+ /**
1028
+ * Scales the read (`get`) limits of a profile by a trust multiplier. Mutation
1029
+ * limits are left flat — those are already trust-scaled at the DB and in the
1030
+ * downstream session/API wrappers, so scaling them here would double-apply.
1031
+ */
1032
+ function scaleReadLimits(
1033
+ profile: RateLimitProfile,
1034
+ multiplier: number
1035
+ ): RateLimitProfile {
1036
+ if (multiplier <= 1) {
1037
+ return profile;
1038
+ }
1039
+
1040
+ return {
1041
+ ...profile,
1042
+ get: profile.get.map((config) => ({
1043
+ ...config,
1044
+ limit: Math.max(config.limit, Math.floor(config.limit * multiplier)),
1045
+ })),
1046
+ };
1047
+ }
1048
+
1049
+ function entryReadFloor(abs: NonNullable<CachedTrustEntry['abs']>): number {
1050
+ return abs.minute ?? abs.hour ?? abs.day ?? 0;
1051
+ }
1052
+
1053
+ /**
1054
+ * Combines all applicable cached entries (session + location/workspace) into a
1055
+ * single effective read decision. Most-permissive wins: unlimited beats
1056
+ * absolute beats the highest multiplier.
1057
+ */
1058
+ function combineTrustEntries(
1059
+ entries: Array<CachedTrustEntry | null | undefined>
1060
+ ): CachedTrustEntry | null {
1061
+ const active = entries.filter((entry): entry is CachedTrustEntry => !!entry);
1062
+ if (active.length === 0) {
1063
+ return null;
1064
+ }
1065
+
1066
+ if (active.some((entry) => entry.mode === 'unlimited')) {
1067
+ return { m: 1, mode: 'unlimited' };
1068
+ }
1069
+
1070
+ const absolutes = active.filter(
1071
+ (
1072
+ entry
1073
+ ): entry is CachedTrustEntry & {
1074
+ abs: NonNullable<CachedTrustEntry['abs']>;
1075
+ } => entry.mode === 'absolute' && !!entry.abs
1076
+ );
1077
+ if (absolutes.length > 0) {
1078
+ const best = absolutes.reduce((winner, candidate) =>
1079
+ entryReadFloor(candidate.abs) > entryReadFloor(winner.abs)
1080
+ ? candidate
1081
+ : winner
1082
+ );
1083
+ return { abs: best.abs, m: best.m, mode: 'absolute' };
1084
+ }
1085
+
1086
+ return { m: Math.max(...active.map((entry) => entry.m)) };
1087
+ }
1088
+
1089
+ /**
1090
+ * Applies a combined trust entry to a route profile's READ limits and returns
1091
+ * the scaled profile plus a bucket-prefix suffix that uniquely encodes the
1092
+ * effective limit (so distinct limits never share a Redis sliding window).
1093
+ */
1094
+ function applyTrustEntryToReads(
1095
+ profile: RateLimitProfile,
1096
+ entry: CachedTrustEntry | null
1097
+ ): { profile: RateLimitProfile; suffix: string } {
1098
+ if (!entry) {
1099
+ return { profile, suffix: '' };
1100
+ }
1101
+
1102
+ if (entry.mode === 'unlimited') {
1103
+ // Drop the read cap entirely (no read limiter is constructed for [] get).
1104
+ return { profile: { ...profile, get: [] }, suffix: ':unl' };
1105
+ }
1106
+
1107
+ if (entry.mode === 'absolute' && entry.abs) {
1108
+ const get = profile.get.map((config) => {
1109
+ const absolute = entry.abs?.[config.window];
1110
+ return absolute != null ? { ...config, limit: absolute } : config;
1111
+ });
1112
+ const { minute, hour, day } = entry.abs;
1113
+ return {
1114
+ profile: { ...profile, get },
1115
+ suffix: `:abs-${minute ?? 'x'}-${hour ?? 'x'}-${day ?? 'x'}`,
1116
+ };
1117
+ }
1118
+
1119
+ const tier = quantizeTrustMultiplier(entry.m);
1120
+ if (tier <= 1) {
1121
+ return { profile, suffix: '' };
1122
+ }
1123
+ return { profile: scaleReadLimits(profile, tier), suffix: `:t${tier}` };
1124
+ }
1125
+
773
1126
  function getEffectiveRateLimits(
774
1127
  routePolicy: ProxyRoutePolicy,
775
1128
  callerClass: CallerClass
776
1129
  ): RateLimitProfile {
777
1130
  if (callerClass === 'authenticated') {
1131
+ if (routePolicy.key === 'default') {
1132
+ return {
1133
+ get: DEFAULT_AUTHENTICATED_READ_RATE_LIMITS,
1134
+ mutate: DEFAULT_MUTATE_RATE_LIMITS,
1135
+ };
1136
+ }
1137
+
778
1138
  return routePolicy.rateLimits;
779
1139
  }
780
1140
 
@@ -788,21 +1148,17 @@ function getEffectiveRateLimits(
788
1148
  return routePolicy.rateLimits;
789
1149
  }
790
1150
 
791
- function shouldScopeRateLimitByPath(routePolicy: ProxyRoutePolicy): boolean {
792
- return routePolicy.key === 'default' || routePolicy.key === 'users-me';
793
- }
794
-
795
- function getPathScopedRateLimitPrefix(
1151
+ function getRateLimitPrefix(
796
1152
  prefixBase: string,
797
1153
  routePolicy: ProxyRoutePolicy,
798
1154
  callerClass: CallerClass,
799
- pathname: string
1155
+ trustSuffix = ''
800
1156
  ): string {
801
- const scopeSuffix = shouldScopeRateLimitByPath(routePolicy)
802
- ? `:${pathname.replaceAll('/', ':') || ':root'}`
803
- : '';
804
-
805
- return `${prefixBase}:${routePolicy.key}:${callerClass}${scopeSuffix}`;
1157
+ // Only trusted/uplifted traffic gets a suffixed bucket; untrusted traffic
1158
+ // keeps the original prefix so its sliding windows are unaffected. The suffix
1159
+ // must uniquely encode the effective limit (tier or absolute values) so two
1160
+ // different effective limits never share a Redis sliding-window prefix.
1161
+ return `${prefixBase}:${routePolicy.key}:${callerClass}${trustSuffix}`;
806
1162
  }
807
1163
 
808
1164
  export function isTrustedProxyBypassRequest(
@@ -870,7 +1226,10 @@ export async function guardApiProxyRequest(
870
1226
  );
871
1227
  }
872
1228
 
873
- const routePolicies = options.routePolicies ?? DEFAULT_ROUTE_POLICIES;
1229
+ const routePolicies = [
1230
+ ...(options.additionalRoutePolicies ?? []),
1231
+ ...(options.routePolicies ?? DEFAULT_ROUTE_POLICIES),
1232
+ ];
874
1233
  const trustedBypassRules = [
875
1234
  ...DEFAULT_TRUSTED_BYPASS_RULES,
876
1235
  ...(options.trustedBypassRules ?? []),
@@ -886,8 +1245,9 @@ export async function guardApiProxyRequest(
886
1245
  ) {
887
1246
  const callerClass = getCallerClass(req);
888
1247
  const ip = extractIPFromRequest(req.headers);
1248
+ const identity = await resolveProxyIdentity(req, ip);
889
1249
 
890
- if (ip !== 'unknown' && callerClass === 'anonymous') {
1250
+ if (ip !== 'unknown') {
891
1251
  const blockInfo = await isIPBlockedEdge(ip);
892
1252
  if (blockInfo) {
893
1253
  const retryAfter = Math.max(
@@ -899,15 +1259,76 @@ export async function guardApiProxyRequest(
899
1259
  'X-Proxy-Block-Reason': 'ip-already-blocked',
900
1260
  });
901
1261
  }
1262
+ }
1263
+
1264
+ const routePolicy = getRoutePolicy(req, routePolicies);
1265
+ const isRead = req.method === 'GET' || req.method === 'HEAD';
1266
+
1267
+ // Resolve server-verified session keying for reads and mutations, but apply
1268
+ // trust uplift to read limits only. Account trust (session key) and location
1269
+ // trust (cidr/ip keys) are distinguished so that:
1270
+ // * a server-verified SESSION (cache entry keyed on the real cookie's
1271
+ // hash — unforgeable) gets its OWN per-session bucket, so signed-in
1272
+ // teammates behind one NAT no longer collide; and
1273
+ // * a trusted LOCATION (office cidr/ip) uplifts the shared per-IP bucket
1274
+ // for the whole team without any per-request credential.
1275
+ // Untrusted traffic keeps legacy per-IP keying, preserving single-IP abuse
1276
+ // caps. A forged cookie hashes to a key with no cache entry, so it cannot
1277
+ // escape the shared IP bucket.
1278
+ let trustEntry: CachedTrustEntry | null = null;
1279
+ let sessionScoped = false;
1280
+ if (edgeTrustEnabled()) {
1281
+ const lookupKeys = [
1282
+ identity.sessionKey,
1283
+ ...(isRead
1284
+ ? [identity.cidrKey, identity.ipKey, identity.workspaceKey]
1285
+ : []),
1286
+ ].filter((key): key is string => key != null);
1287
+ const entries = await getCachedTrustEntries(lookupKeys);
1288
+
1289
+ const sessionEntry = identity.sessionKey
1290
+ ? entries.get(identity.sessionKey)
1291
+ : undefined;
1292
+ sessionScoped = !!identity.sessionKey && !!sessionEntry;
1293
+
1294
+ if (isRead) {
1295
+ // Location/workspace trust applies to the shared per-IP bucket for the
1296
+ // whole team; session trust applies to the per-session bucket.
1297
+ const locationEntry = combineTrustEntries([
1298
+ identity.cidrKey ? entries.get(identity.cidrKey) : undefined,
1299
+ identity.ipKey ? entries.get(identity.ipKey) : undefined,
1300
+ identity.workspaceKey
1301
+ ? entries.get(identity.workspaceKey)
1302
+ : undefined,
1303
+ ]);
1304
+
1305
+ trustEntry = combineTrustEntries([sessionEntry, locationEntry]);
1306
+ }
1307
+ }
902
1308
 
903
- const routePolicy = getRoutePolicy(req, routePolicies);
904
- const isRead = req.method === 'GET' || req.method === 'HEAD';
905
- const rateLimits = getEffectiveRateLimits(routePolicy, callerClass);
906
- const limiterPrefix = getPathScopedRateLimitPrefix(
1309
+ // Server-verified sessions key per-session; everyone else keys per-IP.
1310
+ const limiterId =
1311
+ sessionScoped && identity.sessionKey
1312
+ ? identity.sessionKey
1313
+ : identity.ipKey;
1314
+
1315
+ if (limiterId) {
1316
+ const effectiveCallerClass: CallerClass = sessionScoped
1317
+ ? 'authenticated'
1318
+ : callerClass;
1319
+ const baseRateLimits = getEffectiveRateLimits(
1320
+ routePolicy,
1321
+ effectiveCallerClass
1322
+ );
1323
+ const { profile: rateLimits, suffix: trustSuffix } = isRead
1324
+ ? applyTrustEntryToReads(baseRateLimits, trustEntry)
1325
+ : { profile: baseRateLimits, suffix: '' };
1326
+
1327
+ const limiterPrefix = getRateLimitPrefix(
907
1328
  options.prefixBase,
908
1329
  routePolicy,
909
- callerClass,
910
- req.nextUrl.pathname
1330
+ effectiveCallerClass,
1331
+ trustSuffix
911
1332
  );
912
1333
  const limiterCacheKey = `${limiterPrefix}:${JSON.stringify(rateLimits)}`;
913
1334
  const limiters = await getRateLimiters(limiterPrefix, rateLimits);
@@ -918,7 +1339,7 @@ export async function guardApiProxyRequest(
918
1339
  let result: RateLimitResult;
919
1340
 
920
1341
  try {
921
- result = await limiter.limit(ip);
1342
+ result = await limiter.limit(limiterId);
922
1343
  } catch {
923
1344
  const fallbackLimiters = replaceWithLocalRateLimiters(
924
1345
  limiterCacheKey,
@@ -936,7 +1357,7 @@ export async function guardApiProxyRequest(
936
1357
  continue;
937
1358
  }
938
1359
 
939
- result = await fallbackBucket.limiter.limit(ip);
1360
+ result = await fallbackBucket.limiter.limit(limiterId);
940
1361
  }
941
1362
 
942
1363
  const { success, limit, remaining, reset } = result;
@@ -945,7 +1366,7 @@ export async function guardApiProxyRequest(
945
1366
  const consumed = limit - remaining;
946
1367
  const kind = isRead ? 'read' : 'mutate';
947
1368
  console.log(
948
- `[ProxyGuard] ${routePolicy.key}:${kind}:${window} ${consumed}/${limit} | IP: ${ip} | path: ${req.nextUrl.pathname}`
1369
+ `[ProxyGuard] ${routePolicy.key}:${kind}:${window} ${consumed}/${limit} | id: ${limiterId} | path: ${req.nextUrl.pathname}`
949
1370
  );
950
1371
  }
951
1372
 
@@ -960,7 +1381,7 @@ export async function guardApiProxyRequest(
960
1381
  'X-RateLimit-Limit': `${limit}`,
961
1382
  'X-RateLimit-Remaining': `${remaining}`,
962
1383
  'X-RateLimit-Reset': `${Math.ceil(reset / 1000)}`,
963
- 'X-RateLimit-Caller-Class': callerClass,
1384
+ 'X-RateLimit-Caller-Class': effectiveCallerClass,
964
1385
  'X-RateLimit-Window': window,
965
1386
  'X-RateLimit-Policy': routePolicy.key,
966
1387
  });
@@ -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
+ });