@adcp/sdk 13.0.0-rc.8 → 13.0.0-rc.9

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.
Files changed (200) hide show
  1. package/dist/lib/adapters/roster-account-store.d.mts +9 -8
  2. package/dist/lib/adapters/roster-account-store.d.ts +9 -8
  3. package/dist/lib/adapters/roster-account-store.d.ts.map +1 -1
  4. package/dist/lib/adapters/roster-account-store.js +2 -1
  5. package/dist/lib/adapters/roster-account-store.js.map +1 -1
  6. package/dist/lib/adapters/roster-account-store.mjs +2 -1
  7. package/dist/lib/adapters/roster-account-store.mjs.map +1 -1
  8. package/dist/lib/net/address-guards.d.ts.map +1 -1
  9. package/dist/lib/net/address-guards.js +6 -0
  10. package/dist/lib/net/address-guards.js.map +1 -1
  11. package/dist/lib/net/address-guards.mjs +6 -0
  12. package/dist/lib/net/address-guards.mjs.map +1 -1
  13. package/dist/lib/net/ssrf-fetch.d.ts.map +1 -1
  14. package/dist/lib/net/ssrf-fetch.js +66 -53
  15. package/dist/lib/net/ssrf-fetch.js.map +1 -1
  16. package/dist/lib/net/ssrf-fetch.mjs +66 -53
  17. package/dist/lib/net/ssrf-fetch.mjs.map +1 -1
  18. package/dist/lib/schemas-data/v2.5/_provenance.json +1 -1
  19. package/dist/lib/server/account-mode.d.mts +2 -1
  20. package/dist/lib/server/account-mode.d.ts +2 -1
  21. package/dist/lib/server/account-mode.d.ts.map +1 -1
  22. package/dist/lib/server/account-mode.js +1 -0
  23. package/dist/lib/server/account-mode.js.map +1 -1
  24. package/dist/lib/server/account-mode.mjs +1 -0
  25. package/dist/lib/server/account-mode.mjs.map +1 -1
  26. package/dist/lib/server/create-adcp-server.d.mts +10 -6
  27. package/dist/lib/server/create-adcp-server.d.ts +10 -6
  28. package/dist/lib/server/create-adcp-server.d.ts.map +1 -1
  29. package/dist/lib/server/create-adcp-server.js +2 -1
  30. package/dist/lib/server/create-adcp-server.js.map +1 -1
  31. package/dist/lib/server/create-adcp-server.mjs +2 -1
  32. package/dist/lib/server/create-adcp-server.mjs.map +1 -1
  33. package/dist/lib/server/decisioning/account.d.mts +16 -11
  34. package/dist/lib/server/decisioning/account.d.ts +16 -11
  35. package/dist/lib/server/decisioning/account.d.ts.map +1 -1
  36. package/dist/lib/server/decisioning/account.js.map +1 -1
  37. package/dist/lib/server/decisioning/account.mjs.map +1 -1
  38. package/dist/lib/server/decisioning/pagination.d.mts +2 -0
  39. package/dist/lib/server/decisioning/pagination.d.ts +2 -0
  40. package/dist/lib/server/decisioning/pagination.d.ts.map +1 -1
  41. package/dist/lib/server/decisioning/pagination.js.map +1 -1
  42. package/dist/lib/server/decisioning/runtime/from-platform.d.mts +20 -14
  43. package/dist/lib/server/decisioning/runtime/from-platform.d.ts +20 -14
  44. package/dist/lib/server/decisioning/runtime/from-platform.d.ts.map +1 -1
  45. package/dist/lib/server/decisioning/runtime/from-platform.js +32 -6
  46. package/dist/lib/server/decisioning/runtime/from-platform.js.map +1 -1
  47. package/dist/lib/server/decisioning/runtime/from-platform.mjs +32 -6
  48. package/dist/lib/server/decisioning/runtime/from-platform.mjs.map +1 -1
  49. package/dist/lib/server/decisioning/specialisms/sales.d.mts +16 -4
  50. package/dist/lib/server/decisioning/specialisms/sales.d.ts +16 -4
  51. package/dist/lib/server/decisioning/specialisms/sales.d.ts.map +1 -1
  52. package/dist/lib/server/decisioning/specialisms/sales.js.map +1 -1
  53. package/dist/lib/server/index.d.ts.map +1 -1
  54. package/dist/lib/server/index.js.map +1 -1
  55. package/dist/lib/server/index.mjs.map +1 -1
  56. package/dist/lib/server/legacy/v5/index.d.mts +2 -1
  57. package/dist/lib/server/legacy/v5/index.d.ts +2 -1
  58. package/dist/lib/server/legacy/v5/index.d.ts.map +1 -1
  59. package/dist/lib/server/legacy/v5/index.js.map +1 -1
  60. package/dist/lib/server/legacy/v5/index.mjs.map +1 -1
  61. package/dist/lib/testing/compliance/comply.d.mts +2 -0
  62. package/dist/lib/testing/compliance/comply.d.ts +2 -0
  63. package/dist/lib/testing/compliance/comply.d.ts.map +1 -1
  64. package/dist/lib/testing/compliance/comply.js +45 -14
  65. package/dist/lib/testing/compliance/comply.js.map +1 -1
  66. package/dist/lib/testing/compliance/comply.mjs +45 -14
  67. package/dist/lib/testing/compliance/comply.mjs.map +1 -1
  68. package/dist/lib/testing/compliance/index.d.mts +1 -1
  69. package/dist/lib/testing/compliance/index.d.ts +1 -1
  70. package/dist/lib/testing/compliance/index.d.ts.map +1 -1
  71. package/dist/lib/testing/compliance/index.js.map +1 -1
  72. package/dist/lib/testing/compliance/index.mjs.map +1 -1
  73. package/dist/lib/testing/compliance/storyboard-tracks.d.ts.map +1 -1
  74. package/dist/lib/testing/compliance/storyboard-tracks.js +9 -1
  75. package/dist/lib/testing/compliance/storyboard-tracks.js.map +1 -1
  76. package/dist/lib/testing/compliance/storyboard-tracks.mjs +9 -1
  77. package/dist/lib/testing/compliance/storyboard-tracks.mjs.map +1 -1
  78. package/dist/lib/testing/compliance/types.d.mts +26 -25
  79. package/dist/lib/testing/compliance/types.d.ts +26 -25
  80. package/dist/lib/testing/compliance/types.d.ts.map +1 -1
  81. package/dist/lib/testing/compliance/types.js.map +1 -1
  82. package/dist/lib/testing/index.d.mts +2 -2
  83. package/dist/lib/testing/index.d.ts +2 -2
  84. package/dist/lib/testing/index.d.ts.map +1 -1
  85. package/dist/lib/testing/index.js +16 -0
  86. package/dist/lib/testing/index.js.map +1 -1
  87. package/dist/lib/testing/index.mjs +16 -0
  88. package/dist/lib/testing/index.mjs.map +1 -1
  89. package/dist/lib/testing/scenarios/media-buy.d.ts.map +1 -1
  90. package/dist/lib/testing/scenarios/media-buy.js +20 -12
  91. package/dist/lib/testing/scenarios/media-buy.js.map +1 -1
  92. package/dist/lib/testing/scenarios/media-buy.mjs +20 -12
  93. package/dist/lib/testing/scenarios/media-buy.mjs.map +1 -1
  94. package/dist/lib/testing/storyboard/creative-assets.d.mts +23 -0
  95. package/dist/lib/testing/storyboard/creative-assets.d.ts +23 -0
  96. package/dist/lib/testing/storyboard/creative-assets.d.ts.map +1 -1
  97. package/dist/lib/testing/storyboard/creative-assets.js +322 -45
  98. package/dist/lib/testing/storyboard/creative-assets.js.map +1 -1
  99. package/dist/lib/testing/storyboard/creative-assets.mjs +321 -45
  100. package/dist/lib/testing/storyboard/creative-assets.mjs.map +1 -1
  101. package/dist/lib/testing/storyboard/index.d.mts +4 -1
  102. package/dist/lib/testing/storyboard/index.d.ts +4 -1
  103. package/dist/lib/testing/storyboard/index.d.ts.map +1 -1
  104. package/dist/lib/testing/storyboard/index.js +20 -0
  105. package/dist/lib/testing/storyboard/index.js.map +1 -1
  106. package/dist/lib/testing/storyboard/index.mjs +19 -0
  107. package/dist/lib/testing/storyboard/index.mjs.map +1 -1
  108. package/dist/lib/testing/storyboard/oauth-metadata-graph/grader.d.mts +21 -0
  109. package/dist/lib/testing/storyboard/oauth-metadata-graph/grader.d.ts +22 -0
  110. package/dist/lib/testing/storyboard/oauth-metadata-graph/grader.d.ts.map +1 -0
  111. package/dist/lib/testing/storyboard/oauth-metadata-graph/grader.js +668 -0
  112. package/dist/lib/testing/storyboard/oauth-metadata-graph/grader.js.map +1 -0
  113. package/dist/lib/testing/storyboard/oauth-metadata-graph/grader.mjs +638 -0
  114. package/dist/lib/testing/storyboard/oauth-metadata-graph/grader.mjs.map +1 -0
  115. package/dist/lib/testing/storyboard/oauth-metadata-graph/index.d.mts +5 -0
  116. package/dist/lib/testing/storyboard/oauth-metadata-graph/index.d.ts +6 -0
  117. package/dist/lib/testing/storyboard/oauth-metadata-graph/index.d.ts.map +1 -0
  118. package/dist/lib/testing/storyboard/oauth-metadata-graph/index.js +44 -0
  119. package/dist/lib/testing/storyboard/oauth-metadata-graph/index.js.map +1 -0
  120. package/dist/lib/testing/storyboard/oauth-metadata-graph/index.mjs +20 -0
  121. package/dist/lib/testing/storyboard/oauth-metadata-graph/index.mjs.map +1 -0
  122. package/dist/lib/testing/storyboard/oauth-metadata-graph/types.d.mts +38 -0
  123. package/dist/lib/testing/storyboard/oauth-metadata-graph/types.d.ts +39 -0
  124. package/dist/lib/testing/storyboard/oauth-metadata-graph/types.d.ts.map +1 -0
  125. package/dist/lib/testing/storyboard/oauth-metadata-graph/types.js +17 -0
  126. package/dist/lib/testing/storyboard/oauth-metadata-graph/types.js.map +1 -0
  127. package/dist/lib/testing/storyboard/oauth-metadata-graph/types.mjs +1 -0
  128. package/dist/lib/testing/storyboard/oauth-metadata-graph/types.mjs.map +1 -0
  129. package/dist/lib/testing/storyboard/oauth-metadata-graph/vector-loader.d.mts +32 -0
  130. package/dist/lib/testing/storyboard/oauth-metadata-graph/vector-loader.d.ts +33 -0
  131. package/dist/lib/testing/storyboard/oauth-metadata-graph/vector-loader.d.ts.map +1 -0
  132. package/dist/lib/testing/storyboard/oauth-metadata-graph/vector-loader.js +105 -0
  133. package/dist/lib/testing/storyboard/oauth-metadata-graph/vector-loader.js.map +1 -0
  134. package/dist/lib/testing/storyboard/oauth-metadata-graph/vector-loader.mjs +80 -0
  135. package/dist/lib/testing/storyboard/oauth-metadata-graph/vector-loader.mjs.map +1 -0
  136. package/dist/lib/testing/storyboard/probes.d.ts.map +1 -1
  137. package/dist/lib/testing/storyboard/probes.js +2 -1
  138. package/dist/lib/testing/storyboard/probes.js.map +1 -1
  139. package/dist/lib/testing/storyboard/probes.mjs +2 -1
  140. package/dist/lib/testing/storyboard/probes.mjs.map +1 -1
  141. package/dist/lib/testing/storyboard/runner.d.ts.map +1 -1
  142. package/dist/lib/testing/storyboard/runner.js +382 -69
  143. package/dist/lib/testing/storyboard/runner.js.map +1 -1
  144. package/dist/lib/testing/storyboard/runner.mjs +387 -70
  145. package/dist/lib/testing/storyboard/runner.mjs.map +1 -1
  146. package/dist/lib/testing/storyboard/seeding.js +3 -1
  147. package/dist/lib/testing/storyboard/seeding.js.map +1 -1
  148. package/dist/lib/testing/storyboard/seeding.mjs +3 -1
  149. package/dist/lib/testing/storyboard/seeding.mjs.map +1 -1
  150. package/dist/lib/testing/storyboard/task-map.d.ts.map +1 -1
  151. package/dist/lib/testing/storyboard/task-map.js +16 -2
  152. package/dist/lib/testing/storyboard/task-map.js.map +1 -1
  153. package/dist/lib/testing/storyboard/task-map.mjs +16 -2
  154. package/dist/lib/testing/storyboard/task-map.mjs.map +1 -1
  155. package/dist/lib/testing/storyboard/trusted-match-context-replay.d.mts +11 -0
  156. package/dist/lib/testing/storyboard/trusted-match-context-replay.d.ts +12 -0
  157. package/dist/lib/testing/storyboard/trusted-match-context-replay.d.ts.map +1 -0
  158. package/dist/lib/testing/storyboard/trusted-match-context-replay.js +340 -0
  159. package/dist/lib/testing/storyboard/trusted-match-context-replay.js.map +1 -0
  160. package/dist/lib/testing/storyboard/trusted-match-context-replay.mjs +316 -0
  161. package/dist/lib/testing/storyboard/trusted-match-context-replay.mjs.map +1 -0
  162. package/dist/lib/testing/storyboard/types.d.mts +84 -15
  163. package/dist/lib/testing/storyboard/types.d.ts +84 -15
  164. package/dist/lib/testing/storyboard/types.d.ts.map +1 -1
  165. package/dist/lib/testing/storyboard/types.js +2 -0
  166. package/dist/lib/testing/storyboard/types.js.map +1 -1
  167. package/dist/lib/testing/storyboard/types.mjs +2 -0
  168. package/dist/lib/testing/storyboard/types.mjs.map +1 -1
  169. package/dist/lib/testing/storyboard/validations.d.mts +3 -0
  170. package/dist/lib/testing/storyboard/validations.d.ts +3 -0
  171. package/dist/lib/testing/storyboard/validations.d.ts.map +1 -1
  172. package/dist/lib/testing/storyboard/validations.js +64 -24
  173. package/dist/lib/testing/storyboard/validations.js.map +1 -1
  174. package/dist/lib/testing/storyboard/validations.mjs +64 -24
  175. package/dist/lib/testing/storyboard/validations.mjs.map +1 -1
  176. package/dist/lib/types/server-payload-aliases.d.mts +8 -2
  177. package/dist/lib/types/server-payload-aliases.d.ts +8 -2
  178. package/dist/lib/types/server-payload-aliases.d.ts.map +1 -1
  179. package/dist/lib/types/server-payload-aliases.js.map +1 -1
  180. package/dist/lib/upstream-recorder/recorder.d.mts +7 -18
  181. package/dist/lib/upstream-recorder/recorder.d.ts +7 -18
  182. package/dist/lib/upstream-recorder/recorder.d.ts.map +1 -1
  183. package/dist/lib/upstream-recorder/recorder.js +17 -10
  184. package/dist/lib/upstream-recorder/recorder.js.map +1 -1
  185. package/dist/lib/upstream-recorder/recorder.mjs +12 -5
  186. package/dist/lib/upstream-recorder/recorder.mjs.map +1 -1
  187. package/dist/lib/utils/redact-secrets.d.ts.map +1 -1
  188. package/dist/lib/utils/redact-secrets.js +7 -1
  189. package/dist/lib/utils/redact-secrets.js.map +1 -1
  190. package/dist/lib/utils/redact-secrets.mjs +7 -1
  191. package/dist/lib/utils/redact-secrets.mjs.map +1 -1
  192. package/dist/lib/version.d.mts +3 -3
  193. package/dist/lib/version.d.ts +3 -3
  194. package/dist/lib/version.js +3 -3
  195. package/dist/lib/version.js.map +1 -1
  196. package/dist/lib/version.mjs +3 -3
  197. package/dist/lib/version.mjs.map +1 -1
  198. package/docs/guides/BUILD-AN-AGENT.md +4 -2
  199. package/docs/llms.txt +1 -1
  200. package/package.json +2 -2
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/lib/net/ssrf-fetch.ts"],"sourcesContent":["/**\n * SSRF-safe HTTP fetch primitive.\n *\n * Used by compliance probes, storyboard runners, and (future) JWKS /\n * revocation-list resolvers — anywhere the library dispatches a request to a\n * URL that came from counterparty-controlled data and therefore might point at\n * the host's private network or a cloud metadata endpoint.\n *\n * Guarantees:\n * - Scheme: only `https:` by default; `http:` allowed under the dev-opt-in\n * `allowPrivateIp` flag. `file:`, `ftp:`, `data:`, etc. are always refused.\n * - DNS: resolves every A/AAAA record once, validates the full set, then\n * pins the outbound connection to the first validated address via an\n * undici `Agent` whose `connect.lookup` returns the pinned tuple.\n * Defeats DNS rebinding (attacker returns a public address to the guard\n * lookup and a private address to the connect-time lookup) and any other\n * TOCTOU gap between validation and connect.\n * - IMDS (`169.254.169.254`, `fe80::`) stays refused even under\n * `allowPrivateIp` — cloud metadata exfiltration is never a legitimate\n * dev-loop use case.\n * - Redirects are not followed (`redirect: 'manual'`). The 3xx response is\n * returned with `Location` populated so callers can inspect it, but they\n * MUST NOT re-dispatch to the `Location` URL themselves — that bypasses\n * every guard above. To follow a redirect safely, re-invoke\n * `ssrfSafeFetch` with the new URL so it runs through validation again.\n * - Response body is buffered up to `maxBodyBytes` (default 64 KiB) and\n * returned as raw bytes; the dispatcher is torn down after reading so\n * connection-reuse can't carry an attacker-controlled keepalive.\n *\n * Returns a fully-buffered result. Callers that need streaming or large bodies\n * should extend this primitive rather than bypass it.\n */\nimport { type LookupAddress, type LookupOptions } from 'dns';\nimport { lookup as dnsLookupAsync } from 'dns/promises';\nimport { Agent, fetch as undiciFetch } from 'undici';\nimport { isAlwaysBlocked, isPrivateIp } from './address-guards';\n\nconst DEFAULT_TIMEOUT_MS = 10_000;\nconst DEFAULT_MAX_BODY_BYTES = 64 * 1024;\nconst ALLOWED_SCHEMES = new Set(['https:', 'http:']);\n\nexport type SsrfRefusedCode =\n | 'invalid_url'\n | 'scheme_not_allowed'\n | 'non_https_without_opt_in'\n | 'dns_lookup_failed'\n | 'dns_empty'\n | 'always_blocked_address'\n | 'private_address'\n | 'body_exceeds_limit';\n\n/**\n * SSRF refusal codes that indicate runtime/network conditions rather than\n * policy refusal. Callers that want to fall back to a \"host unreachable\" or\n * \"treat as unknown\" path on these codes — rather than surfacing the\n * SsrfRefusedError to their own caller — can intersect against this set:\n *\n * ```ts\n * if (err instanceof SsrfRefusedError && SSRF_TRANSIENT_CODES.has(err.code)) {\n * // network condition, not a policy attack — treat as unreachable\n * } else if (err instanceof SsrfRefusedError) {\n * // policy refusal — must propagate; silently downgrading to \"unreachable\"\n * // reintroduces the catch-swallow class flagged in adcp-client#1618 review\n * throw err;\n * }\n * ```\n *\n * - `dns_lookup_failed`, `dns_empty`: name does not resolve. CLI fixtures\n * use `*.example.invalid` to provoke these — preserving the runtime-error\n * path matches pre-`ssrfSafeFetch` native-fetch behavior.\n * - `body_exceeds_limit`: response started OK (validated address, scheme,\n * etc.) but exceeded the caller's defensive cap. The host is real and\n * knows the URL; classifying as \"policy attack\" would be a misread.\n *\n * NOT in this set: `always_blocked_address`, `private_address`,\n * `scheme_not_allowed`, `non_https_without_opt_in`, `invalid_url`. Those\n * are policy refusals — silently downgrading them to \"unreachable\" or\n * \"suspect\" reintroduces the SSRF gap the gate was added to close.\n *\n * Added in adcp-client#1633.\n */\nexport const SSRF_TRANSIENT_CODES: ReadonlySet<SsrfRefusedCode> = new Set([\n 'dns_lookup_failed',\n 'dns_empty',\n 'body_exceeds_limit',\n]);\n\n/**\n * Thrown when the SSRF guard refuses a request before (or during) the fetch.\n * Network failures after the guard passes are not wrapped in this type —\n * callers that want to distinguish \"we refused this\" from \"the remote broke\"\n * can `instanceof SsrfRefusedError` the catch.\n */\nexport class SsrfRefusedError extends Error {\n readonly code: SsrfRefusedCode;\n readonly url: string;\n readonly hostname?: string;\n readonly address?: string;\n\n constructor(code: SsrfRefusedCode, message: string, meta: { url: string; hostname?: string; address?: string }) {\n super(message);\n this.name = 'SsrfRefusedError';\n this.code = code;\n this.url = meta.url;\n this.hostname = meta.hostname;\n this.address = meta.address;\n }\n}\n\nexport interface SsrfFetchOptions {\n method?: string;\n /** Lowercased keys preferred; values preserved verbatim. */\n headers?: Record<string, string>;\n body?: string | Uint8Array;\n /** Allow `http://` and private/loopback targets. Default false. */\n allowPrivateIp?: boolean;\n /** Overall timeout including DNS + connect + body read. Default 10_000 ms. */\n timeoutMs?: number;\n /** Hard cap on response body bytes. Default 64 KiB. */\n maxBodyBytes?: number;\n /** Caller-provided abort signal, composed with the internal timeout. */\n signal?: AbortSignal;\n /**\n * Trusted scoped fetch implementation. URL validation and address\n * classification still run before invocation, but DNS pinning is delegated\n * to this implementation because custom fetchers do not accept undici\n * dispatchers. The caller MUST enforce DNS-rebinding protection itself.\n *\n * This deliberately explicit name prevents callers from mistaking a custom\n * transport for the internally DNS-pinned path.\n */\n trustedFetchFn?: typeof fetch;\n}\n\nexport interface SsrfFetchResult {\n url: string;\n status: number;\n /** Response headers, lowercased. */\n headers: Record<string, string>;\n /** Raw response body bytes (empty Uint8Array if no body). */\n body: Uint8Array;\n /** The validated IP address. The connection used it only when `connectionPinned` is true. */\n pinnedAddress: string;\n pinnedFamily: 4 | 6;\n /** Whether the internal undici dispatcher pinned the connection to `pinnedAddress`. */\n connectionPinned: boolean;\n}\n\n/**\n * GET/POST/etc. a URL with SSRF guarding + DNS pinning. Throws\n * {@link SsrfRefusedError} when the guard refuses; other errors (network\n * timeouts, remote resets) propagate as the native fetch error.\n */\nexport async function ssrfSafeFetch(url: string, options: SsrfFetchOptions = {}): Promise<SsrfFetchResult> {\n throwIfSignalAborted(options.signal);\n const allowPrivateIp = options.allowPrivateIp === true;\n const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;\n\n let parsed: URL;\n try {\n parsed = new URL(url);\n } catch {\n throw new SsrfRefusedError('invalid_url', `Invalid URL: ${url}`, { url });\n }\n\n // `URL.hostname` wraps IPv6 literals in brackets (`https://[::1]/` →\n // `[::1]`). `dns.lookup` and the address classifier both want the bare\n // form; strip brackets here so IPv6 localhost URLs work under\n // `allowPrivateIp` and so bracketed literals can't slip past classification\n // on a future Node release that tolerates them.\n const hostname = parsed.hostname.replace(/^\\[|\\]$/g, '');\n\n if (!ALLOWED_SCHEMES.has(parsed.protocol)) {\n throw new SsrfRefusedError(\n 'scheme_not_allowed',\n `Refusing to fetch URL with unsupported scheme: ${parsed.protocol}`,\n { url, hostname }\n );\n }\n if (parsed.protocol !== 'https:' && !allowPrivateIp) {\n throw new SsrfRefusedError('non_https_without_opt_in', `Refusing to fetch non-HTTPS URL: ${url}`, {\n url,\n hostname,\n });\n }\n\n let addresses: { address: string; family: number }[];\n try {\n addresses = await dnsLookupAsync(hostname, { all: true });\n } catch (err) {\n throwIfSignalAborted(options.signal);\n throw new SsrfRefusedError(\n 'dns_lookup_failed',\n `DNS lookup failed for ${hostname}: ${err instanceof Error ? err.message : String(err)}`,\n { url, hostname }\n );\n }\n throwIfSignalAborted(options.signal);\n if (addresses.length === 0) {\n throw new SsrfRefusedError('dns_empty', `DNS returned no addresses for ${hostname}`, {\n url,\n hostname,\n });\n }\n // Error messages intentionally do NOT include the resolved IP — a\n // counterparty-supplied hostname that resolves into the caller's internal\n // address space would otherwise leak network topology into compliance\n // reports and log aggregators. The address is still available on the\n // thrown error's `.address` field for programmatic debugging.\n for (const a of addresses) {\n if (isAlwaysBlocked(a.address)) {\n throw new SsrfRefusedError(\n 'always_blocked_address',\n `Refusing to fetch: ${hostname} resolves to an always-blocked address (link-local or cloud metadata)`,\n { url, hostname, address: a.address }\n );\n }\n }\n if (!allowPrivateIp) {\n for (const a of addresses) {\n if (isPrivateIp(a.address)) {\n throw new SsrfRefusedError(\n 'private_address',\n `Refusing to fetch: ${hostname} resolves to a private/loopback address`,\n { url, hostname, address: a.address }\n );\n }\n }\n }\n\n const pinned = addresses[0]!;\n const pinnedFamily = pinned.family === 6 ? 6 : 4;\n const dispatcher = new Agent({\n connect: {\n // All addresses were validated above; pin the connect to the first. The\n // custom lookup also means undici won't re-resolve and pick up a rebind.\n // undici's Agent may call lookup with `{ all: true }` (it does for HTTPS\n // targets under Node 22+), which expects the array form of the callback.\n lookup: (\n _h: string,\n opts: LookupOptions | undefined,\n cb: (err: NodeJS.ErrnoException | null, address: string | LookupAddress[], family?: number) => void\n ) => {\n if (opts?.all) {\n cb(null, [{ address: pinned.address, family: pinnedFamily }]);\n } else {\n cb(null, pinned.address, pinnedFamily);\n }\n },\n },\n });\n\n const ac = new AbortController();\n const onExternalAbort = () => ac.abort(options.signal?.reason);\n options.signal?.addEventListener('abort', onExternalAbort, { once: true });\n if (options.signal?.aborted) onExternalAbort();\n const timer = setTimeout(() => ac.abort(new Error('ssrf-fetch: timeout')), timeoutMs);\n\n try {\n const res = options.trustedFetchFn\n ? await options.trustedFetchFn(url, {\n method: options.method ?? 'GET',\n redirect: 'manual',\n signal: ac.signal,\n headers: options.headers,\n ...(options.body !== undefined && { body: options.body as BodyInit }),\n })\n : await undiciFetch(url, {\n method: options.method ?? 'GET',\n redirect: 'manual',\n signal: ac.signal,\n headers: options.headers,\n dispatcher,\n ...(options.body !== undefined && { body: options.body }),\n });\n\n const headers: Record<string, string> = {};\n res.headers.forEach((v, k) => {\n headers[k.toLowerCase()] = v;\n });\n\n const reader = res.body?.getReader();\n if (!reader) {\n return {\n url,\n status: res.status,\n headers,\n body: new Uint8Array(),\n pinnedAddress: pinned.address,\n pinnedFamily,\n connectionPinned: !options.trustedFetchFn,\n };\n }\n\n const chunks: Uint8Array[] = [];\n let bytes = 0;\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n bytes += value.byteLength;\n if (bytes > maxBodyBytes) {\n await reader.cancel();\n throw new SsrfRefusedError('body_exceeds_limit', `Response body exceeded ${maxBodyBytes} bytes`, {\n url,\n hostname: parsed.hostname,\n address: pinned.address,\n });\n }\n chunks.push(value);\n }\n\n const buf = new Uint8Array(bytes);\n let offset = 0;\n for (const c of chunks) {\n buf.set(c, offset);\n offset += c.byteLength;\n }\n\n return {\n url,\n status: res.status,\n headers,\n body: buf,\n pinnedAddress: pinned.address,\n pinnedFamily,\n connectionPinned: !options.trustedFetchFn,\n };\n } finally {\n clearTimeout(timer);\n options.signal?.removeEventListener('abort', onExternalAbort);\n await dispatcher.close().catch(() => {});\n }\n}\n\nfunction throwIfSignalAborted(signal?: AbortSignal): void {\n if (!signal?.aborted) return;\n if (signal.reason instanceof Error) throw signal.reason;\n const error = new Error(signal.reason == null ? 'The operation was aborted' : String(signal.reason));\n error.name = 'AbortError';\n throw error;\n}\n\n/**\n * Decode a UTF-8 byte buffer as JSON when the content-type declares JSON, or\n * fall back to a UTF-8 string. Handy shared helper for probe-style call sites\n * that don't care about binary bodies.\n */\nexport function decodeBodyAsJsonOrText(body: Uint8Array, contentType: string | undefined): unknown {\n if (body.byteLength === 0) return null;\n const text = Buffer.from(body.buffer, body.byteOffset, body.byteLength).toString('utf8');\n if (contentType?.toLowerCase().includes('application/json')) {\n try {\n return JSON.parse(text);\n } catch {\n return text;\n }\n }\n return text;\n}\n"],"mappings":"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","names":["dnsLookupAsync","undiciFetch"]}
1
+ {"version":3,"sources":["../../../src/lib/net/ssrf-fetch.ts"],"sourcesContent":["/**\n * SSRF-safe HTTP fetch primitive.\n *\n * Used by compliance probes, storyboard runners, and (future) JWKS /\n * revocation-list resolvers — anywhere the library dispatches a request to a\n * URL that came from counterparty-controlled data and therefore might point at\n * the host's private network or a cloud metadata endpoint.\n *\n * Guarantees:\n * - Scheme: only `https:` by default; `http:` allowed under the dev-opt-in\n * `allowPrivateIp` flag. `file:`, `ftp:`, `data:`, etc. are always refused.\n * - DNS: resolves every A/AAAA record once, validates the full set, then\n * pins the outbound connection to the first validated address via an\n * undici `Agent` whose `connect.lookup` returns the pinned tuple.\n * Defeats DNS rebinding (attacker returns a public address to the guard\n * lookup and a private address to the connect-time lookup) and any other\n * TOCTOU gap between validation and connect.\n * - IMDS (`169.254.169.254`, `fe80::`) stays refused even under\n * `allowPrivateIp` — cloud metadata exfiltration is never a legitimate\n * dev-loop use case.\n * - Redirects are not followed (`redirect: 'manual'`). The 3xx response is\n * returned with `Location` populated so callers can inspect it, but they\n * MUST NOT re-dispatch to the `Location` URL themselves — that bypasses\n * every guard above. To follow a redirect safely, re-invoke\n * `ssrfSafeFetch` with the new URL so it runs through validation again.\n * - Response body is buffered up to `maxBodyBytes` (default 64 KiB) and\n * returned as raw bytes; the dispatcher is torn down after reading so\n * connection-reuse can't carry an attacker-controlled keepalive.\n *\n * Returns a fully-buffered result. Callers that need streaming or large bodies\n * should extend this primitive rather than bypass it.\n */\nimport { type LookupAddress, type LookupOptions } from 'dns';\nimport { lookup as dnsLookupAsync } from 'dns/promises';\nimport { Agent, fetch as undiciFetch } from 'undici';\nimport { isAlwaysBlocked, isPrivateIp } from './address-guards';\n\nconst DEFAULT_TIMEOUT_MS = 10_000;\nconst DEFAULT_MAX_BODY_BYTES = 64 * 1024;\nconst ALLOWED_SCHEMES = new Set(['https:', 'http:']);\n\nexport type SsrfRefusedCode =\n | 'invalid_url'\n | 'scheme_not_allowed'\n | 'non_https_without_opt_in'\n | 'dns_lookup_failed'\n | 'dns_empty'\n | 'always_blocked_address'\n | 'private_address'\n | 'body_exceeds_limit';\n\n/**\n * SSRF refusal codes that indicate runtime/network conditions rather than\n * policy refusal. Callers that want to fall back to a \"host unreachable\" or\n * \"treat as unknown\" path on these codes — rather than surfacing the\n * SsrfRefusedError to their own caller — can intersect against this set:\n *\n * ```ts\n * if (err instanceof SsrfRefusedError && SSRF_TRANSIENT_CODES.has(err.code)) {\n * // network condition, not a policy attack — treat as unreachable\n * } else if (err instanceof SsrfRefusedError) {\n * // policy refusal — must propagate; silently downgrading to \"unreachable\"\n * // reintroduces the catch-swallow class flagged in adcp-client#1618 review\n * throw err;\n * }\n * ```\n *\n * - `dns_lookup_failed`, `dns_empty`: name does not resolve. CLI fixtures\n * use `*.example.invalid` to provoke these — preserving the runtime-error\n * path matches pre-`ssrfSafeFetch` native-fetch behavior.\n * - `body_exceeds_limit`: response started OK (validated address, scheme,\n * etc.) but exceeded the caller's defensive cap. The host is real and\n * knows the URL; classifying as \"policy attack\" would be a misread.\n *\n * NOT in this set: `always_blocked_address`, `private_address`,\n * `scheme_not_allowed`, `non_https_without_opt_in`, `invalid_url`. Those\n * are policy refusals — silently downgrading them to \"unreachable\" or\n * \"suspect\" reintroduces the SSRF gap the gate was added to close.\n *\n * Added in adcp-client#1633.\n */\nexport const SSRF_TRANSIENT_CODES: ReadonlySet<SsrfRefusedCode> = new Set([\n 'dns_lookup_failed',\n 'dns_empty',\n 'body_exceeds_limit',\n]);\n\n/**\n * Thrown when the SSRF guard refuses a request before (or during) the fetch.\n * Network failures after the guard passes are not wrapped in this type —\n * callers that want to distinguish \"we refused this\" from \"the remote broke\"\n * can `instanceof SsrfRefusedError` the catch.\n */\nexport class SsrfRefusedError extends Error {\n readonly code: SsrfRefusedCode;\n readonly url: string;\n readonly hostname?: string;\n readonly address?: string;\n\n constructor(code: SsrfRefusedCode, message: string, meta: { url: string; hostname?: string; address?: string }) {\n super(message);\n this.name = 'SsrfRefusedError';\n this.code = code;\n this.url = meta.url;\n this.hostname = meta.hostname;\n this.address = meta.address;\n }\n}\n\nexport interface SsrfFetchOptions {\n method?: string;\n /** Lowercased keys preferred; values preserved verbatim. */\n headers?: Record<string, string>;\n body?: string | Uint8Array;\n /** Allow `http://` and private/loopback targets. Default false. */\n allowPrivateIp?: boolean;\n /** Overall timeout including DNS + connect + body read. Default 10_000 ms. */\n timeoutMs?: number;\n /** Hard cap on response body bytes. Default 64 KiB. */\n maxBodyBytes?: number;\n /** Caller-provided abort signal, composed with the internal timeout. */\n signal?: AbortSignal;\n /**\n * Trusted scoped fetch implementation. URL validation and address\n * classification still run before invocation, but DNS pinning is delegated\n * to this implementation because custom fetchers do not accept undici\n * dispatchers. The caller MUST enforce DNS-rebinding protection itself.\n *\n * This deliberately explicit name prevents callers from mistaking a custom\n * transport for the internally DNS-pinned path.\n */\n trustedFetchFn?: typeof fetch;\n}\n\nexport interface SsrfFetchResult {\n url: string;\n status: number;\n /** Response headers, lowercased. */\n headers: Record<string, string>;\n /** Raw response body bytes (empty Uint8Array if no body). */\n body: Uint8Array;\n /** The validated IP address. The connection used it only when `connectionPinned` is true. */\n pinnedAddress: string;\n pinnedFamily: 4 | 6;\n /** Whether the internal undici dispatcher pinned the connection to `pinnedAddress`. */\n connectionPinned: boolean;\n}\n\n/**\n * GET/POST/etc. a URL with SSRF guarding + DNS pinning. Throws\n * {@link SsrfRefusedError} when the guard refuses; other errors (network\n * timeouts, remote resets) propagate as the native fetch error.\n */\nexport async function ssrfSafeFetch(url: string, options: SsrfFetchOptions = {}): Promise<SsrfFetchResult> {\n throwIfSignalAborted(options.signal);\n const allowPrivateIp = options.allowPrivateIp === true;\n const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;\n\n let parsed: URL;\n try {\n parsed = new URL(url);\n } catch {\n throw new SsrfRefusedError('invalid_url', `Invalid URL: ${url}`, { url });\n }\n\n // `URL.hostname` wraps IPv6 literals in brackets (`https://[::1]/` →\n // `[::1]`). `dns.lookup` and the address classifier both want the bare\n // form; strip brackets here so IPv6 localhost URLs work under\n // `allowPrivateIp` and so bracketed literals can't slip past classification\n // on a future Node release that tolerates them.\n const hostname = parsed.hostname.replace(/^\\[|\\]$/g, '');\n\n if (!ALLOWED_SCHEMES.has(parsed.protocol)) {\n throw new SsrfRefusedError(\n 'scheme_not_allowed',\n `Refusing to fetch URL with unsupported scheme: ${parsed.protocol}`,\n { url, hostname }\n );\n }\n if (parsed.protocol !== 'https:' && !allowPrivateIp) {\n throw new SsrfRefusedError('non_https_without_opt_in', `Refusing to fetch non-HTTPS URL: ${url}`, {\n url,\n hostname,\n });\n }\n\n // Start the request deadline before DNS. `dns.lookup()` does not accept an\n // AbortSignal, so race it against the same controller used for connect and\n // body reads. The lookup may continue inside libuv after an abort, but its\n // eventual settlement is observed by `raceWithAbort` and cannot keep this\n // caller pending or produce an unhandled rejection.\n const ac = new AbortController();\n const onExternalAbort = () => ac.abort(options.signal?.reason);\n options.signal?.addEventListener('abort', onExternalAbort, { once: true });\n if (options.signal?.aborted) onExternalAbort();\n const timer = setTimeout(() => ac.abort(new Error('ssrf-fetch: timeout')), timeoutMs);\n let dispatcher: Agent | undefined;\n\n try {\n let addresses: { address: string; family: number }[];\n try {\n addresses = await raceWithAbort(dnsLookupAsync(hostname, { all: true }), ac.signal);\n } catch (err) {\n throwIfSignalAborted(ac.signal);\n throw new SsrfRefusedError(\n 'dns_lookup_failed',\n `DNS lookup failed for ${hostname}: ${err instanceof Error ? err.message : String(err)}`,\n { url, hostname }\n );\n }\n throwIfSignalAborted(ac.signal);\n if (addresses.length === 0) {\n throw new SsrfRefusedError('dns_empty', `DNS returned no addresses for ${hostname}`, {\n url,\n hostname,\n });\n }\n // Error messages intentionally do NOT include the resolved IP — a\n // counterparty-supplied hostname that resolves into the caller's internal\n // address space would otherwise leak network topology into compliance\n // reports and log aggregators. The address is still available on the\n // thrown error's `.address` field for programmatic debugging.\n for (const a of addresses) {\n if (isAlwaysBlocked(a.address)) {\n throw new SsrfRefusedError(\n 'always_blocked_address',\n `Refusing to fetch: ${hostname} resolves to an always-blocked address (link-local or cloud metadata)`,\n { url, hostname, address: a.address }\n );\n }\n }\n if (!allowPrivateIp) {\n for (const a of addresses) {\n if (isPrivateIp(a.address)) {\n throw new SsrfRefusedError(\n 'private_address',\n `Refusing to fetch: ${hostname} resolves to a private/loopback address`,\n { url, hostname, address: a.address }\n );\n }\n }\n }\n\n const pinned = addresses[0]!;\n const pinnedFamily = pinned.family === 6 ? 6 : 4;\n dispatcher = new Agent({\n connect: {\n timeout: Math.min(5_000, timeoutMs),\n // All addresses were validated above; pin the connect to the first. The\n // custom lookup also means undici won't re-resolve and pick up a rebind.\n // undici's Agent may call lookup with `{ all: true }` (it does for HTTPS\n // targets under Node 22+), which expects the array form of the callback.\n lookup: (\n _h: string,\n opts: LookupOptions | undefined,\n cb: (err: NodeJS.ErrnoException | null, address: string | LookupAddress[], family?: number) => void\n ) => {\n if (opts?.all) {\n cb(null, [{ address: pinned.address, family: pinnedFamily }]);\n } else {\n cb(null, pinned.address, pinnedFamily);\n }\n },\n },\n });\n\n const res = options.trustedFetchFn\n ? await options.trustedFetchFn(url, {\n method: options.method ?? 'GET',\n redirect: 'manual',\n signal: ac.signal,\n headers: options.headers,\n ...(options.body !== undefined && { body: options.body as BodyInit }),\n })\n : await undiciFetch(url, {\n method: options.method ?? 'GET',\n redirect: 'manual',\n signal: ac.signal,\n headers: options.headers,\n dispatcher,\n ...(options.body !== undefined && { body: options.body }),\n });\n\n const headers: Record<string, string> = {};\n res.headers.forEach((v, k) => {\n headers[k.toLowerCase()] = v;\n });\n\n const reader = res.body?.getReader();\n if (!reader) {\n return {\n url,\n status: res.status,\n headers,\n body: new Uint8Array(),\n pinnedAddress: pinned.address,\n pinnedFamily,\n connectionPinned: !options.trustedFetchFn,\n };\n }\n\n const chunks: Uint8Array[] = [];\n let bytes = 0;\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n bytes += value.byteLength;\n if (bytes > maxBodyBytes) {\n await reader.cancel();\n throw new SsrfRefusedError('body_exceeds_limit', `Response body exceeded ${maxBodyBytes} bytes`, {\n url,\n hostname: parsed.hostname,\n address: pinned.address,\n });\n }\n chunks.push(value);\n }\n\n const buf = new Uint8Array(bytes);\n let offset = 0;\n for (const c of chunks) {\n buf.set(c, offset);\n offset += c.byteLength;\n }\n\n return {\n url,\n status: res.status,\n headers,\n body: buf,\n pinnedAddress: pinned.address,\n pinnedFamily,\n connectionPinned: !options.trustedFetchFn,\n };\n } finally {\n clearTimeout(timer);\n options.signal?.removeEventListener('abort', onExternalAbort);\n await dispatcher?.close().catch(() => {});\n }\n}\n\n/**\n * Await an operation that has no native AbortSignal support without allowing\n * it to retain the caller after cancellation. Attaching both fulfillment and\n * rejection handlers also observes a late operation failure after the abort\n * branch has already won the race.\n */\nfunction raceWithAbort<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {\n throwIfSignalAborted(signal);\n return new Promise<T>((resolve, reject) => {\n const onAbort = () => reject(signalAbortError(signal));\n signal.addEventListener('abort', onAbort, { once: true });\n operation.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort));\n });\n}\n\nfunction signalAbortError(signal: AbortSignal): Error {\n if (signal.reason instanceof Error) return signal.reason;\n const error = new Error(signal.reason == null ? 'The operation was aborted' : String(signal.reason));\n error.name = 'AbortError';\n return error;\n}\n\nfunction throwIfSignalAborted(signal?: AbortSignal): void {\n if (!signal?.aborted) return;\n throw signalAbortError(signal);\n}\n\n/**\n * Decode a UTF-8 byte buffer as JSON when the content-type declares JSON, or\n * fall back to a UTF-8 string. Handy shared helper for probe-style call sites\n * that don't care about binary bodies.\n */\nexport function decodeBodyAsJsonOrText(body: Uint8Array, contentType: string | undefined): unknown {\n if (body.byteLength === 0) return null;\n const text = Buffer.from(body.buffer, body.byteOffset, body.byteLength).toString('utf8');\n if (contentType?.toLowerCase().includes('application/json')) {\n try {\n return JSON.parse(text);\n } catch {\n return text;\n }\n }\n return text;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAiCA,sBAAyC;AACzC,oBAA4C;AAC5C,4BAA6C;AAE7C,MAAM,qBAAqB;AAC3B,MAAM,yBAAyB,KAAK;AACpC,MAAM,kBAAkB,oBAAI,IAAI,CAAC,UAAU,OAAO,CAAC;AA0C5C,MAAM,uBAAqD,oBAAI,IAAI;AAAA,EACxE;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAQM,MAAM,yBAAyB,MAAM;AAAA,EACjC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EAET,YAAY,MAAuB,SAAiB,MAA4D;AAC9G,UAAM,OAAO;AACb,SAAK,OAAO;AACZ,SAAK,OAAO;AACZ,SAAK,MAAM,KAAK;AAChB,SAAK,WAAW,KAAK;AACrB,SAAK,UAAU,KAAK;AAAA,EACtB;AACF;AA8CA,eAAsB,cAAc,KAAa,UAA4B,CAAC,GAA6B;AACzG,uBAAqB,QAAQ,MAAM;AACnC,QAAM,iBAAiB,QAAQ,mBAAmB;AAClD,QAAM,YAAY,QAAQ,aAAa;AACvC,QAAM,eAAe,QAAQ,gBAAgB;AAE7C,MAAI;AACJ,MAAI;AACF,aAAS,IAAI,IAAI,GAAG;AAAA,EACtB,QAAQ;AACN,UAAM,IAAI,iBAAiB,eAAe,gBAAgB,GAAG,IAAI,EAAE,IAAI,CAAC;AAAA,EAC1E;AAOA,QAAM,WAAW,OAAO,SAAS,QAAQ,YAAY,EAAE;AAEvD,MAAI,CAAC,gBAAgB,IAAI,OAAO,QAAQ,GAAG;AACzC,UAAM,IAAI;AAAA,MACR;AAAA,MACA,kDAAkD,OAAO,QAAQ;AAAA,MACjE,EAAE,KAAK,SAAS;AAAA,IAClB;AAAA,EACF;AACA,MAAI,OAAO,aAAa,YAAY,CAAC,gBAAgB;AACnD,UAAM,IAAI,iBAAiB,4BAA4B,oCAAoC,GAAG,IAAI;AAAA,MAChG;AAAA,MACA;AAAA,IACF,CAAC;AAAA,EACH;AAOA,QAAM,KAAK,IAAI,gBAAgB;AAC/B,QAAM,kBAAkB,MAAM,GAAG,MAAM,QAAQ,QAAQ,MAAM;AAC7D,UAAQ,QAAQ,iBAAiB,SAAS,iBAAiB,EAAE,MAAM,KAAK,CAAC;AACzE,MAAI,QAAQ,QAAQ,QAAS,iBAAgB;AAC7C,QAAM,QAAQ,WAAW,MAAM,GAAG,MAAM,IAAI,MAAM,qBAAqB,CAAC,GAAG,SAAS;AACpF,MAAI;AAEJ,MAAI;AACF,QAAI;AACJ,QAAI;AACF,kBAAY,MAAM,kBAAc,gBAAAA,QAAe,UAAU,EAAE,KAAK,KAAK,CAAC,GAAG,GAAG,MAAM;AAAA,IACpF,SAAS,KAAK;AACZ,2BAAqB,GAAG,MAAM;AAC9B,YAAM,IAAI;AAAA,QACR;AAAA,QACA,yBAAyB,QAAQ,KAAK,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG,CAAC;AAAA,QACtF,EAAE,KAAK,SAAS;AAAA,MAClB;AAAA,IACF;AACA,yBAAqB,GAAG,MAAM;AAC9B,QAAI,UAAU,WAAW,GAAG;AAC1B,YAAM,IAAI,iBAAiB,aAAa,iCAAiC,QAAQ,IAAI;AAAA,QACnF;AAAA,QACA;AAAA,MACF,CAAC;AAAA,IACH;AAMA,eAAW,KAAK,WAAW;AACzB,cAAI,uCAAgB,EAAE,OAAO,GAAG;AAC9B,cAAM,IAAI;AAAA,UACR;AAAA,UACA,sBAAsB,QAAQ;AAAA,UAC9B,EAAE,KAAK,UAAU,SAAS,EAAE,QAAQ;AAAA,QACtC;AAAA,MACF;AAAA,IACF;AACA,QAAI,CAAC,gBAAgB;AACnB,iBAAW,KAAK,WAAW;AACzB,gBAAI,mCAAY,EAAE,OAAO,GAAG;AAC1B,gBAAM,IAAI;AAAA,YACR;AAAA,YACA,sBAAsB,QAAQ;AAAA,YAC9B,EAAE,KAAK,UAAU,SAAS,EAAE,QAAQ;AAAA,UACtC;AAAA,QACF;AAAA,MACF;AAAA,IACF;AAEA,UAAM,SAAS,UAAU,CAAC;AAC1B,UAAM,eAAe,OAAO,WAAW,IAAI,IAAI;AAC/C,iBAAa,IAAI,oBAAM;AAAA,MACrB,SAAS;AAAA,QACP,SAAS,KAAK,IAAI,KAAO,SAAS;AAAA;AAAA;AAAA;AAAA;AAAA,QAKlC,QAAQ,CACN,IACA,MACA,OACG;AACH,cAAI,MAAM,KAAK;AACb,eAAG,MAAM,CAAC,EAAE,SAAS,OAAO,SAAS,QAAQ,aAAa,CAAC,CAAC;AAAA,UAC9D,OAAO;AACL,eAAG,MAAM,OAAO,SAAS,YAAY;AAAA,UACvC;AAAA,QACF;AAAA,MACF;AAAA,IACF,CAAC;AAED,UAAM,MAAM,QAAQ,iBAChB,MAAM,QAAQ,eAAe,KAAK;AAAA,MAChC,QAAQ,QAAQ,UAAU;AAAA,MAC1B,UAAU;AAAA,MACV,QAAQ,GAAG;AAAA,MACX,SAAS,QAAQ;AAAA,MACjB,GAAI,QAAQ,SAAS,UAAa,EAAE,MAAM,QAAQ,KAAiB;AAAA,IACrE,CAAC,IACD,UAAM,cAAAC,OAAY,KAAK;AAAA,MACrB,QAAQ,QAAQ,UAAU;AAAA,MAC1B,UAAU;AAAA,MACV,QAAQ,GAAG;AAAA,MACX,SAAS,QAAQ;AAAA,MACjB;AAAA,MACA,GAAI,QAAQ,SAAS,UAAa,EAAE,MAAM,QAAQ,KAAK;AAAA,IACzD,CAAC;AAEL,UAAM,UAAkC,CAAC;AACzC,QAAI,QAAQ,QAAQ,CAAC,GAAG,MAAM;AAC5B,cAAQ,EAAE,YAAY,CAAC,IAAI;AAAA,IAC7B,CAAC;AAED,UAAM,SAAS,IAAI,MAAM,UAAU;AACnC,QAAI,CAAC,QAAQ;AACX,aAAO;AAAA,QACL;AAAA,QACA,QAAQ,IAAI;AAAA,QACZ;AAAA,QACA,MAAM,IAAI,WAAW;AAAA,QACrB,eAAe,OAAO;AAAA,QACtB;AAAA,QACA,kBAAkB,CAAC,QAAQ;AAAA,MAC7B;AAAA,IACF;AAEA,UAAM,SAAuB,CAAC;AAC9B,QAAI,QAAQ;AACZ,WAAO,MAAM;AACX,YAAM,EAAE,MAAM,MAAM,IAAI,MAAM,OAAO,KAAK;AAC1C,UAAI,KAAM;AACV,eAAS,MAAM;AACf,UAAI,QAAQ,cAAc;AACxB,cAAM,OAAO,OAAO;AACpB,cAAM,IAAI,iBAAiB,sBAAsB,0BAA0B,YAAY,UAAU;AAAA,UAC/F;AAAA,UACA,UAAU,OAAO;AAAA,UACjB,SAAS,OAAO;AAAA,QAClB,CAAC;AAAA,MACH;AACA,aAAO,KAAK,KAAK;AAAA,IACnB;AAEA,UAAM,MAAM,IAAI,WAAW,KAAK;AAChC,QAAI,SAAS;AACb,eAAW,KAAK,QAAQ;AACtB,UAAI,IAAI,GAAG,MAAM;AACjB,gBAAU,EAAE;AAAA,IACd;AAEA,WAAO;AAAA,MACL;AAAA,MACA,QAAQ,IAAI;AAAA,MACZ;AAAA,MACA,MAAM;AAAA,MACN,eAAe,OAAO;AAAA,MACtB;AAAA,MACA,kBAAkB,CAAC,QAAQ;AAAA,IAC7B;AAAA,EACF,UAAE;AACA,iBAAa,KAAK;AAClB,YAAQ,QAAQ,oBAAoB,SAAS,eAAe;AAC5D,UAAM,YAAY,MAAM,EAAE,MAAM,MAAM;AAAA,IAAC,CAAC;AAAA,EAC1C;AACF;AAQA,SAAS,cAAiB,WAAuB,QAAiC;AAChF,uBAAqB,MAAM;AAC3B,SAAO,IAAI,QAAW,CAAC,SAAS,WAAW;AACzC,UAAM,UAAU,MAAM,OAAO,iBAAiB,MAAM,CAAC;AACrD,WAAO,iBAAiB,SAAS,SAAS,EAAE,MAAM,KAAK,CAAC;AACxD,cAAU,KAAK,SAAS,MAAM,EAAE,QAAQ,MAAM,OAAO,oBAAoB,SAAS,OAAO,CAAC;AAAA,EAC5F,CAAC;AACH;AAEA,SAAS,iBAAiB,QAA4B;AACpD,MAAI,OAAO,kBAAkB,MAAO,QAAO,OAAO;AAClD,QAAM,QAAQ,IAAI,MAAM,OAAO,UAAU,OAAO,8BAA8B,OAAO,OAAO,MAAM,CAAC;AACnG,QAAM,OAAO;AACb,SAAO;AACT;AAEA,SAAS,qBAAqB,QAA4B;AACxD,MAAI,CAAC,QAAQ,QAAS;AACtB,QAAM,iBAAiB,MAAM;AAC/B;AAOO,SAAS,uBAAuB,MAAkB,aAA0C;AACjG,MAAI,KAAK,eAAe,EAAG,QAAO;AAClC,QAAM,OAAO,OAAO,KAAK,KAAK,QAAQ,KAAK,YAAY,KAAK,UAAU,EAAE,SAAS,MAAM;AACvF,MAAI,aAAa,YAAY,EAAE,SAAS,kBAAkB,GAAG;AAC3D,QAAI;AACF,aAAO,KAAK,MAAM,IAAI;AAAA,IACxB,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AACA,SAAO;AACT;","names":["dnsLookupAsync","undiciFetch"]}
@@ -48,67 +48,69 @@ async function ssrfSafeFetch(url, options = {}) {
48
48
  hostname
49
49
  });
50
50
  }
51
- let addresses;
51
+ const ac = new AbortController();
52
+ const onExternalAbort = () => ac.abort(options.signal?.reason);
53
+ options.signal?.addEventListener("abort", onExternalAbort, { once: true });
54
+ if (options.signal?.aborted) onExternalAbort();
55
+ const timer = setTimeout(() => ac.abort(new Error("ssrf-fetch: timeout")), timeoutMs);
56
+ let dispatcher;
52
57
  try {
53
- addresses = await dnsLookupAsync(hostname, { all: true });
54
- } catch (err) {
55
- throwIfSignalAborted(options.signal);
56
- throw new SsrfRefusedError(
57
- "dns_lookup_failed",
58
- `DNS lookup failed for ${hostname}: ${err instanceof Error ? err.message : String(err)}`,
59
- { url, hostname }
60
- );
61
- }
62
- throwIfSignalAborted(options.signal);
63
- if (addresses.length === 0) {
64
- throw new SsrfRefusedError("dns_empty", `DNS returned no addresses for ${hostname}`, {
65
- url,
66
- hostname
67
- });
68
- }
69
- for (const a of addresses) {
70
- if (isAlwaysBlocked(a.address)) {
58
+ let addresses;
59
+ try {
60
+ addresses = await raceWithAbort(dnsLookupAsync(hostname, { all: true }), ac.signal);
61
+ } catch (err) {
62
+ throwIfSignalAborted(ac.signal);
71
63
  throw new SsrfRefusedError(
72
- "always_blocked_address",
73
- `Refusing to fetch: ${hostname} resolves to an always-blocked address (link-local or cloud metadata)`,
74
- { url, hostname, address: a.address }
64
+ "dns_lookup_failed",
65
+ `DNS lookup failed for ${hostname}: ${err instanceof Error ? err.message : String(err)}`,
66
+ { url, hostname }
75
67
  );
76
68
  }
77
- }
78
- if (!allowPrivateIp) {
69
+ throwIfSignalAborted(ac.signal);
70
+ if (addresses.length === 0) {
71
+ throw new SsrfRefusedError("dns_empty", `DNS returned no addresses for ${hostname}`, {
72
+ url,
73
+ hostname
74
+ });
75
+ }
79
76
  for (const a of addresses) {
80
- if (isPrivateIp(a.address)) {
77
+ if (isAlwaysBlocked(a.address)) {
81
78
  throw new SsrfRefusedError(
82
- "private_address",
83
- `Refusing to fetch: ${hostname} resolves to a private/loopback address`,
79
+ "always_blocked_address",
80
+ `Refusing to fetch: ${hostname} resolves to an always-blocked address (link-local or cloud metadata)`,
84
81
  { url, hostname, address: a.address }
85
82
  );
86
83
  }
87
84
  }
88
- }
89
- const pinned = addresses[0];
90
- const pinnedFamily = pinned.family === 6 ? 6 : 4;
91
- const dispatcher = new Agent({
92
- connect: {
93
- // All addresses were validated above; pin the connect to the first. The
94
- // custom lookup also means undici won't re-resolve and pick up a rebind.
95
- // undici's Agent may call lookup with `{ all: true }` (it does for HTTPS
96
- // targets under Node 22+), which expects the array form of the callback.
97
- lookup: (_h, opts, cb) => {
98
- if (opts?.all) {
99
- cb(null, [{ address: pinned.address, family: pinnedFamily }]);
100
- } else {
101
- cb(null, pinned.address, pinnedFamily);
85
+ if (!allowPrivateIp) {
86
+ for (const a of addresses) {
87
+ if (isPrivateIp(a.address)) {
88
+ throw new SsrfRefusedError(
89
+ "private_address",
90
+ `Refusing to fetch: ${hostname} resolves to a private/loopback address`,
91
+ { url, hostname, address: a.address }
92
+ );
102
93
  }
103
94
  }
104
95
  }
105
- });
106
- const ac = new AbortController();
107
- const onExternalAbort = () => ac.abort(options.signal?.reason);
108
- options.signal?.addEventListener("abort", onExternalAbort, { once: true });
109
- if (options.signal?.aborted) onExternalAbort();
110
- const timer = setTimeout(() => ac.abort(new Error("ssrf-fetch: timeout")), timeoutMs);
111
- try {
96
+ const pinned = addresses[0];
97
+ const pinnedFamily = pinned.family === 6 ? 6 : 4;
98
+ dispatcher = new Agent({
99
+ connect: {
100
+ timeout: Math.min(5e3, timeoutMs),
101
+ // All addresses were validated above; pin the connect to the first. The
102
+ // custom lookup also means undici won't re-resolve and pick up a rebind.
103
+ // undici's Agent may call lookup with `{ all: true }` (it does for HTTPS
104
+ // targets under Node 22+), which expects the array form of the callback.
105
+ lookup: (_h, opts, cb) => {
106
+ if (opts?.all) {
107
+ cb(null, [{ address: pinned.address, family: pinnedFamily }]);
108
+ } else {
109
+ cb(null, pinned.address, pinnedFamily);
110
+ }
111
+ }
112
+ }
113
+ });
112
114
  const res = options.trustedFetchFn ? await options.trustedFetchFn(url, {
113
115
  method: options.method ?? "GET",
114
116
  redirect: "manual",
@@ -173,16 +175,27 @@ async function ssrfSafeFetch(url, options = {}) {
173
175
  } finally {
174
176
  clearTimeout(timer);
175
177
  options.signal?.removeEventListener("abort", onExternalAbort);
176
- await dispatcher.close().catch(() => {
178
+ await dispatcher?.close().catch(() => {
177
179
  });
178
180
  }
179
181
  }
180
- function throwIfSignalAborted(signal) {
181
- if (!signal?.aborted) return;
182
- if (signal.reason instanceof Error) throw signal.reason;
182
+ function raceWithAbort(operation, signal) {
183
+ throwIfSignalAborted(signal);
184
+ return new Promise((resolve, reject) => {
185
+ const onAbort = () => reject(signalAbortError(signal));
186
+ signal.addEventListener("abort", onAbort, { once: true });
187
+ operation.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
188
+ });
189
+ }
190
+ function signalAbortError(signal) {
191
+ if (signal.reason instanceof Error) return signal.reason;
183
192
  const error = new Error(signal.reason == null ? "The operation was aborted" : String(signal.reason));
184
193
  error.name = "AbortError";
185
- throw error;
194
+ return error;
195
+ }
196
+ function throwIfSignalAborted(signal) {
197
+ if (!signal?.aborted) return;
198
+ throw signalAbortError(signal);
186
199
  }
187
200
  function decodeBodyAsJsonOrText(body, contentType) {
188
201
  if (body.byteLength === 0) return null;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/lib/net/ssrf-fetch.ts"],"sourcesContent":["/**\n * SSRF-safe HTTP fetch primitive.\n *\n * Used by compliance probes, storyboard runners, and (future) JWKS /\n * revocation-list resolvers — anywhere the library dispatches a request to a\n * URL that came from counterparty-controlled data and therefore might point at\n * the host's private network or a cloud metadata endpoint.\n *\n * Guarantees:\n * - Scheme: only `https:` by default; `http:` allowed under the dev-opt-in\n * `allowPrivateIp` flag. `file:`, `ftp:`, `data:`, etc. are always refused.\n * - DNS: resolves every A/AAAA record once, validates the full set, then\n * pins the outbound connection to the first validated address via an\n * undici `Agent` whose `connect.lookup` returns the pinned tuple.\n * Defeats DNS rebinding (attacker returns a public address to the guard\n * lookup and a private address to the connect-time lookup) and any other\n * TOCTOU gap between validation and connect.\n * - IMDS (`169.254.169.254`, `fe80::`) stays refused even under\n * `allowPrivateIp` — cloud metadata exfiltration is never a legitimate\n * dev-loop use case.\n * - Redirects are not followed (`redirect: 'manual'`). The 3xx response is\n * returned with `Location` populated so callers can inspect it, but they\n * MUST NOT re-dispatch to the `Location` URL themselves — that bypasses\n * every guard above. To follow a redirect safely, re-invoke\n * `ssrfSafeFetch` with the new URL so it runs through validation again.\n * - Response body is buffered up to `maxBodyBytes` (default 64 KiB) and\n * returned as raw bytes; the dispatcher is torn down after reading so\n * connection-reuse can't carry an attacker-controlled keepalive.\n *\n * Returns a fully-buffered result. Callers that need streaming or large bodies\n * should extend this primitive rather than bypass it.\n */\nimport { type LookupAddress, type LookupOptions } from 'dns';\nimport { lookup as dnsLookupAsync } from 'dns/promises';\nimport { Agent, fetch as undiciFetch } from 'undici';\nimport { isAlwaysBlocked, isPrivateIp } from './address-guards';\n\nconst DEFAULT_TIMEOUT_MS = 10_000;\nconst DEFAULT_MAX_BODY_BYTES = 64 * 1024;\nconst ALLOWED_SCHEMES = new Set(['https:', 'http:']);\n\nexport type SsrfRefusedCode =\n | 'invalid_url'\n | 'scheme_not_allowed'\n | 'non_https_without_opt_in'\n | 'dns_lookup_failed'\n | 'dns_empty'\n | 'always_blocked_address'\n | 'private_address'\n | 'body_exceeds_limit';\n\n/**\n * SSRF refusal codes that indicate runtime/network conditions rather than\n * policy refusal. Callers that want to fall back to a \"host unreachable\" or\n * \"treat as unknown\" path on these codes — rather than surfacing the\n * SsrfRefusedError to their own caller — can intersect against this set:\n *\n * ```ts\n * if (err instanceof SsrfRefusedError && SSRF_TRANSIENT_CODES.has(err.code)) {\n * // network condition, not a policy attack — treat as unreachable\n * } else if (err instanceof SsrfRefusedError) {\n * // policy refusal — must propagate; silently downgrading to \"unreachable\"\n * // reintroduces the catch-swallow class flagged in adcp-client#1618 review\n * throw err;\n * }\n * ```\n *\n * - `dns_lookup_failed`, `dns_empty`: name does not resolve. CLI fixtures\n * use `*.example.invalid` to provoke these — preserving the runtime-error\n * path matches pre-`ssrfSafeFetch` native-fetch behavior.\n * - `body_exceeds_limit`: response started OK (validated address, scheme,\n * etc.) but exceeded the caller's defensive cap. The host is real and\n * knows the URL; classifying as \"policy attack\" would be a misread.\n *\n * NOT in this set: `always_blocked_address`, `private_address`,\n * `scheme_not_allowed`, `non_https_without_opt_in`, `invalid_url`. Those\n * are policy refusals — silently downgrading them to \"unreachable\" or\n * \"suspect\" reintroduces the SSRF gap the gate was added to close.\n *\n * Added in adcp-client#1633.\n */\nexport const SSRF_TRANSIENT_CODES: ReadonlySet<SsrfRefusedCode> = new Set([\n 'dns_lookup_failed',\n 'dns_empty',\n 'body_exceeds_limit',\n]);\n\n/**\n * Thrown when the SSRF guard refuses a request before (or during) the fetch.\n * Network failures after the guard passes are not wrapped in this type —\n * callers that want to distinguish \"we refused this\" from \"the remote broke\"\n * can `instanceof SsrfRefusedError` the catch.\n */\nexport class SsrfRefusedError extends Error {\n readonly code: SsrfRefusedCode;\n readonly url: string;\n readonly hostname?: string;\n readonly address?: string;\n\n constructor(code: SsrfRefusedCode, message: string, meta: { url: string; hostname?: string; address?: string }) {\n super(message);\n this.name = 'SsrfRefusedError';\n this.code = code;\n this.url = meta.url;\n this.hostname = meta.hostname;\n this.address = meta.address;\n }\n}\n\nexport interface SsrfFetchOptions {\n method?: string;\n /** Lowercased keys preferred; values preserved verbatim. */\n headers?: Record<string, string>;\n body?: string | Uint8Array;\n /** Allow `http://` and private/loopback targets. Default false. */\n allowPrivateIp?: boolean;\n /** Overall timeout including DNS + connect + body read. Default 10_000 ms. */\n timeoutMs?: number;\n /** Hard cap on response body bytes. Default 64 KiB. */\n maxBodyBytes?: number;\n /** Caller-provided abort signal, composed with the internal timeout. */\n signal?: AbortSignal;\n /**\n * Trusted scoped fetch implementation. URL validation and address\n * classification still run before invocation, but DNS pinning is delegated\n * to this implementation because custom fetchers do not accept undici\n * dispatchers. The caller MUST enforce DNS-rebinding protection itself.\n *\n * This deliberately explicit name prevents callers from mistaking a custom\n * transport for the internally DNS-pinned path.\n */\n trustedFetchFn?: typeof fetch;\n}\n\nexport interface SsrfFetchResult {\n url: string;\n status: number;\n /** Response headers, lowercased. */\n headers: Record<string, string>;\n /** Raw response body bytes (empty Uint8Array if no body). */\n body: Uint8Array;\n /** The validated IP address. The connection used it only when `connectionPinned` is true. */\n pinnedAddress: string;\n pinnedFamily: 4 | 6;\n /** Whether the internal undici dispatcher pinned the connection to `pinnedAddress`. */\n connectionPinned: boolean;\n}\n\n/**\n * GET/POST/etc. a URL with SSRF guarding + DNS pinning. Throws\n * {@link SsrfRefusedError} when the guard refuses; other errors (network\n * timeouts, remote resets) propagate as the native fetch error.\n */\nexport async function ssrfSafeFetch(url: string, options: SsrfFetchOptions = {}): Promise<SsrfFetchResult> {\n throwIfSignalAborted(options.signal);\n const allowPrivateIp = options.allowPrivateIp === true;\n const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;\n\n let parsed: URL;\n try {\n parsed = new URL(url);\n } catch {\n throw new SsrfRefusedError('invalid_url', `Invalid URL: ${url}`, { url });\n }\n\n // `URL.hostname` wraps IPv6 literals in brackets (`https://[::1]/` →\n // `[::1]`). `dns.lookup` and the address classifier both want the bare\n // form; strip brackets here so IPv6 localhost URLs work under\n // `allowPrivateIp` and so bracketed literals can't slip past classification\n // on a future Node release that tolerates them.\n const hostname = parsed.hostname.replace(/^\\[|\\]$/g, '');\n\n if (!ALLOWED_SCHEMES.has(parsed.protocol)) {\n throw new SsrfRefusedError(\n 'scheme_not_allowed',\n `Refusing to fetch URL with unsupported scheme: ${parsed.protocol}`,\n { url, hostname }\n );\n }\n if (parsed.protocol !== 'https:' && !allowPrivateIp) {\n throw new SsrfRefusedError('non_https_without_opt_in', `Refusing to fetch non-HTTPS URL: ${url}`, {\n url,\n hostname,\n });\n }\n\n let addresses: { address: string; family: number }[];\n try {\n addresses = await dnsLookupAsync(hostname, { all: true });\n } catch (err) {\n throwIfSignalAborted(options.signal);\n throw new SsrfRefusedError(\n 'dns_lookup_failed',\n `DNS lookup failed for ${hostname}: ${err instanceof Error ? err.message : String(err)}`,\n { url, hostname }\n );\n }\n throwIfSignalAborted(options.signal);\n if (addresses.length === 0) {\n throw new SsrfRefusedError('dns_empty', `DNS returned no addresses for ${hostname}`, {\n url,\n hostname,\n });\n }\n // Error messages intentionally do NOT include the resolved IP — a\n // counterparty-supplied hostname that resolves into the caller's internal\n // address space would otherwise leak network topology into compliance\n // reports and log aggregators. The address is still available on the\n // thrown error's `.address` field for programmatic debugging.\n for (const a of addresses) {\n if (isAlwaysBlocked(a.address)) {\n throw new SsrfRefusedError(\n 'always_blocked_address',\n `Refusing to fetch: ${hostname} resolves to an always-blocked address (link-local or cloud metadata)`,\n { url, hostname, address: a.address }\n );\n }\n }\n if (!allowPrivateIp) {\n for (const a of addresses) {\n if (isPrivateIp(a.address)) {\n throw new SsrfRefusedError(\n 'private_address',\n `Refusing to fetch: ${hostname} resolves to a private/loopback address`,\n { url, hostname, address: a.address }\n );\n }\n }\n }\n\n const pinned = addresses[0]!;\n const pinnedFamily = pinned.family === 6 ? 6 : 4;\n const dispatcher = new Agent({\n connect: {\n // All addresses were validated above; pin the connect to the first. The\n // custom lookup also means undici won't re-resolve and pick up a rebind.\n // undici's Agent may call lookup with `{ all: true }` (it does for HTTPS\n // targets under Node 22+), which expects the array form of the callback.\n lookup: (\n _h: string,\n opts: LookupOptions | undefined,\n cb: (err: NodeJS.ErrnoException | null, address: string | LookupAddress[], family?: number) => void\n ) => {\n if (opts?.all) {\n cb(null, [{ address: pinned.address, family: pinnedFamily }]);\n } else {\n cb(null, pinned.address, pinnedFamily);\n }\n },\n },\n });\n\n const ac = new AbortController();\n const onExternalAbort = () => ac.abort(options.signal?.reason);\n options.signal?.addEventListener('abort', onExternalAbort, { once: true });\n if (options.signal?.aborted) onExternalAbort();\n const timer = setTimeout(() => ac.abort(new Error('ssrf-fetch: timeout')), timeoutMs);\n\n try {\n const res = options.trustedFetchFn\n ? await options.trustedFetchFn(url, {\n method: options.method ?? 'GET',\n redirect: 'manual',\n signal: ac.signal,\n headers: options.headers,\n ...(options.body !== undefined && { body: options.body as BodyInit }),\n })\n : await undiciFetch(url, {\n method: options.method ?? 'GET',\n redirect: 'manual',\n signal: ac.signal,\n headers: options.headers,\n dispatcher,\n ...(options.body !== undefined && { body: options.body }),\n });\n\n const headers: Record<string, string> = {};\n res.headers.forEach((v, k) => {\n headers[k.toLowerCase()] = v;\n });\n\n const reader = res.body?.getReader();\n if (!reader) {\n return {\n url,\n status: res.status,\n headers,\n body: new Uint8Array(),\n pinnedAddress: pinned.address,\n pinnedFamily,\n connectionPinned: !options.trustedFetchFn,\n };\n }\n\n const chunks: Uint8Array[] = [];\n let bytes = 0;\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n bytes += value.byteLength;\n if (bytes > maxBodyBytes) {\n await reader.cancel();\n throw new SsrfRefusedError('body_exceeds_limit', `Response body exceeded ${maxBodyBytes} bytes`, {\n url,\n hostname: parsed.hostname,\n address: pinned.address,\n });\n }\n chunks.push(value);\n }\n\n const buf = new Uint8Array(bytes);\n let offset = 0;\n for (const c of chunks) {\n buf.set(c, offset);\n offset += c.byteLength;\n }\n\n return {\n url,\n status: res.status,\n headers,\n body: buf,\n pinnedAddress: pinned.address,\n pinnedFamily,\n connectionPinned: !options.trustedFetchFn,\n };\n } finally {\n clearTimeout(timer);\n options.signal?.removeEventListener('abort', onExternalAbort);\n await dispatcher.close().catch(() => {});\n }\n}\n\nfunction throwIfSignalAborted(signal?: AbortSignal): void {\n if (!signal?.aborted) return;\n if (signal.reason instanceof Error) throw signal.reason;\n const error = new Error(signal.reason == null ? 'The operation was aborted' : String(signal.reason));\n error.name = 'AbortError';\n throw error;\n}\n\n/**\n * Decode a UTF-8 byte buffer as JSON when the content-type declares JSON, or\n * fall back to a UTF-8 string. Handy shared helper for probe-style call sites\n * that don't care about binary bodies.\n */\nexport function decodeBodyAsJsonOrText(body: Uint8Array, contentType: string | undefined): unknown {\n if (body.byteLength === 0) return null;\n const text = Buffer.from(body.buffer, body.byteOffset, body.byteLength).toString('utf8');\n if (contentType?.toLowerCase().includes('application/json')) {\n try {\n return JSON.parse(text);\n } catch {\n return text;\n }\n }\n return text;\n}\n"],"mappings":"AAiCA,SAAS,UAAU,sBAAsB;AACzC,SAAS,OAAO,SAAS,mBAAmB;AAC5C,SAAS,iBAAiB,mBAAmB;AAE7C,MAAM,qBAAqB;AAC3B,MAAM,yBAAyB,KAAK;AACpC,MAAM,kBAAkB,oBAAI,IAAI,CAAC,UAAU,OAAO,CAAC;AA0C5C,MAAM,uBAAqD,oBAAI,IAAI;AAAA,EACxE;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAQM,MAAM,yBAAyB,MAAM;AAAA,EACjC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EAET,YAAY,MAAuB,SAAiB,MAA4D;AAC9G,UAAM,OAAO;AACb,SAAK,OAAO;AACZ,SAAK,OAAO;AACZ,SAAK,MAAM,KAAK;AAChB,SAAK,WAAW,KAAK;AACrB,SAAK,UAAU,KAAK;AAAA,EACtB;AACF;AA8CA,eAAsB,cAAc,KAAa,UAA4B,CAAC,GAA6B;AACzG,uBAAqB,QAAQ,MAAM;AACnC,QAAM,iBAAiB,QAAQ,mBAAmB;AAClD,QAAM,YAAY,QAAQ,aAAa;AACvC,QAAM,eAAe,QAAQ,gBAAgB;AAE7C,MAAI;AACJ,MAAI;AACF,aAAS,IAAI,IAAI,GAAG;AAAA,EACtB,QAAQ;AACN,UAAM,IAAI,iBAAiB,eAAe,gBAAgB,GAAG,IAAI,EAAE,IAAI,CAAC;AAAA,EAC1E;AAOA,QAAM,WAAW,OAAO,SAAS,QAAQ,YAAY,EAAE;AAEvD,MAAI,CAAC,gBAAgB,IAAI,OAAO,QAAQ,GAAG;AACzC,UAAM,IAAI;AAAA,MACR;AAAA,MACA,kDAAkD,OAAO,QAAQ;AAAA,MACjE,EAAE,KAAK,SAAS;AAAA,IAClB;AAAA,EACF;AACA,MAAI,OAAO,aAAa,YAAY,CAAC,gBAAgB;AACnD,UAAM,IAAI,iBAAiB,4BAA4B,oCAAoC,GAAG,IAAI;AAAA,MAChG;AAAA,MACA;AAAA,IACF,CAAC;AAAA,EACH;AAEA,MAAI;AACJ,MAAI;AACF,gBAAY,MAAM,eAAe,UAAU,EAAE,KAAK,KAAK,CAAC;AAAA,EAC1D,SAAS,KAAK;AACZ,yBAAqB,QAAQ,MAAM;AACnC,UAAM,IAAI;AAAA,MACR;AAAA,MACA,yBAAyB,QAAQ,KAAK,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG,CAAC;AAAA,MACtF,EAAE,KAAK,SAAS;AAAA,IAClB;AAAA,EACF;AACA,uBAAqB,QAAQ,MAAM;AACnC,MAAI,UAAU,WAAW,GAAG;AAC1B,UAAM,IAAI,iBAAiB,aAAa,iCAAiC,QAAQ,IAAI;AAAA,MACnF;AAAA,MACA;AAAA,IACF,CAAC;AAAA,EACH;AAMA,aAAW,KAAK,WAAW;AACzB,QAAI,gBAAgB,EAAE,OAAO,GAAG;AAC9B,YAAM,IAAI;AAAA,QACR;AAAA,QACA,sBAAsB,QAAQ;AAAA,QAC9B,EAAE,KAAK,UAAU,SAAS,EAAE,QAAQ;AAAA,MACtC;AAAA,IACF;AAAA,EACF;AACA,MAAI,CAAC,gBAAgB;AACnB,eAAW,KAAK,WAAW;AACzB,UAAI,YAAY,EAAE,OAAO,GAAG;AAC1B,cAAM,IAAI;AAAA,UACR;AAAA,UACA,sBAAsB,QAAQ;AAAA,UAC9B,EAAE,KAAK,UAAU,SAAS,EAAE,QAAQ;AAAA,QACtC;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAEA,QAAM,SAAS,UAAU,CAAC;AAC1B,QAAM,eAAe,OAAO,WAAW,IAAI,IAAI;AAC/C,QAAM,aAAa,IAAI,MAAM;AAAA,IAC3B,SAAS;AAAA;AAAA;AAAA;AAAA;AAAA,MAKP,QAAQ,CACN,IACA,MACA,OACG;AACH,YAAI,MAAM,KAAK;AACb,aAAG,MAAM,CAAC,EAAE,SAAS,OAAO,SAAS,QAAQ,aAAa,CAAC,CAAC;AAAA,QAC9D,OAAO;AACL,aAAG,MAAM,OAAO,SAAS,YAAY;AAAA,QACvC;AAAA,MACF;AAAA,IACF;AAAA,EACF,CAAC;AAED,QAAM,KAAK,IAAI,gBAAgB;AAC/B,QAAM,kBAAkB,MAAM,GAAG,MAAM,QAAQ,QAAQ,MAAM;AAC7D,UAAQ,QAAQ,iBAAiB,SAAS,iBAAiB,EAAE,MAAM,KAAK,CAAC;AACzE,MAAI,QAAQ,QAAQ,QAAS,iBAAgB;AAC7C,QAAM,QAAQ,WAAW,MAAM,GAAG,MAAM,IAAI,MAAM,qBAAqB,CAAC,GAAG,SAAS;AAEpF,MAAI;AACF,UAAM,MAAM,QAAQ,iBAChB,MAAM,QAAQ,eAAe,KAAK;AAAA,MAChC,QAAQ,QAAQ,UAAU;AAAA,MAC1B,UAAU;AAAA,MACV,QAAQ,GAAG;AAAA,MACX,SAAS,QAAQ;AAAA,MACjB,GAAI,QAAQ,SAAS,UAAa,EAAE,MAAM,QAAQ,KAAiB;AAAA,IACrE,CAAC,IACD,MAAM,YAAY,KAAK;AAAA,MACrB,QAAQ,QAAQ,UAAU;AAAA,MAC1B,UAAU;AAAA,MACV,QAAQ,GAAG;AAAA,MACX,SAAS,QAAQ;AAAA,MACjB;AAAA,MACA,GAAI,QAAQ,SAAS,UAAa,EAAE,MAAM,QAAQ,KAAK;AAAA,IACzD,CAAC;AAEL,UAAM,UAAkC,CAAC;AACzC,QAAI,QAAQ,QAAQ,CAAC,GAAG,MAAM;AAC5B,cAAQ,EAAE,YAAY,CAAC,IAAI;AAAA,IAC7B,CAAC;AAED,UAAM,SAAS,IAAI,MAAM,UAAU;AACnC,QAAI,CAAC,QAAQ;AACX,aAAO;AAAA,QACL;AAAA,QACA,QAAQ,IAAI;AAAA,QACZ;AAAA,QACA,MAAM,IAAI,WAAW;AAAA,QACrB,eAAe,OAAO;AAAA,QACtB;AAAA,QACA,kBAAkB,CAAC,QAAQ;AAAA,MAC7B;AAAA,IACF;AAEA,UAAM,SAAuB,CAAC;AAC9B,QAAI,QAAQ;AACZ,WAAO,MAAM;AACX,YAAM,EAAE,MAAM,MAAM,IAAI,MAAM,OAAO,KAAK;AAC1C,UAAI,KAAM;AACV,eAAS,MAAM;AACf,UAAI,QAAQ,cAAc;AACxB,cAAM,OAAO,OAAO;AACpB,cAAM,IAAI,iBAAiB,sBAAsB,0BAA0B,YAAY,UAAU;AAAA,UAC/F;AAAA,UACA,UAAU,OAAO;AAAA,UACjB,SAAS,OAAO;AAAA,QAClB,CAAC;AAAA,MACH;AACA,aAAO,KAAK,KAAK;AAAA,IACnB;AAEA,UAAM,MAAM,IAAI,WAAW,KAAK;AAChC,QAAI,SAAS;AACb,eAAW,KAAK,QAAQ;AACtB,UAAI,IAAI,GAAG,MAAM;AACjB,gBAAU,EAAE;AAAA,IACd;AAEA,WAAO;AAAA,MACL;AAAA,MACA,QAAQ,IAAI;AAAA,MACZ;AAAA,MACA,MAAM;AAAA,MACN,eAAe,OAAO;AAAA,MACtB;AAAA,MACA,kBAAkB,CAAC,QAAQ;AAAA,IAC7B;AAAA,EACF,UAAE;AACA,iBAAa,KAAK;AAClB,YAAQ,QAAQ,oBAAoB,SAAS,eAAe;AAC5D,UAAM,WAAW,MAAM,EAAE,MAAM,MAAM;AAAA,IAAC,CAAC;AAAA,EACzC;AACF;AAEA,SAAS,qBAAqB,QAA4B;AACxD,MAAI,CAAC,QAAQ,QAAS;AACtB,MAAI,OAAO,kBAAkB,MAAO,OAAM,OAAO;AACjD,QAAM,QAAQ,IAAI,MAAM,OAAO,UAAU,OAAO,8BAA8B,OAAO,OAAO,MAAM,CAAC;AACnG,QAAM,OAAO;AACb,QAAM;AACR;AAOO,SAAS,uBAAuB,MAAkB,aAA0C;AACjG,MAAI,KAAK,eAAe,EAAG,QAAO;AAClC,QAAM,OAAO,OAAO,KAAK,KAAK,QAAQ,KAAK,YAAY,KAAK,UAAU,EAAE,SAAS,MAAM;AACvF,MAAI,aAAa,YAAY,EAAE,SAAS,kBAAkB,GAAG;AAC3D,QAAI;AACF,aAAO,KAAK,MAAM,IAAI;AAAA,IACxB,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AACA,SAAO;AACT;","names":[]}
1
+ {"version":3,"sources":["../../../src/lib/net/ssrf-fetch.ts"],"sourcesContent":["/**\n * SSRF-safe HTTP fetch primitive.\n *\n * Used by compliance probes, storyboard runners, and (future) JWKS /\n * revocation-list resolvers — anywhere the library dispatches a request to a\n * URL that came from counterparty-controlled data and therefore might point at\n * the host's private network or a cloud metadata endpoint.\n *\n * Guarantees:\n * - Scheme: only `https:` by default; `http:` allowed under the dev-opt-in\n * `allowPrivateIp` flag. `file:`, `ftp:`, `data:`, etc. are always refused.\n * - DNS: resolves every A/AAAA record once, validates the full set, then\n * pins the outbound connection to the first validated address via an\n * undici `Agent` whose `connect.lookup` returns the pinned tuple.\n * Defeats DNS rebinding (attacker returns a public address to the guard\n * lookup and a private address to the connect-time lookup) and any other\n * TOCTOU gap between validation and connect.\n * - IMDS (`169.254.169.254`, `fe80::`) stays refused even under\n * `allowPrivateIp` — cloud metadata exfiltration is never a legitimate\n * dev-loop use case.\n * - Redirects are not followed (`redirect: 'manual'`). The 3xx response is\n * returned with `Location` populated so callers can inspect it, but they\n * MUST NOT re-dispatch to the `Location` URL themselves — that bypasses\n * every guard above. To follow a redirect safely, re-invoke\n * `ssrfSafeFetch` with the new URL so it runs through validation again.\n * - Response body is buffered up to `maxBodyBytes` (default 64 KiB) and\n * returned as raw bytes; the dispatcher is torn down after reading so\n * connection-reuse can't carry an attacker-controlled keepalive.\n *\n * Returns a fully-buffered result. Callers that need streaming or large bodies\n * should extend this primitive rather than bypass it.\n */\nimport { type LookupAddress, type LookupOptions } from 'dns';\nimport { lookup as dnsLookupAsync } from 'dns/promises';\nimport { Agent, fetch as undiciFetch } from 'undici';\nimport { isAlwaysBlocked, isPrivateIp } from './address-guards';\n\nconst DEFAULT_TIMEOUT_MS = 10_000;\nconst DEFAULT_MAX_BODY_BYTES = 64 * 1024;\nconst ALLOWED_SCHEMES = new Set(['https:', 'http:']);\n\nexport type SsrfRefusedCode =\n | 'invalid_url'\n | 'scheme_not_allowed'\n | 'non_https_without_opt_in'\n | 'dns_lookup_failed'\n | 'dns_empty'\n | 'always_blocked_address'\n | 'private_address'\n | 'body_exceeds_limit';\n\n/**\n * SSRF refusal codes that indicate runtime/network conditions rather than\n * policy refusal. Callers that want to fall back to a \"host unreachable\" or\n * \"treat as unknown\" path on these codes — rather than surfacing the\n * SsrfRefusedError to their own caller — can intersect against this set:\n *\n * ```ts\n * if (err instanceof SsrfRefusedError && SSRF_TRANSIENT_CODES.has(err.code)) {\n * // network condition, not a policy attack — treat as unreachable\n * } else if (err instanceof SsrfRefusedError) {\n * // policy refusal — must propagate; silently downgrading to \"unreachable\"\n * // reintroduces the catch-swallow class flagged in adcp-client#1618 review\n * throw err;\n * }\n * ```\n *\n * - `dns_lookup_failed`, `dns_empty`: name does not resolve. CLI fixtures\n * use `*.example.invalid` to provoke these — preserving the runtime-error\n * path matches pre-`ssrfSafeFetch` native-fetch behavior.\n * - `body_exceeds_limit`: response started OK (validated address, scheme,\n * etc.) but exceeded the caller's defensive cap. The host is real and\n * knows the URL; classifying as \"policy attack\" would be a misread.\n *\n * NOT in this set: `always_blocked_address`, `private_address`,\n * `scheme_not_allowed`, `non_https_without_opt_in`, `invalid_url`. Those\n * are policy refusals — silently downgrading them to \"unreachable\" or\n * \"suspect\" reintroduces the SSRF gap the gate was added to close.\n *\n * Added in adcp-client#1633.\n */\nexport const SSRF_TRANSIENT_CODES: ReadonlySet<SsrfRefusedCode> = new Set([\n 'dns_lookup_failed',\n 'dns_empty',\n 'body_exceeds_limit',\n]);\n\n/**\n * Thrown when the SSRF guard refuses a request before (or during) the fetch.\n * Network failures after the guard passes are not wrapped in this type —\n * callers that want to distinguish \"we refused this\" from \"the remote broke\"\n * can `instanceof SsrfRefusedError` the catch.\n */\nexport class SsrfRefusedError extends Error {\n readonly code: SsrfRefusedCode;\n readonly url: string;\n readonly hostname?: string;\n readonly address?: string;\n\n constructor(code: SsrfRefusedCode, message: string, meta: { url: string; hostname?: string; address?: string }) {\n super(message);\n this.name = 'SsrfRefusedError';\n this.code = code;\n this.url = meta.url;\n this.hostname = meta.hostname;\n this.address = meta.address;\n }\n}\n\nexport interface SsrfFetchOptions {\n method?: string;\n /** Lowercased keys preferred; values preserved verbatim. */\n headers?: Record<string, string>;\n body?: string | Uint8Array;\n /** Allow `http://` and private/loopback targets. Default false. */\n allowPrivateIp?: boolean;\n /** Overall timeout including DNS + connect + body read. Default 10_000 ms. */\n timeoutMs?: number;\n /** Hard cap on response body bytes. Default 64 KiB. */\n maxBodyBytes?: number;\n /** Caller-provided abort signal, composed with the internal timeout. */\n signal?: AbortSignal;\n /**\n * Trusted scoped fetch implementation. URL validation and address\n * classification still run before invocation, but DNS pinning is delegated\n * to this implementation because custom fetchers do not accept undici\n * dispatchers. The caller MUST enforce DNS-rebinding protection itself.\n *\n * This deliberately explicit name prevents callers from mistaking a custom\n * transport for the internally DNS-pinned path.\n */\n trustedFetchFn?: typeof fetch;\n}\n\nexport interface SsrfFetchResult {\n url: string;\n status: number;\n /** Response headers, lowercased. */\n headers: Record<string, string>;\n /** Raw response body bytes (empty Uint8Array if no body). */\n body: Uint8Array;\n /** The validated IP address. The connection used it only when `connectionPinned` is true. */\n pinnedAddress: string;\n pinnedFamily: 4 | 6;\n /** Whether the internal undici dispatcher pinned the connection to `pinnedAddress`. */\n connectionPinned: boolean;\n}\n\n/**\n * GET/POST/etc. a URL with SSRF guarding + DNS pinning. Throws\n * {@link SsrfRefusedError} when the guard refuses; other errors (network\n * timeouts, remote resets) propagate as the native fetch error.\n */\nexport async function ssrfSafeFetch(url: string, options: SsrfFetchOptions = {}): Promise<SsrfFetchResult> {\n throwIfSignalAborted(options.signal);\n const allowPrivateIp = options.allowPrivateIp === true;\n const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;\n\n let parsed: URL;\n try {\n parsed = new URL(url);\n } catch {\n throw new SsrfRefusedError('invalid_url', `Invalid URL: ${url}`, { url });\n }\n\n // `URL.hostname` wraps IPv6 literals in brackets (`https://[::1]/` →\n // `[::1]`). `dns.lookup` and the address classifier both want the bare\n // form; strip brackets here so IPv6 localhost URLs work under\n // `allowPrivateIp` and so bracketed literals can't slip past classification\n // on a future Node release that tolerates them.\n const hostname = parsed.hostname.replace(/^\\[|\\]$/g, '');\n\n if (!ALLOWED_SCHEMES.has(parsed.protocol)) {\n throw new SsrfRefusedError(\n 'scheme_not_allowed',\n `Refusing to fetch URL with unsupported scheme: ${parsed.protocol}`,\n { url, hostname }\n );\n }\n if (parsed.protocol !== 'https:' && !allowPrivateIp) {\n throw new SsrfRefusedError('non_https_without_opt_in', `Refusing to fetch non-HTTPS URL: ${url}`, {\n url,\n hostname,\n });\n }\n\n // Start the request deadline before DNS. `dns.lookup()` does not accept an\n // AbortSignal, so race it against the same controller used for connect and\n // body reads. The lookup may continue inside libuv after an abort, but its\n // eventual settlement is observed by `raceWithAbort` and cannot keep this\n // caller pending or produce an unhandled rejection.\n const ac = new AbortController();\n const onExternalAbort = () => ac.abort(options.signal?.reason);\n options.signal?.addEventListener('abort', onExternalAbort, { once: true });\n if (options.signal?.aborted) onExternalAbort();\n const timer = setTimeout(() => ac.abort(new Error('ssrf-fetch: timeout')), timeoutMs);\n let dispatcher: Agent | undefined;\n\n try {\n let addresses: { address: string; family: number }[];\n try {\n addresses = await raceWithAbort(dnsLookupAsync(hostname, { all: true }), ac.signal);\n } catch (err) {\n throwIfSignalAborted(ac.signal);\n throw new SsrfRefusedError(\n 'dns_lookup_failed',\n `DNS lookup failed for ${hostname}: ${err instanceof Error ? err.message : String(err)}`,\n { url, hostname }\n );\n }\n throwIfSignalAborted(ac.signal);\n if (addresses.length === 0) {\n throw new SsrfRefusedError('dns_empty', `DNS returned no addresses for ${hostname}`, {\n url,\n hostname,\n });\n }\n // Error messages intentionally do NOT include the resolved IP — a\n // counterparty-supplied hostname that resolves into the caller's internal\n // address space would otherwise leak network topology into compliance\n // reports and log aggregators. The address is still available on the\n // thrown error's `.address` field for programmatic debugging.\n for (const a of addresses) {\n if (isAlwaysBlocked(a.address)) {\n throw new SsrfRefusedError(\n 'always_blocked_address',\n `Refusing to fetch: ${hostname} resolves to an always-blocked address (link-local or cloud metadata)`,\n { url, hostname, address: a.address }\n );\n }\n }\n if (!allowPrivateIp) {\n for (const a of addresses) {\n if (isPrivateIp(a.address)) {\n throw new SsrfRefusedError(\n 'private_address',\n `Refusing to fetch: ${hostname} resolves to a private/loopback address`,\n { url, hostname, address: a.address }\n );\n }\n }\n }\n\n const pinned = addresses[0]!;\n const pinnedFamily = pinned.family === 6 ? 6 : 4;\n dispatcher = new Agent({\n connect: {\n timeout: Math.min(5_000, timeoutMs),\n // All addresses were validated above; pin the connect to the first. The\n // custom lookup also means undici won't re-resolve and pick up a rebind.\n // undici's Agent may call lookup with `{ all: true }` (it does for HTTPS\n // targets under Node 22+), which expects the array form of the callback.\n lookup: (\n _h: string,\n opts: LookupOptions | undefined,\n cb: (err: NodeJS.ErrnoException | null, address: string | LookupAddress[], family?: number) => void\n ) => {\n if (opts?.all) {\n cb(null, [{ address: pinned.address, family: pinnedFamily }]);\n } else {\n cb(null, pinned.address, pinnedFamily);\n }\n },\n },\n });\n\n const res = options.trustedFetchFn\n ? await options.trustedFetchFn(url, {\n method: options.method ?? 'GET',\n redirect: 'manual',\n signal: ac.signal,\n headers: options.headers,\n ...(options.body !== undefined && { body: options.body as BodyInit }),\n })\n : await undiciFetch(url, {\n method: options.method ?? 'GET',\n redirect: 'manual',\n signal: ac.signal,\n headers: options.headers,\n dispatcher,\n ...(options.body !== undefined && { body: options.body }),\n });\n\n const headers: Record<string, string> = {};\n res.headers.forEach((v, k) => {\n headers[k.toLowerCase()] = v;\n });\n\n const reader = res.body?.getReader();\n if (!reader) {\n return {\n url,\n status: res.status,\n headers,\n body: new Uint8Array(),\n pinnedAddress: pinned.address,\n pinnedFamily,\n connectionPinned: !options.trustedFetchFn,\n };\n }\n\n const chunks: Uint8Array[] = [];\n let bytes = 0;\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n bytes += value.byteLength;\n if (bytes > maxBodyBytes) {\n await reader.cancel();\n throw new SsrfRefusedError('body_exceeds_limit', `Response body exceeded ${maxBodyBytes} bytes`, {\n url,\n hostname: parsed.hostname,\n address: pinned.address,\n });\n }\n chunks.push(value);\n }\n\n const buf = new Uint8Array(bytes);\n let offset = 0;\n for (const c of chunks) {\n buf.set(c, offset);\n offset += c.byteLength;\n }\n\n return {\n url,\n status: res.status,\n headers,\n body: buf,\n pinnedAddress: pinned.address,\n pinnedFamily,\n connectionPinned: !options.trustedFetchFn,\n };\n } finally {\n clearTimeout(timer);\n options.signal?.removeEventListener('abort', onExternalAbort);\n await dispatcher?.close().catch(() => {});\n }\n}\n\n/**\n * Await an operation that has no native AbortSignal support without allowing\n * it to retain the caller after cancellation. Attaching both fulfillment and\n * rejection handlers also observes a late operation failure after the abort\n * branch has already won the race.\n */\nfunction raceWithAbort<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {\n throwIfSignalAborted(signal);\n return new Promise<T>((resolve, reject) => {\n const onAbort = () => reject(signalAbortError(signal));\n signal.addEventListener('abort', onAbort, { once: true });\n operation.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort));\n });\n}\n\nfunction signalAbortError(signal: AbortSignal): Error {\n if (signal.reason instanceof Error) return signal.reason;\n const error = new Error(signal.reason == null ? 'The operation was aborted' : String(signal.reason));\n error.name = 'AbortError';\n return error;\n}\n\nfunction throwIfSignalAborted(signal?: AbortSignal): void {\n if (!signal?.aborted) return;\n throw signalAbortError(signal);\n}\n\n/**\n * Decode a UTF-8 byte buffer as JSON when the content-type declares JSON, or\n * fall back to a UTF-8 string. Handy shared helper for probe-style call sites\n * that don't care about binary bodies.\n */\nexport function decodeBodyAsJsonOrText(body: Uint8Array, contentType: string | undefined): unknown {\n if (body.byteLength === 0) return null;\n const text = Buffer.from(body.buffer, body.byteOffset, body.byteLength).toString('utf8');\n if (contentType?.toLowerCase().includes('application/json')) {\n try {\n return JSON.parse(text);\n } catch {\n return text;\n }\n }\n return text;\n}\n"],"mappings":"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","names":[]}
@@ -4,5 +4,5 @@
4
4
  "source_sha": "4e553ad955f83b49c7d221ab5c3ff78237ad02e3",
5
5
  "source_tarball_sha256": "580656d6466ef9f0d1119985e6726c2efea718dc671e2ad30957fcb2fd54af0f",
6
6
  "upstream_adcp_version": "2.5.3",
7
- "synced_at": "2026-08-07T10:02:15.145Z"
7
+ "synced_at": "2026-08-08T13:45:35.171Z"
8
8
  }
@@ -14,6 +14,7 @@
14
14
  *
15
15
  * @public
16
16
  */
17
+ import type { Account } from './decisioning/account.mjs';
17
18
  /**
18
19
  * Three operationally distinct account modes:
19
20
  *
@@ -50,7 +51,7 @@ export type AccountMode = 'live' | 'sandbox' | 'mock';
50
51
  * downgrade every account's gate to a no-op. The own-property check makes
51
52
  * the gate immune to that class of attack regardless of upstream hardening.
52
53
  */
53
- export declare function getAccountMode(account: unknown): AccountMode;
54
+ export declare function getAccountMode(account: Account<unknown>): AccountMode;
54
55
  /**
55
56
  * Predicate: is the account in a non-production mode that admits
56
57
  * test-only surfaces (comply controller, force_*, simulate_*)?
@@ -14,6 +14,7 @@
14
14
  *
15
15
  * @public
16
16
  */
17
+ import type { Account } from './decisioning/account';
17
18
  /**
18
19
  * Three operationally distinct account modes:
19
20
  *
@@ -50,7 +51,7 @@ export type AccountMode = 'live' | 'sandbox' | 'mock';
50
51
  * downgrade every account's gate to a no-op. The own-property check makes
51
52
  * the gate immune to that class of attack regardless of upstream hardening.
52
53
  */
53
- export declare function getAccountMode(account: unknown): AccountMode;
54
+ export declare function getAccountMode(account: Account<unknown>): AccountMode;
54
55
  /**
55
56
  * Predicate: is the account in a non-production mode that admits
56
57
  * test-only surfaces (comply controller, force_*, simulate_*)?
@@ -1 +1 @@
1
- {"version":3,"file":"account-mode.d.ts","sourceRoot":"","sources":["../../../src/lib/server/account-mode.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAIH;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,MAAM,WAAW,GAAG,MAAM,GAAG,SAAS,GAAG,MAAM,CAAC;AAEtD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,OAAO,GAAG,WAAW,CAW5D;AAED;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAGhE;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6CG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,GAAE;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAO,GAAG,IAAI,CAU3G"}
1
+ {"version":3,"file":"account-mode.d.ts","sourceRoot":"","sources":["../../../src/lib/server/account-mode.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAGH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,uBAAuB,CAAC;AAErD;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,MAAM,WAAW,GAAG,MAAM,GAAG,SAAS,GAAG,MAAM,CAAC;AAEtD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,OAAO,CAAC,OAAO,CAAC,GAAG,WAAW,CAWrE;AAED;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAIhE;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6CG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,GAAE;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAO,GAAG,IAAI,CAU3G"}
@@ -36,6 +36,7 @@ function getAccountMode(account) {
36
36
  return "live";
37
37
  }
38
38
  function isSandboxOrMockAccount(account) {
39
+ if (account == null || typeof account !== "object") return false;
39
40
  const mode = getAccountMode(account);
40
41
  return mode === "sandbox" || mode === "mock";
41
42
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/lib/server/account-mode.ts"],"sourcesContent":["/**\n * Account-mode primitives for sandbox-authority enforcement of\n * `comply_test_controller` and other test-only surfaces.\n *\n * The hard rule: under no circumstances should the comply test\n * controller (or any test-only surface) operate on a `live`-mode\n * account. The flag must live on the resolved account, not on a\n * process-level env var — env vars are operator-error-prone proxies\n * for what is fundamentally an authority decision per principal.\n *\n * This module ships the type + helpers; auto-wiring into\n * `createAdcpServerFromPlatform`'s `complyTest` path lands in a\n * follow-up alongside mock-mode routing (#1435 Phase 2).\n *\n * @public\n */\n\nimport { AdcpError } from './decisioning/async-outcome';\n\n/**\n * Three operationally distinct account modes:\n *\n * - `live`: production traffic. Adopter's upstream is truth. Test-only\n * surfaces (comply controller, force_*, simulate_*) are denied.\n * - `sandbox`: adopter's own test account. Their code path runs against\n * their test infrastructure. Test-only surfaces are allowed.\n * - `mock`: SDK-routed-to-mock-server. Adopter's code is bypassed; the\n * SDK forwards to the mock upstream backend. Test-only surfaces are\n * allowed.\n *\n * Default when unspecified: `live`. A missing or unknown `mode` reads\n * as production, fail-closed for any test-only dispatch.\n *\n * See `docs/proposals/lifecycle-state-and-sandbox-authority.md` for the\n * full three-mode design and Phase 1/2/3 rollout.\n */\nexport type AccountMode = 'live' | 'sandbox' | 'mock';\n\n/**\n * Reads `mode` off any account-shaped value, with back-compat for\n * the legacy `sandbox: boolean` field. Returns the explicit mode if\n * present; otherwise infers `'sandbox'` from `sandbox === true`;\n * otherwise `'live'`.\n *\n * Adopters that have not yet migrated to the `mode` field continue to\n * work — `account.sandbox === true` reads as sandbox mode through this\n * helper. New code should prefer `mode` directly.\n *\n * Prototype-pollution defense: both `mode` and `sandbox` are read via\n * `Object.hasOwn` rather than bare property access. Bare access traverses\n * the prototype chain, so an attacker who reaches a `__proto__`-via-merge\n * sink upstream (reachable in MCP envelope handling and similar deep-merge\n * sites) could stamp `Object.prototype.mode = 'sandbox'` and silently\n * downgrade every account's gate to a no-op. The own-property check makes\n * the gate immune to that class of attack regardless of upstream hardening.\n */\nexport function getAccountMode(account: unknown): AccountMode {\n if (account == null || typeof account !== 'object') return 'live';\n if (Object.hasOwn(account, 'mode')) {\n const mode = (account as { mode?: unknown }).mode;\n if (mode === 'live' || mode === 'sandbox' || mode === 'mock') return mode;\n }\n // Back-compat: legacy `sandbox: true` flag reads as `sandbox` mode.\n if (Object.hasOwn(account, 'sandbox') && (account as { sandbox?: unknown }).sandbox === true) {\n return 'sandbox';\n }\n return 'live';\n}\n\n/**\n * Predicate: is the account in a non-production mode that admits\n * test-only surfaces (comply controller, force_*, simulate_*)?\n *\n * Returns `true` for `mode === 'sandbox' | 'mock'` (or legacy\n * `sandbox: true`); `false` for `mode === 'live'` or any account\n * shape that doesn't carry the field.\n */\nexport function isSandboxOrMockAccount(account: unknown): boolean {\n const mode = getAccountMode(account);\n return mode === 'sandbox' || mode === 'mock';\n}\n\n/**\n * Throws an `AdcpError('PERMISSION_DENIED')` if the account is not in\n * a non-production mode. Use to gate dispatch of test-only surfaces.\n *\n * Fail-closed semantics:\n * - `account === undefined` (no resolved account): throws.\n * - `account.mode === 'live'` or unspecified + no `sandbox: true`:\n * throws.\n * - `account.mode === 'sandbox' | 'mock'` (or legacy `sandbox: true`):\n * no-op, dispatch proceeds.\n *\n * The `details` payload carries `{ scope: 'sandbox-gate', tool? }` so\n * dashboards can distinguish gate-rejections from other permission\n * denials.\n *\n * **Resolver discipline.** The strength of this gate depends entirely on\n * how the adopter's `AccountStore.resolve` constructs its return value.\n * Resolvers MUST NOT spread untrusted input (request body, headers,\n * `ctx_metadata`, query params) into the resolved account — doing so lets\n * a buyer self-promote to `mode: 'sandbox'` and unlock test-only surfaces\n * on a live principal. Source `mode` (and `sandbox`) from a trusted store\n * keyed by the authenticated principal; never from request data.\n *\n * **opts.message must be a static string literal.** The message is echoed\n * on the wire inside the error envelope. Interpolating user-controlled\n * values into it creates a reflection sink (PII leakage, log injection,\n * downstream HTML rendering). Pick from a fixed set of messages keyed by\n * `tool` if you need variants.\n *\n * @param account The resolved account (typically `ctx.account` inside\n * a tool dispatch). Pass `undefined` if no account resolved — the\n * helper fails closed.\n * @param opts.tool Optional tool name to surface in the error details\n * (e.g., `'comply_test_controller'`).\n * @param opts.message Optional override for the user-facing message.\n * MUST be a static string literal — see \"opts.message\" note above.\n *\n * @example\n * import { assertSandboxAccount } from '@adcp/sdk/server';\n *\n * sandboxGate: input => {\n * const account = await resolveAccount(input);\n * assertSandboxAccount(account, { tool: 'comply_test_controller' });\n * return true;\n * }\n */\nexport function assertSandboxAccount(account: unknown, opts: { tool?: string; message?: string } = {}): void {\n if (isSandboxOrMockAccount(account)) return;\n throw new AdcpError('PERMISSION_DENIED', {\n message: opts.message ?? 'Test-only surface requires a sandbox or mock account; resolved account is in live mode.',\n details: {\n scope: 'sandbox-gate',\n reason: 'sandbox-or-mock-required',\n ...(opts.tool && { tool: opts.tool }),\n },\n });\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAiBA,2BAA0B;AAuCnB,SAAS,eAAe,SAA+B;AAC5D,MAAI,WAAW,QAAQ,OAAO,YAAY,SAAU,QAAO;AAC3D,MAAI,OAAO,OAAO,SAAS,MAAM,GAAG;AAClC,UAAM,OAAQ,QAA+B;AAC7C,QAAI,SAAS,UAAU,SAAS,aAAa,SAAS,OAAQ,QAAO;AAAA,EACvE;AAEA,MAAI,OAAO,OAAO,SAAS,SAAS,KAAM,QAAkC,YAAY,MAAM;AAC5F,WAAO;AAAA,EACT;AACA,SAAO;AACT;AAUO,SAAS,uBAAuB,SAA2B;AAChE,QAAM,OAAO,eAAe,OAAO;AACnC,SAAO,SAAS,aAAa,SAAS;AACxC;AAgDO,SAAS,qBAAqB,SAAkB,OAA4C,CAAC,GAAS;AAC3G,MAAI,uBAAuB,OAAO,EAAG;AACrC,QAAM,IAAI,+BAAU,qBAAqB;AAAA,IACvC,SAAS,KAAK,WAAW;AAAA,IACzB,SAAS;AAAA,MACP,OAAO;AAAA,MACP,QAAQ;AAAA,MACR,GAAI,KAAK,QAAQ,EAAE,MAAM,KAAK,KAAK;AAAA,IACrC;AAAA,EACF,CAAC;AACH;","names":[]}
1
+ {"version":3,"sources":["../../../src/lib/server/account-mode.ts"],"sourcesContent":["/**\n * Account-mode primitives for sandbox-authority enforcement of\n * `comply_test_controller` and other test-only surfaces.\n *\n * The hard rule: under no circumstances should the comply test\n * controller (or any test-only surface) operate on a `live`-mode\n * account. The flag must live on the resolved account, not on a\n * process-level env var — env vars are operator-error-prone proxies\n * for what is fundamentally an authority decision per principal.\n *\n * This module ships the type + helpers; auto-wiring into\n * `createAdcpServerFromPlatform`'s `complyTest` path lands in a\n * follow-up alongside mock-mode routing (#1435 Phase 2).\n *\n * @public\n */\n\nimport { AdcpError } from './decisioning/async-outcome';\nimport type { Account } from './decisioning/account';\n\n/**\n * Three operationally distinct account modes:\n *\n * - `live`: production traffic. Adopter's upstream is truth. Test-only\n * surfaces (comply controller, force_*, simulate_*) are denied.\n * - `sandbox`: adopter's own test account. Their code path runs against\n * their test infrastructure. Test-only surfaces are allowed.\n * - `mock`: SDK-routed-to-mock-server. Adopter's code is bypassed; the\n * SDK forwards to the mock upstream backend. Test-only surfaces are\n * allowed.\n *\n * Default when unspecified: `live`. A missing or unknown `mode` reads\n * as production, fail-closed for any test-only dispatch.\n *\n * See `docs/proposals/lifecycle-state-and-sandbox-authority.md` for the\n * full three-mode design and Phase 1/2/3 rollout.\n */\nexport type AccountMode = 'live' | 'sandbox' | 'mock';\n\n/**\n * Reads `mode` off any account-shaped value, with back-compat for\n * the legacy `sandbox: boolean` field. Returns the explicit mode if\n * present; otherwise infers `'sandbox'` from `sandbox === true`;\n * otherwise `'live'`.\n *\n * Adopters that have not yet migrated to the `mode` field continue to\n * work — `account.sandbox === true` reads as sandbox mode through this\n * helper. New code should prefer `mode` directly.\n *\n * Prototype-pollution defense: both `mode` and `sandbox` are read via\n * `Object.hasOwn` rather than bare property access. Bare access traverses\n * the prototype chain, so an attacker who reaches a `__proto__`-via-merge\n * sink upstream (reachable in MCP envelope handling and similar deep-merge\n * sites) could stamp `Object.prototype.mode = 'sandbox'` and silently\n * downgrade every account's gate to a no-op. The own-property check makes\n * the gate immune to that class of attack regardless of upstream hardening.\n */\nexport function getAccountMode(account: Account<unknown>): AccountMode {\n if (account == null || typeof account !== 'object') return 'live';\n if (Object.hasOwn(account, 'mode')) {\n const mode = (account as { mode?: unknown }).mode;\n if (mode === 'live' || mode === 'sandbox' || mode === 'mock') return mode;\n }\n // Back-compat: legacy `sandbox: true` flag reads as `sandbox` mode.\n if (Object.hasOwn(account, 'sandbox') && (account as { sandbox?: unknown }).sandbox === true) {\n return 'sandbox';\n }\n return 'live';\n}\n\n/**\n * Predicate: is the account in a non-production mode that admits\n * test-only surfaces (comply controller, force_*, simulate_*)?\n *\n * Returns `true` for `mode === 'sandbox' | 'mock'` (or legacy\n * `sandbox: true`); `false` for `mode === 'live'` or any account\n * shape that doesn't carry the field.\n */\nexport function isSandboxOrMockAccount(account: unknown): boolean {\n if (account == null || typeof account !== 'object') return false;\n const mode = getAccountMode(account as Account<unknown>);\n return mode === 'sandbox' || mode === 'mock';\n}\n\n/**\n * Throws an `AdcpError('PERMISSION_DENIED')` if the account is not in\n * a non-production mode. Use to gate dispatch of test-only surfaces.\n *\n * Fail-closed semantics:\n * - `account === undefined` (no resolved account): throws.\n * - `account.mode === 'live'` or unspecified + no `sandbox: true`:\n * throws.\n * - `account.mode === 'sandbox' | 'mock'` (or legacy `sandbox: true`):\n * no-op, dispatch proceeds.\n *\n * The `details` payload carries `{ scope: 'sandbox-gate', tool? }` so\n * dashboards can distinguish gate-rejections from other permission\n * denials.\n *\n * **Resolver discipline.** The strength of this gate depends entirely on\n * how the adopter's `AccountStore.resolve` constructs its return value.\n * Resolvers MUST NOT spread untrusted input (request body, headers,\n * `ctx_metadata`, query params) into the resolved account — doing so lets\n * a buyer self-promote to `mode: 'sandbox'` and unlock test-only surfaces\n * on a live principal. Source `mode` (and `sandbox`) from a trusted store\n * keyed by the authenticated principal; never from request data.\n *\n * **opts.message must be a static string literal.** The message is echoed\n * on the wire inside the error envelope. Interpolating user-controlled\n * values into it creates a reflection sink (PII leakage, log injection,\n * downstream HTML rendering). Pick from a fixed set of messages keyed by\n * `tool` if you need variants.\n *\n * @param account The resolved account (typically `ctx.account` inside\n * a tool dispatch). Pass `undefined` if no account resolved — the\n * helper fails closed.\n * @param opts.tool Optional tool name to surface in the error details\n * (e.g., `'comply_test_controller'`).\n * @param opts.message Optional override for the user-facing message.\n * MUST be a static string literal — see \"opts.message\" note above.\n *\n * @example\n * import { assertSandboxAccount } from '@adcp/sdk/server';\n *\n * sandboxGate: input => {\n * const account = await resolveAccount(input);\n * assertSandboxAccount(account, { tool: 'comply_test_controller' });\n * return true;\n * }\n */\nexport function assertSandboxAccount(account: unknown, opts: { tool?: string; message?: string } = {}): void {\n if (isSandboxOrMockAccount(account)) return;\n throw new AdcpError('PERMISSION_DENIED', {\n message: opts.message ?? 'Test-only surface requires a sandbox or mock account; resolved account is in live mode.',\n details: {\n scope: 'sandbox-gate',\n reason: 'sandbox-or-mock-required',\n ...(opts.tool && { tool: opts.tool }),\n },\n });\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAiBA,2BAA0B;AAwCnB,SAAS,eAAe,SAAwC;AACrE,MAAI,WAAW,QAAQ,OAAO,YAAY,SAAU,QAAO;AAC3D,MAAI,OAAO,OAAO,SAAS,MAAM,GAAG;AAClC,UAAM,OAAQ,QAA+B;AAC7C,QAAI,SAAS,UAAU,SAAS,aAAa,SAAS,OAAQ,QAAO;AAAA,EACvE;AAEA,MAAI,OAAO,OAAO,SAAS,SAAS,KAAM,QAAkC,YAAY,MAAM;AAC5F,WAAO;AAAA,EACT;AACA,SAAO;AACT;AAUO,SAAS,uBAAuB,SAA2B;AAChE,MAAI,WAAW,QAAQ,OAAO,YAAY,SAAU,QAAO;AAC3D,QAAM,OAAO,eAAe,OAA2B;AACvD,SAAO,SAAS,aAAa,SAAS;AACxC;AAgDO,SAAS,qBAAqB,SAAkB,OAA4C,CAAC,GAAS;AAC3G,MAAI,uBAAuB,OAAO,EAAG;AACrC,QAAM,IAAI,+BAAU,qBAAqB;AAAA,IACvC,SAAS,KAAK,WAAW;AAAA,IACzB,SAAS;AAAA,MACP,OAAO;AAAA,MACP,QAAQ;AAAA,MACR,GAAI,KAAK,QAAQ,EAAE,MAAM,KAAK,KAAK;AAAA,IACrC;AAAA,EACF,CAAC;AACH;","names":[]}
@@ -11,6 +11,7 @@ function getAccountMode(account) {
11
11
  return "live";
12
12
  }
13
13
  function isSandboxOrMockAccount(account) {
14
+ if (account == null || typeof account !== "object") return false;
14
15
  const mode = getAccountMode(account);
15
16
  return mode === "sandbox" || mode === "mock";
16
17
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/lib/server/account-mode.ts"],"sourcesContent":["/**\n * Account-mode primitives for sandbox-authority enforcement of\n * `comply_test_controller` and other test-only surfaces.\n *\n * The hard rule: under no circumstances should the comply test\n * controller (or any test-only surface) operate on a `live`-mode\n * account. The flag must live on the resolved account, not on a\n * process-level env var — env vars are operator-error-prone proxies\n * for what is fundamentally an authority decision per principal.\n *\n * This module ships the type + helpers; auto-wiring into\n * `createAdcpServerFromPlatform`'s `complyTest` path lands in a\n * follow-up alongside mock-mode routing (#1435 Phase 2).\n *\n * @public\n */\n\nimport { AdcpError } from './decisioning/async-outcome';\n\n/**\n * Three operationally distinct account modes:\n *\n * - `live`: production traffic. Adopter's upstream is truth. Test-only\n * surfaces (comply controller, force_*, simulate_*) are denied.\n * - `sandbox`: adopter's own test account. Their code path runs against\n * their test infrastructure. Test-only surfaces are allowed.\n * - `mock`: SDK-routed-to-mock-server. Adopter's code is bypassed; the\n * SDK forwards to the mock upstream backend. Test-only surfaces are\n * allowed.\n *\n * Default when unspecified: `live`. A missing or unknown `mode` reads\n * as production, fail-closed for any test-only dispatch.\n *\n * See `docs/proposals/lifecycle-state-and-sandbox-authority.md` for the\n * full three-mode design and Phase 1/2/3 rollout.\n */\nexport type AccountMode = 'live' | 'sandbox' | 'mock';\n\n/**\n * Reads `mode` off any account-shaped value, with back-compat for\n * the legacy `sandbox: boolean` field. Returns the explicit mode if\n * present; otherwise infers `'sandbox'` from `sandbox === true`;\n * otherwise `'live'`.\n *\n * Adopters that have not yet migrated to the `mode` field continue to\n * work — `account.sandbox === true` reads as sandbox mode through this\n * helper. New code should prefer `mode` directly.\n *\n * Prototype-pollution defense: both `mode` and `sandbox` are read via\n * `Object.hasOwn` rather than bare property access. Bare access traverses\n * the prototype chain, so an attacker who reaches a `__proto__`-via-merge\n * sink upstream (reachable in MCP envelope handling and similar deep-merge\n * sites) could stamp `Object.prototype.mode = 'sandbox'` and silently\n * downgrade every account's gate to a no-op. The own-property check makes\n * the gate immune to that class of attack regardless of upstream hardening.\n */\nexport function getAccountMode(account: unknown): AccountMode {\n if (account == null || typeof account !== 'object') return 'live';\n if (Object.hasOwn(account, 'mode')) {\n const mode = (account as { mode?: unknown }).mode;\n if (mode === 'live' || mode === 'sandbox' || mode === 'mock') return mode;\n }\n // Back-compat: legacy `sandbox: true` flag reads as `sandbox` mode.\n if (Object.hasOwn(account, 'sandbox') && (account as { sandbox?: unknown }).sandbox === true) {\n return 'sandbox';\n }\n return 'live';\n}\n\n/**\n * Predicate: is the account in a non-production mode that admits\n * test-only surfaces (comply controller, force_*, simulate_*)?\n *\n * Returns `true` for `mode === 'sandbox' | 'mock'` (or legacy\n * `sandbox: true`); `false` for `mode === 'live'` or any account\n * shape that doesn't carry the field.\n */\nexport function isSandboxOrMockAccount(account: unknown): boolean {\n const mode = getAccountMode(account);\n return mode === 'sandbox' || mode === 'mock';\n}\n\n/**\n * Throws an `AdcpError('PERMISSION_DENIED')` if the account is not in\n * a non-production mode. Use to gate dispatch of test-only surfaces.\n *\n * Fail-closed semantics:\n * - `account === undefined` (no resolved account): throws.\n * - `account.mode === 'live'` or unspecified + no `sandbox: true`:\n * throws.\n * - `account.mode === 'sandbox' | 'mock'` (or legacy `sandbox: true`):\n * no-op, dispatch proceeds.\n *\n * The `details` payload carries `{ scope: 'sandbox-gate', tool? }` so\n * dashboards can distinguish gate-rejections from other permission\n * denials.\n *\n * **Resolver discipline.** The strength of this gate depends entirely on\n * how the adopter's `AccountStore.resolve` constructs its return value.\n * Resolvers MUST NOT spread untrusted input (request body, headers,\n * `ctx_metadata`, query params) into the resolved account — doing so lets\n * a buyer self-promote to `mode: 'sandbox'` and unlock test-only surfaces\n * on a live principal. Source `mode` (and `sandbox`) from a trusted store\n * keyed by the authenticated principal; never from request data.\n *\n * **opts.message must be a static string literal.** The message is echoed\n * on the wire inside the error envelope. Interpolating user-controlled\n * values into it creates a reflection sink (PII leakage, log injection,\n * downstream HTML rendering). Pick from a fixed set of messages keyed by\n * `tool` if you need variants.\n *\n * @param account The resolved account (typically `ctx.account` inside\n * a tool dispatch). Pass `undefined` if no account resolved — the\n * helper fails closed.\n * @param opts.tool Optional tool name to surface in the error details\n * (e.g., `'comply_test_controller'`).\n * @param opts.message Optional override for the user-facing message.\n * MUST be a static string literal — see \"opts.message\" note above.\n *\n * @example\n * import { assertSandboxAccount } from '@adcp/sdk/server';\n *\n * sandboxGate: input => {\n * const account = await resolveAccount(input);\n * assertSandboxAccount(account, { tool: 'comply_test_controller' });\n * return true;\n * }\n */\nexport function assertSandboxAccount(account: unknown, opts: { tool?: string; message?: string } = {}): void {\n if (isSandboxOrMockAccount(account)) return;\n throw new AdcpError('PERMISSION_DENIED', {\n message: opts.message ?? 'Test-only surface requires a sandbox or mock account; resolved account is in live mode.',\n details: {\n scope: 'sandbox-gate',\n reason: 'sandbox-or-mock-required',\n ...(opts.tool && { tool: opts.tool }),\n },\n });\n}\n"],"mappings":"AAiBA,SAAS,iBAAiB;AAuCnB,SAAS,eAAe,SAA+B;AAC5D,MAAI,WAAW,QAAQ,OAAO,YAAY,SAAU,QAAO;AAC3D,MAAI,OAAO,OAAO,SAAS,MAAM,GAAG;AAClC,UAAM,OAAQ,QAA+B;AAC7C,QAAI,SAAS,UAAU,SAAS,aAAa,SAAS,OAAQ,QAAO;AAAA,EACvE;AAEA,MAAI,OAAO,OAAO,SAAS,SAAS,KAAM,QAAkC,YAAY,MAAM;AAC5F,WAAO;AAAA,EACT;AACA,SAAO;AACT;AAUO,SAAS,uBAAuB,SAA2B;AAChE,QAAM,OAAO,eAAe,OAAO;AACnC,SAAO,SAAS,aAAa,SAAS;AACxC;AAgDO,SAAS,qBAAqB,SAAkB,OAA4C,CAAC,GAAS;AAC3G,MAAI,uBAAuB,OAAO,EAAG;AACrC,QAAM,IAAI,UAAU,qBAAqB;AAAA,IACvC,SAAS,KAAK,WAAW;AAAA,IACzB,SAAS;AAAA,MACP,OAAO;AAAA,MACP,QAAQ;AAAA,MACR,GAAI,KAAK,QAAQ,EAAE,MAAM,KAAK,KAAK;AAAA,IACrC;AAAA,EACF,CAAC;AACH;","names":[]}
1
+ {"version":3,"sources":["../../../src/lib/server/account-mode.ts"],"sourcesContent":["/**\n * Account-mode primitives for sandbox-authority enforcement of\n * `comply_test_controller` and other test-only surfaces.\n *\n * The hard rule: under no circumstances should the comply test\n * controller (or any test-only surface) operate on a `live`-mode\n * account. The flag must live on the resolved account, not on a\n * process-level env var — env vars are operator-error-prone proxies\n * for what is fundamentally an authority decision per principal.\n *\n * This module ships the type + helpers; auto-wiring into\n * `createAdcpServerFromPlatform`'s `complyTest` path lands in a\n * follow-up alongside mock-mode routing (#1435 Phase 2).\n *\n * @public\n */\n\nimport { AdcpError } from './decisioning/async-outcome';\nimport type { Account } from './decisioning/account';\n\n/**\n * Three operationally distinct account modes:\n *\n * - `live`: production traffic. Adopter's upstream is truth. Test-only\n * surfaces (comply controller, force_*, simulate_*) are denied.\n * - `sandbox`: adopter's own test account. Their code path runs against\n * their test infrastructure. Test-only surfaces are allowed.\n * - `mock`: SDK-routed-to-mock-server. Adopter's code is bypassed; the\n * SDK forwards to the mock upstream backend. Test-only surfaces are\n * allowed.\n *\n * Default when unspecified: `live`. A missing or unknown `mode` reads\n * as production, fail-closed for any test-only dispatch.\n *\n * See `docs/proposals/lifecycle-state-and-sandbox-authority.md` for the\n * full three-mode design and Phase 1/2/3 rollout.\n */\nexport type AccountMode = 'live' | 'sandbox' | 'mock';\n\n/**\n * Reads `mode` off any account-shaped value, with back-compat for\n * the legacy `sandbox: boolean` field. Returns the explicit mode if\n * present; otherwise infers `'sandbox'` from `sandbox === true`;\n * otherwise `'live'`.\n *\n * Adopters that have not yet migrated to the `mode` field continue to\n * work — `account.sandbox === true` reads as sandbox mode through this\n * helper. New code should prefer `mode` directly.\n *\n * Prototype-pollution defense: both `mode` and `sandbox` are read via\n * `Object.hasOwn` rather than bare property access. Bare access traverses\n * the prototype chain, so an attacker who reaches a `__proto__`-via-merge\n * sink upstream (reachable in MCP envelope handling and similar deep-merge\n * sites) could stamp `Object.prototype.mode = 'sandbox'` and silently\n * downgrade every account's gate to a no-op. The own-property check makes\n * the gate immune to that class of attack regardless of upstream hardening.\n */\nexport function getAccountMode(account: Account<unknown>): AccountMode {\n if (account == null || typeof account !== 'object') return 'live';\n if (Object.hasOwn(account, 'mode')) {\n const mode = (account as { mode?: unknown }).mode;\n if (mode === 'live' || mode === 'sandbox' || mode === 'mock') return mode;\n }\n // Back-compat: legacy `sandbox: true` flag reads as `sandbox` mode.\n if (Object.hasOwn(account, 'sandbox') && (account as { sandbox?: unknown }).sandbox === true) {\n return 'sandbox';\n }\n return 'live';\n}\n\n/**\n * Predicate: is the account in a non-production mode that admits\n * test-only surfaces (comply controller, force_*, simulate_*)?\n *\n * Returns `true` for `mode === 'sandbox' | 'mock'` (or legacy\n * `sandbox: true`); `false` for `mode === 'live'` or any account\n * shape that doesn't carry the field.\n */\nexport function isSandboxOrMockAccount(account: unknown): boolean {\n if (account == null || typeof account !== 'object') return false;\n const mode = getAccountMode(account as Account<unknown>);\n return mode === 'sandbox' || mode === 'mock';\n}\n\n/**\n * Throws an `AdcpError('PERMISSION_DENIED')` if the account is not in\n * a non-production mode. Use to gate dispatch of test-only surfaces.\n *\n * Fail-closed semantics:\n * - `account === undefined` (no resolved account): throws.\n * - `account.mode === 'live'` or unspecified + no `sandbox: true`:\n * throws.\n * - `account.mode === 'sandbox' | 'mock'` (or legacy `sandbox: true`):\n * no-op, dispatch proceeds.\n *\n * The `details` payload carries `{ scope: 'sandbox-gate', tool? }` so\n * dashboards can distinguish gate-rejections from other permission\n * denials.\n *\n * **Resolver discipline.** The strength of this gate depends entirely on\n * how the adopter's `AccountStore.resolve` constructs its return value.\n * Resolvers MUST NOT spread untrusted input (request body, headers,\n * `ctx_metadata`, query params) into the resolved account — doing so lets\n * a buyer self-promote to `mode: 'sandbox'` and unlock test-only surfaces\n * on a live principal. Source `mode` (and `sandbox`) from a trusted store\n * keyed by the authenticated principal; never from request data.\n *\n * **opts.message must be a static string literal.** The message is echoed\n * on the wire inside the error envelope. Interpolating user-controlled\n * values into it creates a reflection sink (PII leakage, log injection,\n * downstream HTML rendering). Pick from a fixed set of messages keyed by\n * `tool` if you need variants.\n *\n * @param account The resolved account (typically `ctx.account` inside\n * a tool dispatch). Pass `undefined` if no account resolved — the\n * helper fails closed.\n * @param opts.tool Optional tool name to surface in the error details\n * (e.g., `'comply_test_controller'`).\n * @param opts.message Optional override for the user-facing message.\n * MUST be a static string literal — see \"opts.message\" note above.\n *\n * @example\n * import { assertSandboxAccount } from '@adcp/sdk/server';\n *\n * sandboxGate: input => {\n * const account = await resolveAccount(input);\n * assertSandboxAccount(account, { tool: 'comply_test_controller' });\n * return true;\n * }\n */\nexport function assertSandboxAccount(account: unknown, opts: { tool?: string; message?: string } = {}): void {\n if (isSandboxOrMockAccount(account)) return;\n throw new AdcpError('PERMISSION_DENIED', {\n message: opts.message ?? 'Test-only surface requires a sandbox or mock account; resolved account is in live mode.',\n details: {\n scope: 'sandbox-gate',\n reason: 'sandbox-or-mock-required',\n ...(opts.tool && { tool: opts.tool }),\n },\n });\n}\n"],"mappings":"AAiBA,SAAS,iBAAiB;AAwCnB,SAAS,eAAe,SAAwC;AACrE,MAAI,WAAW,QAAQ,OAAO,YAAY,SAAU,QAAO;AAC3D,MAAI,OAAO,OAAO,SAAS,MAAM,GAAG;AAClC,UAAM,OAAQ,QAA+B;AAC7C,QAAI,SAAS,UAAU,SAAS,aAAa,SAAS,OAAQ,QAAO;AAAA,EACvE;AAEA,MAAI,OAAO,OAAO,SAAS,SAAS,KAAM,QAAkC,YAAY,MAAM;AAC5F,WAAO;AAAA,EACT;AACA,SAAO;AACT;AAUO,SAAS,uBAAuB,SAA2B;AAChE,MAAI,WAAW,QAAQ,OAAO,YAAY,SAAU,QAAO;AAC3D,QAAM,OAAO,eAAe,OAA2B;AACvD,SAAO,SAAS,aAAa,SAAS;AACxC;AAgDO,SAAS,qBAAqB,SAAkB,OAA4C,CAAC,GAAS;AAC3G,MAAI,uBAAuB,OAAO,EAAG;AACrC,QAAM,IAAI,UAAU,qBAAqB;AAAA,IACvC,SAAS,KAAK,WAAW;AAAA,IACzB,SAAS;AAAA,MACP,OAAO;AAAA,MACP,QAAQ;AAAA,MACR,GAAI,KAAK,QAAQ,EAAE,MAAM,KAAK,KAAK;AAAA,IACrC;AAAA,EACF,CAAC;AACH;","names":[]}
@@ -57,10 +57,11 @@ import type { RevocationStore } from '../signing/revocation.mjs';
57
57
  import type { ContentDigestPolicy } from '../signing/types.mjs';
58
58
  import type { GetProductsRequestSchema, CreateMediaBuyRequestSchema, UpdateMediaBuyRequestSchema, GetMediaBuysRequestSchema, GetMediaBuyDeliveryRequestSchema, ProvidePerformanceFeedbackRequestSchema, GetTaskStatusRequestSchema, ListTasksRequestSchema, ListCreativeFormatsRequestSchema, ListTransformersRequestSchema, BuildCreativeRequestSchema, GetCreativeDeliveryRequestSchema, ListCreativesRequestSchema, SyncCreativesRequestSchema, GetSignalsRequestSchema, ActivateSignalRequestSchema, ListAccountsRequestSchema, SyncAccountsRequestSchema, SyncGovernanceRequestSchema, GetAccountFinancialsRequestSchema, ReportUsageRequestSchema, SyncEventSourcesRequestSchema, LogEventRequestSchema, SyncAudiencesRequestSchema, SyncCatalogsRequestSchema, CreatePropertyListRequestSchema, UpdatePropertyListRequestSchema, GetPropertyListRequestSchema, ListPropertyListsRequestSchema, DeletePropertyListRequestSchema, CreateCollectionListRequestSchema, UpdateCollectionListRequestSchema, GetCollectionListRequestSchema, ListCollectionListsRequestSchema, DeleteCollectionListRequestSchema, ListContentStandardsRequestSchema, GetContentStandardsRequestSchema, CreateContentStandardsRequestSchema, UpdateContentStandardsRequestSchema, CalibrateContentRequestSchema, ValidateContentDeliveryRequestSchema, GetMediaBuyArtifactsRequestSchema, GetCreativeFeaturesRequestSchema, SyncPlansRequestSchema, CheckGovernanceRequestSchema, ReportPlanOutcomeRequestSchema, GetPlanAuditLogsRequestSchema, SIGetOfferingRequestSchema, SIInitiateSessionRequestSchema, SISendMessageRequestSchema, SITerminateSessionRequestSchema, PreviewCreativeRequestSchema, GetBrandIdentityRequestSchema, GetRightsRequestSchema, AcquireRightsRequestSchema, UpdateRightsRequestSchema } from '../types/schemas.generated.mjs';
59
59
  import type { AcquireRightsAcquired, AcquireRightsPendingApproval, AcquireRightsRejected, UpdateRightsResponse, UpdateRightsSuccess, GetBrandIdentitySuccess, GetRightsSuccess } from '../types/core.generated.mjs';
60
- import type { GetProductsResponse, CreateMediaBuySuccess, CreateMediaBuyResponse, UpdateMediaBuySuccess, UpdateMediaBuyResponse, GetMediaBuysResponse, GetMediaBuyDeliveryResponse, GetTaskStatusResponse, ListTasksResponse, ListAccountsResponse, ListCreativeFormatsResponse, ListTransformersResponse, ProvidePerformanceFeedbackSuccess, ProvidePerformanceFeedbackResponse, BuildCreativeSuccess, BuildCreativeMultiSuccess, BuildCreativeResponse, GetCreativeDeliveryResponse, ListCreativesResponse, SyncCreativesError, SyncCreativesSuccess, SyncCreativesResponse, GetSignalsResponse, ActivateSignalSuccess, ActivateSignalResponse, GetAdCPCapabilitiesResponse, CreatePropertyListResponse, UpdatePropertyListResponse, GetPropertyListResponse, ListPropertyListsResponse, DeletePropertyListResponse, CreateCollectionListResponse, UpdateCollectionListResponse, GetCollectionListResponse, ListCollectionListsResponse, DeleteCollectionListResponse, SyncPlansResponse, CheckGovernanceResponse, ReportPlanOutcomeResponse, GetPlanAuditLogsResponse, SIGetOfferingResponse, SIInitiateSessionResponse, SISendMessageResponse, SITerminateSessionResponse, SyncEventSourcesSuccess, SyncEventSourcesResponse, LogEventSuccess, LogEventResponse, SyncAudiencesSuccess, SyncAudiencesResponse, SyncCatalogsSuccess, SyncCatalogsResponse, SyncAccountsSuccess, SyncAccountsResponse, SyncGovernanceSuccess, SyncGovernanceResponse, GetAccountFinancialsSuccess, GetAccountFinancialsResponse, GetCreativeFeaturesResponse, ReportUsageResponse, PreviewCreativeResponse, AccountReference, BrandReference, ListContentStandardsResponse, GetContentStandardsResponse, CreateContentStandardsResponse, UpdateContentStandardsResponse, CalibrateContentResponse, ValidateContentDeliveryResponse, GetMediaBuyArtifactsResponse } from '../types/tools.generated.mjs';
60
+ import type { GetProductsResponse, CreateMediaBuyResponse, UpdateMediaBuySuccess, UpdateMediaBuyResponse, GetMediaBuysResponse, GetMediaBuyDeliveryResponse, GetTaskStatusResponse, ListTasksResponse, ListAccountsResponse, ListCreativeFormatsResponse, ListTransformersResponse, ProvidePerformanceFeedbackSuccess, ProvidePerformanceFeedbackResponse, BuildCreativeSuccess, BuildCreativeMultiSuccess, BuildCreativeResponse, GetCreativeDeliveryResponse, ListCreativesResponse, SyncCreativesError, SyncCreativesSuccess, SyncCreativesResponse, GetSignalsResponse, ActivateSignalSuccess, ActivateSignalResponse, GetAdCPCapabilitiesResponse, CreatePropertyListResponse, UpdatePropertyListResponse, GetPropertyListResponse, ListPropertyListsResponse, DeletePropertyListResponse, CreateCollectionListResponse, UpdateCollectionListResponse, GetCollectionListResponse, ListCollectionListsResponse, DeleteCollectionListResponse, SyncPlansResponse, CheckGovernanceResponse, ReportPlanOutcomeResponse, GetPlanAuditLogsResponse, SIGetOfferingResponse, SIInitiateSessionResponse, SISendMessageResponse, SITerminateSessionResponse, SyncEventSourcesSuccess, SyncEventSourcesResponse, LogEventSuccess, LogEventResponse, SyncAudiencesSuccess, SyncAudiencesResponse, SyncCatalogsSuccess, SyncCatalogsResponse, SyncAccountsSuccess, SyncAccountsResponse, SyncGovernanceSuccess, SyncGovernanceResponse, GetAccountFinancialsSuccess, GetAccountFinancialsResponse, GetCreativeFeaturesResponse, ReportUsageResponse, PreviewCreativeResponse, AccountReference, BrandReference, ListContentStandardsResponse, GetContentStandardsResponse, CreateContentStandardsResponse, UpdateContentStandardsResponse, CalibrateContentResponse, ValidateContentDeliveryResponse, GetMediaBuyArtifactsResponse } from '../types/tools.generated.mjs';
61
61
  import type { MediaBuyFeatures, AccountCapabilities, CreativeCapabilities } from '../utils/capabilities.mjs';
62
62
  import type { MediaChannel } from '../types/tools.generated.mjs';
63
63
  import type { RequireCacheScopeWhenProducts, ServerPayload } from '../types/server-payload.mjs';
64
+ import type { CreateMediaBuyPayload as CreateMediaBuyServerPayload } from '../types/server-payload-aliases.mjs';
64
65
  export interface AdcpLogger {
65
66
  debug(message: string, data?: Record<string, unknown>): void;
66
67
  info(message: string, data?: Record<string, unknown>): void;
@@ -190,8 +191,10 @@ export declare function requireSessionKey<TAccount = unknown>(ctx: HandlerContex
190
191
  /**
191
192
  * Per-tool param / result / response types.
192
193
  *
193
- * `result` is the narrow success arm — what the framework's response
194
- * builders (`mediaBuyResponse`, `syncCreativesResponse`, ...) expect.
194
+ * `result` is the server-handler payload — normally the narrow success arm
195
+ * consumed by the framework's response builders (`mediaBuyResponse`,
196
+ * `syncCreativesResponse`, ...), plus a structured Error arm when the tool
197
+ * supports returning one directly.
195
198
  * `response` is the full AdCP response union (Success | Error | Submitted).
196
199
  * Handlers can return either shape: adapter patterns that produce
197
200
  * `Result<FooResponse, ...>` now type-check without `as any`, and the
@@ -206,7 +209,7 @@ export interface AdcpToolMap {
206
209
  };
207
210
  create_media_buy: {
208
211
  params: z.input<typeof CreateMediaBuyRequestSchema>;
209
- result: ServerPayload<CreateMediaBuySuccess>;
212
+ result: CreateMediaBuyServerPayload;
210
213
  response: CreateMediaBuyResponse;
211
214
  };
212
215
  update_media_buy: {
@@ -1509,8 +1512,9 @@ export interface BridgeMarker {
1509
1512
  * auto-hydration of `req.packages[i].product` on createMediaBuy,
1510
1513
  * default `resolveIdempotencyPrincipal` synthesis, capability projection,
1511
1514
  * async-task envelopes, status normalization via `StatusMappers`,
1512
- * multi-tenant routing via `TenantRegistry`, and webhook auto-emit on
1513
- * sync responses with `push_notification_config.url`.
1515
+ * multi-tenant routing via `TenantRegistry`, and async task completion
1516
+ * webhook delivery. Synchronous terminal responses remain inline unless
1517
+ * an adopter explicitly enables the non-conformant compatibility option.
1514
1518
  *
1515
1519
  * Reach for `createAdcpServer` directly only when you need fine control
1516
1520
  * over individual handlers, are mid-migration from a v5 codebase, or
@@ -57,10 +57,11 @@ import type { RevocationStore } from '../signing/revocation';
57
57
  import type { ContentDigestPolicy } from '../signing/types';
58
58
  import type { GetProductsRequestSchema, CreateMediaBuyRequestSchema, UpdateMediaBuyRequestSchema, GetMediaBuysRequestSchema, GetMediaBuyDeliveryRequestSchema, ProvidePerformanceFeedbackRequestSchema, GetTaskStatusRequestSchema, ListTasksRequestSchema, ListCreativeFormatsRequestSchema, ListTransformersRequestSchema, BuildCreativeRequestSchema, GetCreativeDeliveryRequestSchema, ListCreativesRequestSchema, SyncCreativesRequestSchema, GetSignalsRequestSchema, ActivateSignalRequestSchema, ListAccountsRequestSchema, SyncAccountsRequestSchema, SyncGovernanceRequestSchema, GetAccountFinancialsRequestSchema, ReportUsageRequestSchema, SyncEventSourcesRequestSchema, LogEventRequestSchema, SyncAudiencesRequestSchema, SyncCatalogsRequestSchema, CreatePropertyListRequestSchema, UpdatePropertyListRequestSchema, GetPropertyListRequestSchema, ListPropertyListsRequestSchema, DeletePropertyListRequestSchema, CreateCollectionListRequestSchema, UpdateCollectionListRequestSchema, GetCollectionListRequestSchema, ListCollectionListsRequestSchema, DeleteCollectionListRequestSchema, ListContentStandardsRequestSchema, GetContentStandardsRequestSchema, CreateContentStandardsRequestSchema, UpdateContentStandardsRequestSchema, CalibrateContentRequestSchema, ValidateContentDeliveryRequestSchema, GetMediaBuyArtifactsRequestSchema, GetCreativeFeaturesRequestSchema, SyncPlansRequestSchema, CheckGovernanceRequestSchema, ReportPlanOutcomeRequestSchema, GetPlanAuditLogsRequestSchema, SIGetOfferingRequestSchema, SIInitiateSessionRequestSchema, SISendMessageRequestSchema, SITerminateSessionRequestSchema, PreviewCreativeRequestSchema, GetBrandIdentityRequestSchema, GetRightsRequestSchema, AcquireRightsRequestSchema, UpdateRightsRequestSchema } from '../types/schemas.generated';
59
59
  import type { AcquireRightsAcquired, AcquireRightsPendingApproval, AcquireRightsRejected, UpdateRightsResponse, UpdateRightsSuccess, GetBrandIdentitySuccess, GetRightsSuccess } from '../types/core.generated';
60
- import type { GetProductsResponse, CreateMediaBuySuccess, CreateMediaBuyResponse, UpdateMediaBuySuccess, UpdateMediaBuyResponse, GetMediaBuysResponse, GetMediaBuyDeliveryResponse, GetTaskStatusResponse, ListTasksResponse, ListAccountsResponse, ListCreativeFormatsResponse, ListTransformersResponse, ProvidePerformanceFeedbackSuccess, ProvidePerformanceFeedbackResponse, BuildCreativeSuccess, BuildCreativeMultiSuccess, BuildCreativeResponse, GetCreativeDeliveryResponse, ListCreativesResponse, SyncCreativesError, SyncCreativesSuccess, SyncCreativesResponse, GetSignalsResponse, ActivateSignalSuccess, ActivateSignalResponse, GetAdCPCapabilitiesResponse, CreatePropertyListResponse, UpdatePropertyListResponse, GetPropertyListResponse, ListPropertyListsResponse, DeletePropertyListResponse, CreateCollectionListResponse, UpdateCollectionListResponse, GetCollectionListResponse, ListCollectionListsResponse, DeleteCollectionListResponse, SyncPlansResponse, CheckGovernanceResponse, ReportPlanOutcomeResponse, GetPlanAuditLogsResponse, SIGetOfferingResponse, SIInitiateSessionResponse, SISendMessageResponse, SITerminateSessionResponse, SyncEventSourcesSuccess, SyncEventSourcesResponse, LogEventSuccess, LogEventResponse, SyncAudiencesSuccess, SyncAudiencesResponse, SyncCatalogsSuccess, SyncCatalogsResponse, SyncAccountsSuccess, SyncAccountsResponse, SyncGovernanceSuccess, SyncGovernanceResponse, GetAccountFinancialsSuccess, GetAccountFinancialsResponse, GetCreativeFeaturesResponse, ReportUsageResponse, PreviewCreativeResponse, AccountReference, BrandReference, ListContentStandardsResponse, GetContentStandardsResponse, CreateContentStandardsResponse, UpdateContentStandardsResponse, CalibrateContentResponse, ValidateContentDeliveryResponse, GetMediaBuyArtifactsResponse } from '../types/tools.generated';
60
+ import type { GetProductsResponse, CreateMediaBuyResponse, UpdateMediaBuySuccess, UpdateMediaBuyResponse, GetMediaBuysResponse, GetMediaBuyDeliveryResponse, GetTaskStatusResponse, ListTasksResponse, ListAccountsResponse, ListCreativeFormatsResponse, ListTransformersResponse, ProvidePerformanceFeedbackSuccess, ProvidePerformanceFeedbackResponse, BuildCreativeSuccess, BuildCreativeMultiSuccess, BuildCreativeResponse, GetCreativeDeliveryResponse, ListCreativesResponse, SyncCreativesError, SyncCreativesSuccess, SyncCreativesResponse, GetSignalsResponse, ActivateSignalSuccess, ActivateSignalResponse, GetAdCPCapabilitiesResponse, CreatePropertyListResponse, UpdatePropertyListResponse, GetPropertyListResponse, ListPropertyListsResponse, DeletePropertyListResponse, CreateCollectionListResponse, UpdateCollectionListResponse, GetCollectionListResponse, ListCollectionListsResponse, DeleteCollectionListResponse, SyncPlansResponse, CheckGovernanceResponse, ReportPlanOutcomeResponse, GetPlanAuditLogsResponse, SIGetOfferingResponse, SIInitiateSessionResponse, SISendMessageResponse, SITerminateSessionResponse, SyncEventSourcesSuccess, SyncEventSourcesResponse, LogEventSuccess, LogEventResponse, SyncAudiencesSuccess, SyncAudiencesResponse, SyncCatalogsSuccess, SyncCatalogsResponse, SyncAccountsSuccess, SyncAccountsResponse, SyncGovernanceSuccess, SyncGovernanceResponse, GetAccountFinancialsSuccess, GetAccountFinancialsResponse, GetCreativeFeaturesResponse, ReportUsageResponse, PreviewCreativeResponse, AccountReference, BrandReference, ListContentStandardsResponse, GetContentStandardsResponse, CreateContentStandardsResponse, UpdateContentStandardsResponse, CalibrateContentResponse, ValidateContentDeliveryResponse, GetMediaBuyArtifactsResponse } from '../types/tools.generated';
61
61
  import type { MediaBuyFeatures, AccountCapabilities, CreativeCapabilities } from '../utils/capabilities';
62
62
  import type { MediaChannel } from '../types/tools.generated';
63
63
  import type { RequireCacheScopeWhenProducts, ServerPayload } from '../types/server-payload';
64
+ import type { CreateMediaBuyPayload as CreateMediaBuyServerPayload } from '../types/server-payload-aliases';
64
65
  export interface AdcpLogger {
65
66
  debug(message: string, data?: Record<string, unknown>): void;
66
67
  info(message: string, data?: Record<string, unknown>): void;
@@ -190,8 +191,10 @@ export declare function requireSessionKey<TAccount = unknown>(ctx: HandlerContex
190
191
  /**
191
192
  * Per-tool param / result / response types.
192
193
  *
193
- * `result` is the narrow success arm — what the framework's response
194
- * builders (`mediaBuyResponse`, `syncCreativesResponse`, ...) expect.
194
+ * `result` is the server-handler payload — normally the narrow success arm
195
+ * consumed by the framework's response builders (`mediaBuyResponse`,
196
+ * `syncCreativesResponse`, ...), plus a structured Error arm when the tool
197
+ * supports returning one directly.
195
198
  * `response` is the full AdCP response union (Success | Error | Submitted).
196
199
  * Handlers can return either shape: adapter patterns that produce
197
200
  * `Result<FooResponse, ...>` now type-check without `as any`, and the
@@ -206,7 +209,7 @@ export interface AdcpToolMap {
206
209
  };
207
210
  create_media_buy: {
208
211
  params: z.input<typeof CreateMediaBuyRequestSchema>;
209
- result: ServerPayload<CreateMediaBuySuccess>;
212
+ result: CreateMediaBuyServerPayload;
210
213
  response: CreateMediaBuyResponse;
211
214
  };
212
215
  update_media_buy: {
@@ -1509,8 +1512,9 @@ export interface BridgeMarker {
1509
1512
  * auto-hydration of `req.packages[i].product` on createMediaBuy,
1510
1513
  * default `resolveIdempotencyPrincipal` synthesis, capability projection,
1511
1514
  * async-task envelopes, status normalization via `StatusMappers`,
1512
- * multi-tenant routing via `TenantRegistry`, and webhook auto-emit on
1513
- * sync responses with `push_notification_config.url`.
1515
+ * multi-tenant routing via `TenantRegistry`, and async task completion
1516
+ * webhook delivery. Synchronous terminal responses remain inline unless
1517
+ * an adopter explicitly enables the non-conformant compatibility option.
1514
1518
  *
1515
1519
  * Reach for `createAdcpServer` directly only when you need fine control
1516
1520
  * over individual handlers, are mid-migration from a v5 codebase, or