@open-mercato/shared 0.6.6-develop.6543.1.49fadb4c31 → 0.6.6-develop.6551.1.1511bfa890

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.
@@ -69,11 +69,30 @@ function isPrivateIPv6(address) {
69
69
  if (segments.slice(0, 7).every((segment) => segment === 0) && segments[7] === 1) return true;
70
70
  if (isIPv4CompatibleIPv6(segments)) return true;
71
71
  const embedded = embeddedIPv4FromIPv6(segments);
72
- if (embedded && isPrivateIPv4(embedded)) return true;
72
+ if (embedded) return isPrivateIPv4(embedded);
73
73
  const [a] = segments;
74
74
  if ((a & 65024) === 64512) return true;
75
75
  if ((a & 65472) === 65152) return true;
76
76
  if ((a & 65280) === 65280) return true;
77
+ if (a === 256 && segments[1] === 0 && segments[2] === 0 && segments[3] === 0) {
78
+ return true;
79
+ }
80
+ if (a === 8193 && (segments[1] & 65024) === 0) {
81
+ return !isGloballyReachableIetfProtocolAssignment(segments);
82
+ }
83
+ if (a === 8193 && segments[1] === 3512) return true;
84
+ if ((a & 65520) === 16368) return true;
85
+ if ((a & 57344) !== 8192) return true;
86
+ return false;
87
+ }
88
+ function isGloballyReachableIetfProtocolAssignment(segments) {
89
+ const [, b, c, d, e, f, g, h] = segments;
90
+ const trailingZero = c === 0 && d === 0 && e === 0 && f === 0 && g === 0;
91
+ if (b === 1 && trailingZero && h >= 1 && h <= 3) return true;
92
+ if (b === 3) return true;
93
+ if (b === 4 && c === 274) return true;
94
+ if ((b & 65520) === 32) return true;
95
+ if ((b & 65520) === 48) return true;
77
96
  return false;
78
97
  }
79
98
  function expandIPv6(address) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/network.ts"],
4
- "sourcesContent": ["import { isIP } from 'node:net'\n\nconst BLOCKED_HOSTNAMES = new Set([\n 'localhost',\n 'ip6-localhost',\n 'ip6-loopback',\n 'broadcasthost',\n])\n\nexport function isPrivateIpAddress(address: string): boolean {\n const family = isIP(address)\n if (family === 4) return isPrivateIPv4(address)\n if (family === 6) return isPrivateIPv6(address)\n return false\n}\n\nexport function isBlockedHostname(hostname: string): boolean {\n const normalized = normalizeHostname(hostname)\n if (!normalized) return true\n if (BLOCKED_HOSTNAMES.has(normalized)) return true\n if (normalized.endsWith('.localhost')) return true\n if (normalized.endsWith('.internal')) return true\n if (normalized.endsWith('.local')) return true\n return false\n}\n\nexport function isPrivateUrl(rawUrl: string): boolean {\n const urlToParse = rawUrl.replace(/\\[([0-9a-fA-F:]+)%25[^\\]]*\\]/g, '[$1]')\n let url: URL\n try {\n url = new URL(urlToParse)\n } catch {\n return false\n }\n\n const hostname = normalizeHostname(url.hostname)\n if (isBlockedHostname(hostname)) return true\n return isPrivateIpAddress(hostname)\n}\n\nfunction normalizeHostname(hostname: string): string {\n let normalized = hostname.trim().toLowerCase()\n if (normalized.startsWith('[') && normalized.endsWith(']')) {\n normalized = normalized.slice(1, -1)\n }\n while (normalized.endsWith('.')) {\n normalized = normalized.slice(0, -1)\n }\n return normalized\n}\n\nexport function isPrivateIPv4(address: string): boolean {\n const parts = address.split('.')\n if (parts.length !== 4) return false\n const octets = parts.map((part) => Number(part))\n if (octets.some((o) => !Number.isInteger(o) || o < 0 || o > 255)) return false\n\n const [a, b, c] = octets\n if (a === 0) return true\n if (a === 10) return true\n if (a === 100 && b >= 64 && b <= 127) return true\n if (a === 127) return true\n if (a === 169 && b === 254) return true\n if (a === 172 && b >= 16 && b <= 31) return true\n if (a === 192 && b === 0 && (c === 0 || c === 2)) return true\n if (a === 192 && b === 168) return true\n if (a === 198 && (b === 18 || b === 19)) return true\n if (a === 198 && b === 51 && c === 100) return true\n if (a === 203 && b === 0 && c === 113) return true\n if (a >= 224) return true\n return false\n}\n\nexport function isPrivateIPv6(address: string): boolean {\n const segments = expandIPv6(address)\n if (!segments) return false\n\n if (segments.every((segment) => segment === 0)) return true\n if (segments.slice(0, 7).every((segment) => segment === 0) && segments[7] === 1) return true\n if (isIPv4CompatibleIPv6(segments)) return true\n\n const embedded = embeddedIPv4FromIPv6(segments)\n if (embedded && isPrivateIPv4(embedded)) return true\n\n const [a] = segments\n if ((a & 0xfe00) === 0xfc00) return true\n if ((a & 0xffc0) === 0xfe80) return true\n if ((a & 0xff00) === 0xff00) return true\n return false\n}\n\nfunction expandIPv6(address: string): number[] | null {\n let normalized = address.toLowerCase()\n if (normalized.includes('.')) {\n const lastColon = normalized.lastIndexOf(':')\n if (lastColon === -1) return null\n const ipv4Segments = ipv4ToHexSegments(normalized.slice(lastColon + 1))\n if (!ipv4Segments) return null\n normalized = `${normalized.slice(0, lastColon)}:${ipv4Segments[0].toString(16)}:${ipv4Segments[1].toString(16)}`\n }\n\n const doubleColonParts = normalized.split('::')\n if (doubleColonParts.length > 2) return null\n\n const head = doubleColonParts[0] ? doubleColonParts[0].split(':') : []\n const tail = doubleColonParts[1] ? doubleColonParts[1].split(':') : []\n const headSegments = parseIPv6SegmentList(head)\n const tailSegments = parseIPv6SegmentList(tail)\n if (!headSegments || !tailSegments) return null\n\n if (doubleColonParts.length === 1) {\n return headSegments.length === 8 ? headSegments : null\n }\n\n const missing = 8 - headSegments.length - tailSegments.length\n if (missing < 1) return null\n return [\n ...headSegments,\n ...Array.from({ length: missing }, () => 0),\n ...tailSegments,\n ]\n}\n\nfunction parseIPv6SegmentList(parts: string[]): number[] | null {\n const segments: number[] = []\n for (const part of parts) {\n if (!/^[0-9a-f]{1,4}$/.test(part)) return null\n const value = Number.parseInt(part, 16)\n if (!Number.isInteger(value) || value < 0 || value > 0xffff) return null\n segments.push(value)\n }\n return segments\n}\n\nfunction ipv4ToHexSegments(address: string): [number, number] | null {\n const octets = address.split('.').map((part) => Number(part))\n if (octets.length !== 4) return null\n if (octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) return null\n return [\n (octets[0] << 8) + octets[1],\n (octets[2] << 8) + octets[3],\n ]\n}\n\nfunction embeddedIPv4FromIPv6(segments: number[]): string | null {\n const firstFiveZero = segments.slice(0, 5).every((segment) => segment === 0)\n const isMapped = firstFiveZero && segments[5] === 0xffff\n const isNat64 = segments[0] === 0x0064\n && segments[1] === 0xff9b\n && segments.slice(2, 6).every((segment) => segment === 0)\n const isSixToFour = segments[0] === 0x2002\n\n if (isMapped || isNat64) {\n return ipv4FromHexSegments(segments[6], segments[7])\n }\n if (isSixToFour) {\n return ipv4FromHexSegments(segments[1], segments[2])\n }\n return null\n}\n\nfunction isIPv4CompatibleIPv6(segments: number[]): boolean {\n return segments.slice(0, 6).every((segment) => segment === 0)\n}\n\nfunction ipv4FromHexSegments(high: number, low: number): string {\n return [\n (high >> 8) & 0xff,\n high & 0xff,\n (low >> 8) & 0xff,\n low & 0xff,\n ].join('.')\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { isIP } from 'node:net'\n\nconst BLOCKED_HOSTNAMES = new Set([\n 'localhost',\n 'ip6-localhost',\n 'ip6-loopback',\n 'broadcasthost',\n])\n\nexport function isPrivateIpAddress(address: string): boolean {\n const family = isIP(address)\n if (family === 4) return isPrivateIPv4(address)\n if (family === 6) return isPrivateIPv6(address)\n return false\n}\n\nexport function isBlockedHostname(hostname: string): boolean {\n const normalized = normalizeHostname(hostname)\n if (!normalized) return true\n if (BLOCKED_HOSTNAMES.has(normalized)) return true\n if (normalized.endsWith('.localhost')) return true\n if (normalized.endsWith('.internal')) return true\n if (normalized.endsWith('.local')) return true\n return false\n}\n\nexport function isPrivateUrl(rawUrl: string): boolean {\n const urlToParse = rawUrl.replace(/\\[([0-9a-fA-F:]+)%25[^\\]]*\\]/g, '[$1]')\n let url: URL\n try {\n url = new URL(urlToParse)\n } catch {\n return false\n }\n\n const hostname = normalizeHostname(url.hostname)\n if (isBlockedHostname(hostname)) return true\n return isPrivateIpAddress(hostname)\n}\n\nfunction normalizeHostname(hostname: string): string {\n let normalized = hostname.trim().toLowerCase()\n if (normalized.startsWith('[') && normalized.endsWith(']')) {\n normalized = normalized.slice(1, -1)\n }\n while (normalized.endsWith('.')) {\n normalized = normalized.slice(0, -1)\n }\n return normalized\n}\n\nexport function isPrivateIPv4(address: string): boolean {\n const parts = address.split('.')\n if (parts.length !== 4) return false\n const octets = parts.map((part) => Number(part))\n if (octets.some((o) => !Number.isInteger(o) || o < 0 || o > 255)) return false\n\n const [a, b, c] = octets\n if (a === 0) return true\n if (a === 10) return true\n if (a === 100 && b >= 64 && b <= 127) return true\n if (a === 127) return true\n if (a === 169 && b === 254) return true\n if (a === 172 && b >= 16 && b <= 31) return true\n if (a === 192 && b === 0 && (c === 0 || c === 2)) return true\n if (a === 192 && b === 168) return true\n if (a === 198 && (b === 18 || b === 19)) return true\n if (a === 198 && b === 51 && c === 100) return true\n if (a === 203 && b === 0 && c === 113) return true\n if (a >= 224) return true\n return false\n}\n\nexport function isPrivateIPv6(address: string): boolean {\n const segments = expandIPv6(address)\n if (!segments) return false\n\n if (segments.every((segment) => segment === 0)) return true\n if (segments.slice(0, 7).every((segment) => segment === 0) && segments[7] === 1) return true\n if (isIPv4CompatibleIPv6(segments)) return true\n\n const embedded = embeddedIPv4FromIPv6(segments)\n if (embedded) return isPrivateIPv4(embedded)\n\n const [a] = segments\n if ((a & 0xfe00) === 0xfc00) return true\n if ((a & 0xffc0) === 0xfe80) return true\n if ((a & 0xff00) === 0xff00) return true\n if (\n a === 0x0100 &&\n segments[1] === 0 &&\n segments[2] === 0 &&\n segments[3] === 0\n ) {\n return true\n }\n if (a === 0x2001 && (segments[1] & 0xfe00) === 0) {\n return !isGloballyReachableIetfProtocolAssignment(segments)\n }\n if (a === 0x2001 && segments[1] === 0x0db8) return true\n if ((a & 0xfff0) === 0x3ff0) return true\n // Except for the explicitly handled IPv4 translation ranges above, globally\n // routable unicast IPv6 addresses are allocated from 2000::/3.\n if ((a & 0xe000) !== 0x2000) return true\n return false\n}\n\nfunction isGloballyReachableIetfProtocolAssignment(segments: number[]): boolean {\n const [, b, c, d, e, f, g, h] = segments\n const trailingZero = c === 0 && d === 0 && e === 0 && f === 0 && g === 0\n if (b === 0x0001 && trailingZero && h >= 1 && h <= 3) return true\n if (b === 0x0003) return true\n if (b === 0x0004 && c === 0x0112) return true\n if ((b & 0xfff0) === 0x0020) return true\n if ((b & 0xfff0) === 0x0030) return true\n return false\n}\n\nfunction expandIPv6(address: string): number[] | null {\n let normalized = address.toLowerCase()\n if (normalized.includes('.')) {\n const lastColon = normalized.lastIndexOf(':')\n if (lastColon === -1) return null\n const ipv4Segments = ipv4ToHexSegments(normalized.slice(lastColon + 1))\n if (!ipv4Segments) return null\n normalized = `${normalized.slice(0, lastColon)}:${ipv4Segments[0].toString(16)}:${ipv4Segments[1].toString(16)}`\n }\n\n const doubleColonParts = normalized.split('::')\n if (doubleColonParts.length > 2) return null\n\n const head = doubleColonParts[0] ? doubleColonParts[0].split(':') : []\n const tail = doubleColonParts[1] ? doubleColonParts[1].split(':') : []\n const headSegments = parseIPv6SegmentList(head)\n const tailSegments = parseIPv6SegmentList(tail)\n if (!headSegments || !tailSegments) return null\n\n if (doubleColonParts.length === 1) {\n return headSegments.length === 8 ? headSegments : null\n }\n\n const missing = 8 - headSegments.length - tailSegments.length\n if (missing < 1) return null\n return [\n ...headSegments,\n ...Array.from({ length: missing }, () => 0),\n ...tailSegments,\n ]\n}\n\nfunction parseIPv6SegmentList(parts: string[]): number[] | null {\n const segments: number[] = []\n for (const part of parts) {\n if (!/^[0-9a-f]{1,4}$/.test(part)) return null\n const value = Number.parseInt(part, 16)\n if (!Number.isInteger(value) || value < 0 || value > 0xffff) return null\n segments.push(value)\n }\n return segments\n}\n\nfunction ipv4ToHexSegments(address: string): [number, number] | null {\n const octets = address.split('.').map((part) => Number(part))\n if (octets.length !== 4) return null\n if (octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) return null\n return [\n (octets[0] << 8) + octets[1],\n (octets[2] << 8) + octets[3],\n ]\n}\n\nfunction embeddedIPv4FromIPv6(segments: number[]): string | null {\n const firstFiveZero = segments.slice(0, 5).every((segment) => segment === 0)\n const isMapped = firstFiveZero && segments[5] === 0xffff\n const isNat64 = segments[0] === 0x0064\n && segments[1] === 0xff9b\n && segments.slice(2, 6).every((segment) => segment === 0)\n const isSixToFour = segments[0] === 0x2002\n\n if (isMapped || isNat64) {\n return ipv4FromHexSegments(segments[6], segments[7])\n }\n if (isSixToFour) {\n return ipv4FromHexSegments(segments[1], segments[2])\n }\n return null\n}\n\nfunction isIPv4CompatibleIPv6(segments: number[]): boolean {\n return segments.slice(0, 6).every((segment) => segment === 0)\n}\n\nfunction ipv4FromHexSegments(high: number, low: number): string {\n return [\n (high >> 8) & 0xff,\n high & 0xff,\n (low >> 8) & 0xff,\n low & 0xff,\n ].join('.')\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,6 +1,6 @@
1
1
  import { lookup } from "node:dns/promises";
2
2
  import { isIP } from "node:net";
3
- import { Agent } from "undici";
3
+ import { Agent, fetch as undiciFetch } from "undici";
4
4
  import { isBlockedHostname, isPrivateIpAddress } from "./network.js";
5
5
  class UnsafeOutboundUrlError extends Error {
6
6
  constructor(reason, message) {
@@ -60,12 +60,14 @@ async function resolveSafeOutboundUrl(rawUrl, options = {}) {
60
60
  const subject = options.subject ?? "URL";
61
61
  const factory = options.errorFactory ?? defaultErrorFactory;
62
62
  const { url, hostname } = parseOutboundUrl(rawUrl, options);
63
- if (options.allowPrivate) return { url, hostname, addresses: null };
64
- if (isBlockedHostname(hostname)) {
63
+ if (options.allowPrivate && !options.pinAllowedPrivate) {
64
+ return { url, hostname, addresses: null };
65
+ }
66
+ if (!options.allowPrivate && isBlockedHostname(hostname)) {
65
67
  throw factory("blocked_hostname", `${subject} host "${hostname}" is not allowed`);
66
68
  }
67
69
  if (isIP(hostname)) {
68
- if (isPrivateIpAddress(hostname)) {
70
+ if (!options.allowPrivate && isPrivateIpAddress(hostname)) {
69
71
  throw factory(
70
72
  "private_ip_literal",
71
73
  `${subject} host "${hostname}" resolves to a private or reserved IP range`
@@ -93,7 +95,7 @@ async function resolveSafeOutboundUrl(rawUrl, options = {}) {
93
95
  );
94
96
  }
95
97
  for (const record of addresses) {
96
- if (isPrivateIpAddress(record.address)) {
98
+ if (!options.allowPrivate && isPrivateIpAddress(record.address)) {
97
99
  throw factory(
98
100
  "private_ip_resolved",
99
101
  `${subject} host "${hostname}" resolves to a private or reserved IP address (${record.address})`
@@ -122,14 +124,15 @@ async function safeOutboundFetch(rawUrl, init = {}, options = {}) {
122
124
  }
123
125
  });
124
126
  try {
125
- return await globalThis.fetch(rawUrl, { ...mergedInit, dispatcher });
127
+ const pinnedFetch = options.pinnedFetchImpl ?? undiciFetch;
128
+ return await pinnedFetch(rawUrl, { ...mergedInit, dispatcher });
126
129
  } finally {
127
130
  dispatcher.close().catch(() => {
128
131
  });
129
132
  }
130
133
  }
131
134
  function createPinnedDnsLookup(expectedHostname, pinned) {
132
- return (host, _opts, cb) => {
135
+ return (host, opts, cb) => {
133
136
  if (host !== expectedHostname) {
134
137
  const err = new Error(
135
138
  `Refusing DNS lookup for unexpected host "${host}" (expected "${expectedHostname}")`
@@ -138,6 +141,10 @@ function createPinnedDnsLookup(expectedHostname, pinned) {
138
141
  cb(err, "", 0);
139
142
  return;
140
143
  }
144
+ if (opts.all) {
145
+ cb(null, [pinned]);
146
+ return;
147
+ }
141
148
  cb(null, pinned.address, pinned.family);
142
149
  };
143
150
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/url-safety.ts"],
4
- "sourcesContent": ["import { lookup } from 'node:dns/promises'\nimport { isIP } from 'node:net'\nimport { Agent, type Dispatcher } from 'undici'\nimport { isBlockedHostname, isPrivateIpAddress } from './network'\n\nexport type UrlSafetyReason =\n | 'invalid_url'\n | 'forbidden_protocol'\n | 'credentials_in_url'\n | 'missing_host'\n | 'blocked_hostname'\n | 'private_ip_literal'\n | 'private_ip_resolved'\n | 'dns_resolution_failed'\n | 'dns_resolution_empty'\n\nexport class UnsafeOutboundUrlError extends Error {\n public readonly reason: UrlSafetyReason\n\n constructor(reason: UrlSafetyReason, message?: string) {\n super(message ?? `Outbound URL rejected: ${reason}`)\n this.name = 'UnsafeOutboundUrlError'\n this.reason = reason\n }\n}\n\nconst ALLOWED_PROTOCOLS = new Set(['http:', 'https:'])\n\nexport type ParsedOutboundUrl = {\n url: URL\n hostname: string\n}\n\nexport type UrlSafetyErrorFactory = (reason: UrlSafetyReason, message: string) => Error\n\nconst defaultErrorFactory: UrlSafetyErrorFactory = (reason, message) =>\n new UnsafeOutboundUrlError(reason, message)\n\nexport type ParseOutboundUrlOptions = {\n errorFactory?: UrlSafetyErrorFactory\n subject?: string\n}\n\nexport function parseOutboundUrl(\n rawUrl: string,\n options: ParseOutboundUrlOptions = {},\n): ParsedOutboundUrl {\n const subject = options.subject ?? 'URL'\n const factory = options.errorFactory ?? defaultErrorFactory\n let url: URL\n try {\n url = new URL(rawUrl)\n } catch {\n throw factory('invalid_url', `${subject} is not a valid URL`)\n }\n if (!ALLOWED_PROTOCOLS.has(url.protocol)) {\n throw factory(\n 'forbidden_protocol',\n `${subject} protocol \"${url.protocol.replace(':', '')}\" is not allowed; use http or https`,\n )\n }\n if (url.username || url.password) {\n throw factory('credentials_in_url', `${subject} must not embed basic-auth credentials`)\n }\n let hostname = url.hostname.trim().toLowerCase()\n if (!hostname) {\n throw factory('missing_host', `${subject} must include a hostname`)\n }\n if (hostname.startsWith('[') && hostname.endsWith(']')) {\n hostname = hostname.slice(1, -1)\n }\n return { url, hostname }\n}\n\nexport type HostLookup = (\n hostname: string,\n) => Promise<ReadonlyArray<{ address: string; family: number }>>\n\nexport type AssertStaticUrlOptions = ParseOutboundUrlOptions & {\n allowPrivate?: boolean\n}\n\nexport function assertStaticallySafeOutboundUrl(\n rawUrl: string,\n options: AssertStaticUrlOptions = {},\n): void {\n const subject = options.subject ?? 'URL'\n const factory = options.errorFactory ?? defaultErrorFactory\n const { hostname } = parseOutboundUrl(rawUrl, options)\n if (options.allowPrivate) return\n\n if (isBlockedHostname(hostname)) {\n throw factory('blocked_hostname', `${subject} host \"${hostname}\" is not allowed`)\n }\n if (isIP(hostname) && isPrivateIpAddress(hostname)) {\n throw factory(\n 'private_ip_literal',\n `${subject} host \"${hostname}\" resolves to a private or reserved IP range`,\n )\n }\n}\n\nexport type AssertSafeOutboundUrlOptions = AssertStaticUrlOptions & {\n lookupHost?: HostLookup\n}\n\nexport async function assertSafeOutboundUrl(\n rawUrl: string,\n options: AssertSafeOutboundUrlOptions = {},\n): Promise<void> {\n await resolveSafeOutboundUrl(rawUrl, options)\n}\n\nexport type ResolvedHostAddress = { address: string; family: number }\n\nexport type ResolveSafeOutboundUrlResult = {\n url: URL\n hostname: string\n /**\n * The validated DNS records, in lookup order. `null` when the hostname is an IP literal\n * (no DNS lookup performed) or when `allowPrivate` short-circuited validation.\n */\n addresses: ReadonlyArray<ResolvedHostAddress> | null\n}\n\n/**\n * Validates an outbound URL exactly like `assertSafeOutboundUrl()` and additionally returns\n * the resolved DNS records so the caller can pin the subsequent connection to the same\n * address. This is what `safeOutboundFetch()` uses internally to defeat DNS rebinding \u2014\n * call it directly only if you need to drive the fetch yourself.\n */\nexport async function resolveSafeOutboundUrl(\n rawUrl: string,\n options: AssertSafeOutboundUrlOptions = {},\n): Promise<ResolveSafeOutboundUrlResult> {\n const subject = options.subject ?? 'URL'\n const factory = options.errorFactory ?? defaultErrorFactory\n const { url, hostname } = parseOutboundUrl(rawUrl, options)\n if (options.allowPrivate) return { url, hostname, addresses: null }\n\n if (isBlockedHostname(hostname)) {\n throw factory('blocked_hostname', `${subject} host \"${hostname}\" is not allowed`)\n }\n\n if (isIP(hostname)) {\n if (isPrivateIpAddress(hostname)) {\n throw factory(\n 'private_ip_literal',\n `${subject} host \"${hostname}\" resolves to a private or reserved IP range`,\n )\n }\n return { url, hostname, addresses: null }\n }\n\n const resolver: HostLookup =\n options.lookupHost ??\n (async (host) => {\n const records = await lookup(host, { all: true, verbatim: true })\n return records\n })\n\n let addresses: ReadonlyArray<ResolvedHostAddress>\n try {\n addresses = await resolver(hostname)\n } catch (error) {\n throw factory(\n 'dns_resolution_failed',\n `${subject} host \"${hostname}\" could not be resolved: ${\n error instanceof Error ? error.message : 'lookup failed'\n }`,\n )\n }\n\n if (!addresses || addresses.length === 0) {\n throw factory(\n 'dns_resolution_empty',\n `${subject} host \"${hostname}\" has no DNS A/AAAA records`,\n )\n }\n\n for (const record of addresses) {\n if (isPrivateIpAddress(record.address)) {\n throw factory(\n 'private_ip_resolved',\n `${subject} host \"${hostname}\" resolves to a private or reserved IP address (${record.address})`,\n )\n }\n }\n\n return { url, hostname, addresses }\n}\n\nexport type SafeOutboundFetchOptions = AssertSafeOutboundUrlOptions & {\n /**\n * Test/seam injection. When provided, `safeOutboundFetch` calls `fetchImpl(url, init)` after\n * URL validation instead of using the global `fetch` with a DNS-pinned dispatcher. Tests do\n * not actually open sockets, so DNS pinning is unnecessary and would just complicate mocking.\n */\n fetchImpl?: typeof fetch\n}\n\n/**\n * Validates an outbound URL and performs `fetch()` with the connection pinned to a\n * pre-validated IP address, so DNS cannot be re-resolved between validation and connect\n * (DNS rebinding). Always defaults to `redirect: 'manual'` \u2014 callers MUST decide what to\n * do with 3xx responses (re-validate the redirect target before following).\n *\n * For IP literal hosts and `allowPrivate=true`, no DNS pinning is performed because there\n * is no DNS lookup to defeat.\n */\nexport async function safeOutboundFetch(\n rawUrl: string,\n init: RequestInit = {},\n options: SafeOutboundFetchOptions = {},\n): Promise<Response> {\n const { url, hostname, addresses } = await resolveSafeOutboundUrl(rawUrl, options)\n void url\n\n const mergedInit: RequestInit = {\n redirect: 'manual',\n ...init,\n }\n\n const fetchImpl = options.fetchImpl\n if (fetchImpl) {\n return fetchImpl(rawUrl, mergedInit)\n }\n\n if (!addresses || addresses.length === 0) {\n return globalThis.fetch(rawUrl, mergedInit)\n }\n\n const dispatcher: Dispatcher = new Agent({\n connect: {\n lookup: createPinnedDnsLookup(hostname, addresses[0]),\n },\n })\n\n try {\n return await globalThis.fetch(rawUrl, { ...mergedInit, dispatcher } as RequestInit & {\n dispatcher: Dispatcher\n })\n } finally {\n dispatcher.close().catch(() => {})\n }\n}\n\nexport type PinnedDnsLookup = (\n host: string,\n opts: unknown,\n cb: (err: NodeJS.ErrnoException | null, address: string, family: number) => void,\n) => void\n\n/**\n * Builds a `dns.lookup`-shaped callback that always returns the supplied pre-validated\n * address for the expected hostname, and refuses to resolve any other hostname. Used as\n * `Agent.connect.lookup` to bind an outbound TCP connect to an IP that has already been\n * validated, so attacker-controlled DNS cannot rebind between validation and connect.\n */\nexport function createPinnedDnsLookup(\n expectedHostname: string,\n pinned: ResolvedHostAddress,\n): PinnedDnsLookup {\n return (host, _opts, cb) => {\n if (host !== expectedHostname) {\n const err: NodeJS.ErrnoException = new Error(\n `Refusing DNS lookup for unexpected host \"${host}\" (expected \"${expectedHostname}\")`,\n )\n err.code = 'EREFUSED'\n cb(err, '', 0)\n return\n }\n cb(null, pinned.address, pinned.family)\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { lookup } from 'node:dns/promises'\nimport { isIP } from 'node:net'\nimport { Agent, fetch as undiciFetch, type Dispatcher } from 'undici'\nimport { isBlockedHostname, isPrivateIpAddress } from './network'\n\nexport type UrlSafetyReason =\n | 'invalid_url'\n | 'forbidden_protocol'\n | 'credentials_in_url'\n | 'missing_host'\n | 'blocked_hostname'\n | 'private_ip_literal'\n | 'private_ip_resolved'\n | 'dns_resolution_failed'\n | 'dns_resolution_empty'\n\nexport class UnsafeOutboundUrlError extends Error {\n public readonly reason: UrlSafetyReason\n\n constructor(reason: UrlSafetyReason, message?: string) {\n super(message ?? `Outbound URL rejected: ${reason}`)\n this.name = 'UnsafeOutboundUrlError'\n this.reason = reason\n }\n}\n\nconst ALLOWED_PROTOCOLS = new Set(['http:', 'https:'])\n\nexport type ParsedOutboundUrl = {\n url: URL\n hostname: string\n}\n\nexport type UrlSafetyErrorFactory = (reason: UrlSafetyReason, message: string) => Error\n\nconst defaultErrorFactory: UrlSafetyErrorFactory = (reason, message) =>\n new UnsafeOutboundUrlError(reason, message)\n\nexport type ParseOutboundUrlOptions = {\n errorFactory?: UrlSafetyErrorFactory\n subject?: string\n}\n\nexport function parseOutboundUrl(\n rawUrl: string,\n options: ParseOutboundUrlOptions = {},\n): ParsedOutboundUrl {\n const subject = options.subject ?? 'URL'\n const factory = options.errorFactory ?? defaultErrorFactory\n let url: URL\n try {\n url = new URL(rawUrl)\n } catch {\n throw factory('invalid_url', `${subject} is not a valid URL`)\n }\n if (!ALLOWED_PROTOCOLS.has(url.protocol)) {\n throw factory(\n 'forbidden_protocol',\n `${subject} protocol \"${url.protocol.replace(':', '')}\" is not allowed; use http or https`,\n )\n }\n if (url.username || url.password) {\n throw factory('credentials_in_url', `${subject} must not embed basic-auth credentials`)\n }\n let hostname = url.hostname.trim().toLowerCase()\n if (!hostname) {\n throw factory('missing_host', `${subject} must include a hostname`)\n }\n if (hostname.startsWith('[') && hostname.endsWith(']')) {\n hostname = hostname.slice(1, -1)\n }\n return { url, hostname }\n}\n\nexport type HostLookup = (\n hostname: string,\n) => Promise<ReadonlyArray<{ address: string; family: number }>>\n\nexport type AssertStaticUrlOptions = ParseOutboundUrlOptions & {\n allowPrivate?: boolean\n}\n\nexport function assertStaticallySafeOutboundUrl(\n rawUrl: string,\n options: AssertStaticUrlOptions = {},\n): void {\n const subject = options.subject ?? 'URL'\n const factory = options.errorFactory ?? defaultErrorFactory\n const { hostname } = parseOutboundUrl(rawUrl, options)\n if (options.allowPrivate) return\n\n if (isBlockedHostname(hostname)) {\n throw factory('blocked_hostname', `${subject} host \"${hostname}\" is not allowed`)\n }\n if (isIP(hostname) && isPrivateIpAddress(hostname)) {\n throw factory(\n 'private_ip_literal',\n `${subject} host \"${hostname}\" resolves to a private or reserved IP range`,\n )\n }\n}\n\nexport type AssertSafeOutboundUrlOptions = AssertStaticUrlOptions & {\n lookupHost?: HostLookup\n /** Resolve and return DNS answers even when private addresses are explicitly allowed. */\n pinAllowedPrivate?: boolean\n}\n\nexport async function assertSafeOutboundUrl(\n rawUrl: string,\n options: AssertSafeOutboundUrlOptions = {},\n): Promise<void> {\n await resolveSafeOutboundUrl(rawUrl, options)\n}\n\nexport type ResolvedHostAddress = { address: string; family: number }\n\nexport type ResolveSafeOutboundUrlResult = {\n url: URL\n hostname: string\n /**\n * The validated DNS records, in lookup order. `null` when the hostname is an IP literal\n * (no DNS lookup performed) or when `allowPrivate` short-circuited validation without\n * `pinAllowedPrivate`.\n */\n addresses: ReadonlyArray<ResolvedHostAddress> | null\n}\n\n/**\n * Validates an outbound URL exactly like `assertSafeOutboundUrl()` and additionally returns\n * the resolved DNS records so the caller can pin the subsequent connection to the same\n * address. This is what `safeOutboundFetch()` uses internally to defeat DNS rebinding \u2014\n * call it directly only if you need to drive the fetch yourself.\n */\nexport async function resolveSafeOutboundUrl(\n rawUrl: string,\n options: AssertSafeOutboundUrlOptions = {},\n): Promise<ResolveSafeOutboundUrlResult> {\n const subject = options.subject ?? 'URL'\n const factory = options.errorFactory ?? defaultErrorFactory\n const { url, hostname } = parseOutboundUrl(rawUrl, options)\n if (options.allowPrivate && !options.pinAllowedPrivate) {\n return { url, hostname, addresses: null }\n }\n\n if (!options.allowPrivate && isBlockedHostname(hostname)) {\n throw factory('blocked_hostname', `${subject} host \"${hostname}\" is not allowed`)\n }\n\n if (isIP(hostname)) {\n if (!options.allowPrivate && isPrivateIpAddress(hostname)) {\n throw factory(\n 'private_ip_literal',\n `${subject} host \"${hostname}\" resolves to a private or reserved IP range`,\n )\n }\n return { url, hostname, addresses: null }\n }\n\n const resolver: HostLookup =\n options.lookupHost ??\n (async (host) => {\n const records = await lookup(host, { all: true, verbatim: true })\n return records\n })\n\n let addresses: ReadonlyArray<ResolvedHostAddress>\n try {\n addresses = await resolver(hostname)\n } catch (error) {\n throw factory(\n 'dns_resolution_failed',\n `${subject} host \"${hostname}\" could not be resolved: ${\n error instanceof Error ? error.message : 'lookup failed'\n }`,\n )\n }\n\n if (!addresses || addresses.length === 0) {\n throw factory(\n 'dns_resolution_empty',\n `${subject} host \"${hostname}\" has no DNS A/AAAA records`,\n )\n }\n\n for (const record of addresses) {\n if (!options.allowPrivate && isPrivateIpAddress(record.address)) {\n throw factory(\n 'private_ip_resolved',\n `${subject} host \"${hostname}\" resolves to a private or reserved IP address (${record.address})`,\n )\n }\n }\n\n return { url, hostname, addresses }\n}\n\nexport type SafeOutboundFetchOptions = AssertSafeOutboundUrlOptions & {\n /**\n * Test/seam injection. When provided, `safeOutboundFetch` calls `fetchImpl(url, init)` after\n * URL validation instead of using the global `fetch` with a DNS-pinned dispatcher. Tests do\n * not actually open sockets, so DNS pinning is unnecessary and would just complicate mocking.\n */\n fetchImpl?: typeof fetch\n /** Test seam for the dispatcher-compatible fetch used by DNS-pinned requests. */\n pinnedFetchImpl?: PinnedFetch\n}\n\nexport type PinnedFetch = (\n rawUrl: string,\n init: RequestInit & { dispatcher: Dispatcher },\n) => Promise<Response>\n\n/**\n * Validates an outbound URL and performs `fetch()` with the connection pinned to a\n * pre-validated IP address, so DNS cannot be re-resolved between validation and connect\n * (DNS rebinding). Always defaults to `redirect: 'manual'` \u2014 callers MUST decide what to\n * do with 3xx responses (re-validate the redirect target before following).\n *\n * For IP literal hosts no DNS pinning is performed because there is no DNS lookup to defeat.\n * Callers that allow private hostnames can set `pinAllowedPrivate` to retain DNS pinning.\n */\nexport async function safeOutboundFetch(\n rawUrl: string,\n init: RequestInit = {},\n options: SafeOutboundFetchOptions = {},\n): Promise<Response> {\n const { url, hostname, addresses } = await resolveSafeOutboundUrl(rawUrl, options)\n void url\n\n const mergedInit: RequestInit = {\n redirect: 'manual',\n ...init,\n }\n\n const fetchImpl = options.fetchImpl\n if (fetchImpl) {\n return fetchImpl(rawUrl, mergedInit)\n }\n\n if (!addresses || addresses.length === 0) {\n return globalThis.fetch(rawUrl, mergedInit)\n }\n\n const dispatcher: Dispatcher = new Agent({\n connect: {\n lookup: createPinnedDnsLookup(hostname, addresses[0]),\n },\n })\n\n try {\n const pinnedFetch = options.pinnedFetchImpl ?? (undiciFetch as unknown as PinnedFetch)\n return await pinnedFetch(rawUrl, { ...mergedInit, dispatcher })\n } finally {\n dispatcher.close().catch(() => {})\n }\n}\n\nexport type PinnedDnsLookup = (\n host: string,\n opts: { all?: boolean },\n cb: (\n err: NodeJS.ErrnoException | null,\n address: string | ResolvedHostAddress[],\n family?: number,\n ) => void,\n) => void\n\n/**\n * Builds a `dns.lookup`-shaped callback that always returns the supplied pre-validated\n * address for the expected hostname, and refuses to resolve any other hostname. Used as\n * `Agent.connect.lookup` to bind an outbound TCP connect to an IP that has already been\n * validated, so attacker-controlled DNS cannot rebind between validation and connect.\n */\nexport function createPinnedDnsLookup(\n expectedHostname: string,\n pinned: ResolvedHostAddress,\n): PinnedDnsLookup {\n return (host, opts, cb) => {\n if (host !== expectedHostname) {\n const err: NodeJS.ErrnoException = new Error(\n `Refusing DNS lookup for unexpected host \"${host}\" (expected \"${expectedHostname}\")`,\n )\n err.code = 'EREFUSED'\n cb(err, '', 0)\n return\n }\n if (opts.all) {\n cb(null, [pinned])\n return\n }\n cb(null, pinned.address, pinned.family)\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,4 +1,4 @@
1
- const APP_VERSION = "0.6.6-develop.6543.1.49fadb4c31";
1
+ const APP_VERSION = "0.6.6-develop.6551.1.1511bfa890";
2
2
  const appVersion = APP_VERSION;
3
3
  export {
4
4
  APP_VERSION,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/version.ts"],
4
- "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.6-develop.6543.1.49fadb4c31'\nexport const appVersion = APP_VERSION\n"],
4
+ "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.6-develop.6551.1.1511bfa890'\nexport const appVersion = APP_VERSION\n"],
5
5
  "mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
6
6
  "names": []
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/modules/payment_gateways/types.ts"],
4
- "sourcesContent": ["// \u2500\u2500 Unified Payment Status \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport type UnifiedPaymentStatus =\n | 'pending'\n | 'authorized'\n | 'captured'\n | 'partially_captured'\n | 'refunded'\n | 'partially_refunded'\n | 'cancelled'\n | 'failed'\n | 'expired'\n | 'unknown'\n\n// \u2500\u2500 GatewayAdapter Interface \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport interface GatewayAdapter {\n readonly providerKey: string\n\n /** Create a payment session / payment intent */\n createSession(input: CreateSessionInput): Promise<CreateSessionResult>\n\n /** Capture an authorized payment */\n capture(input: CaptureInput): Promise<CaptureResult>\n\n /** Refund a captured payment (full or partial) */\n refund(input: RefundInput): Promise<RefundResult>\n\n /** Cancel / void an authorized payment before capture */\n cancel(input: CancelInput): Promise<CancelResult>\n\n /** Get current payment status from provider */\n getStatus(input: GetStatusInput): Promise<GatewayPaymentStatus>\n\n /** Verify and parse an inbound webhook event */\n verifyWebhook(input: VerifyWebhookInput): Promise<WebhookEvent>\n\n /** Map provider status to unified status */\n mapStatus(providerStatus: string, eventType?: string): UnifiedPaymentStatus\n}\n\nexport type PaymentGatewayDescriptorFieldType =\n | 'text'\n | 'number'\n | 'select'\n | 'multiselect'\n | 'boolean'\n | 'textarea'\n | 'secret'\n | 'url'\n\nexport interface PaymentGatewayDescriptorField {\n key: string\n label: string\n type: PaymentGatewayDescriptorFieldType\n description?: string\n required?: boolean\n options?: Array<{ value: string; label: string }>\n}\n\nexport type PaymentGatewayRendererType = 'embedded' | 'redirect'\n\nexport interface PaymentGatewayRendererDescriptor {\n key: string\n label: string\n type: PaymentGatewayRendererType\n description?: string\n supportedPaymentTypes?: '*' | string[]\n settingsFields?: PaymentGatewayDescriptorField[]\n}\n\nexport interface PaymentGatewayPresentationRequest {\n mode?: 'auto' | PaymentGatewayRendererType\n rendererKey?: string\n rendererSettings?: Record<string, unknown>\n}\n\nexport interface PaymentGatewayDescriptor {\n providerKey: string\n label: string\n sessionConfig?: {\n fields?: PaymentGatewayDescriptorField[]\n supportedCurrencies?: '*' | string[]\n supportedPaymentTypes?: Array<{ value: string; label: string }>\n defaultRendererKey?: string\n renderers?: PaymentGatewayRendererDescriptor[]\n /**\n * @deprecated Use `renderers` and `defaultRendererKey`.\n */\n presentation?: 'embedded' | 'redirect' | 'either'\n /**\n * @deprecated Use `renderers`.\n */\n embeddedRenderers?: string[]\n }\n}\n\nexport interface EmbeddedPaymentGatewayClientSession {\n type: 'embedded'\n rendererKey: string\n payload?: Record<string, unknown>\n settings?: Record<string, unknown>\n}\n\nexport interface RedirectPaymentGatewayClientSession {\n type: 'redirect'\n redirectUrl: string\n target?: 'self' | 'top'\n}\n\nexport type PaymentGatewayClientSession =\n | EmbeddedPaymentGatewayClientSession\n | RedirectPaymentGatewayClientSession\n\n// \u2500\u2500 Input / Output Types \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport interface CreateSessionInput {\n orderId?: string\n paymentId: string\n tenantId: string\n organizationId: string\n amount: number\n currencyCode: string\n captureMethod?: 'automatic' | 'manual'\n paymentTypes?: string[]\n description?: string\n successUrl?: string\n cancelUrl?: string\n metadata?: Record<string, unknown>\n credentials: Record<string, unknown>\n lineItems?: SessionLineItem[]\n presentation?: PaymentGatewayPresentationRequest\n}\n\nexport interface SessionLineItem {\n name: string\n quantity: number\n unitAmount: number\n currencyCode: string\n}\n\nexport interface CreateSessionResult {\n sessionId: string\n clientSecret?: string\n redirectUrl?: string\n status: UnifiedPaymentStatus\n providerData?: Record<string, unknown>\n clientSession?: PaymentGatewayClientSession\n}\n\nexport interface CaptureInput {\n sessionId: string\n amount?: number\n credentials: Record<string, unknown>\n metadata?: Record<string, unknown>\n}\n\nexport interface CaptureResult {\n status: UnifiedPaymentStatus\n capturedAmount: number\n providerData?: Record<string, unknown>\n}\n\nexport interface RefundInput {\n sessionId: string\n amount?: number\n reason?: string\n credentials: Record<string, unknown>\n metadata?: Record<string, unknown>\n}\n\nexport interface RefundResult {\n refundId: string\n status: UnifiedPaymentStatus\n refundedAmount: number\n providerData?: Record<string, unknown>\n}\n\nexport interface CancelInput {\n sessionId: string\n reason?: string\n credentials: Record<string, unknown>\n}\n\nexport interface CancelResult {\n status: UnifiedPaymentStatus\n providerData?: Record<string, unknown>\n}\n\nexport interface GetStatusInput {\n sessionId: string\n credentials: Record<string, unknown>\n}\n\nexport interface GatewayPaymentStatus {\n status: UnifiedPaymentStatus\n amount: number\n amountReceived: number\n currencyCode: string\n providerData?: Record<string, unknown>\n}\n\nexport interface VerifyWebhookInput {\n rawBody: string | Buffer\n headers: Record<string, string | string[] | undefined>\n credentials: Record<string, unknown>\n}\n\nexport interface WebhookEvent {\n eventType: string\n eventId: string\n data: Record<string, unknown>\n idempotencyKey: string\n timestamp: Date\n}\n\n// \u2500\u2500 Webhook Handler \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport interface WebhookHandlerRegistration {\n handler: (input: VerifyWebhookInput) => Promise<WebhookEvent>\n queue?: string\n readSessionIdHint?: (payload: Record<string, unknown> | null) => string | null\n}\n\n// \u2500\u2500 Adapter Registry Options \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport interface RegisterAdapterOptions {\n version?: string\n}\n\n// \u2500\u2500 Registries \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst ADAPTER_REGISTRY_KEY = '__openMercatoPaymentGatewayAdapters__'\nconst WEBHOOK_REGISTRY_KEY = '__openMercatoPaymentGatewayWebhookHandlers__'\nconst DESCRIPTOR_REGISTRY_KEY = '__openMercatoPaymentGatewayDescriptors__'\n\nfunction getAdapterRegistry(): Map<string, GatewayAdapter> {\n const globalState = globalThis as typeof globalThis & {\n [ADAPTER_REGISTRY_KEY]?: Map<string, GatewayAdapter>\n }\n if (!globalState[ADAPTER_REGISTRY_KEY]) {\n globalState[ADAPTER_REGISTRY_KEY] = new Map<string, GatewayAdapter>()\n }\n return globalState[ADAPTER_REGISTRY_KEY]\n}\n\nfunction getWebhookHandlerRegistry(): Map<string, WebhookHandlerRegistration> {\n const globalState = globalThis as typeof globalThis & {\n [WEBHOOK_REGISTRY_KEY]?: Map<string, WebhookHandlerRegistration>\n }\n if (!globalState[WEBHOOK_REGISTRY_KEY]) {\n globalState[WEBHOOK_REGISTRY_KEY] = new Map<string, WebhookHandlerRegistration>()\n }\n return globalState[WEBHOOK_REGISTRY_KEY]\n}\n\nfunction getDescriptorRegistry(): Map<string, PaymentGatewayDescriptor> {\n const globalState = globalThis as typeof globalThis & {\n [DESCRIPTOR_REGISTRY_KEY]?: Map<string, PaymentGatewayDescriptor>\n }\n if (!globalState[DESCRIPTOR_REGISTRY_KEY]) {\n globalState[DESCRIPTOR_REGISTRY_KEY] = new Map<string, PaymentGatewayDescriptor>()\n }\n return globalState[DESCRIPTOR_REGISTRY_KEY]\n}\n\nfunction adapterKey(providerKey: string, version?: string): string {\n return version ? `${providerKey}:${version}` : providerKey\n}\n\nexport function registerGatewayAdapter(adapter: GatewayAdapter, options?: RegisterAdapterOptions): () => void {\n const adapterRegistry = getAdapterRegistry()\n const key = adapterKey(adapter.providerKey, options?.version)\n adapterRegistry.set(key, adapter)\n if (options?.version) {\n // Also register as default if no default exists\n if (!adapterRegistry.has(adapter.providerKey)) {\n adapterRegistry.set(adapter.providerKey, adapter)\n }\n }\n return () => {\n adapterRegistry.delete(key)\n }\n}\n\nexport function getGatewayAdapter(providerKey: string, version?: string): GatewayAdapter | undefined {\n const adapterRegistry = getAdapterRegistry()\n if (version) {\n return adapterRegistry.get(adapterKey(providerKey, version)) ?? adapterRegistry.get(providerKey)\n }\n return adapterRegistry.get(providerKey)\n}\n\nexport function listGatewayAdapters(): GatewayAdapter[] {\n const adapterRegistry = getAdapterRegistry()\n const seen = new Set<string>()\n const result: GatewayAdapter[] = []\n for (const [key, adapter] of adapterRegistry) {\n if (!key.includes(':') && !seen.has(adapter.providerKey)) {\n seen.add(adapter.providerKey)\n result.push(adapter)\n }\n }\n return result\n}\n\nexport function clearGatewayAdapters(): void {\n getAdapterRegistry().clear()\n}\n\nexport function registerWebhookHandler(\n providerKey: string,\n handler: (input: VerifyWebhookInput) => Promise<WebhookEvent>,\n options?: {\n queue?: string\n readSessionIdHint?: (payload: Record<string, unknown> | null) => string | null\n },\n): () => void {\n const webhookHandlerRegistry = getWebhookHandlerRegistry()\n webhookHandlerRegistry.set(providerKey, {\n handler,\n queue: options?.queue,\n readSessionIdHint: options?.readSessionIdHint,\n })\n return () => {\n webhookHandlerRegistry.delete(providerKey)\n }\n}\n\nexport function getWebhookHandler(providerKey: string): WebhookHandlerRegistration | undefined {\n return getWebhookHandlerRegistry().get(providerKey)\n}\n\nexport function clearWebhookHandlers(): void {\n getWebhookHandlerRegistry().clear()\n}\n\nexport function registerPaymentGatewayDescriptor(descriptor: PaymentGatewayDescriptor): () => void {\n const descriptorRegistry = getDescriptorRegistry()\n descriptorRegistry.set(descriptor.providerKey, descriptor)\n return () => {\n descriptorRegistry.delete(descriptor.providerKey)\n }\n}\n\nexport function getPaymentGatewayDescriptor(providerKey: string): PaymentGatewayDescriptor | undefined {\n return getDescriptorRegistry().get(providerKey)\n}\n\nexport function listPaymentGatewayDescriptors(): PaymentGatewayDescriptor[] {\n return Array.from(getDescriptorRegistry().values()).sort((left, right) => left.label.localeCompare(right.label))\n}\n\nexport function clearPaymentGatewayDescriptors(): void {\n getDescriptorRegistry().clear()\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["// \u2500\u2500 Unified Payment Status \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport type UnifiedPaymentStatus =\n | 'pending'\n | 'authorized'\n | 'captured'\n | 'partially_captured'\n | 'refunded'\n | 'partially_refunded'\n | 'cancelled'\n | 'failed'\n | 'expired'\n | 'unknown'\n\n// \u2500\u2500 GatewayAdapter Interface \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport interface GatewayAdapter {\n readonly providerKey: string\n\n /** Create a payment session / payment intent */\n createSession(input: CreateSessionInput): Promise<CreateSessionResult>\n\n /** Capture an authorized payment */\n capture(input: CaptureInput): Promise<CaptureResult>\n\n /** Refund a captured payment (full or partial) */\n refund(input: RefundInput): Promise<RefundResult>\n\n /** Cancel / void an authorized payment before capture */\n cancel(input: CancelInput): Promise<CancelResult>\n\n /** Get current payment status from provider */\n getStatus(input: GetStatusInput): Promise<GatewayPaymentStatus>\n\n /** Verify and parse an inbound webhook event */\n verifyWebhook(input: VerifyWebhookInput): Promise<WebhookEvent>\n\n /** Map provider status to unified status */\n mapStatus(providerStatus: string, eventType?: string): UnifiedPaymentStatus\n}\n\nexport type PaymentGatewayDescriptorFieldType =\n | 'text'\n | 'number'\n | 'select'\n | 'multiselect'\n | 'boolean'\n | 'textarea'\n | 'secret'\n | 'url'\n\nexport interface PaymentGatewayDescriptorField {\n key: string\n label: string\n type: PaymentGatewayDescriptorFieldType\n description?: string\n required?: boolean\n options?: Array<{ value: string; label: string }>\n}\n\nexport type PaymentGatewayRendererType = 'embedded' | 'redirect'\n\nexport interface PaymentGatewayRendererDescriptor {\n key: string\n label: string\n type: PaymentGatewayRendererType\n description?: string\n supportedPaymentTypes?: '*' | string[]\n settingsFields?: PaymentGatewayDescriptorField[]\n}\n\nexport interface PaymentGatewayPresentationRequest {\n mode?: 'auto' | PaymentGatewayRendererType\n rendererKey?: string\n rendererSettings?: Record<string, unknown>\n}\n\nexport interface PaymentGatewayDescriptor {\n providerKey: string\n label: string\n sessionConfig?: {\n fields?: PaymentGatewayDescriptorField[]\n supportedCurrencies?: '*' | string[]\n supportedPaymentTypes?: Array<{ value: string; label: string }>\n defaultRendererKey?: string\n renderers?: PaymentGatewayRendererDescriptor[]\n /**\n * @deprecated Use `renderers` and `defaultRendererKey`.\n */\n presentation?: 'embedded' | 'redirect' | 'either'\n /**\n * @deprecated Use `renderers`.\n */\n embeddedRenderers?: string[]\n }\n}\n\nexport interface EmbeddedPaymentGatewayClientSession {\n type: 'embedded'\n rendererKey: string\n payload?: Record<string, unknown>\n settings?: Record<string, unknown>\n}\n\nexport interface RedirectPaymentGatewayClientSession {\n type: 'redirect'\n redirectUrl: string\n target?: 'self' | 'top'\n}\n\nexport type PaymentGatewayClientSession =\n | EmbeddedPaymentGatewayClientSession\n | RedirectPaymentGatewayClientSession\n\n// \u2500\u2500 Input / Output Types \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport interface CreateSessionInput {\n orderId?: string\n paymentId: string\n /** Stable operation key for provider-native session creation idempotency. */\n idempotencyKey?: string\n tenantId: string\n organizationId: string\n amount: number\n currencyCode: string\n captureMethod?: 'automatic' | 'manual'\n paymentTypes?: string[]\n description?: string\n successUrl?: string\n cancelUrl?: string\n metadata?: Record<string, unknown>\n credentials: Record<string, unknown>\n lineItems?: SessionLineItem[]\n presentation?: PaymentGatewayPresentationRequest\n}\n\nexport interface SessionLineItem {\n name: string\n quantity: number\n unitAmount: number\n currencyCode: string\n}\n\nexport interface CreateSessionResult {\n sessionId: string\n clientSecret?: string\n redirectUrl?: string\n status: UnifiedPaymentStatus\n providerData?: Record<string, unknown>\n clientSession?: PaymentGatewayClientSession\n}\n\nexport interface CaptureInput {\n sessionId: string\n amount?: number\n credentials: Record<string, unknown>\n metadata?: Record<string, unknown>\n}\n\nexport interface CaptureResult {\n status: UnifiedPaymentStatus\n capturedAmount: number\n providerData?: Record<string, unknown>\n}\n\nexport interface RefundInput {\n sessionId: string\n amount?: number\n reason?: string\n credentials: Record<string, unknown>\n metadata?: Record<string, unknown>\n}\n\nexport interface RefundResult {\n refundId: string\n status: UnifiedPaymentStatus\n refundedAmount: number\n providerData?: Record<string, unknown>\n}\n\nexport interface CancelInput {\n sessionId: string\n reason?: string\n credentials: Record<string, unknown>\n}\n\nexport interface CancelResult {\n status: UnifiedPaymentStatus\n providerData?: Record<string, unknown>\n}\n\nexport interface GetStatusInput {\n sessionId: string\n credentials: Record<string, unknown>\n}\n\nexport interface GatewayPaymentStatus {\n status: UnifiedPaymentStatus\n amount: number\n amountReceived: number\n currencyCode: string\n providerData?: Record<string, unknown>\n}\n\nexport interface VerifyWebhookInput {\n rawBody: string | Buffer\n headers: Record<string, string | string[] | undefined>\n credentials: Record<string, unknown>\n}\n\nexport interface WebhookEvent {\n eventType: string\n eventId: string\n data: Record<string, unknown>\n idempotencyKey: string\n timestamp: Date\n}\n\n// \u2500\u2500 Webhook Handler \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport interface WebhookHandlerRegistration {\n handler: (input: VerifyWebhookInput) => Promise<WebhookEvent>\n queue?: string\n readSessionIdHint?: (payload: Record<string, unknown> | null) => string | null\n}\n\n// \u2500\u2500 Adapter Registry Options \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport interface RegisterAdapterOptions {\n version?: string\n}\n\n// \u2500\u2500 Registries \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst ADAPTER_REGISTRY_KEY = '__openMercatoPaymentGatewayAdapters__'\nconst WEBHOOK_REGISTRY_KEY = '__openMercatoPaymentGatewayWebhookHandlers__'\nconst DESCRIPTOR_REGISTRY_KEY = '__openMercatoPaymentGatewayDescriptors__'\n\nfunction getAdapterRegistry(): Map<string, GatewayAdapter> {\n const globalState = globalThis as typeof globalThis & {\n [ADAPTER_REGISTRY_KEY]?: Map<string, GatewayAdapter>\n }\n if (!globalState[ADAPTER_REGISTRY_KEY]) {\n globalState[ADAPTER_REGISTRY_KEY] = new Map<string, GatewayAdapter>()\n }\n return globalState[ADAPTER_REGISTRY_KEY]\n}\n\nfunction getWebhookHandlerRegistry(): Map<string, WebhookHandlerRegistration> {\n const globalState = globalThis as typeof globalThis & {\n [WEBHOOK_REGISTRY_KEY]?: Map<string, WebhookHandlerRegistration>\n }\n if (!globalState[WEBHOOK_REGISTRY_KEY]) {\n globalState[WEBHOOK_REGISTRY_KEY] = new Map<string, WebhookHandlerRegistration>()\n }\n return globalState[WEBHOOK_REGISTRY_KEY]\n}\n\nfunction getDescriptorRegistry(): Map<string, PaymentGatewayDescriptor> {\n const globalState = globalThis as typeof globalThis & {\n [DESCRIPTOR_REGISTRY_KEY]?: Map<string, PaymentGatewayDescriptor>\n }\n if (!globalState[DESCRIPTOR_REGISTRY_KEY]) {\n globalState[DESCRIPTOR_REGISTRY_KEY] = new Map<string, PaymentGatewayDescriptor>()\n }\n return globalState[DESCRIPTOR_REGISTRY_KEY]\n}\n\nfunction adapterKey(providerKey: string, version?: string): string {\n return version ? `${providerKey}:${version}` : providerKey\n}\n\nexport function registerGatewayAdapter(adapter: GatewayAdapter, options?: RegisterAdapterOptions): () => void {\n const adapterRegistry = getAdapterRegistry()\n const key = adapterKey(adapter.providerKey, options?.version)\n adapterRegistry.set(key, adapter)\n if (options?.version) {\n // Also register as default if no default exists\n if (!adapterRegistry.has(adapter.providerKey)) {\n adapterRegistry.set(adapter.providerKey, adapter)\n }\n }\n return () => {\n adapterRegistry.delete(key)\n }\n}\n\nexport function getGatewayAdapter(providerKey: string, version?: string): GatewayAdapter | undefined {\n const adapterRegistry = getAdapterRegistry()\n if (version) {\n return adapterRegistry.get(adapterKey(providerKey, version)) ?? adapterRegistry.get(providerKey)\n }\n return adapterRegistry.get(providerKey)\n}\n\nexport function listGatewayAdapters(): GatewayAdapter[] {\n const adapterRegistry = getAdapterRegistry()\n const seen = new Set<string>()\n const result: GatewayAdapter[] = []\n for (const [key, adapter] of adapterRegistry) {\n if (!key.includes(':') && !seen.has(adapter.providerKey)) {\n seen.add(adapter.providerKey)\n result.push(adapter)\n }\n }\n return result\n}\n\nexport function clearGatewayAdapters(): void {\n getAdapterRegistry().clear()\n}\n\nexport function registerWebhookHandler(\n providerKey: string,\n handler: (input: VerifyWebhookInput) => Promise<WebhookEvent>,\n options?: {\n queue?: string\n readSessionIdHint?: (payload: Record<string, unknown> | null) => string | null\n },\n): () => void {\n const webhookHandlerRegistry = getWebhookHandlerRegistry()\n webhookHandlerRegistry.set(providerKey, {\n handler,\n queue: options?.queue,\n readSessionIdHint: options?.readSessionIdHint,\n })\n return () => {\n webhookHandlerRegistry.delete(providerKey)\n }\n}\n\nexport function getWebhookHandler(providerKey: string): WebhookHandlerRegistration | undefined {\n return getWebhookHandlerRegistry().get(providerKey)\n}\n\nexport function clearWebhookHandlers(): void {\n getWebhookHandlerRegistry().clear()\n}\n\nexport function registerPaymentGatewayDescriptor(descriptor: PaymentGatewayDescriptor): () => void {\n const descriptorRegistry = getDescriptorRegistry()\n descriptorRegistry.set(descriptor.providerKey, descriptor)\n return () => {\n descriptorRegistry.delete(descriptor.providerKey)\n }\n}\n\nexport function getPaymentGatewayDescriptor(providerKey: string): PaymentGatewayDescriptor | undefined {\n return getDescriptorRegistry().get(providerKey)\n}\n\nexport function listPaymentGatewayDescriptors(): PaymentGatewayDescriptor[] {\n return Array.from(getDescriptorRegistry().values()).sort((left, right) => left.label.localeCompare(right.label))\n}\n\nexport function clearPaymentGatewayDescriptors(): void {\n getDescriptorRegistry().clear()\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/shared",
3
- "version": "0.6.6-develop.6543.1.49fadb4c31",
3
+ "version": "0.6.6-develop.6551.1.1511bfa890",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -97,7 +97,7 @@
97
97
  "@mikro-orm/core": "^7.1.5",
98
98
  "@mikro-orm/decorators": "^7.1.5",
99
99
  "@mikro-orm/postgresql": "^7.1.5",
100
- "@open-mercato/cache": "0.6.6-develop.6543.1.49fadb4c31",
100
+ "@open-mercato/cache": "0.6.6-develop.6551.1.1511bfa890",
101
101
  "@types/sanitize-html": "^2.16.1",
102
102
  "dotenv": "^17.4.2",
103
103
  "pino": "^10.3.1",
@@ -228,6 +228,16 @@ describe('url-safety — createPinnedDnsLookup', () => {
228
228
  expect(cb).toHaveBeenCalledWith(null, '93.184.216.34', 4)
229
229
  })
230
230
 
231
+ it('returns an address array when Node requests all lookup results', () => {
232
+ const pinned = { address: '93.184.216.34', family: 4 }
233
+ const lookup = createPinnedDnsLookup('good.example', pinned)
234
+ const cb = jest.fn()
235
+
236
+ lookup('good.example', { all: true }, cb)
237
+
238
+ expect(cb).toHaveBeenCalledWith(null, [pinned])
239
+ })
240
+
231
241
  it('refuses to resolve a different hostname (defeats redirect to private host via Host header)', () => {
232
242
  const lookup = createPinnedDnsLookup('good.example', { address: '93.184.216.34', family: 4 })
233
243
  const cb = jest.fn()
@@ -305,4 +315,22 @@ describe('url-safety — safeOutboundFetch', () => {
305
315
  )
306
316
  expect(lookupHost).toHaveBeenCalledTimes(1)
307
317
  })
318
+
319
+ it('uses the dispatcher-compatible fetch implementation for pinned requests', async () => {
320
+ const pinnedFetchImpl = jest.fn(async () => new Response('ok', { status: 200 }))
321
+ const lookupHost = jest.fn(async () => [{ address: '93.184.216.34', family: 4 }])
322
+
323
+ const response = await safeOutboundFetch(
324
+ 'https://good.example/hook',
325
+ {},
326
+ { lookupHost, pinnedFetchImpl },
327
+ )
328
+
329
+ expect(response.status).toBe(200)
330
+ expect(pinnedFetchImpl).toHaveBeenCalledTimes(1)
331
+ expect(pinnedFetchImpl).toHaveBeenCalledWith(
332
+ 'https://good.example/hook',
333
+ expect.objectContaining({ dispatcher: expect.any(Object), redirect: 'manual' }),
334
+ )
335
+ })
308
336
  })
@@ -80,12 +80,39 @@ export function isPrivateIPv6(address: string): boolean {
80
80
  if (isIPv4CompatibleIPv6(segments)) return true
81
81
 
82
82
  const embedded = embeddedIPv4FromIPv6(segments)
83
- if (embedded && isPrivateIPv4(embedded)) return true
83
+ if (embedded) return isPrivateIPv4(embedded)
84
84
 
85
85
  const [a] = segments
86
86
  if ((a & 0xfe00) === 0xfc00) return true
87
87
  if ((a & 0xffc0) === 0xfe80) return true
88
88
  if ((a & 0xff00) === 0xff00) return true
89
+ if (
90
+ a === 0x0100 &&
91
+ segments[1] === 0 &&
92
+ segments[2] === 0 &&
93
+ segments[3] === 0
94
+ ) {
95
+ return true
96
+ }
97
+ if (a === 0x2001 && (segments[1] & 0xfe00) === 0) {
98
+ return !isGloballyReachableIetfProtocolAssignment(segments)
99
+ }
100
+ if (a === 0x2001 && segments[1] === 0x0db8) return true
101
+ if ((a & 0xfff0) === 0x3ff0) return true
102
+ // Except for the explicitly handled IPv4 translation ranges above, globally
103
+ // routable unicast IPv6 addresses are allocated from 2000::/3.
104
+ if ((a & 0xe000) !== 0x2000) return true
105
+ return false
106
+ }
107
+
108
+ function isGloballyReachableIetfProtocolAssignment(segments: number[]): boolean {
109
+ const [, b, c, d, e, f, g, h] = segments
110
+ const trailingZero = c === 0 && d === 0 && e === 0 && f === 0 && g === 0
111
+ if (b === 0x0001 && trailingZero && h >= 1 && h <= 3) return true
112
+ if (b === 0x0003) return true
113
+ if (b === 0x0004 && c === 0x0112) return true
114
+ if ((b & 0xfff0) === 0x0020) return true
115
+ if ((b & 0xfff0) === 0x0030) return true
89
116
  return false
90
117
  }
91
118
 
@@ -1,6 +1,6 @@
1
1
  import { lookup } from 'node:dns/promises'
2
2
  import { isIP } from 'node:net'
3
- import { Agent, type Dispatcher } from 'undici'
3
+ import { Agent, fetch as undiciFetch, type Dispatcher } from 'undici'
4
4
  import { isBlockedHostname, isPrivateIpAddress } from './network'
5
5
 
6
6
  export type UrlSafetyReason =
@@ -102,6 +102,8 @@ export function assertStaticallySafeOutboundUrl(
102
102
 
103
103
  export type AssertSafeOutboundUrlOptions = AssertStaticUrlOptions & {
104
104
  lookupHost?: HostLookup
105
+ /** Resolve and return DNS answers even when private addresses are explicitly allowed. */
106
+ pinAllowedPrivate?: boolean
105
107
  }
106
108
 
107
109
  export async function assertSafeOutboundUrl(
@@ -118,7 +120,8 @@ export type ResolveSafeOutboundUrlResult = {
118
120
  hostname: string
119
121
  /**
120
122
  * The validated DNS records, in lookup order. `null` when the hostname is an IP literal
121
- * (no DNS lookup performed) or when `allowPrivate` short-circuited validation.
123
+ * (no DNS lookup performed) or when `allowPrivate` short-circuited validation without
124
+ * `pinAllowedPrivate`.
122
125
  */
123
126
  addresses: ReadonlyArray<ResolvedHostAddress> | null
124
127
  }
@@ -136,14 +139,16 @@ export async function resolveSafeOutboundUrl(
136
139
  const subject = options.subject ?? 'URL'
137
140
  const factory = options.errorFactory ?? defaultErrorFactory
138
141
  const { url, hostname } = parseOutboundUrl(rawUrl, options)
139
- if (options.allowPrivate) return { url, hostname, addresses: null }
142
+ if (options.allowPrivate && !options.pinAllowedPrivate) {
143
+ return { url, hostname, addresses: null }
144
+ }
140
145
 
141
- if (isBlockedHostname(hostname)) {
146
+ if (!options.allowPrivate && isBlockedHostname(hostname)) {
142
147
  throw factory('blocked_hostname', `${subject} host "${hostname}" is not allowed`)
143
148
  }
144
149
 
145
150
  if (isIP(hostname)) {
146
- if (isPrivateIpAddress(hostname)) {
151
+ if (!options.allowPrivate && isPrivateIpAddress(hostname)) {
147
152
  throw factory(
148
153
  'private_ip_literal',
149
154
  `${subject} host "${hostname}" resolves to a private or reserved IP range`,
@@ -179,7 +184,7 @@ export async function resolveSafeOutboundUrl(
179
184
  }
180
185
 
181
186
  for (const record of addresses) {
182
- if (isPrivateIpAddress(record.address)) {
187
+ if (!options.allowPrivate && isPrivateIpAddress(record.address)) {
183
188
  throw factory(
184
189
  'private_ip_resolved',
185
190
  `${subject} host "${hostname}" resolves to a private or reserved IP address (${record.address})`,
@@ -197,16 +202,23 @@ export type SafeOutboundFetchOptions = AssertSafeOutboundUrlOptions & {
197
202
  * not actually open sockets, so DNS pinning is unnecessary and would just complicate mocking.
198
203
  */
199
204
  fetchImpl?: typeof fetch
205
+ /** Test seam for the dispatcher-compatible fetch used by DNS-pinned requests. */
206
+ pinnedFetchImpl?: PinnedFetch
200
207
  }
201
208
 
209
+ export type PinnedFetch = (
210
+ rawUrl: string,
211
+ init: RequestInit & { dispatcher: Dispatcher },
212
+ ) => Promise<Response>
213
+
202
214
  /**
203
215
  * Validates an outbound URL and performs `fetch()` with the connection pinned to a
204
216
  * pre-validated IP address, so DNS cannot be re-resolved between validation and connect
205
217
  * (DNS rebinding). Always defaults to `redirect: 'manual'` — callers MUST decide what to
206
218
  * do with 3xx responses (re-validate the redirect target before following).
207
219
  *
208
- * For IP literal hosts and `allowPrivate=true`, no DNS pinning is performed because there
209
- * is no DNS lookup to defeat.
220
+ * For IP literal hosts no DNS pinning is performed because there is no DNS lookup to defeat.
221
+ * Callers that allow private hostnames can set `pinAllowedPrivate` to retain DNS pinning.
210
222
  */
211
223
  export async function safeOutboundFetch(
212
224
  rawUrl: string,
@@ -237,9 +249,8 @@ export async function safeOutboundFetch(
237
249
  })
238
250
 
239
251
  try {
240
- return await globalThis.fetch(rawUrl, { ...mergedInit, dispatcher } as RequestInit & {
241
- dispatcher: Dispatcher
242
- })
252
+ const pinnedFetch = options.pinnedFetchImpl ?? (undiciFetch as unknown as PinnedFetch)
253
+ return await pinnedFetch(rawUrl, { ...mergedInit, dispatcher })
243
254
  } finally {
244
255
  dispatcher.close().catch(() => {})
245
256
  }
@@ -247,8 +258,12 @@ export async function safeOutboundFetch(
247
258
 
248
259
  export type PinnedDnsLookup = (
249
260
  host: string,
250
- opts: unknown,
251
- cb: (err: NodeJS.ErrnoException | null, address: string, family: number) => void,
261
+ opts: { all?: boolean },
262
+ cb: (
263
+ err: NodeJS.ErrnoException | null,
264
+ address: string | ResolvedHostAddress[],
265
+ family?: number,
266
+ ) => void,
252
267
  ) => void
253
268
 
254
269
  /**
@@ -261,7 +276,7 @@ export function createPinnedDnsLookup(
261
276
  expectedHostname: string,
262
277
  pinned: ResolvedHostAddress,
263
278
  ): PinnedDnsLookup {
264
- return (host, _opts, cb) => {
279
+ return (host, opts, cb) => {
265
280
  if (host !== expectedHostname) {
266
281
  const err: NodeJS.ErrnoException = new Error(
267
282
  `Refusing DNS lookup for unexpected host "${host}" (expected "${expectedHostname}")`,
@@ -270,6 +285,10 @@ export function createPinnedDnsLookup(
270
285
  cb(err, '', 0)
271
286
  return
272
287
  }
288
+ if (opts.all) {
289
+ cb(null, [pinned])
290
+ return
291
+ }
273
292
  cb(null, pinned.address, pinned.family)
274
293
  }
275
294
  }
@@ -117,6 +117,8 @@ export type PaymentGatewayClientSession =
117
117
  export interface CreateSessionInput {
118
118
  orderId?: string
119
119
  paymentId: string
120
+ /** Stable operation key for provider-native session creation idempotency. */
121
+ idempotencyKey?: string
120
122
  tenantId: string
121
123
  organizationId: string
122
124
  amount: number