@uncefact/untp-utils 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/README.md +23 -1
  2. package/build/artefacts/detect-version-from-context.d.ts +46 -0
  3. package/build/artefacts/detect-version-from-context.js +77 -0
  4. package/build/artefacts/detect-version-from-context.js.map +1 -0
  5. package/build/artefacts/detect-version-from-context.test.d.ts +1 -0
  6. package/build/artefacts/detect-version-from-context.test.js +69 -0
  7. package/build/artefacts/detect-version-from-context.test.js.map +1 -0
  8. package/build/artefacts/index.d.ts +3 -0
  9. package/build/artefacts/index.js +4 -0
  10. package/build/artefacts/index.js.map +1 -0
  11. package/build/artefacts/urls.d.ts +79 -0
  12. package/build/artefacts/urls.js +133 -0
  13. package/build/artefacts/urls.js.map +1 -0
  14. package/build/artefacts/urls.test.d.ts +1 -0
  15. package/build/artefacts/urls.test.js +91 -0
  16. package/build/artefacts/urls.test.js.map +1 -0
  17. package/build/artefacts/version.d.ts +9 -0
  18. package/build/artefacts/version.js +17 -0
  19. package/build/artefacts/version.js.map +1 -0
  20. package/build/artefacts/version.test.d.ts +1 -0
  21. package/build/artefacts/version.test.js +16 -0
  22. package/build/artefacts/version.test.js.map +1 -0
  23. package/build/cache/in-memory-ttl-cache.d.ts +2 -0
  24. package/build/cache/in-memory-ttl-cache.js +76 -0
  25. package/build/cache/in-memory-ttl-cache.js.map +1 -0
  26. package/build/cache/in-memory-ttl-cache.test.d.ts +1 -0
  27. package/build/cache/in-memory-ttl-cache.test.js +236 -0
  28. package/build/cache/in-memory-ttl-cache.test.js.map +1 -0
  29. package/build/cache/index.d.ts +2 -0
  30. package/build/cache/index.js +2 -0
  31. package/build/cache/index.js.map +1 -0
  32. package/build/cache/ttl-cache.d.ts +22 -0
  33. package/build/cache/ttl-cache.js +2 -0
  34. package/build/cache/ttl-cache.js.map +1 -0
  35. package/build/common/as-non-empty-string.d.ts +7 -0
  36. package/build/common/as-non-empty-string.js +10 -0
  37. package/build/common/as-non-empty-string.js.map +1 -0
  38. package/build/common/as-non-empty-string.test.d.ts +1 -0
  39. package/build/common/as-non-empty-string.test.js +25 -0
  40. package/build/common/as-non-empty-string.test.js.map +1 -0
  41. package/build/common/require-string.d.ts +12 -0
  42. package/build/common/require-string.js +27 -0
  43. package/build/common/require-string.js.map +1 -0
  44. package/build/common/require-string.test.d.ts +1 -0
  45. package/build/common/require-string.test.js +53 -0
  46. package/build/common/require-string.test.js.map +1 -0
  47. package/build/conformity-vocabulary/codes.d.ts +10 -0
  48. package/build/conformity-vocabulary/codes.js +10 -0
  49. package/build/conformity-vocabulary/codes.js.map +1 -0
  50. package/build/conformity-vocabulary/errors.d.ts +44 -0
  51. package/build/conformity-vocabulary/errors.js +68 -0
  52. package/build/conformity-vocabulary/errors.js.map +1 -0
  53. package/build/conformity-vocabulary/index.d.ts +6 -0
  54. package/build/conformity-vocabulary/index.js +7 -0
  55. package/build/conformity-vocabulary/index.js.map +1 -0
  56. package/build/conformity-vocabulary/parse-conformity-catalogue.d.ts +26 -0
  57. package/build/conformity-vocabulary/parse-conformity-catalogue.js +106 -0
  58. package/build/conformity-vocabulary/parse-conformity-catalogue.js.map +1 -0
  59. package/build/conformity-vocabulary/parse-conformity-catalogue.test.d.ts +1 -0
  60. package/build/conformity-vocabulary/parse-conformity-catalogue.test.js +327 -0
  61. package/build/conformity-vocabulary/parse-conformity-catalogue.test.js.map +1 -0
  62. package/build/conformity-vocabulary/parse-conformity-scheme.d.ts +28 -0
  63. package/build/conformity-vocabulary/parse-conformity-scheme.js +47 -0
  64. package/build/conformity-vocabulary/parse-conformity-scheme.js.map +1 -0
  65. package/build/conformity-vocabulary/parse-conformity-scheme.test.d.ts +1 -0
  66. package/build/conformity-vocabulary/parse-conformity-scheme.test.js +284 -0
  67. package/build/conformity-vocabulary/parse-conformity-scheme.test.js.map +1 -0
  68. package/build/conformity-vocabulary/parsers/v0-7-0.parser.d.ts +19 -0
  69. package/build/conformity-vocabulary/parsers/v0-7-0.parser.js +219 -0
  70. package/build/conformity-vocabulary/parsers/v0-7-0.parser.js.map +1 -0
  71. package/build/conformity-vocabulary/types.d.ts +180 -0
  72. package/build/conformity-vocabulary/types.js +2 -0
  73. package/build/conformity-vocabulary/types.js.map +1 -0
  74. package/build/conformity-vocabulary/validate-conformity-claim.d.ts +26 -0
  75. package/build/conformity-vocabulary/validate-conformity-claim.js +165 -0
  76. package/build/conformity-vocabulary/validate-conformity-claim.js.map +1 -0
  77. package/build/conformity-vocabulary/validate-conformity-claim.test.d.ts +1 -0
  78. package/build/conformity-vocabulary/validate-conformity-claim.test.js +478 -0
  79. package/build/conformity-vocabulary/validate-conformity-claim.test.js.map +1 -0
  80. package/build/http-headers/index.d.ts +5 -0
  81. package/build/http-headers/index.js +6 -0
  82. package/build/http-headers/index.js.map +1 -0
  83. package/build/http-headers/is-safe-header-value.d.ts +10 -0
  84. package/build/http-headers/is-safe-header-value.js +15 -0
  85. package/build/http-headers/is-safe-header-value.js.map +1 -0
  86. package/build/http-headers/is-safe-header-value.test.d.ts +1 -0
  87. package/build/http-headers/is-safe-header-value.test.js +37 -0
  88. package/build/http-headers/is-safe-header-value.test.js.map +1 -0
  89. package/build/http-headers/parse-entity-tag.d.ts +9 -0
  90. package/build/http-headers/parse-entity-tag.js +18 -0
  91. package/build/http-headers/parse-entity-tag.js.map +1 -0
  92. package/build/http-headers/parse-entity-tag.test.d.ts +1 -0
  93. package/build/http-headers/parse-entity-tag.test.js +41 -0
  94. package/build/http-headers/parse-entity-tag.test.js.map +1 -0
  95. package/build/http-headers/parse-imf-date.d.ts +13 -0
  96. package/build/http-headers/parse-imf-date.js +23 -0
  97. package/build/http-headers/parse-imf-date.js.map +1 -0
  98. package/build/http-headers/parse-imf-date.test.d.ts +1 -0
  99. package/build/http-headers/parse-imf-date.test.js +31 -0
  100. package/build/http-headers/parse-imf-date.test.js.map +1 -0
  101. package/build/http-headers/parse-media-type.d.ts +8 -0
  102. package/build/http-headers/parse-media-type.js +21 -0
  103. package/build/http-headers/parse-media-type.js.map +1 -0
  104. package/build/http-headers/parse-media-type.test.d.ts +1 -0
  105. package/build/http-headers/parse-media-type.test.js +39 -0
  106. package/build/http-headers/parse-media-type.test.js.map +1 -0
  107. package/build/http-headers/user-agent.d.ts +35 -0
  108. package/build/http-headers/user-agent.js +45 -0
  109. package/build/http-headers/user-agent.js.map +1 -0
  110. package/build/http-headers/user-agent.test.d.ts +1 -0
  111. package/build/http-headers/user-agent.test.js +27 -0
  112. package/build/http-headers/user-agent.test.js.map +1 -0
  113. package/build/index.d.ts +1 -1
  114. package/build/index.js +1 -1
  115. package/build/index.js.map +1 -1
  116. package/build/loaders/errors.d.ts +21 -0
  117. package/build/loaders/errors.js +45 -0
  118. package/build/loaders/errors.js.map +1 -0
  119. package/build/loaders/index.d.ts +3 -0
  120. package/build/loaders/index.js +4 -0
  121. package/build/loaders/index.js.map +1 -0
  122. package/build/loaders/jsonld-document-loader.d.ts +46 -0
  123. package/build/loaders/jsonld-document-loader.js +34 -0
  124. package/build/loaders/jsonld-document-loader.js.map +1 -0
  125. package/build/loaders/jsonld-document-loader.test.d.ts +1 -0
  126. package/build/loaders/jsonld-document-loader.test.js +71 -0
  127. package/build/loaders/jsonld-document-loader.test.js.map +1 -0
  128. package/build/loaders/schema-loader.d.ts +21 -0
  129. package/build/loaders/schema-loader.js +67 -0
  130. package/build/loaders/schema-loader.js.map +1 -0
  131. package/build/loaders/schema-loader.ssrf.test.d.ts +1 -0
  132. package/build/loaders/schema-loader.ssrf.test.js +55 -0
  133. package/build/loaders/schema-loader.ssrf.test.js.map +1 -0
  134. package/build/loaders/schema-loader.test.d.ts +1 -0
  135. package/build/loaders/schema-loader.test.js +162 -0
  136. package/build/loaders/schema-loader.test.js.map +1 -0
  137. package/build/multibase-digest/multibase-digest.d.ts +23 -0
  138. package/build/multibase-digest/multibase-digest.js +40 -0
  139. package/build/multibase-digest/multibase-digest.js.map +1 -1
  140. package/build/multibase-digest/multibase-digest.test.js +46 -0
  141. package/build/multibase-digest/multibase-digest.test.js.map +1 -1
  142. package/build/node/errors.d.ts +41 -0
  143. package/build/node/errors.js +80 -0
  144. package/build/node/errors.js.map +1 -0
  145. package/build/node/index.d.ts +3 -0
  146. package/build/node/index.js +4 -0
  147. package/build/node/index.js.map +1 -0
  148. package/build/node/is-private-ip.d.ts +41 -0
  149. package/build/node/is-private-ip.js +138 -0
  150. package/build/node/is-private-ip.js.map +1 -0
  151. package/build/node/is-private-ip.test.d.ts +1 -0
  152. package/build/node/is-private-ip.test.js +96 -0
  153. package/build/node/is-private-ip.test.js.map +1 -0
  154. package/build/node/validate-public-url.d.ts +49 -0
  155. package/build/node/validate-public-url.js +94 -0
  156. package/build/node/validate-public-url.js.map +1 -0
  157. package/build/node/validate-public-url.test.d.ts +1 -0
  158. package/build/node/validate-public-url.test.js +177 -0
  159. package/build/node/validate-public-url.test.js.map +1 -0
  160. package/build/resolvers/errors.d.ts +43 -0
  161. package/build/resolvers/errors.js +99 -0
  162. package/build/resolvers/errors.js.map +1 -0
  163. package/build/resolvers/index.d.ts +5 -0
  164. package/build/resolvers/index.js +6 -0
  165. package/build/resolvers/index.js.map +1 -0
  166. package/build/resolvers/resolve-document-if-changed.d.ts +48 -0
  167. package/build/resolvers/resolve-document-if-changed.js +42 -0
  168. package/build/resolvers/resolve-document-if-changed.js.map +1 -0
  169. package/build/resolvers/resolve-document-if-changed.test.d.ts +1 -0
  170. package/build/resolvers/resolve-document-if-changed.test.js +109 -0
  171. package/build/resolvers/resolve-document-if-changed.test.js.map +1 -0
  172. package/build/resolvers/resolve-document.d.ts +86 -0
  173. package/build/resolvers/resolve-document.js +239 -0
  174. package/build/resolvers/resolve-document.js.map +1 -0
  175. package/build/resolvers/resolve-document.test.d.ts +1 -0
  176. package/build/resolvers/resolve-document.test.js +299 -0
  177. package/build/resolvers/resolve-document.test.js.map +1 -0
  178. package/build/resolvers/resolve-json-document.d.ts +40 -0
  179. package/build/resolvers/resolve-json-document.js +42 -0
  180. package/build/resolvers/resolve-json-document.js.map +1 -0
  181. package/build/resolvers/resolve-json-document.test.d.ts +1 -0
  182. package/build/resolvers/resolve-json-document.test.js +61 -0
  183. package/build/resolvers/resolve-json-document.test.js.map +1 -0
  184. package/build/structured-error.d.ts +49 -0
  185. package/build/structured-error.js +17 -0
  186. package/build/structured-error.js.map +1 -0
  187. package/build/structured-error.test.d.ts +1 -0
  188. package/build/structured-error.test.js +112 -0
  189. package/build/structured-error.test.js.map +1 -0
  190. package/build/validation/describe-jsonld-failure.d.ts +50 -0
  191. package/build/validation/describe-jsonld-failure.js +104 -0
  192. package/build/validation/describe-jsonld-failure.js.map +1 -0
  193. package/build/validation/describe-jsonld-failure.test.d.ts +1 -0
  194. package/build/validation/describe-jsonld-failure.test.js +141 -0
  195. package/build/validation/describe-jsonld-failure.test.js.map +1 -0
  196. package/build/validation/errors.d.ts +40 -0
  197. package/build/validation/errors.js +75 -0
  198. package/build/validation/errors.js.map +1 -0
  199. package/build/validation/index.d.ts +4 -0
  200. package/build/validation/index.js +5 -0
  201. package/build/validation/index.js.map +1 -0
  202. package/build/validation/validate-against-schemas.d.ts +22 -0
  203. package/build/validation/validate-against-schemas.js +65 -0
  204. package/build/validation/validate-against-schemas.js.map +1 -0
  205. package/build/validation/validate-against-schemas.test.d.ts +1 -0
  206. package/build/validation/validate-against-schemas.test.js +169 -0
  207. package/build/validation/validate-against-schemas.test.js.map +1 -0
  208. package/build/validation/validate-jsonld.context-cache.test.d.ts +1 -0
  209. package/build/validation/validate-jsonld.context-cache.test.js +32 -0
  210. package/build/validation/validate-jsonld.context-cache.test.js.map +1 -0
  211. package/build/validation/validate-jsonld.d.ts +33 -0
  212. package/build/validation/validate-jsonld.js +99 -0
  213. package/build/validation/validate-jsonld.js.map +1 -0
  214. package/build/validation/validate-jsonld.ssrf.test.d.ts +1 -0
  215. package/build/validation/validate-jsonld.ssrf.test.js +108 -0
  216. package/build/validation/validate-jsonld.ssrf.test.js.map +1 -0
  217. package/build/validation/validate-jsonld.test.d.ts +1 -0
  218. package/build/validation/validate-jsonld.test.js +200 -0
  219. package/build/validation/validate-jsonld.test.js.map +1 -0
  220. package/package.json +64 -3
@@ -0,0 +1,86 @@
1
+ import { MultibaseDigest } from '../multibase-digest/index.js';
2
+ /**
3
+ * Defaults applied by {@link resolveDocument} (and, via composition,
4
+ * by {@link import('./resolve-document-if-changed.js').resolveDocumentIfChanged})
5
+ * when the caller does not supply a value.
6
+ */
7
+ export declare const RESOLVER_DEFAULTS: {
8
+ /** Body-size cap in bytes; exceeding throws {@link ResolverTooLargeError}. */
9
+ readonly maxResponseBytes: 1048576;
10
+ /** Total request timeout in milliseconds (DNS + connect + TLS + first byte + body). */
11
+ readonly totalTimeoutMs: 10000;
12
+ /** Maximum additional hops after the initial request; exceeding throws {@link ResolverTooManyRedirectsError}. */
13
+ readonly maxRedirects: 3;
14
+ /** Multibase digest algorithm used to hash response bodies. */
15
+ readonly digestAlgorithm: "sha2-256";
16
+ /** Multibase encoding used for response body digests. */
17
+ readonly digestEncoding: "base58btc";
18
+ };
19
+ /**
20
+ * Result of a fetch that produced a final response (any non-redirect status,
21
+ * including `304 Not Modified`).
22
+ *
23
+ * Only an allowlisted subset of response headers is exposed (the ones
24
+ * downstream consumers need: `etag` and `last-modified` for the conditional-
25
+ * fetch skip chain, `content-type` for body-type checks).
26
+ *
27
+ * All fields are `readonly`: a `LoadResult` is a frozen snapshot of one
28
+ * fetch and must not be mutated by consumers.
29
+ */
30
+ export interface LoadResult {
31
+ /** Final URL after redirect chasing (matches `url` when there were no redirects). */
32
+ readonly finalUrl: string;
33
+ /** HTTP status code of the final response (the redirect chain terminator, or `304`). */
34
+ readonly status: number;
35
+ /** Raw response body bytes. */
36
+ readonly body: Uint8Array;
37
+ /** Multibase digest of {@link body} (algorithm/encoding per {@link RESOLVER_DEFAULTS}). */
38
+ readonly bodyDigest: MultibaseDigest;
39
+ /** The response's `ETag` header if present. */
40
+ readonly etag?: string;
41
+ /** The response's `Last-Modified` header if present. */
42
+ readonly lastModified?: string;
43
+ /** The response's `Content-Type` header if present. */
44
+ readonly contentType?: string;
45
+ }
46
+ /**
47
+ * Options accepted by {@link resolveDocument} and
48
+ * {@link import('./resolve-document-if-changed.js').resolveDocumentIfChanged}.
49
+ *
50
+ * Header keys are case-insensitive on the wire; `resolveDocumentIfChanged`
51
+ * lowercases all keys before merging and reserves `if-none-match` /
52
+ * `if-modified-since` for its conditional-fetch wiring.
53
+ */
54
+ export interface ResolveDocumentOptions {
55
+ /** Override {@link RESOLVER_DEFAULTS.maxResponseBytes}. */
56
+ maxResponseBytes?: number;
57
+ /** Override {@link RESOLVER_DEFAULTS.totalTimeoutMs}. */
58
+ totalTimeoutMs?: number;
59
+ /** Override {@link RESOLVER_DEFAULTS.maxRedirects}. */
60
+ maxRedirects?: number;
61
+ /** Additional headers to send with the request (e.g. `Accept`). */
62
+ headers?: Record<string, string>;
63
+ /** Allowed URL schemes (forwarded to {@link import('../node/index.js').validatePublicUrl}). */
64
+ allowedSchemes?: readonly string[];
65
+ }
66
+ /**
67
+ * Fetches `url` with the standard SSRF / size / timeout / redirect guards
68
+ * applied, returning the response body + metadata as a {@link LoadResult}.
69
+ *
70
+ * Each redirect hop is re-validated through
71
+ * {@link import('../node/index.js').validatePublicUrl}, and the connection
72
+ * to each hop is pinned to the IP that validation resolved, so an upstream
73
+ * cannot redirect to a private URL or rebind its hostname between check
74
+ * and connect.
75
+ *
76
+ * @throws {UrlValidationError} for URL / scheme / hostname / DNS / private-address rejections from `validatePublicUrl`.
77
+ * @throws {ResolverNetworkError} when fetch rejects before producing a response.
78
+ * @throws {ResolverHttpError} on a non-2xx response status (with `.status`).
79
+ * @throws {ResolverTooLargeError} when the body exceeds the size cap (with `.limit`).
80
+ * @throws {ResolverTooManyRedirectsError} when the redirect chain exceeds the hop cap (with `.limit`).
81
+ * @throws {ResolverTimedOutError} when the total timeout fires (with `.timeoutMs`).
82
+ * @throws {ResolverRedirectMissingLocationError} for a 3xx with no / unparseable Location header.
83
+ *
84
+ * @see https://owasp.org/www-community/attacks/Server_Side_Request_Forgery
85
+ */
86
+ export declare function resolveDocument(url: string, options?: ResolveDocumentOptions): Promise<LoadResult>;
@@ -0,0 +1,239 @@
1
+ import { parseEntityTag, parseImfDate, parseMediaType, DEFAULT_USER_AGENT, USER_AGENT_ENV_VAR, } from '../http-headers/index.js';
2
+ import { MultibaseDigest } from '../multibase-digest/index.js';
3
+ import { validatePublicUrl } from '../node/index.js';
4
+ import { ResolverHttpError, ResolverNetworkError, ResolverRedirectMissingLocationError, ResolverTimedOutError, ResolverTooLargeError, ResolverTooManyRedirectsError, } from './errors.js';
5
+ /**
6
+ * Defaults applied by {@link resolveDocument} (and, via composition,
7
+ * by {@link import('./resolve-document-if-changed.js').resolveDocumentIfChanged})
8
+ * when the caller does not supply a value.
9
+ */
10
+ export const RESOLVER_DEFAULTS = {
11
+ /** Body-size cap in bytes; exceeding throws {@link ResolverTooLargeError}. */
12
+ maxResponseBytes: 1_048_576,
13
+ /** Total request timeout in milliseconds (DNS + connect + TLS + first byte + body). */
14
+ totalTimeoutMs: 10_000,
15
+ /** Maximum additional hops after the initial request; exceeding throws {@link ResolverTooManyRedirectsError}. */
16
+ maxRedirects: 3,
17
+ /** Multibase digest algorithm used to hash response bodies. */
18
+ digestAlgorithm: 'sha2-256',
19
+ /** Multibase encoding used for response body digests. */
20
+ digestEncoding: 'base58btc',
21
+ };
22
+ /**
23
+ * Merges the resolved `User-Agent` into the caller's headers. A caller-supplied
24
+ * `user-agent` (any casing) wins; otherwise the `RI_HTTP_USER_AGENT` env
25
+ * override (blank treated as unset), then the default. The override is not
26
+ * validated here: deployments validate it at boot via
27
+ * {@link isValidHttpUserAgent} (the RI does, in instrumentation.node.ts),
28
+ * and a value that slips through with control characters makes the fetch
29
+ * itself fail loudly rather than being silently replaced.
30
+ */
31
+ function withUserAgent(headers) {
32
+ const hasExplicit = Object.keys(headers ?? {}).some((key) => key.toLowerCase() === 'user-agent');
33
+ if (hasExplicit)
34
+ return headers;
35
+ const fromEnv = process.env[USER_AGENT_ENV_VAR];
36
+ const userAgent = fromEnv !== undefined && fromEnv.trim() !== '' ? fromEnv : DEFAULT_USER_AGENT;
37
+ return { ...headers, 'User-Agent': userAgent };
38
+ }
39
+ /**
40
+ * Fetches `url` with the standard SSRF / size / timeout / redirect guards
41
+ * applied, returning the response body + metadata as a {@link LoadResult}.
42
+ *
43
+ * Each redirect hop is re-validated through
44
+ * {@link import('../node/index.js').validatePublicUrl}, and the connection
45
+ * to each hop is pinned to the IP that validation resolved, so an upstream
46
+ * cannot redirect to a private URL or rebind its hostname between check
47
+ * and connect.
48
+ *
49
+ * @throws {UrlValidationError} for URL / scheme / hostname / DNS / private-address rejections from `validatePublicUrl`.
50
+ * @throws {ResolverNetworkError} when fetch rejects before producing a response.
51
+ * @throws {ResolverHttpError} on a non-2xx response status (with `.status`).
52
+ * @throws {ResolverTooLargeError} when the body exceeds the size cap (with `.limit`).
53
+ * @throws {ResolverTooManyRedirectsError} when the redirect chain exceeds the hop cap (with `.limit`).
54
+ * @throws {ResolverTimedOutError} when the total timeout fires (with `.timeoutMs`).
55
+ * @throws {ResolverRedirectMissingLocationError} for a 3xx with no / unparseable Location header.
56
+ *
57
+ * @see https://owasp.org/www-community/attacks/Server_Side_Request_Forgery
58
+ */
59
+ export async function resolveDocument(url, options) {
60
+ // Dynamic import: undici's module initialisation needs web globals
61
+ // (TextDecoder) that jsdom test environments lack, so it loads at fetch
62
+ // time to keep this module, and the resolvers barrel, importable there.
63
+ // Same pattern as validate-jsonld.ts's lazy jsonld import.
64
+ const { Agent, fetch: undiciFetch } = await import('undici');
65
+ const maxBytes = options?.maxResponseBytes ?? RESOLVER_DEFAULTS.maxResponseBytes;
66
+ const totalTimeoutMs = options?.totalTimeoutMs ?? RESOLVER_DEFAULTS.totalTimeoutMs;
67
+ const maxRedirects = options?.maxRedirects ?? RESOLVER_DEFAULTS.maxRedirects;
68
+ const controller = new AbortController();
69
+ const timeoutHandle = setTimeout(() => controller.abort(), totalTimeoutMs);
70
+ try {
71
+ let currentUrl = url;
72
+ for (let hop = 0; hop <= maxRedirects; hop += 1) {
73
+ const { address: pinnedAddress, family: pinnedFamily } = await validatePublicUrl(currentUrl, {
74
+ allowedSchemes: options?.allowedSchemes,
75
+ });
76
+ const dispatcher = new Agent({
77
+ connect: {
78
+ // undici resolves the connect target with `all: true` to support
79
+ // happy-eyeballs; the callback receives a `LookupAddress[]`.
80
+ // Return a single-entry array containing the IP we pinned via
81
+ // `validatePublicUrl`, so the connection target is exactly the
82
+ // address that the SSRF check validated.
83
+ lookup: (_hostname, _opts, cb) => cb(null, [{ address: pinnedAddress, family: pinnedFamily }]),
84
+ },
85
+ });
86
+ // The entire request lifecycle (fetch + status-check + body read +
87
+ // digest) sits inside one try/finally with the dispatcher close:
88
+ // `undici.Agent.close()` waits for active requests to drain, and a
89
+ // request is "drained" only once its body has been consumed. Closing
90
+ // before `readWithLimit` deadlocks the close on the unconsumed body.
91
+ try {
92
+ const response = await undiciFetch(currentUrl, {
93
+ method: 'GET',
94
+ redirect: 'manual',
95
+ signal: controller.signal,
96
+ headers: withUserAgent(options?.headers),
97
+ dispatcher,
98
+ });
99
+ // 304 Not Modified: body is intentionally empty; the caller maps it
100
+ // to `unchanged` in `resolveDocumentIfChanged`.
101
+ if (response.status === 304) {
102
+ const headerView = extractHeaders(response.headers);
103
+ const empty = new Uint8Array(0);
104
+ const bodyDigest = await computeDigest(empty);
105
+ return {
106
+ finalUrl: currentUrl,
107
+ status: 304,
108
+ body: empty,
109
+ bodyDigest,
110
+ etag: headerView.etag,
111
+ lastModified: headerView.lastModified,
112
+ contentType: headerView.contentType,
113
+ };
114
+ }
115
+ if (response.status >= 300 && response.status < 400) {
116
+ const location = response.headers.get('location');
117
+ if (!location) {
118
+ throw new ResolverRedirectMissingLocationError(currentUrl, response.status);
119
+ }
120
+ let nextUrl;
121
+ try {
122
+ nextUrl = new URL(location, currentUrl).toString();
123
+ }
124
+ catch (cause) {
125
+ throw new ResolverRedirectMissingLocationError(currentUrl, location, cause);
126
+ }
127
+ // Cancel the 3xx body so `dispatcher.close()` in `finally` can
128
+ // proceed without waiting for body drain.
129
+ if (response.body)
130
+ await response.body.cancel().catch(() => undefined);
131
+ currentUrl = nextUrl;
132
+ continue;
133
+ }
134
+ if (!response.ok) {
135
+ if (response.body)
136
+ await response.body.cancel().catch(() => undefined);
137
+ throw new ResolverHttpError(currentUrl, response.status);
138
+ }
139
+ const body = await readWithLimit(response, maxBytes, currentUrl, totalTimeoutMs);
140
+ const bodyDigest = await computeDigest(body);
141
+ const headerView = extractHeaders(response.headers);
142
+ return {
143
+ finalUrl: currentUrl,
144
+ status: response.status,
145
+ body,
146
+ bodyDigest,
147
+ etag: headerView.etag,
148
+ lastModified: headerView.lastModified,
149
+ contentType: headerView.contentType,
150
+ };
151
+ }
152
+ finally {
153
+ await dispatcher.close().catch(() => undefined);
154
+ }
155
+ }
156
+ throw new ResolverTooManyRedirectsError(url, maxRedirects);
157
+ }
158
+ catch (cause) {
159
+ if (cause instanceof Error && cause.code?.startsWith('resolver.')) {
160
+ throw cause;
161
+ }
162
+ // Re-throw UrlValidationErrors and other StructuredErrors unwrapped.
163
+ if (cause instanceof Error &&
164
+ typeof cause.code === 'string' &&
165
+ (cause.code.startsWith('url.') ||
166
+ cause.code.startsWith('resolver.'))) {
167
+ throw cause;
168
+ }
169
+ if (isAbortLikeError(cause)) {
170
+ throw new ResolverTimedOutError(url, totalTimeoutMs, cause);
171
+ }
172
+ throw new ResolverNetworkError(url, cause);
173
+ }
174
+ finally {
175
+ clearTimeout(timeoutHandle);
176
+ }
177
+ }
178
+ function isAbortLikeError(error) {
179
+ return error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError');
180
+ }
181
+ function extractHeaders(headers) {
182
+ const view = {};
183
+ const etag = parseEntityTag(headers.get('etag') ?? '');
184
+ if (etag !== undefined)
185
+ view.etag = etag;
186
+ const lastModified = parseImfDate(headers.get('last-modified') ?? '');
187
+ if (lastModified !== undefined)
188
+ view.lastModified = lastModified;
189
+ const contentType = parseMediaType(headers.get('content-type') ?? '');
190
+ if (contentType !== undefined)
191
+ view.contentType = contentType;
192
+ return view;
193
+ }
194
+ async function computeDigest(body) {
195
+ return MultibaseDigest.fromData(body, {
196
+ algorithm: RESOLVER_DEFAULTS.digestAlgorithm,
197
+ base: RESOLVER_DEFAULTS.digestEncoding,
198
+ });
199
+ }
200
+ async function readWithLimit(response, limit, url, totalTimeoutMs) {
201
+ if (!response.body)
202
+ return new Uint8Array(0);
203
+ const reader = response.body.getReader();
204
+ const chunks = [];
205
+ let total = 0;
206
+ try {
207
+ // eslint-disable-next-line no-constant-condition
208
+ while (true) {
209
+ const { value, done } = await reader.read();
210
+ if (done)
211
+ break;
212
+ if (!value)
213
+ continue;
214
+ total += value.byteLength;
215
+ if (total > limit) {
216
+ await reader.cancel().catch(() => undefined);
217
+ throw new ResolverTooLargeError(url, limit);
218
+ }
219
+ chunks.push(value);
220
+ }
221
+ }
222
+ catch (error) {
223
+ // Release the reader so the upstream socket can be recycled.
224
+ await reader.cancel().catch(() => undefined);
225
+ if (error instanceof ResolverTooLargeError)
226
+ throw error;
227
+ if (isAbortLikeError(error))
228
+ throw new ResolverTimedOutError(url, totalTimeoutMs, error);
229
+ throw new ResolverNetworkError(url, error);
230
+ }
231
+ const merged = new Uint8Array(total);
232
+ let offset = 0;
233
+ for (const chunk of chunks) {
234
+ merged.set(chunk, offset);
235
+ offset += chunk.byteLength;
236
+ }
237
+ return merged;
238
+ }
239
+ //# sourceMappingURL=resolve-document.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resolve-document.js","sourceRoot":"","sources":["../../src/resolvers/resolve-document.ts"],"names":[],"mappings":"AACA,OAAO,EACL,cAAc,EACd,YAAY,EACZ,cAAc,EACd,kBAAkB,EAClB,kBAAkB,GAEnB,MAAM,0BAA0B,CAAC;AAClC,OAAO,EAAE,eAAe,EAA8C,MAAM,8BAA8B,CAAC;AAC3G,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AACrD,OAAO,EACL,iBAAiB,EACjB,oBAAoB,EACpB,oCAAoC,EACpC,qBAAqB,EACrB,qBAAqB,EACrB,6BAA6B,GAC9B,MAAM,aAAa,CAAC;AAErB;;;;GAIG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAAG;IAC/B,8EAA8E;IAC9E,gBAAgB,EAAE,SAAS;IAC3B,uFAAuF;IACvF,cAAc,EAAE,MAAM;IACtB,iHAAiH;IACjH,YAAY,EAAE,CAAC;IACf,+DAA+D;IAC/D,eAAe,EAAE,UAAU;IAC3B,yDAAyD;IACzD,cAAc,EAAE,WAAW;CAO5B,CAAC;AAEF;;;;;;;;GAQG;AACH,SAAS,aAAa,CAAC,OAA2C;IAChE,MAAM,WAAW,GAAG,MAAM,CAAC,IAAI,CAAC,OAAO,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,WAAW,EAAE,KAAK,YAAY,CAAC,CAAC;IACjG,IAAI,WAAW;QAAE,OAAO,OAAiC,CAAC;IAC1D,MAAM,OAAO,GAAG,OAAO,CAAC,GAAG,CAAC,kBAAkB,CAAC,CAAC;IAChD,MAAM,SAAS,GAAG,OAAO,KAAK,SAAS,IAAI,OAAO,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,kBAAkB,CAAC;IAChG,OAAO,EAAE,GAAG,OAAO,EAAE,YAAY,EAAE,SAAS,EAAE,CAAC;AACjD,CAAC;AAmDD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CAAC,GAAW,EAAE,OAAgC;IACjF,mEAAmE;IACnE,wEAAwE;IACxE,wEAAwE;IACxE,2DAA2D;IAC3D,MAAM,EAAE,KAAK,EAAE,KAAK,EAAE,WAAW,EAAE,GAAG,MAAM,MAAM,CAAC,QAAQ,CAAC,CAAC;IAC7D,MAAM,QAAQ,GAAG,OAAO,EAAE,gBAAgB,IAAI,iBAAiB,CAAC,gBAAgB,CAAC;IACjF,MAAM,cAAc,GAAG,OAAO,EAAE,cAAc,IAAI,iBAAiB,CAAC,cAAc,CAAC;IACnF,MAAM,YAAY,GAAG,OAAO,EAAE,YAAY,IAAI,iBAAiB,CAAC,YAAY,CAAC;IAE7E,MAAM,UAAU,GAAG,IAAI,eAAe,EAAE,CAAC;IACzC,MAAM,aAAa,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,UAAU,CAAC,KAAK,EAAE,EAAE,cAAc,CAAC,CAAC;IAE3E,IAAI,CAAC;QACH,IAAI,UAAU,GAAG,GAAG,CAAC;QACrB,KAAK,IAAI,GAAG,GAAG,CAAC,EAAE,GAAG,IAAI,YAAY,EAAE,GAAG,IAAI,CAAC,EAAE,CAAC;YAChD,MAAM,EAAE,OAAO,EAAE,aAAa,EAAE,MAAM,EAAE,YAAY,EAAE,GAAG,MAAM,iBAAiB,CAAC,UAAU,EAAE;gBAC3F,cAAc,EAAE,OAAO,EAAE,cAAc;aACxC,CAAC,CAAC;YAEH,MAAM,UAAU,GAAG,IAAI,KAAK,CAAC;gBAC3B,OAAO,EAAE;oBACP,iEAAiE;oBACjE,6DAA6D;oBAC7D,8DAA8D;oBAC9D,+DAA+D;oBAC/D,yCAAyC;oBACzC,MAAM,EAAE,CAAC,SAAS,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,CAAC,EAAE,CAAC,IAAI,EAAE,CAAC,EAAE,OAAO,EAAE,aAAa,EAAE,MAAM,EAAE,YAAY,EAAE,CAAC,CAAC;iBAC/F;aACF,CAAC,CAAC;YAEH,mEAAmE;YACnE,iEAAiE;YACjE,mEAAmE;YACnE,qEAAqE;YACrE,qEAAqE;YACrE,IAAI,CAAC;gBACH,MAAM,QAAQ,GAAG,MAAM,WAAW,CAAC,UAAU,EAAE;oBAC7C,MAAM,EAAE,KAAK;oBACb,QAAQ,EAAE,QAAQ;oBAClB,MAAM,EAAE,UAAU,CAAC,MAAM;oBACzB,OAAO,EAAE,aAAa,CAAC,OAAO,EAAE,OAAO,CAAC;oBACxC,UAAU;iBACX,CAAC,CAAC;gBAEH,oEAAoE;gBACpE,gDAAgD;gBAChD,IAAI,QAAQ,CAAC,MAAM,KAAK,GAAG,EAAE,CAAC;oBAC5B,MAAM,UAAU,GAAG,cAAc,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC;oBACpD,MAAM,KAAK,GAAG,IAAI,UAAU,CAAC,CAAC,CAAC,CAAC;oBAChC,MAAM,UAAU,GAAG,MAAM,aAAa,CAAC,KAAK,CAAC,CAAC;oBAC9C,OAAO;wBACL,QAAQ,EAAE,UAAU;wBACpB,MAAM,EAAE,GAAG;wBACX,IAAI,EAAE,KAAK;wBACX,UAAU;wBACV,IAAI,EAAE,UAAU,CAAC,IAAI;wBACrB,YAAY,EAAE,UAAU,CAAC,YAAY;wBACrC,WAAW,EAAE,UAAU,CAAC,WAAW;qBACpC,CAAC;gBACJ,CAAC;gBAED,IAAI,QAAQ,CAAC,MAAM,IAAI,GAAG,IAAI,QAAQ,CAAC,MAAM,GAAG,GAAG,EAAE,CAAC;oBACpD,MAAM,QAAQ,GAAG,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC;oBAClD,IAAI,CAAC,QAAQ,EAAE,CAAC;wBACd,MAAM,IAAI,oCAAoC,CAAC,UAAU,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAC;oBAC9E,CAAC;oBACD,IAAI,OAAe,CAAC;oBACpB,IAAI,CAAC;wBACH,OAAO,GAAG,IAAI,GAAG,CAAC,QAAQ,EAAE,UAAU,CAAC,CAAC,QAAQ,EAAE,CAAC;oBACrD,CAAC;oBAAC,OAAO,KAAK,EAAE,CAAC;wBACf,MAAM,IAAI,oCAAoC,CAAC,UAAU,EAAE,QAAQ,EAAE,KAAK,CAAC,CAAC;oBAC9E,CAAC;oBACD,+DAA+D;oBAC/D,0CAA0C;oBAC1C,IAAI,QAAQ,CAAC,IAAI;wBAAE,MAAM,QAAQ,CAAC,IAAI,CAAC,MAAM,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC;oBACvE,UAAU,GAAG,OAAO,CAAC;oBACrB,SAAS;gBACX,CAAC;gBAED,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;oBACjB,IAAI,QAAQ,CAAC,IAAI;wBAAE,MAAM,QAAQ,CAAC,IAAI,CAAC,MAAM,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC;oBACvE,MAAM,IAAI,iBAAiB,CAAC,UAAU,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAC;gBAC3D,CAAC;gBAED,MAAM,IAAI,GAAG,MAAM,aAAa,CAAC,QAAQ,EAAE,QAAQ,EAAE,UAAU,EAAE,cAAc,CAAC,CAAC;gBACjF,MAAM,UAAU,GAAG,MAAM,aAAa,CAAC,IAAI,CAAC,CAAC;gBAC7C,MAAM,UAAU,GAAG,cAAc,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC;gBACpD,OAAO;oBACL,QAAQ,EAAE,UAAU;oBACpB,MAAM,EAAE,QAAQ,CAAC,MAAM;oBACvB,IAAI;oBACJ,UAAU;oBACV,IAAI,EAAE,UAAU,CAAC,IAAI;oBACrB,YAAY,EAAE,UAAU,CAAC,YAAY;oBACrC,WAAW,EAAE,UAAU,CAAC,WAAW;iBACpC,CAAC;YACJ,CAAC;oBAAS,CAAC;gBACT,MAAM,UAAU,CAAC,KAAK,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC;YAClD,CAAC;QACH,CAAC;QAED,MAAM,IAAI,6BAA6B,CAAC,GAAG,EAAE,YAAY,CAAC,CAAC;IAC7D,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAI,KAAK,YAAY,KAAK,IAAK,KAAmC,CAAC,IAAI,EAAE,UAAU,CAAC,WAAW,CAAC,EAAE,CAAC;YACjG,MAAM,KAAK,CAAC;QACd,CAAC;QACD,qEAAqE;QACrE,IACE,KAAK,YAAY,KAAK;YACtB,OAAQ,KAAoC,CAAC,IAAI,KAAK,QAAQ;YAC9D,CAAE,KAAkC,CAAC,IAAI,CAAC,UAAU,CAAC,MAAM,CAAC;gBACzD,KAAkC,CAAC,IAAI,CAAC,UAAU,CAAC,WAAW,CAAC,CAAC,EACnE,CAAC;YACD,MAAM,KAAK,CAAC;QACd,CAAC;QACD,IAAI,gBAAgB,CAAC,KAAK,CAAC,EAAE,CAAC;YAC5B,MAAM,IAAI,qBAAqB,CAAC,GAAG,EAAE,cAAc,EAAE,KAAK,CAAC,CAAC;QAC9D,CAAC;QACD,MAAM,IAAI,oBAAoB,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;IAC7C,CAAC;YAAS,CAAC;QACT,YAAY,CAAC,aAAa,CAAC,CAAC;IAC9B,CAAC;AACH,CAAC;AAED,SAAS,gBAAgB,CAAC,KAAc;IACtC,OAAO,KAAK,YAAY,KAAK,IAAI,CAAC,KAAK,CAAC,IAAI,KAAK,YAAY,IAAI,KAAK,CAAC,IAAI,KAAK,cAAc,CAAC,CAAC;AAClG,CAAC;AAQD,SAAS,cAAc,CAAC,OAAgB;IACtC,MAAM,IAAI,GAAe,EAAE,CAAC;IAC5B,MAAM,IAAI,GAAG,cAAc,CAAC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC;IACvD,IAAI,IAAI,KAAK,SAAS;QAAE,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;IACzC,MAAM,YAAY,GAAG,YAAY,CAAC,OAAO,CAAC,GAAG,CAAC,eAAe,CAAC,IAAI,EAAE,CAAC,CAAC;IACtE,IAAI,YAAY,KAAK,SAAS;QAAE,IAAI,CAAC,YAAY,GAAG,YAAY,CAAC;IACjE,MAAM,WAAW,GAAG,cAAc,CAAC,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC,IAAI,EAAE,CAAC,CAAC;IACtE,IAAI,WAAW,KAAK,SAAS;QAAE,IAAI,CAAC,WAAW,GAAG,WAAW,CAAC;IAC9D,OAAO,IAAI,CAAC;AACd,CAAC;AAED,KAAK,UAAU,aAAa,CAAC,IAAgB;IAC3C,OAAO,eAAe,CAAC,QAAQ,CAAC,IAAI,EAAE;QACpC,SAAS,EAAE,iBAAiB,CAAC,eAAe;QAC5C,IAAI,EAAE,iBAAiB,CAAC,cAAc;KACvC,CAAC,CAAC;AACL,CAAC;AAED,KAAK,UAAU,aAAa,CAC1B,QAAqD,EACrD,KAAa,EACb,GAAW,EACX,cAAsB;IAEtB,IAAI,CAAC,QAAQ,CAAC,IAAI;QAAE,OAAO,IAAI,UAAU,CAAC,CAAC,CAAC,CAAC;IAE7C,MAAM,MAAM,GAAG,QAAQ,CAAC,IAAI,CAAC,SAAS,EAAE,CAAC;IACzC,MAAM,MAAM,GAAiB,EAAE,CAAC;IAChC,IAAI,KAAK,GAAG,CAAC,CAAC;IAEd,IAAI,CAAC;QACH,iDAAiD;QACjD,OAAO,IAAI,EAAE,CAAC;YACZ,MAAM,EAAE,KAAK,EAAE,IAAI,EAAE,GAAG,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;YAC5C,IAAI,IAAI;gBAAE,MAAM;YAChB,IAAI,CAAC,KAAK;gBAAE,SAAS;YACrB,KAAK,IAAI,KAAK,CAAC,UAAU,CAAC;YAC1B,IAAI,KAAK,GAAG,KAAK,EAAE,CAAC;gBAClB,MAAM,MAAM,CAAC,MAAM,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC;gBAC7C,MAAM,IAAI,qBAAqB,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;YAC9C,CAAC;YACD,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QACrB,CAAC;IACH,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,6DAA6D;QAC7D,MAAM,MAAM,CAAC,MAAM,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC;QAC7C,IAAI,KAAK,YAAY,qBAAqB;YAAE,MAAM,KAAK,CAAC;QACxD,IAAI,gBAAgB,CAAC,KAAK,CAAC;YAAE,MAAM,IAAI,qBAAqB,CAAC,GAAG,EAAE,cAAc,EAAE,KAAK,CAAC,CAAC;QACzF,MAAM,IAAI,oBAAoB,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;IAC7C,CAAC;IAED,MAAM,MAAM,GAAG,IAAI,UAAU,CAAC,KAAK,CAAC,CAAC;IACrC,IAAI,MAAM,GAAG,CAAC,CAAC;IACf,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;QAC3B,MAAM,CAAC,GAAG,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;QAC1B,MAAM,IAAI,KAAK,CAAC,UAAU,CAAC;IAC7B,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC"}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,299 @@
1
+ import { jest } from '@jest/globals';
2
+ import { ReadableStream } from 'node:stream/web';
3
+ import { PrivateAddressError } from '../node/index.js';
4
+ import { ResolverHttpError, ResolverNetworkError, ResolverRedirectMissingLocationError, ResolverTimedOutError, ResolverTooLargeError, ResolverTooManyRedirectsError, } from './errors.js';
5
+ const undiciFetch = jest.fn();
6
+ let lastAgentClose = jest.fn(() => Promise.resolve());
7
+ class FakeAgent {
8
+ close;
9
+ constructor() {
10
+ this.close = jest.fn(() => Promise.resolve());
11
+ lastAgentClose = this.close;
12
+ }
13
+ }
14
+ jest.unstable_mockModule('undici', () => ({
15
+ Agent: FakeAgent,
16
+ fetch: undiciFetch,
17
+ }));
18
+ const validatePublicUrl = jest.fn();
19
+ jest.unstable_mockModule('../node/index.js', () => ({
20
+ validatePublicUrl,
21
+ PrivateAddressError,
22
+ }));
23
+ const { resolveDocument } = await import('./resolve-document.js');
24
+ const { DEFAULT_USER_AGENT, USER_AGENT_ENV_VAR } = await import('../http-headers/index.js');
25
+ function makeResponse(opts) {
26
+ const status = opts.status ?? 200;
27
+ const headers = new Headers(opts.headers ?? {});
28
+ let bodyStream;
29
+ if (opts.body === null || opts.body === undefined) {
30
+ bodyStream = null;
31
+ }
32
+ else {
33
+ const bytes = typeof opts.body === 'string' ? new TextEncoder().encode(opts.body) : opts.body;
34
+ bodyStream = new ReadableStream({
35
+ start(controller) {
36
+ controller.enqueue(bytes);
37
+ controller.close();
38
+ },
39
+ });
40
+ }
41
+ return {
42
+ status,
43
+ ok: opts.ok ?? (status >= 200 && status < 300),
44
+ headers,
45
+ body: bodyStream,
46
+ };
47
+ }
48
+ function resolvedAddress(address = '1.1.1.1', family = 4) {
49
+ return { address, family };
50
+ }
51
+ describe('resolveDocument', () => {
52
+ beforeEach(() => {
53
+ undiciFetch.mockReset();
54
+ validatePublicUrl.mockReset();
55
+ });
56
+ describe('SSRF guard', () => {
57
+ it('propagates validatePublicUrl errors unwrapped and never calls fetch', async () => {
58
+ validatePublicUrl.mockRejectedValue(new PrivateAddressError('https://attacker.example/', ['10.0.0.1']));
59
+ await expect(resolveDocument('https://attacker.example/')).rejects.toBeInstanceOf(PrivateAddressError);
60
+ expect(undiciFetch).not.toHaveBeenCalled();
61
+ });
62
+ });
63
+ describe('happy path', () => {
64
+ it('returns a LoadResult with body, digest, and the allowlisted headers', async () => {
65
+ validatePublicUrl.mockResolvedValue(resolvedAddress());
66
+ undiciFetch.mockResolvedValue(makeResponse({
67
+ body: '{"hello":"world"}',
68
+ headers: {
69
+ 'content-type': 'application/json',
70
+ etag: '"abc"',
71
+ 'last-modified': 'Wed, 21 May 2026 12:00:00 GMT',
72
+ 'set-cookie': 'session=secret', // must NOT appear on the LoadResult
73
+ 'x-internal-trace': 'noisy', // ditto
74
+ },
75
+ }));
76
+ const result = await resolveDocument('https://example.com/doc.json');
77
+ expect(result.status).toBe(200);
78
+ expect(result.finalUrl).toBe('https://example.com/doc.json');
79
+ expect(result.etag).toBe('"abc"');
80
+ expect(result.lastModified).toBe('Wed, 21 May 2026 12:00:00 GMT');
81
+ expect(result.contentType).toBe('application/json');
82
+ expect(new TextDecoder().decode(result.body)).toBe('{"hello":"world"}');
83
+ expect(result.bodyDigest.toString()).toMatch(/^z[1-9A-HJ-NP-Za-km-z]+$/); // multibase base58btc
84
+ // Arbitrary upstream headers must NOT be echoed onto the LoadResult.
85
+ const valueAsRecord = result;
86
+ expect(valueAsRecord['set-cookie']).toBeUndefined();
87
+ expect(valueAsRecord['x-internal-trace']).toBeUndefined();
88
+ });
89
+ });
90
+ describe('HTTP errors', () => {
91
+ it('throws ResolverHttpError for a 4xx response with status attached', async () => {
92
+ validatePublicUrl.mockResolvedValue(resolvedAddress());
93
+ undiciFetch.mockResolvedValue(makeResponse({ status: 404, ok: false, body: 'not found' }));
94
+ const error = (await resolveDocument('https://example.com/missing').catch((e) => e));
95
+ expect(error).toBeInstanceOf(ResolverHttpError);
96
+ expect(error.status).toBe(404);
97
+ });
98
+ it('throws ResolverHttpError for a 5xx response', async () => {
99
+ validatePublicUrl.mockResolvedValue(resolvedAddress());
100
+ undiciFetch.mockResolvedValue(makeResponse({ status: 503, ok: false, body: 'busy' }));
101
+ const error = (await resolveDocument('https://example.com/busy').catch((e) => e));
102
+ expect(error).toBeInstanceOf(ResolverHttpError);
103
+ expect(error.status).toBe(503);
104
+ });
105
+ });
106
+ describe('network errors and timeouts', () => {
107
+ it('throws ResolverNetworkError on fetch rejection', async () => {
108
+ validatePublicUrl.mockResolvedValue(resolvedAddress());
109
+ undiciFetch.mockRejectedValue(new Error('ECONNREFUSED'));
110
+ const error = (await resolveDocument('https://example.com/').catch((e) => e));
111
+ expect(error).toBeInstanceOf(ResolverNetworkError);
112
+ expect(error.cause).toBeInstanceOf(Error);
113
+ });
114
+ it('throws ResolverTimedOutError when the fetch is aborted', async () => {
115
+ validatePublicUrl.mockResolvedValue(resolvedAddress());
116
+ const abort = new Error('aborted');
117
+ abort.name = 'AbortError';
118
+ undiciFetch.mockRejectedValue(abort);
119
+ await expect(resolveDocument('https://example.com/')).rejects.toBeInstanceOf(ResolverTimedOutError);
120
+ });
121
+ it('throws ResolverNetworkError when the response body stream rejects mid-read', async () => {
122
+ validatePublicUrl.mockResolvedValue(resolvedAddress());
123
+ const erroringStream = new ReadableStream({
124
+ pull(controller) {
125
+ controller.error(new Error('connection reset by peer'));
126
+ },
127
+ });
128
+ undiciFetch.mockResolvedValue({
129
+ status: 200,
130
+ ok: true,
131
+ headers: new Headers(),
132
+ body: erroringStream,
133
+ });
134
+ await expect(resolveDocument('https://example.com/')).rejects.toBeInstanceOf(ResolverNetworkError);
135
+ });
136
+ });
137
+ describe('size limit', () => {
138
+ it('throws ResolverTooLargeError with limit attached when the body exceeds maxResponseBytes', async () => {
139
+ validatePublicUrl.mockResolvedValue(resolvedAddress());
140
+ const big = new Uint8Array(2048);
141
+ undiciFetch.mockResolvedValue(makeResponse({ body: big }));
142
+ const error = (await resolveDocument('https://example.com/big', { maxResponseBytes: 1024 }).catch((e) => e));
143
+ expect(error).toBeInstanceOf(ResolverTooLargeError);
144
+ expect(error.limit).toBe(1024);
145
+ });
146
+ });
147
+ describe('redirects', () => {
148
+ it('follows a redirect chain within the cap', async () => {
149
+ validatePublicUrl.mockResolvedValue(resolvedAddress());
150
+ undiciFetch
151
+ .mockResolvedValueOnce(makeResponse({ status: 301, headers: { location: 'https://example.com/next' }, body: null }))
152
+ .mockResolvedValueOnce(makeResponse({ body: 'final' }));
153
+ const result = await resolveDocument('https://example.com/start');
154
+ expect(result.finalUrl).toBe('https://example.com/next');
155
+ expect(new TextDecoder().decode(result.body)).toBe('final');
156
+ expect(undiciFetch).toHaveBeenCalledTimes(2);
157
+ });
158
+ it('re-validates each redirect target through validatePublicUrl', async () => {
159
+ validatePublicUrl
160
+ .mockResolvedValueOnce(resolvedAddress('1.1.1.1'))
161
+ .mockRejectedValueOnce(new PrivateAddressError('https://internal.example/', ['10.0.0.1']));
162
+ undiciFetch.mockResolvedValueOnce(makeResponse({ status: 301, headers: { location: 'https://internal.example/' }, body: null }));
163
+ await expect(resolveDocument('https://public.example/')).rejects.toBeInstanceOf(PrivateAddressError);
164
+ expect(validatePublicUrl).toHaveBeenCalledTimes(2);
165
+ // Second hop's URL must be the Location target, not the original.
166
+ expect(validatePublicUrl).toHaveBeenNthCalledWith(2, 'https://internal.example/', expect.anything());
167
+ expect(undiciFetch).toHaveBeenCalledTimes(1);
168
+ });
169
+ it('throws ResolverRedirectMissingLocationError when Location is not a parseable URL', async () => {
170
+ validatePublicUrl.mockResolvedValue(resolvedAddress());
171
+ const malformed = 'http://[';
172
+ undiciFetch.mockResolvedValue(makeResponse({ status: 301, headers: { location: malformed }, body: null }));
173
+ const error = (await resolveDocument('https://example.com/').catch((e) => e));
174
+ expect(error).toBeInstanceOf(ResolverRedirectMissingLocationError);
175
+ expect(error.received).toBe(malformed);
176
+ });
177
+ it('throws ResolverTooManyRedirectsError with limit attached when the chain exceeds the cap', async () => {
178
+ validatePublicUrl.mockResolvedValue(resolvedAddress());
179
+ undiciFetch.mockResolvedValue(makeResponse({ status: 301, headers: { location: 'https://example.com/loop' }, body: null }));
180
+ const error = (await resolveDocument('https://example.com/start', { maxRedirects: 1 }).catch((e) => e));
181
+ expect(error).toBeInstanceOf(ResolverTooManyRedirectsError);
182
+ expect(error.limit).toBe(1);
183
+ });
184
+ it('throws ResolverRedirectMissingLocationError when a 3xx has no Location', async () => {
185
+ validatePublicUrl.mockResolvedValue(resolvedAddress());
186
+ undiciFetch.mockResolvedValue(makeResponse({ status: 302, body: null }));
187
+ await expect(resolveDocument('https://example.com/')).rejects.toBeInstanceOf(ResolverRedirectMissingLocationError);
188
+ });
189
+ });
190
+ describe('dispatcher lifecycle', () => {
191
+ it('closes the dispatcher only after the response body has been fully read', async () => {
192
+ validatePublicUrl.mockResolvedValue(resolvedAddress());
193
+ const bodyPull = jest.fn();
194
+ const slowStream = new ReadableStream({
195
+ async pull(controller) {
196
+ bodyPull();
197
+ controller.enqueue(new TextEncoder().encode('chunk'));
198
+ controller.close();
199
+ },
200
+ });
201
+ undiciFetch.mockResolvedValue({
202
+ status: 200,
203
+ ok: true,
204
+ headers: new Headers(),
205
+ body: slowStream,
206
+ });
207
+ await resolveDocument('https://example.com/');
208
+ expect(bodyPull).toHaveBeenCalled();
209
+ expect(lastAgentClose).toHaveBeenCalled();
210
+ const pullOrder = bodyPull.mock.invocationCallOrder[0];
211
+ const closeOrder = lastAgentClose.mock.invocationCallOrder[0];
212
+ expect(closeOrder).toBeGreaterThan(pullOrder);
213
+ });
214
+ });
215
+ describe('User-Agent', () => {
216
+ afterEach(() => {
217
+ delete process.env[USER_AGENT_ENV_VAR];
218
+ });
219
+ function sentHeaders(callIndex = 0) {
220
+ const init = undiciFetch.mock.calls[callIndex][1];
221
+ return init.headers ?? {};
222
+ }
223
+ it('sends the default User-Agent when the caller supplies none', async () => {
224
+ validatePublicUrl.mockResolvedValue(resolvedAddress());
225
+ undiciFetch.mockResolvedValue(makeResponse({ body: '{}' }));
226
+ await resolveDocument('https://example.com/doc.json', { headers: { Accept: 'application/json' } });
227
+ expect(sentHeaders()['User-Agent']).toBe(DEFAULT_USER_AGENT);
228
+ expect(sentHeaders()['Accept']).toBe('application/json');
229
+ });
230
+ it('sends the default User-Agent when no headers are supplied at all', async () => {
231
+ validatePublicUrl.mockResolvedValue(resolvedAddress());
232
+ undiciFetch.mockResolvedValue(makeResponse({ body: '{}' }));
233
+ await resolveDocument('https://example.com/doc.json');
234
+ expect(sentHeaders()['User-Agent']).toBe(DEFAULT_USER_AGENT);
235
+ });
236
+ it('prefers the RI_HTTP_USER_AGENT environment override to the default', async () => {
237
+ process.env[USER_AGENT_ENV_VAR] = 'acme-operator/1.0';
238
+ validatePublicUrl.mockResolvedValue(resolvedAddress());
239
+ undiciFetch.mockResolvedValue(makeResponse({ body: '{}' }));
240
+ await resolveDocument('https://example.com/doc.json');
241
+ expect(sentHeaders()['User-Agent']).toBe('acme-operator/1.0');
242
+ });
243
+ it('falls back to the default when the environment override is blank (blank is treated as unset)', async () => {
244
+ process.env[USER_AGENT_ENV_VAR] = ' ';
245
+ validatePublicUrl.mockResolvedValue(resolvedAddress());
246
+ undiciFetch.mockResolvedValue(makeResponse({ body: '{}' }));
247
+ await resolveDocument('https://example.com/doc.json');
248
+ expect(sentHeaders()['User-Agent']).toBe(DEFAULT_USER_AGENT);
249
+ });
250
+ it('passes a non-blank override through unvalidated (boot-time validation owns rejection)', async () => {
251
+ process.env[USER_AGENT_ENV_VAR] = 'operator-agent/1.0';
252
+ validatePublicUrl.mockResolvedValue(resolvedAddress());
253
+ undiciFetch.mockResolvedValue(makeResponse({ body: '{}' }));
254
+ await resolveDocument('https://example.com/doc.json');
255
+ expect(sentHeaders()['User-Agent']).toBe('operator-agent/1.0');
256
+ });
257
+ it('passes an invalid override through raw rather than silently substituting the default', async () => {
258
+ process.env[USER_AGENT_ENV_VAR] = 'evil\r\nX-Injected: 1';
259
+ validatePublicUrl.mockResolvedValue(resolvedAddress());
260
+ undiciFetch.mockResolvedValue(makeResponse({ body: '{}' }));
261
+ await resolveDocument('https://example.com/doc.json');
262
+ // The load-bearing rule: no request-time fallback. The raw value reaches
263
+ // the fetch layer, which rejects it loudly in real undici; deployments
264
+ // that want fail-fast validate at boot instead.
265
+ expect(sentHeaders()['User-Agent']).toBe('evil\r\nX-Injected: 1');
266
+ });
267
+ it.each(['user-agent', 'User-Agent', 'USER-AGENT', 'uSeR-aGeNt'])('never overrides a caller-supplied %s header', async (headerName) => {
268
+ process.env[USER_AGENT_ENV_VAR] = 'acme-operator/1.0';
269
+ validatePublicUrl.mockResolvedValue(resolvedAddress());
270
+ undiciFetch.mockResolvedValue(makeResponse({ body: '{}' }));
271
+ await resolveDocument('https://example.com/doc.json', { headers: { [headerName]: 'caller/2.0' } });
272
+ const headers = sentHeaders();
273
+ expect(headers[headerName]).toBe('caller/2.0');
274
+ const uaKeys = Object.keys(headers).filter((k) => k.toLowerCase() === 'user-agent');
275
+ expect(uaKeys).toEqual([headerName]);
276
+ });
277
+ it('sends the User-Agent on every redirect hop', async () => {
278
+ validatePublicUrl.mockResolvedValue(resolvedAddress());
279
+ undiciFetch
280
+ .mockResolvedValueOnce(makeResponse({ status: 302, headers: { location: 'https://example.com/final' }, body: null }))
281
+ .mockResolvedValueOnce(makeResponse({ body: '{}' }));
282
+ await resolveDocument('https://example.com/doc.json');
283
+ expect(undiciFetch).toHaveBeenCalledTimes(2);
284
+ expect(sentHeaders(0)['User-Agent']).toBe(DEFAULT_USER_AGENT);
285
+ expect(sentHeaders(1)['User-Agent']).toBe(DEFAULT_USER_AGENT);
286
+ });
287
+ });
288
+ describe('304 Not Modified', () => {
289
+ it('returns a LoadResult with status 304 and an empty body', async () => {
290
+ validatePublicUrl.mockResolvedValue(resolvedAddress());
291
+ undiciFetch.mockResolvedValue(makeResponse({ status: 304, body: null, headers: { etag: '"abc"' } }));
292
+ const result = await resolveDocument('https://example.com/');
293
+ expect(result.status).toBe(304);
294
+ expect(result.body.byteLength).toBe(0);
295
+ expect(result.etag).toBe('"abc"');
296
+ });
297
+ });
298
+ });
299
+ //# sourceMappingURL=resolve-document.test.js.map