@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.
@@ -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 trusted
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 read limiting without a deploy.
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: NO_READ_RATE_LIMITS,
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. Higher read throughput for genuinely trusted accounts/locations
817
- // is granted instead via the server-written trust cache (see resolveProxyIdentity
818
- // + getCachedTrustMultiplier), which cannot be forged.
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
- ? `session:${await hashStableSubjectEdge(`${sessionCookie.name}:${sessionCookie.value}`)}`
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 retryAfter = Math.max(
1169
- 1,
1170
- Math.ceil((blockInfo.expiresAt.getTime() - Date.now()) / 1000)
1171
- );
1172
-
1173
- return buildRateLimitResponse(429, retryAfter, {
1174
- 'X-Proxy-Block-Reason': 'ip-already-blocked',
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 trust for reads only. Account trust (session key) and location
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 genuinely trusted SESSION (cache entry keyed on the real cookie's
1185
- // hash — unforgeable) gets its OWN per-session bucket, so trusted
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 → no uplift,
1191
- // no per-session escape.
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 sessionTrusted = false;
1194
- if (isRead && edgeTrustEnabled()) {
1340
+ let sessionScoped = false;
1341
+ if (edgeTrustEnabled()) {
1195
1342
  const lookupKeys = [
1196
1343
  identity.sessionKey,
1197
- identity.cidrKey,
1198
- identity.ipKey,
1199
- identity.workspaceKey,
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
- // Location/workspace trust applies to the shared per-IP bucket for the
1207
- // whole team; session trust applies to the per-session bucket.
1208
- const locationEntry = combineTrustEntries([
1209
- identity.cidrKey ? entries.get(identity.cidrKey) : undefined,
1210
- identity.ipKey ? entries.get(identity.ipKey) : undefined,
1211
- identity.workspaceKey ? entries.get(identity.workspaceKey) : undefined,
1212
- ]);
1213
-
1214
- trustEntry = combineTrustEntries([sessionEntry, locationEntry]);
1215
- sessionTrusted = !!identity.sessionKey && !!sessionEntry;
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
- // Trusted sessions key per-session; everyone else keys per-IP.
1370
+ // Server-verified sessions key per-session; everyone else keys per-IP.
1219
1371
  const limiterId =
1220
- sessionTrusted && identity.sessionKey
1372
+ sessionScoped && identity.sessionKey
1221
1373
  ? identity.sessionKey
1222
1374
  : identity.ipKey;
1223
1375
 
1224
1376
  if (limiterId) {
1225
- const baseRateLimits = getEffectiveRateLimits(routePolicy, callerClass);
1226
- const { profile: rateLimits, suffix: trustSuffix } =
1227
- applyTrustEntryToReads(baseRateLimits, trustEntry);
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
- callerClass,
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': callerClass,
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 found = SUPPORTED_CURRENCIES.find(
70
- (c) => c.code === currency.toUpperCase()
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
- return SUPPORTED_CURRENCIES.some((c) => c.code === currency.toUpperCase());
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
- return SUPPORTED_CURRENCIES.find((c) => c.code === currency.toUpperCase());
118
+ const code = currency.trim().toUpperCase();
119
+ return SUPPORTED_CURRENCIES.find((c) => c.code === code);
97
120
  }