@tuturuuu/utils 0.6.1 → 0.8.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 trusted
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 read limiting 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[];
@@ -250,15 +294,24 @@ const TASK_BOARD_READ_RATE_LIMITS: RateLimitProfile = {
250
294
  createConfig(
251
295
  'minute',
252
296
  '1 m',
253
- 300,
297
+ 600,
254
298
  'API_PROXY_TASK_BOARD_READ_LIMIT_MINUTE'
255
299
  ),
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'),
300
+ createConfig('hour', '1 h', 12_000, 'API_PROXY_TASK_BOARD_READ_LIMIT_HOUR'),
301
+ createConfig('day', '1 d', 80_000, 'API_PROXY_TASK_BOARD_READ_LIMIT_DAY'),
258
302
  ],
259
303
  mutate: DEFAULT_MUTATE_RATE_LIMITS,
260
304
  };
261
305
 
306
+ const USERS_DATABASE_READ_OVER_POST_RATE_LIMITS: RateLimitProfile = {
307
+ get: NO_READ_RATE_LIMITS,
308
+ mutate: [
309
+ { window: 'minute', limit: 300, duration: '1 m' },
310
+ { window: 'hour', limit: 3000, duration: '1 h' },
311
+ { window: 'day', limit: 20_000, duration: '1 d' },
312
+ ],
313
+ };
314
+
262
315
  const FINANCE_READ_RATE_LIMITS: RateLimitProfile = {
263
316
  get: [
264
317
  createConfig('minute', '1 m', 1200, 'API_PROXY_FINANCE_READ_LIMIT_MINUTE', [
@@ -299,6 +352,16 @@ function isFinanceRead(req: NextRequest) {
299
352
  );
300
353
  }
301
354
 
355
+ function isUsersDatabaseReadOverPost(req: NextRequest) {
356
+ if (req.method !== 'POST') {
357
+ return false;
358
+ }
359
+
360
+ return /^\/api\/v1\/workspaces\/[^/]+\/users\/(?:database|groups\/(?:featured-counts|possible-excluded))\/?$/u.test(
361
+ req.nextUrl.pathname
362
+ );
363
+ }
364
+
302
365
  const DEFAULT_ROUTE_POLICIES: ProxyRoutePolicy[] = [
303
366
  {
304
367
  key: 'cron',
@@ -363,6 +426,11 @@ const DEFAULT_ROUTE_POLICIES: ProxyRoutePolicy[] = [
363
426
  )),
364
427
  rateLimits: TASK_BOARD_READ_RATE_LIMITS,
365
428
  },
429
+ {
430
+ key: 'users-database-read-over-post',
431
+ matches: isUsersDatabaseReadOverPost,
432
+ rateLimits: USERS_DATABASE_READ_OVER_POST_RATE_LIMITS,
433
+ },
366
434
  {
367
435
  key: 'finance-read',
368
436
  matches: isFinanceRead,
@@ -742,10 +810,241 @@ export function hasAuthenticatedApiSession(req: NextRequest): boolean {
742
810
  }
743
811
 
744
812
  function getCallerClass(req: NextRequest): CallerClass {
813
+ // Intentionally always 'anonymous'. Auth headers/cookies are forgeable at the
814
+ // edge (we can't verify signatures cheaply here), so presence alone must never
815
+ // 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.
745
819
  void req;
746
820
  return 'anonymous';
747
821
  }
748
822
 
823
+ /**
824
+ * Edge-safe stable subject hash. MUST stay byte-identical to
825
+ * `reputation.ts hashStableSubject` (sha256 hex, sliced to 24 chars) so that
826
+ * the edge `session:<hash>` subject key matches the DB-side key used by the
827
+ * trust cache.
828
+ */
829
+ async function hashStableSubjectEdge(value: string): Promise<string> {
830
+ const digest = await crypto.subtle.digest(
831
+ 'SHA-256',
832
+ new TextEncoder().encode(value)
833
+ );
834
+ return Array.from(new Uint8Array(digest))
835
+ .map((byte) => byte.toString(16).padStart(2, '0'))
836
+ .join('')
837
+ .slice(0, 24);
838
+ }
839
+
840
+ function parseCookieHeaderEdge(
841
+ cookieHeader: string | null
842
+ ): Array<{ name: string; value: string }> {
843
+ if (!cookieHeader) {
844
+ return [];
845
+ }
846
+
847
+ return cookieHeader
848
+ .split(';')
849
+ .map((entry) => {
850
+ const separatorIndex = entry.indexOf('=');
851
+ if (separatorIndex < 0) {
852
+ return null;
853
+ }
854
+
855
+ return {
856
+ name: entry.slice(0, separatorIndex).trim(),
857
+ value: entry.slice(separatorIndex + 1).trim(),
858
+ };
859
+ })
860
+ .filter(
861
+ (entry): entry is { name: string; value: string } =>
862
+ !!entry?.name && !!entry.value
863
+ );
864
+ }
865
+
866
+ function getSessionAuthCookie(
867
+ req: NextRequest
868
+ ): { name: string; value: string } | null {
869
+ const cookies = parseCookieHeaderEdge(req.headers.get('cookie'));
870
+ return (
871
+ cookies.find(
872
+ (cookie) =>
873
+ cookie.name === 'tuturuuu_app_session' ||
874
+ GENERIC_SUPABASE_AUTH_COOKIE_NAME_PATTERN.test(cookie.name)
875
+ ) ?? null
876
+ );
877
+ }
878
+
879
+ /**
880
+ * Mirrors `reputation.ts getCidrSubjectKey` so the edge cidr subject key matches
881
+ * the DB-side trusted-location key.
882
+ */
883
+ function getCidrSubjectKeyEdge(ipAddress: string | null): string | null {
884
+ if (!ipAddress) {
885
+ return null;
886
+ }
887
+
888
+ const ipv4Parts = ipAddress.split('.');
889
+ if (
890
+ ipv4Parts.length === 4 &&
891
+ ipv4Parts.every((part) => /^\d{1,3}$/.test(part))
892
+ ) {
893
+ return `cidr:${ipv4Parts.slice(0, 3).join('.')}.0/24`;
894
+ }
895
+
896
+ const ipv6Parts = ipAddress.split(':').filter(Boolean);
897
+ if (ipv6Parts.length >= 4) {
898
+ return `cidr:${ipv6Parts.slice(0, 4).join(':')}::/64`;
899
+ }
900
+
901
+ return null;
902
+ }
903
+
904
+ export type ProxyIdentity = {
905
+ /** `ip:<ip>` subject key, or null when the IP is unknown. */
906
+ ipKey: string | null;
907
+ /** `cidr:<network>` subject key for trusted-location lookups, or null. */
908
+ cidrKey: string | null;
909
+ /** `session:<hash>` subject key for cookie sessions, or null. Matches the
910
+ * DB-side session subject key so trusted sessions resolve from the cache. */
911
+ sessionKey: string | null;
912
+ /** `workspace:<wsId>` subject key parsed from a workspace-scoped API path, or
913
+ * null. Lets an admin workspace rule uplift reads for the whole workspace. */
914
+ workspaceKey: string | null;
915
+ };
916
+
917
+ const WORKSPACE_PATH_PATTERN =
918
+ /^\/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;
919
+
920
+ function getWorkspaceSubjectKey(pathname: string): string | null {
921
+ const match = WORKSPACE_PATH_PATTERN.exec(pathname);
922
+ return match ? `workspace:${match[1]!.toLowerCase()}` : null;
923
+ }
924
+
925
+ /**
926
+ * Resolves the trust/keying subject keys for a request. Limited to
927
+ * session/cidr/ip (keys an attacker would have to actually possess or share a
928
+ * network with) plus a workspace key parsed from the path — so trust uplift and
929
+ * per-session keying can never be granted by a forged credential.
930
+ */
931
+ async function resolveProxyIdentity(
932
+ req: NextRequest,
933
+ ip: string
934
+ ): Promise<ProxyIdentity> {
935
+ const normalizedIp = ip && ip !== 'unknown' ? ip : null;
936
+ const sessionCookie = getSessionAuthCookie(req);
937
+ const sessionKey = sessionCookie
938
+ ? `session:${await hashStableSubjectEdge(`${sessionCookie.name}:${sessionCookie.value}`)}`
939
+ : null;
940
+
941
+ return {
942
+ cidrKey: getCidrSubjectKeyEdge(normalizedIp),
943
+ ipKey: normalizedIp ? `ip:${normalizedIp}` : null,
944
+ sessionKey,
945
+ workspaceKey: getWorkspaceSubjectKey(req.nextUrl.pathname),
946
+ };
947
+ }
948
+
949
+ /**
950
+ * Scales the read (`get`) limits of a profile by a trust multiplier. Mutation
951
+ * limits are left flat — those are already trust-scaled at the DB and in the
952
+ * downstream session/API wrappers, so scaling them here would double-apply.
953
+ */
954
+ function scaleReadLimits(
955
+ profile: RateLimitProfile,
956
+ multiplier: number
957
+ ): RateLimitProfile {
958
+ if (multiplier <= 1) {
959
+ return profile;
960
+ }
961
+
962
+ return {
963
+ ...profile,
964
+ get: profile.get.map((config) => ({
965
+ ...config,
966
+ limit: Math.max(config.limit, Math.floor(config.limit * multiplier)),
967
+ })),
968
+ };
969
+ }
970
+
971
+ function entryReadFloor(abs: NonNullable<CachedTrustEntry['abs']>): number {
972
+ return abs.minute ?? abs.hour ?? abs.day ?? 0;
973
+ }
974
+
975
+ /**
976
+ * Combines all applicable cached entries (session + location/workspace) into a
977
+ * single effective read decision. Most-permissive wins: unlimited beats
978
+ * absolute beats the highest multiplier.
979
+ */
980
+ function combineTrustEntries(
981
+ entries: Array<CachedTrustEntry | null | undefined>
982
+ ): CachedTrustEntry | null {
983
+ const active = entries.filter((entry): entry is CachedTrustEntry => !!entry);
984
+ if (active.length === 0) {
985
+ return null;
986
+ }
987
+
988
+ if (active.some((entry) => entry.mode === 'unlimited')) {
989
+ return { m: 1, mode: 'unlimited' };
990
+ }
991
+
992
+ const absolutes = active.filter(
993
+ (
994
+ entry
995
+ ): entry is CachedTrustEntry & {
996
+ abs: NonNullable<CachedTrustEntry['abs']>;
997
+ } => entry.mode === 'absolute' && !!entry.abs
998
+ );
999
+ if (absolutes.length > 0) {
1000
+ const best = absolutes.reduce((winner, candidate) =>
1001
+ entryReadFloor(candidate.abs) > entryReadFloor(winner.abs)
1002
+ ? candidate
1003
+ : winner
1004
+ );
1005
+ return { abs: best.abs, m: best.m, mode: 'absolute' };
1006
+ }
1007
+
1008
+ return { m: Math.max(...active.map((entry) => entry.m)) };
1009
+ }
1010
+
1011
+ /**
1012
+ * Applies a combined trust entry to a route profile's READ limits and returns
1013
+ * the scaled profile plus a bucket-prefix suffix that uniquely encodes the
1014
+ * effective limit (so distinct limits never share a Redis sliding window).
1015
+ */
1016
+ function applyTrustEntryToReads(
1017
+ profile: RateLimitProfile,
1018
+ entry: CachedTrustEntry | null
1019
+ ): { profile: RateLimitProfile; suffix: string } {
1020
+ if (!entry) {
1021
+ return { profile, suffix: '' };
1022
+ }
1023
+
1024
+ if (entry.mode === 'unlimited') {
1025
+ // Drop the read cap entirely (no read limiter is constructed for [] get).
1026
+ return { profile: { ...profile, get: [] }, suffix: ':unl' };
1027
+ }
1028
+
1029
+ if (entry.mode === 'absolute' && entry.abs) {
1030
+ const get = profile.get.map((config) => {
1031
+ const absolute = entry.abs?.[config.window];
1032
+ return absolute != null ? { ...config, limit: absolute } : config;
1033
+ });
1034
+ const { minute, hour, day } = entry.abs;
1035
+ return {
1036
+ profile: { ...profile, get },
1037
+ suffix: `:abs-${minute ?? 'x'}-${hour ?? 'x'}-${day ?? 'x'}`,
1038
+ };
1039
+ }
1040
+
1041
+ const tier = quantizeTrustMultiplier(entry.m);
1042
+ if (tier <= 1) {
1043
+ return { profile, suffix: '' };
1044
+ }
1045
+ return { profile: scaleReadLimits(profile, tier), suffix: `:t${tier}` };
1046
+ }
1047
+
749
1048
  function getEffectiveRateLimits(
750
1049
  routePolicy: ProxyRoutePolicy,
751
1050
  callerClass: CallerClass
@@ -764,21 +1063,17 @@ function getEffectiveRateLimits(
764
1063
  return routePolicy.rateLimits;
765
1064
  }
766
1065
 
767
- function shouldScopeRateLimitByPath(routePolicy: ProxyRoutePolicy): boolean {
768
- return routePolicy.key === 'default' || routePolicy.key === 'users-me';
769
- }
770
-
771
- function getPathScopedRateLimitPrefix(
1066
+ function getRateLimitPrefix(
772
1067
  prefixBase: string,
773
1068
  routePolicy: ProxyRoutePolicy,
774
1069
  callerClass: CallerClass,
775
- pathname: string
1070
+ trustSuffix = ''
776
1071
  ): string {
777
- const scopeSuffix = shouldScopeRateLimitByPath(routePolicy)
778
- ? `:${pathname.replaceAll('/', ':') || ':root'}`
779
- : '';
780
-
781
- return `${prefixBase}:${routePolicy.key}:${callerClass}${scopeSuffix}`;
1072
+ // Only trusted/uplifted traffic gets a suffixed bucket; untrusted traffic
1073
+ // keeps the original prefix so its sliding windows are unaffected. The suffix
1074
+ // must uniquely encode the effective limit (tier or absolute values) so two
1075
+ // different effective limits never share a Redis sliding-window prefix.
1076
+ return `${prefixBase}:${routePolicy.key}:${callerClass}${trustSuffix}`;
782
1077
  }
783
1078
 
784
1079
  export function isTrustedProxyBypassRequest(
@@ -846,7 +1141,10 @@ export async function guardApiProxyRequest(
846
1141
  );
847
1142
  }
848
1143
 
849
- const routePolicies = options.routePolicies ?? DEFAULT_ROUTE_POLICIES;
1144
+ const routePolicies = [
1145
+ ...(options.additionalRoutePolicies ?? []),
1146
+ ...(options.routePolicies ?? DEFAULT_ROUTE_POLICIES),
1147
+ ];
850
1148
  const trustedBypassRules = [
851
1149
  ...DEFAULT_TRUSTED_BYPASS_RULES,
852
1150
  ...(options.trustedBypassRules ?? []),
@@ -862,8 +1160,9 @@ export async function guardApiProxyRequest(
862
1160
  ) {
863
1161
  const callerClass = getCallerClass(req);
864
1162
  const ip = extractIPFromRequest(req.headers);
1163
+ const identity = await resolveProxyIdentity(req, ip);
865
1164
 
866
- if (ip !== 'unknown' && callerClass === 'anonymous') {
1165
+ if (ip !== 'unknown') {
867
1166
  const blockInfo = await isIPBlockedEdge(ip);
868
1167
  if (blockInfo) {
869
1168
  const retryAfter = Math.max(
@@ -875,15 +1174,63 @@ export async function guardApiProxyRequest(
875
1174
  'X-Proxy-Block-Reason': 'ip-already-blocked',
876
1175
  });
877
1176
  }
1177
+ }
1178
+
1179
+ const routePolicy = getRoutePolicy(req, routePolicies);
1180
+ const isRead = req.method === 'GET' || req.method === 'HEAD';
1181
+
1182
+ // Resolve trust for reads only. Account trust (session key) and location
1183
+ // 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
1186
+ // teammates behind one NAT no longer collide; and
1187
+ // * a trusted LOCATION (office cidr/ip) uplifts the shared per-IP bucket
1188
+ // for the whole team without any per-request credential.
1189
+ // 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.
1192
+ let trustEntry: CachedTrustEntry | null = null;
1193
+ let sessionTrusted = false;
1194
+ if (isRead && edgeTrustEnabled()) {
1195
+ const lookupKeys = [
1196
+ identity.sessionKey,
1197
+ identity.cidrKey,
1198
+ identity.ipKey,
1199
+ identity.workspaceKey,
1200
+ ].filter((key): key is string => key != null);
1201
+ const entries = await getCachedTrustEntries(lookupKeys);
1202
+
1203
+ const sessionEntry = identity.sessionKey
1204
+ ? entries.get(identity.sessionKey)
1205
+ : 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;
1216
+ }
878
1217
 
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(
1218
+ // Trusted sessions key per-session; everyone else keys per-IP.
1219
+ const limiterId =
1220
+ sessionTrusted && identity.sessionKey
1221
+ ? identity.sessionKey
1222
+ : identity.ipKey;
1223
+
1224
+ if (limiterId) {
1225
+ const baseRateLimits = getEffectiveRateLimits(routePolicy, callerClass);
1226
+ const { profile: rateLimits, suffix: trustSuffix } =
1227
+ applyTrustEntryToReads(baseRateLimits, trustEntry);
1228
+
1229
+ const limiterPrefix = getRateLimitPrefix(
883
1230
  options.prefixBase,
884
1231
  routePolicy,
885
1232
  callerClass,
886
- req.nextUrl.pathname
1233
+ trustSuffix
887
1234
  );
888
1235
  const limiterCacheKey = `${limiterPrefix}:${JSON.stringify(rateLimits)}`;
889
1236
  const limiters = await getRateLimiters(limiterPrefix, rateLimits);
@@ -894,7 +1241,7 @@ export async function guardApiProxyRequest(
894
1241
  let result: RateLimitResult;
895
1242
 
896
1243
  try {
897
- result = await limiter.limit(ip);
1244
+ result = await limiter.limit(limiterId);
898
1245
  } catch {
899
1246
  const fallbackLimiters = replaceWithLocalRateLimiters(
900
1247
  limiterCacheKey,
@@ -912,7 +1259,7 @@ export async function guardApiProxyRequest(
912
1259
  continue;
913
1260
  }
914
1261
 
915
- result = await fallbackBucket.limiter.limit(ip);
1262
+ result = await fallbackBucket.limiter.limit(limiterId);
916
1263
  }
917
1264
 
918
1265
  const { success, limit, remaining, reset } = result;
@@ -921,7 +1268,7 @@ export async function guardApiProxyRequest(
921
1268
  const consumed = limit - remaining;
922
1269
  const kind = isRead ? 'read' : 'mutate';
923
1270
  console.log(
924
- `[ProxyGuard] ${routePolicy.key}:${kind}:${window} ${consumed}/${limit} | IP: ${ip} | path: ${req.nextUrl.pathname}`
1271
+ `[ProxyGuard] ${routePolicy.key}:${kind}:${window} ${consumed}/${limit} | id: ${limiterId} | path: ${req.nextUrl.pathname}`
925
1272
  );
926
1273
  }
927
1274
 
@@ -370,12 +370,53 @@ function canUpgradeToRegisteredHttpsOrigin(value: URL, registeredUrl: URL) {
370
370
  }
371
371
 
372
372
  const PORTLESS_APP_HOSTS = new Set(Object.values(TUTURUUU_PORTLESS_APP_HOSTS));
373
+ const DEFAULT_PORTLESS_PROXY_PORT = '1355';
374
+
375
+ function normalizePortlessProxyPort(value: string | undefined) {
376
+ const trimmed = value?.trim();
377
+
378
+ if (!trimmed || !/^\d+$/u.test(trimmed)) {
379
+ return null;
380
+ }
381
+
382
+ const port = Number(trimmed);
383
+ return Number.isInteger(port) && port > 0 && port <= 65535
384
+ ? String(port)
385
+ : null;
386
+ }
387
+
388
+ function getPortFromUrl(value: string | undefined) {
389
+ if (!value?.trim()) {
390
+ return null;
391
+ }
392
+
393
+ try {
394
+ return normalizePortlessProxyPort(new URL(value).port);
395
+ } catch {
396
+ return null;
397
+ }
398
+ }
399
+
400
+ function getAllowedPortlessProxyPorts() {
401
+ return new Set(
402
+ [
403
+ DEFAULT_PORTLESS_PROXY_PORT,
404
+ normalizePortlessProxyPort(process.env.PORTLESS_PORT),
405
+ getPortFromUrl(process.env.PORTLESS_URL),
406
+ ].filter((port): port is string => Boolean(port))
407
+ );
408
+ }
409
+
410
+ function isAllowedPortlessProxyPort(port: string) {
411
+ return getAllowedPortlessProxyPorts().has(port);
412
+ }
373
413
 
374
414
  function canUseLocalPortlessProxyPort(value: URL, registeredUrl: URL) {
375
415
  return (
376
416
  value.protocol === registeredUrl.protocol &&
377
417
  !registeredUrl.port &&
378
418
  Boolean(value.port) &&
419
+ isAllowedPortlessProxyPort(value.port) &&
379
420
  PORTLESS_APP_HOSTS.has(registeredUrl.hostname)
380
421
  );
381
422
  }
@@ -447,16 +488,27 @@ function matchesAppDomainUrl(value: URL, domain: AppDomain) {
447
488
 
448
489
  function canonicalizeAppDomainUrl(value: URL, domain: AppDomain) {
449
490
  const registeredUrl = parseHttpUrl(domain.url);
491
+ const matchesDirectPortlessProxy =
492
+ registeredUrl &&
493
+ domain.kind === 'internal' &&
494
+ matchesPortlessProxyOrigin(value, registeredUrl);
495
+ const matchesPrefixedPortlessProxy =
496
+ registeredUrl &&
497
+ domain.kind === 'internal' &&
498
+ matchesPrefixedPortlessOrigin(value, registeredUrl);
450
499
 
451
500
  if (
452
501
  registeredUrl &&
453
502
  domain.kind === 'internal' &&
454
503
  (canUpgradeToRegisteredHttpsOrigin(value, registeredUrl) ||
455
- matchesPortlessProxyOrigin(value, registeredUrl))
504
+ matchesDirectPortlessProxy ||
505
+ matchesPrefixedPortlessProxy)
456
506
  ) {
457
507
  const canonicalUrl = new URL(value.toString());
458
508
  canonicalUrl.protocol = registeredUrl.protocol;
459
- canonicalUrl.hostname = registeredUrl.hostname;
509
+ canonicalUrl.hostname = matchesPrefixedPortlessProxy
510
+ ? value.hostname
511
+ : registeredUrl.hostname;
460
512
  canonicalUrl.port = registeredUrl.port;
461
513
  return serializeUrl(canonicalUrl);
462
514
  }
@@ -0,0 +1,82 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ formatMoneyFromMinor,
4
+ getAmountStep,
5
+ getCurrencyFractionDigits,
6
+ getMinorUnitFactor,
7
+ majorToMinor,
8
+ minorToMajor,
9
+ normalizeAmount,
10
+ } from './money';
11
+
12
+ describe('currency precision', () => {
13
+ it('knows fraction digits per currency', () => {
14
+ expect(getCurrencyFractionDigits('USD')).toBe(2);
15
+ expect(getCurrencyFractionDigits('EUR')).toBe(2);
16
+ expect(getCurrencyFractionDigits('JPY')).toBe(0);
17
+ expect(getCurrencyFractionDigits('VND')).toBe(0);
18
+ });
19
+
20
+ it('falls back to 2 digits for unknown currencies', () => {
21
+ expect(getCurrencyFractionDigits('XYZ')).toBe(2);
22
+ });
23
+
24
+ it('derives the minor-unit factor', () => {
25
+ expect(getMinorUnitFactor('USD')).toBe(100);
26
+ expect(getMinorUnitFactor('JPY')).toBe(1);
27
+ expect(getMinorUnitFactor('VND')).toBe(1);
28
+ });
29
+ });
30
+
31
+ describe('major <-> minor conversion', () => {
32
+ it('converts dollars to cents', () => {
33
+ expect(majorToMinor(100, 'USD')).toBe(10000);
34
+ expect(majorToMinor(9.99, 'USD')).toBe(999);
35
+ });
36
+
37
+ it('treats zero-decimal currencies 1:1', () => {
38
+ expect(majorToMinor(500, 'JPY')).toBe(500);
39
+ expect(majorToMinor(25000, 'VND')).toBe(25000);
40
+ });
41
+
42
+ it('round-trips cleanly', () => {
43
+ expect(minorToMajor(10000, 'USD')).toBe(100);
44
+ expect(minorToMajor(999, 'USD')).toBe(9.99);
45
+ expect(minorToMajor(500, 'JPY')).toBe(500);
46
+ });
47
+
48
+ it('rounds rather than truncating fractional minor units', () => {
49
+ expect(majorToMinor(1.005, 'USD')).toBe(101);
50
+ });
51
+
52
+ it('handles non-finite input defensively', () => {
53
+ expect(majorToMinor(Number.NaN, 'USD')).toBe(0);
54
+ expect(minorToMajor(Number.POSITIVE_INFINITY, 'USD')).toBe(0);
55
+ });
56
+ });
57
+
58
+ describe('normalizeAmount & getAmountStep', () => {
59
+ it('normalizes to currency precision', () => {
60
+ expect(normalizeAmount(9.999, 'USD')).toBe(10);
61
+ expect(normalizeAmount(9.994, 'USD')).toBe(9.99);
62
+ expect(normalizeAmount(500.7, 'JPY')).toBe(501);
63
+ });
64
+
65
+ it('returns the right input step', () => {
66
+ expect(getAmountStep('USD')).toBe('0.01');
67
+ expect(getAmountStep('JPY')).toBe('1');
68
+ expect(getAmountStep('VND')).toBe('1');
69
+ });
70
+ });
71
+
72
+ describe('formatMoneyFromMinor', () => {
73
+ it('formats USD cents as dollars', () => {
74
+ // Non-breaking space / symbol placement varies by ICU; assert the digits.
75
+ expect(formatMoneyFromMinor(10000, 'USD', 'en-US')).toContain('100.00');
76
+ expect(formatMoneyFromMinor(999, 'USD', 'en-US')).toContain('9.99');
77
+ });
78
+
79
+ it('formats zero-decimal currencies without scaling', () => {
80
+ expect(formatMoneyFromMinor(500, 'JPY', 'ja-JP')).toContain('500');
81
+ });
82
+ });