@fedify/vocab-runtime 2.4.0-pr.936.41 → 2.4.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 (138) hide show
  1. package/deno.json +4 -2
  2. package/dist/{tests/docloader-Ck8bcBir.mjs → contexts-BK1CqBR5.cjs} +164 -275
  3. package/dist/{tests/docloader-S_gQZ2tt.cjs → contexts-DPuJ4UYL.js} +160 -294
  4. package/dist/{docloader-C_dir7Xb.d.ts → docloader-CYwqh5Df.d.cts} +66 -2
  5. package/dist/{docloader-D2DTRiyA.d.cts → docloader-CYwqh5Df.d.ts} +66 -2
  6. package/dist/internal/jsonld-cache.cjs +70 -1
  7. package/dist/internal/jsonld-cache.d.cts +35 -2
  8. package/dist/internal/jsonld-cache.d.ts +35 -2
  9. package/dist/internal/jsonld-cache.js +67 -2
  10. package/dist/internal/portable-dereference.cjs +721 -0
  11. package/dist/internal/portable-dereference.d.cts +349 -0
  12. package/dist/internal/portable-dereference.d.ts +349 -0
  13. package/dist/internal/portable-dereference.js +702 -0
  14. package/dist/internal/signed-representation.cjs +378 -0
  15. package/dist/internal/signed-representation.d.cts +144 -0
  16. package/dist/internal/signed-representation.d.ts +144 -0
  17. package/dist/internal/signed-representation.js +371 -0
  18. package/dist/jsonld.cjs +2 -2
  19. package/dist/mod.cjs +711 -4527
  20. package/dist/mod.d.cts +400 -8
  21. package/dist/mod.d.ts +400 -9
  22. package/dist/mod.js +692 -4522
  23. package/dist/portable-DqtfLy_1.d.cts +160 -0
  24. package/dist/portable-DtWsu2yU.d.ts +160 -0
  25. package/dist/tests/body-CYiu9CO-.mjs +130 -0
  26. package/dist/tests/body-CkEwROsn.cjs +153 -0
  27. package/dist/tests/body.test.cjs +89 -0
  28. package/dist/tests/body.test.d.cts +1 -0
  29. package/dist/tests/body.test.d.mts +1 -0
  30. package/dist/tests/body.test.mjs +90 -0
  31. package/dist/tests/contexts-CIKsin4e.mjs +4512 -0
  32. package/dist/tests/contexts-DizzBjz4.cjs +4523 -0
  33. package/dist/tests/decimal.test.cjs +8 -7
  34. package/dist/tests/decimal.test.mjs +8 -6
  35. package/dist/tests/digest-3FeH2Y-Q.cjs +176 -0
  36. package/dist/tests/digest-COC7xDiQ.mjs +141 -0
  37. package/dist/tests/digest.test.cjs +102 -0
  38. package/dist/tests/digest.test.d.cts +1 -0
  39. package/dist/tests/digest.test.d.mts +1 -0
  40. package/dist/tests/digest.test.mjs +103 -0
  41. package/dist/tests/docloader-C3YFl6y4.cjs +396 -0
  42. package/dist/tests/docloader-CvmS4sVq.mjs +373 -0
  43. package/dist/tests/docloader.test.cjs +692 -39
  44. package/dist/tests/docloader.test.mjs +686 -34
  45. package/dist/tests/internal/multicodec.test.cjs +2 -3
  46. package/dist/tests/internal/multicodec.test.mjs +2 -2
  47. package/dist/tests/internal/portable-dereference.test.cjs +172 -0
  48. package/dist/tests/internal/portable-dereference.test.d.cts +1 -0
  49. package/dist/tests/internal/portable-dereference.test.d.mts +1 -0
  50. package/dist/tests/internal/portable-dereference.test.mjs +173 -0
  51. package/dist/tests/jsonld-cache-BPQmOZWD.mjs +342 -0
  52. package/dist/tests/jsonld-cache-C07AyNOY.cjs +397 -0
  53. package/dist/tests/jsonld-cache.test.cjs +101 -298
  54. package/dist/tests/jsonld-cache.test.mjs +94 -289
  55. package/dist/tests/{key-_wXwomh_.cjs → key-C-AYkdJJ.cjs} +11 -4
  56. package/dist/tests/{key-CDGDH_vC.mjs → key-C2Db_TAJ.mjs} +11 -3
  57. package/dist/tests/key.test.cjs +6 -5
  58. package/dist/tests/key.test.mjs +6 -4
  59. package/dist/tests/langstr.test.cjs +4 -4
  60. package/dist/tests/langstr.test.mjs +2 -2
  61. package/dist/tests/link.test.cjs +2 -3
  62. package/dist/tests/link.test.mjs +2 -2
  63. package/dist/tests/multibase/multibase.test.cjs +8 -9
  64. package/dist/tests/multibase/multibase.test.mjs +6 -6
  65. package/dist/tests/{multibase-Bz_UUDtL.cjs → multibase-B5Mea7Ip.cjs} +19 -2
  66. package/dist/tests/{multibase-B4bvakyA.mjs → multibase-BPnF_L4e.mjs} +12 -1
  67. package/dist/tests/portable-dereference-BNXtgg5U.cjs +278 -0
  68. package/dist/tests/portable-dereference-DRE5bz-l.mjs +249 -0
  69. package/dist/tests/portable-media-BLKllU56.cjs +153 -0
  70. package/dist/tests/portable-media-DRSGBORB.mjs +148 -0
  71. package/dist/tests/portable-media.test.cjs +222 -0
  72. package/dist/tests/portable-media.test.d.cts +1 -0
  73. package/dist/tests/portable-media.test.d.mts +1 -0
  74. package/dist/tests/portable-media.test.mjs +223 -0
  75. package/dist/tests/portable-workers.test.cjs +36 -0
  76. package/dist/tests/portable-workers.test.d.cts +2 -0
  77. package/dist/tests/portable-workers.test.d.mts +2 -0
  78. package/dist/tests/portable-workers.test.mjs +35 -0
  79. package/dist/tests/{request-uk51rkhO.cjs → request-8vPWtV1-.cjs} +10 -4
  80. package/dist/tests/{request-C8CaGwtt.mjs → request-DRaOaTqD.mjs} +8 -2
  81. package/dist/tests/request.test.cjs +7 -4
  82. package/dist/tests/request.test.mjs +5 -2
  83. package/dist/tests/signed-representation.test.cjs +588 -0
  84. package/dist/tests/signed-representation.test.d.cts +1 -0
  85. package/dist/tests/signed-representation.test.d.mts +1 -0
  86. package/dist/tests/signed-representation.test.mjs +589 -0
  87. package/dist/tests/temporal.test.cjs +1 -2
  88. package/dist/tests/temporal.test.mjs +1 -1
  89. package/dist/tests/url-BNakuZ8k.cjs +998 -0
  90. package/dist/tests/url-DMxmp7ZG.mjs +859 -0
  91. package/dist/tests/url.test.cjs +489 -6
  92. package/dist/tests/url.test.mjs +489 -5
  93. package/dist/url-DrGTR8yv.cjs +993 -0
  94. package/dist/url-Dzyp-NsC.js +860 -0
  95. package/package.json +27 -4
  96. package/scripts/test-bun.mjs +17 -0
  97. package/src/body.test.ts +125 -0
  98. package/src/body.ts +152 -0
  99. package/src/contexts/cid-v1.json +114 -0
  100. package/src/contexts/fep-22cd.json +21 -0
  101. package/src/contexts/miscellany.json +17 -0
  102. package/src/contexts.ts +39 -1
  103. package/src/digest.test.ts +220 -0
  104. package/src/digest.ts +229 -0
  105. package/src/docloader.test.ts +918 -27
  106. package/src/docloader.ts +320 -105
  107. package/src/internal/jsonld-cache.ts +97 -1
  108. package/src/internal/portable-dereference.test.ts +254 -0
  109. package/src/internal/portable-dereference.ts +1000 -0
  110. package/src/internal/signed-representation.ts +565 -0
  111. package/src/jsonld-cache.test.ts +89 -0
  112. package/src/key.test.ts +9 -0
  113. package/src/key.ts +11 -1
  114. package/src/mod.ts +28 -0
  115. package/src/multibase/multibase.test.ts +5 -5
  116. package/src/portable-media.test.ts +293 -0
  117. package/src/portable-media.ts +264 -0
  118. package/src/portable-workers.test.ts +81 -0
  119. package/src/portable.ts +180 -0
  120. package/src/preprocessor.ts +7 -0
  121. package/src/request.test.ts +10 -1
  122. package/src/request.ts +9 -1
  123. package/src/signed-representation.test.ts +338 -0
  124. package/src/url.test.ts +823 -3
  125. package/src/url.ts +700 -23
  126. package/tsdown.config.ts +2 -0
  127. package/dist/tests/url-CsOV_B_P.cjs +0 -482
  128. package/dist/tests/url-Du7RQQgP.mjs +0 -392
  129. package/dist/url-DD4F0ULf.cjs +0 -483
  130. package/dist/url-DGVbSVVi.js +0 -393
  131. /package/dist/{chunk-M78iaK0I.cjs → rolldown-runtime-B7lfambq.cjs} +0 -0
  132. /package/dist/tests/{langstr-CbAxaeEZ.cjs → langstr-C4Fl80ae.cjs} +0 -0
  133. /package/dist/tests/{langstr-Di5AvKpB.mjs → langstr-CQ26J_L7.mjs} +0 -0
  134. /package/dist/tests/{link-NUUWCdnK.mjs → link-Cevmc87v.mjs} +0 -0
  135. /package/dist/tests/{link-FguCydMA.cjs → link-DlKm8bEr.cjs} +0 -0
  136. /package/dist/tests/{multicodec-CxGVGa91.cjs → multicodec-CLRPeW4N.cjs} +0 -0
  137. /package/dist/tests/{multicodec-CyFp54fI.mjs → multicodec-CRIj_05H.mjs} +0 -0
  138. /package/dist/tests/{chunk-C2EiDwsr.cjs → rolldown-runtime-emK7D4bc.cjs} +0 -0
package/src/url.ts CHANGED
@@ -1,11 +1,25 @@
1
1
  import type { LookupAddress } from "node:dns";
2
- import { lookup } from "node:dns/promises";
2
+ // FIXME: the default dns import exists since tests can stub `dns.lookup()`.
3
+ // This should be replaced by injecting the lookup function such as an internal
4
+ // option on `validatePublicUrl()`
5
+ import dns from "node:dns/promises";
3
6
  import { isIP } from "node:net";
4
7
 
5
8
  export class UrlError extends Error {
6
- constructor(message: string) {
7
- super(message);
9
+ /**
10
+ * The failure category. Defaults to `"disallowed"`.
11
+ * `"dns"` includes lookup errors and results with no usable IP addresses.
12
+ * @since 2.0.29
13
+ */
14
+ readonly reason: "dns" | "disallowed";
15
+
16
+ constructor(
17
+ message: string,
18
+ options?: { cause?: unknown; reason?: "dns" | "disallowed" },
19
+ ) {
20
+ super(message, options);
8
21
  this.name = "UrlError";
22
+ this.reason = options?.reason ?? "disallowed";
9
23
  }
10
24
  }
11
25
 
@@ -15,9 +29,66 @@ const INVALID_PERCENT_ENCODING_PATTERN = /%(?![0-9A-Fa-f]{2})/;
15
29
  const PERCENT_ENCODING_PATTERN = /%[0-9A-Fa-f]{2}/g;
16
30
  const DID_SCHEME_PATTERN = /^did:/i;
17
31
  const DID_PATTERN = /^did:[a-z0-9]+:[-A-Za-z0-9._%]+(?::[-A-Za-z0-9._%]+)*$/i;
32
+ const DOT_SEGMENT_PATTERN = /(?:^|\/)(?:\.|%2e){1,2}(?=\/|$)/i;
33
+
34
+ function prepareUrlInput(iri: string): string {
35
+ // WHATWG URL removes these characters before it recognizes dot segments.
36
+ // Check the same spelling it will parse, while retaining the raw path for
37
+ // the comparison-only canonicalizer below.
38
+ let prepared = iri.replace(/[\t\n\r]/g, "");
39
+ while (prepared.length > 0 && prepared.charCodeAt(0) <= 0x20) {
40
+ prepared = prepared.slice(1);
41
+ }
42
+ while (
43
+ prepared.length > 0 && prepared.charCodeAt(prepared.length - 1) <= 0x20
44
+ ) {
45
+ prepared = prepared.slice(0, -1);
46
+ }
47
+ return prepared;
48
+ }
49
+
50
+ function assertPortablePathCanBeParsed(iri: string): void {
51
+ const match = prepareUrlInput(iri).match(PORTABLE_IRI_PATTERN);
52
+ if (match != null && DOT_SEGMENT_PATTERN.test(match[3])) {
53
+ throw new TypeError(
54
+ "Portable ActivityPub IRI paths with dot segments cannot be represented as URLs.",
55
+ );
56
+ }
57
+ }
58
+
59
+ function assertCompatiblePathCanBeParsed(raw: string, parsed: URL): void {
60
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return;
61
+ const prepared = prepareUrlInput(raw);
62
+ const match = prepared.match(/^https?:[\/\\]*[^/?#\\]*([^?#]*)/i);
63
+ if (match == null) return;
64
+ const rawPath = match[1].replace(/\\/g, "/");
65
+ const compatiblePath =
66
+ parsed.pathname.startsWith(COMPATIBLE_ID_PATH_PREFIX) &&
67
+ COMPATIBLE_ID_DID_PATTERN.test(
68
+ parsed.pathname.slice(COMPATIBLE_ID_PATH_PREFIX.length),
69
+ );
70
+ const rawCompatiblePath = rawPath.startsWith(COMPATIBLE_ID_PATH_PREFIX) &&
71
+ COMPATIBLE_ID_DID_PATTERN.test(
72
+ rawPath.slice(COMPATIBLE_ID_PATH_PREFIX.length),
73
+ );
74
+ if (
75
+ (compatiblePath || rawCompatiblePath) && DOT_SEGMENT_PATTERN.test(rawPath)
76
+ ) {
77
+ throw new TypeError(
78
+ "FEP-ef61 compatible identifier paths with dot segments cannot be represented as URLs.",
79
+ );
80
+ }
81
+ }
18
82
 
19
83
  /**
20
- * Parses a JSON-LD `@id` value as an IRI.
84
+ * Parses a JSON-LD `@id` value as an IRI, including FEP-ef61 portable
85
+ * ActivityPub IRIs. See {@link parseIri} for how IRIs are parsed.
86
+ * @param id The `@id` value.
87
+ * @param base The base IRI to resolve a relative `@id` against.
88
+ * @returns The parsed IRI, or `undefined` if `id` is missing or a blank node
89
+ * identifier.
90
+ * @throws {TypeError} If `id` is not a valid IRI.
91
+ * @since 2.4.0
21
92
  */
22
93
  export function parseJsonLdId(
23
94
  id: string | undefined,
@@ -33,11 +104,28 @@ export function parseJsonLdId(
33
104
 
34
105
  /**
35
106
  * Parses an IRI as a URL, including FEP-ef61 portable ActivityPub IRIs.
107
+ * Portable URI and FEP-ef61 compatible identifier strings whose path contains
108
+ * a `.` or `..` segment, including percent-encoded spellings, throw a
109
+ * `TypeError`: JavaScript `URL` would otherwise identify a different object.
110
+ * This also applies to compatible identifier strings used as relative bases.
111
+ * A `URL` argument may already have lost such segments before this function
112
+ * receives it.
113
+ *
114
+ * Portable IRIs, e.g., `ap://did:key:z6Mk.../actor`, cannot be represented by
115
+ * JavaScript `URL` as they are, so the returned `URL` keeps the DID authority
116
+ * percent-encoded, e.g., `ap+ef61://did%3Akey%3Az6Mk.../actor`. Use
117
+ * {@link formatIri} to get the canonical string back.
118
+ * @param iri The IRI to parse.
119
+ * @param base The base IRI to resolve a relative IRI against.
120
+ * @returns The parsed IRI.
121
+ * @throws {TypeError} If the IRI is malformed.
122
+ * @since 2.4.0
36
123
  */
37
124
  export function parseIri(iri: string | URL, base?: string | URL): URL {
38
125
  if (iri instanceof URL) {
39
126
  return normalizePortableUrl(iri) ?? new URL(iri.href);
40
127
  }
128
+ assertPortablePathCanBeParsed(iri);
41
129
  const portable = parsePortableIri(iri);
42
130
  if (portable != null) return portable;
43
131
  base = normalizeBaseIri(base);
@@ -45,20 +133,38 @@ export function parseIri(iri: string | URL, base?: string | URL): URL {
45
133
  return parseAtUri(iri);
46
134
  }
47
135
  const parsed = new URL(iri, base);
136
+ assertCompatiblePathCanBeParsed(iri, parsed);
48
137
  return normalizePortableUrl(parsed) ?? parsed;
49
138
  }
50
139
 
51
140
  /**
52
141
  * Formats a URL as an IRI, including FEP-ef61 portable ActivityPub IRIs.
142
+ * Portable URI and FEP-ef61 compatible identifier strings with dot segments
143
+ * throw a `TypeError` because their paths cannot be represented by JavaScript
144
+ * `URL` without normalization.
145
+ *
146
+ * Portable IRIs are formatted with the `ap+ef61:` scheme and a decoded DID
147
+ * authority, even if they were parsed from `ap:` IRIs. FEP-ef61 currently
148
+ * recommends the `ap:` scheme, so a future major version of Fedify may change
149
+ * the scheme this function produces. Do not compare its results as strings to
150
+ * tell whether two portable IRIs identify the same object; use
151
+ * `arePortableUrisEqual()` instead.
152
+ * @param iri The IRI to format.
153
+ * @returns The formatted IRI. A string that cannot be parsed as a URL at all
154
+ * is returned unchanged.
155
+ * @throws {TypeError} If the IRI is a malformed portable IRI or FEP-ef61
156
+ * compatible identifier, e.g., one with dot segments.
157
+ * @since 2.4.0
53
158
  */
54
159
  export function formatIri(iri: string | URL): string {
160
+ if (typeof iri === "string") assertPortablePathCanBeParsed(iri);
55
161
  const parsed = parsePortableIri(iri instanceof URL ? iri.href : iri);
56
162
  if (parsed == null) {
57
- return iri instanceof URL
58
- ? iri.href
59
- : URL.canParse(iri)
60
- ? new URL(iri).href
61
- : iri;
163
+ if (iri instanceof URL) return iri.href;
164
+ if (!URL.canParse(iri)) return iri;
165
+ const url = new URL(iri);
166
+ assertCompatiblePathCanBeParsed(iri, url);
167
+ return url.href;
62
168
  }
63
169
  const authority = decodePortableAuthority(parsed.host);
64
170
  return `ap+ef61://${authority}${parsed.pathname}${parsed.search}${parsed.hash}`;
@@ -73,6 +179,12 @@ export function formatIri(iri: string | URL): string {
73
179
  * string, not a `URL` object, because JavaScript `URL` normalizes opaque path
74
180
  * segments before Fedify can compare them.
75
181
  *
182
+ * The `ap+ef61:` scheme of the result is a choice of Fedify's, whereas
183
+ * FEP-ef61 currently canonicalizes portable URIs with the `ap:` scheme.
184
+ * A future major version of Fedify may change the scheme of the result, so
185
+ * do not persist the result as the only copy of an identifier; keep the
186
+ * original URI, and canonicalize it again when comparing.
187
+ *
76
188
  * @param input The raw portable ActivityPub URI string to canonicalize.
77
189
  * @returns The canonical portable ActivityPub URI string.
78
190
  * @throws {TypeError} If the input is not a valid portable ActivityPub IRI.
@@ -182,7 +294,20 @@ export function haveSameFe34Origin(
182
294
  }
183
295
 
184
296
  /**
185
- * Checks whether two IRIs have the same origin.
297
+ * Checks whether two IRIs have the same origin. Unlike comparing
298
+ * `URL.origin`, which is `"null"` for URLs with non-special schemes, this
299
+ * compares FEP-ef61 portable ActivityPub IRIs by their schemes and DIDs, so
300
+ * that `ap:` and `ap+ef61:` IRIs of the same DID with decoded or
301
+ * percent-encoded authorities have the same origin. Other IRIs with
302
+ * a non-special scheme and a host are compared by their schemes and hosts.
303
+ *
304
+ * This is not an FEP-fe34 origin check: an `ap+ef61:` IRI and the `did:key`
305
+ * verification method of the same DID have different origins here. Use
306
+ * {@link haveSameFe34Origin} for that.
307
+ * @param left The first IRI.
308
+ * @param right The second IRI.
309
+ * @returns `true` if the IRIs have the same origin.
310
+ * @since 2.4.0
186
311
  */
187
312
  export function haveSameIriOrigin(left: URL, right: URL): boolean {
188
313
  return getComparableIriOrigin(left) === getComparableIriOrigin(right);
@@ -238,14 +363,18 @@ function parsePortableIri(iri: string): URL | null {
238
363
 
239
364
  function normalizePortableUrl(iri: URL): URL | null {
240
365
  if (iri.protocol !== "ap:" && iri.protocol !== "ap+ef61:") return null;
241
- return parsePortableIri(
242
- `ap+ef61://${iri.host}${iri.pathname}${iri.search}${iri.hash}`,
243
- );
366
+ const raw = `ap+ef61://${iri.host}${iri.pathname}${iri.search}${iri.hash}`;
367
+ assertPortablePathCanBeParsed(raw);
368
+ return parsePortableIri(raw);
244
369
  }
245
370
 
246
371
  function normalizeBaseIri(base?: string | URL): string | URL | undefined {
247
372
  if (base == null) return undefined;
248
373
  if (base instanceof URL) return normalizePortableUrl(base) ?? base;
374
+ assertPortablePathCanBeParsed(base);
375
+ if (URL.canParse(base)) {
376
+ assertCompatiblePathCanBeParsed(base, new URL(base));
377
+ }
249
378
  return parsePortableIri(base) ??
250
379
  (base.startsWith("at://") && !URL.canParse(".", base)
251
380
  ? parseAtUri(base)
@@ -324,28 +453,544 @@ function parseAtUri(uri: string): URL {
324
453
  }
325
454
 
326
455
  /**
327
- * Checks whether the URL is an FEP-ef61 gateway base URI.
456
+ * Checks whether the URL is an FEP-ef61 gateway base URI, i.e., an HTTP(S)
457
+ * URI with no credentials, path, query, or fragment, such as
458
+ * `https://example.com/`. FEP-ef61 requires every item of a portable actor's
459
+ * `gateways` to be such a URI. A URI with an empty query or fragment
460
+ * delimiter, such as `https://example.com/?`, is not a gateway base URI.
461
+ *
462
+ * Note that the `URL` class normalizes `https://example.com` to
463
+ * `https://example.com/`, so both are gateway base URIs.
464
+ * @param url The URL to check.
465
+ * @returns `true` if the URL is an FEP-ef61 gateway base URI.
466
+ * @since 2.4.0
328
467
  */
329
468
  export function isGatewayUrl(url: URL): boolean {
330
469
  return (url.protocol === "http:" || url.protocol === "https:") &&
331
- url.username === "" && url.password === "" &&
332
- url.pathname === "/" && url.search === "" && url.hash === "";
470
+ url.href === `${url.origin}/`;
333
471
  }
334
472
 
335
473
  /**
336
- * Parses and validates an FEP-ef61 gateway base URI.
474
+ * Parses and validates an FEP-ef61 gateway base URI, i.e., an HTTP(S) URI with
475
+ * no credentials, path, query, or fragment, such as `https://example.com`.
476
+ * See {@link isGatewayUrl} for the rules.
477
+ * @param url The gateway base URI to parse.
478
+ * @returns The parsed gateway base URI, whose path is `/`.
479
+ * @throws {TypeError} If the URI is malformed, or is not an HTTP(S) base URI
480
+ * with no credentials, path, query, or fragment. In the
481
+ * latter case, the message starts with
482
+ * `Invalid FEP-ef61 gateway:`.
483
+ * @since 2.4.0
337
484
  */
338
485
  export function parseGatewayUrl(url: string): URL {
339
486
  const parsed = parseIri(url);
340
487
  if (!isGatewayUrl(parsed)) {
341
488
  throw new TypeError(
342
- "FEP-ef61 gateways must be HTTP(S) base URIs with no credentials, " +
343
- "path, query, or fragment.",
489
+ `Invalid FEP-ef61 gateway: ${url}. FEP-ef61 gateways must be HTTP(S) ` +
490
+ "base URIs with no credentials, path, query, or fragment.",
344
491
  );
345
492
  }
346
493
  return parsed;
347
494
  }
348
495
 
496
+ const COMPATIBLE_ID_PATH_PREFIX = "/.well-known/apgateway/";
497
+ const COMPATIBLE_ID_DID_PATTERN = /^did(?::|%3A)/i;
498
+ const RAW_COMPATIBLE_ID_PREFIX_PATTERN =
499
+ /^(?:[hH][tT][tT][pP][sS]?):\/\/[^/?#]*\/\.well-known\/apgateway\/(?=[dD][iI][dD](?::|%3[aA]))/;
500
+ // `gateways` is the location hint parameter name used by earlier FEP-ef61
501
+ // revisions; strip it as well for compatibility with older publishers.
502
+ const LOCATION_HINT_PARAMETERS: ReadonlySet<string> = new Set([
503
+ "@gateway",
504
+ "gateways",
505
+ ]);
506
+
507
+ /**
508
+ * Converts an [FEP-ef61] compatible identifier into a portable ActivityPub
509
+ * URI.
510
+ *
511
+ * A compatible identifier is an HTTP(S) URL under a gateway's fixed
512
+ * `/.well-known/apgateway/` path, such as
513
+ * `https://server.example/.well-known/apgateway/did:key:z6Mk.../objects/1`.
514
+ * This function removes the gateway part and returns the corresponding
515
+ * portable URI, e.g., `ap+ef61://did:key:z6Mk.../objects/1`, in the same
516
+ * internal `URL` form that {@link parseIri} produces. The path, query, and
517
+ * fragment are preserved, so the result is a portable URI, not a comparison
518
+ * form; pass its `href` to {@link canonicalizePortableUri} or
519
+ * {@link arePortableUrisEqual} to compare it with other portable URIs.
520
+ *
521
+ * The conversion only reveals the *claimed* portable identifier. Anyone can
522
+ * publish a compatible identifier for any DID on their own server, so the
523
+ * gateway that served it is neither the object's origin nor authorized to act
524
+ * for the DID. Callers must still verify the retrieved document's Object
525
+ * Integrity Proof against the DID, as FEP-ef61 requires, and should keep the
526
+ * original URL if they need the gateway as a retrieval hint.
527
+ *
528
+ * Arbitrary gateway paths are not supported.
529
+ *
530
+ * [FEP-ef61]: https://w3id.org/fep/ef61
531
+ *
532
+ * @param input The URL to convert.
533
+ * @returns The portable ActivityPub URI, or `null` if the input is not an
534
+ * HTTP(S) URL whose path starts with `/.well-known/apgateway/did:`.
535
+ * Other gateway routes, such as the gateway discovery endpoint and
536
+ * hashlink media URLs, also yield `null`.
537
+ * @throws {TypeError} If the input looks like a compatible identifier but is
538
+ * malformed, e.g., it has an invalid DID, no object path,
539
+ * invalid percent-encoding, credentials, or location
540
+ * hints (`@gateway` query parameters, or the legacy
541
+ * `gateways` parameter), or its raw string path would
542
+ * change during URL parsing. Already-parsed `URL`
543
+ * arguments cannot reveal segments lost by their parser.
544
+ * @since 2.4.0
545
+ */
546
+ export function fromCompatibleEf61Id(input: string | URL): URL | null {
547
+ return convertCompatibleEf61Id(input)?.url ?? null;
548
+ }
549
+
550
+ function convertCompatibleEf61Id(
551
+ input: string | URL,
552
+ ): { url: URL; iri: string } | null {
553
+ let url: URL;
554
+ const rawPrefix = typeof input === "string"
555
+ ? input.match(RAW_COMPATIBLE_ID_PREFIX_PATTERN)
556
+ : null;
557
+ if (input instanceof URL) url = input;
558
+ else if (typeof input === "string" && URL.canParse(input)) {
559
+ url = new URL(input);
560
+ } else return null;
561
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
562
+ // A raw compatible ID may lose path segments before URL.pathname is read.
563
+ // Likewise, preprocessing must not turn a different raw string into one.
564
+ if (typeof input === "string" && rawPrefix == null) {
565
+ if (
566
+ url.pathname.startsWith(COMPATIBLE_ID_PATH_PREFIX) &&
567
+ COMPATIBLE_ID_DID_PATTERN.test(
568
+ url.pathname.slice(COMPATIBLE_ID_PATH_PREFIX.length),
569
+ )
570
+ ) {
571
+ throw new TypeError("Invalid FEP-ef61 compatible identifier.");
572
+ }
573
+ }
574
+ if (!url.pathname.startsWith(COMPATIBLE_ID_PATH_PREFIX)) {
575
+ if (rawPrefix != null) {
576
+ throw new TypeError("Invalid FEP-ef61 compatible identifier.");
577
+ }
578
+ return null;
579
+ }
580
+ const tail = url.pathname.slice(COMPATIBLE_ID_PATH_PREFIX.length);
581
+ if (!COMPATIBLE_ID_DID_PATTERN.test(tail)) {
582
+ if (rawPrefix != null) {
583
+ throw new TypeError("Invalid FEP-ef61 compatible identifier.");
584
+ }
585
+ return null;
586
+ }
587
+ if (url.username !== "" || url.password !== "") {
588
+ throw new TypeError(
589
+ "Invalid FEP-ef61 compatible identifier: credentials are not allowed.",
590
+ );
591
+ }
592
+ // Slice href instead of concatenating pathname, search, and hash, because
593
+ // the latter two drop empty query and fragment delimiters.
594
+ const iri = "ap+ef61://" +
595
+ url.href.slice(url.origin.length + COMPATIBLE_ID_PATH_PREFIX.length);
596
+ try {
597
+ const parsed = parsePortableIri(iri);
598
+ if (parsed == null) throw new TypeError("Not a portable IRI.");
599
+ if (
600
+ typeof input === "string" && rawPrefix != null &&
601
+ canonicalizePortableUri("ap://" + input.slice(rawPrefix[0].length)) !==
602
+ canonicalizePortableUri(iri)
603
+ ) {
604
+ throw new TypeError("URL parsing changed the portable identifier.");
605
+ }
606
+ // parsePortableIri() does not validate path and fragment
607
+ // percent-encoding, but canonicalizePortableUri() does:
608
+ canonicalizePortableUri(iri);
609
+ // canonicalizePortableUri() ignores the query, so validate it here too.
610
+ // Compatible identifiers must not have location hints:
611
+ const query = url.search === ""
612
+ ? []
613
+ : normalizePortableComponent(url.search.slice(1)).split("&");
614
+ if (query.some(isLocationHint)) {
615
+ throw new TypeError("Location hints are not allowed.");
616
+ }
617
+ return { url: parsed, iri };
618
+ } catch (error) {
619
+ if (error instanceof TypeError) {
620
+ throw new TypeError("Invalid FEP-ef61 compatible identifier.", {
621
+ cause: error,
622
+ });
623
+ }
624
+ throw error;
625
+ }
626
+ }
627
+
628
+ /**
629
+ * Converts a portable ActivityPub URI into an [FEP-ef61] compatible
630
+ * identifier, which is an HTTP(S) URL under the gateway's fixed
631
+ * `/.well-known/apgateway/` path.
632
+ *
633
+ * For example, `ap+ef61://did:key:z6Mk.../objects/1` and
634
+ * `https://server.example` yield
635
+ * `https://server.example/.well-known/apgateway/did:key:z6Mk.../objects/1`.
636
+ * Both `ap:` and `ap+ef61:` URIs with decoded or percent-encoded DID
637
+ * authorities are accepted. Publishers should use the first gateway in the
638
+ * actor's `gateways` list, as FEP-ef61 requires.
639
+ *
640
+ * The path and fragment are preserved, with characters that are not allowed
641
+ * in HTTP(S) URLs percent-encoded the same way as
642
+ * {@link canonicalizePortableUri} does. FEP-ef61 location hints (`@gateway`
643
+ * query parameters, and the legacy `gateways` parameter) are removed, since
644
+ * compatible identifiers must not have them; other query parameters are kept
645
+ * in order.
646
+ *
647
+ * Arbitrary gateway paths are not supported.
648
+ *
649
+ * [FEP-ef61]: https://w3id.org/fep/ef61
650
+ *
651
+ * @param portableId The `ap:` or `ap+ef61:` URI to convert.
652
+ * @param gateway The gateway's HTTP(S) origin, e.g., `https://server.example`.
653
+ * @returns The compatible identifier.
654
+ * @throws {TypeError} If the portable ID is not a valid `ap:` or `ap+ef61:`
655
+ * URI, if its path has `.` or `..` segments (which
656
+ * HTTP(S) URLs cannot represent without changing the
657
+ * identified object), or if the gateway is
658
+ * not an HTTP(S) origin with no credentials, path, query,
659
+ * or fragment.
660
+ * @since 2.4.0
661
+ */
662
+ export function toCompatibleEf61Id(
663
+ portableId: string | URL,
664
+ gateway: string | URL,
665
+ ): URL {
666
+ const gatewayUrl = parseCompatibleEf61Gateway(gateway);
667
+ const raw = getRawPortableIri(portableId);
668
+ const match = raw.match(PORTABLE_IRI_PATTERN);
669
+ const parsed = parsePortableIri(raw);
670
+ if (match == null || parsed == null) {
671
+ throw new TypeError("Invalid portable ActivityPub IRI.");
672
+ }
673
+ // The parser decodes %25 once in a did:-prefixed authority, so escape
674
+ // percent signs only when that would otherwise change the DID. Other DIDs
675
+ // are kept literal (e.g., did:web:example.com%3A8080) so that gateways
676
+ // which read the path segment as is see the same DID:
677
+ let did = decodePortableAuthority(parsed.host);
678
+ if (/%25/i.test(did)) did = did.replace(/%/g, "%25");
679
+ const path = normalizePortableComponent(match[3]);
680
+ if (path.split("/").some((segment) => segment === "." || segment === "..")) {
681
+ throw new TypeError(
682
+ "FEP-ef61 compatible identifiers cannot represent portable IRI paths " +
683
+ "with dot segments.",
684
+ );
685
+ }
686
+ // Normalize the query before looking for location hints so that characters
687
+ // which the URL parser strips (e.g., tabs) cannot form a hint name later:
688
+ const query = match[4] == null
689
+ ? ""
690
+ : stripLocationHints(normalizePortableComponent(match[4].slice(1)));
691
+ const fragment = match[5] == null ? "" : normalizePortableComponent(match[5]);
692
+ const result = new URL(
693
+ gatewayUrl.origin + COMPATIBLE_ID_PATH_PREFIX + did + path + query +
694
+ fragment,
695
+ );
696
+ // Guard against URL parser normalization that would silently change the
697
+ // identified object or reintroduce location hints (the latter makes
698
+ // convertCompatibleEf61Id() throw):
699
+ const converted = convertCompatibleEf61Id(result);
700
+ if (
701
+ converted == null ||
702
+ canonicalizePortableUri(converted.iri) !== canonicalizePortableUri(raw)
703
+ ) {
704
+ throw new TypeError(
705
+ "The portable ActivityPub IRI cannot be represented as an FEP-ef61 " +
706
+ "compatible identifier.",
707
+ );
708
+ }
709
+ return result;
710
+ }
711
+
712
+ function parseCompatibleEf61Gateway(gateway: string | URL): URL {
713
+ const url = gateway instanceof URL
714
+ ? gateway
715
+ : typeof gateway === "string" && URL.canParse(gateway)
716
+ ? new URL(gateway)
717
+ : null;
718
+ if (url == null || !isGatewayUrl(url)) {
719
+ throw new TypeError(
720
+ "FEP-ef61 gateways for compatible identifiers must be HTTP(S) origins " +
721
+ "with no credentials, path, query, or fragment.",
722
+ );
723
+ }
724
+ return url;
725
+ }
726
+
727
+ function getRawPortableIri(portableId: string | URL): string {
728
+ if (portableId instanceof URL) {
729
+ if (portableId.protocol !== "ap:" && portableId.protocol !== "ap+ef61:") {
730
+ throw new TypeError("Invalid portable ActivityPub IRI.");
731
+ }
732
+ // parseIri() would fold a port into the DID and drop credentials:
733
+ if (
734
+ portableId.username !== "" || portableId.password !== "" ||
735
+ portableId.port !== ""
736
+ ) {
737
+ throw new TypeError("Invalid portable ActivityPub IRI authority.");
738
+ }
739
+ return portableId.href;
740
+ }
741
+ if (typeof portableId !== "string") {
742
+ throw new TypeError("Invalid portable ActivityPub IRI.");
743
+ }
744
+ return portableId;
745
+ }
746
+
747
+ function stripLocationHints(query: string): string {
748
+ const pairs = query.split("&").filter((pair) => !isLocationHint(pair));
749
+ return pairs.length < 1 ? "" : `?${pairs.join("&")}`;
750
+ }
751
+
752
+ function isLocationHint(pair: string): boolean {
753
+ const name = pair.split("=", 1)[0].replace(/\+/g, " ");
754
+ try {
755
+ return LOCATION_HINT_PARAMETERS.has(decodeURIComponent(name));
756
+ } catch (error) {
757
+ if (error instanceof URIError) return false;
758
+ throw error;
759
+ }
760
+ }
761
+
762
+ /**
763
+ * The name of the FEP-ef61 location hint query parameter.
764
+ * @internal
765
+ */
766
+ export const GATEWAY_HINT_PARAMETER = "@gateway";
767
+
768
+ /**
769
+ * Parses an FEP-ef61 gateway, which has to be an HTTP(S) origin with no
770
+ * credentials, path, query, or fragment.
771
+ * @returns The gateway, or `null` if it is not a valid gateway.
772
+ * @internal
773
+ */
774
+ export function parseGatewayOrigin(gateway: string | URL): URL | null {
775
+ let url: URL;
776
+ if (gateway instanceof URL) url = new URL(gateway.href);
777
+ else if (typeof gateway === "string" && URL.canParse(gateway)) {
778
+ url = new URL(gateway);
779
+ } else return null;
780
+ return isGatewayUrl(url) ? url : null;
781
+ }
782
+
783
+ /**
784
+ * Returns a copy of an [FEP-ef61] portable ActivityPub URI with `@gateway`
785
+ * location hints for the given gateways, which tell consumers where they can
786
+ * retrieve the object. Put hints on *references* to portable actors, e.g.,
787
+ * in `actor`, `attributedTo`, `to`, or `cc`, when constructing an object, as
788
+ * FEP-ef61 recommends:
789
+ *
790
+ * ~~~~ typescript
791
+ * withGatewayHints("ap://did:key:z6Mk.../actor", [
792
+ * "https://server1.example",
793
+ * "https://server2.example",
794
+ * ]);
795
+ * // ap+ef61://did:key:z6Mk.../actor?@gateway=https%3A%2F%2Fserver1.example&@gateway=https%3A%2F%2Fserver2.example
796
+ * ~~~~
797
+ *
798
+ * Do not put hints on an object's own `id`. Hints do not change the
799
+ * identity of a portable URI, since FEP-ef61 drops the query when comparing
800
+ * portable URIs, but implementations that do not canonicalize portable URIs
801
+ * would take a hinted ID for another object. Add hints before signing the
802
+ * object, since its Object Integrity Proof covers its references too.
803
+ *
804
+ * The hints that the URI already has, including the legacy `gateways`
805
+ * parameter, are replaced. Each gateway becomes a `@gateway` query
806
+ * parameter whose value is its URI-encoded origin, e.g.,
807
+ * `@gateway=https%3A%2F%2Fserver1.example`, in the given order after the
808
+ * other query parameters. Duplicate gateways are dropped, and an empty list
809
+ * removes the hints as {@link withoutGatewayHints} does. The other query
810
+ * parameters, their order, and the fragment are kept, but percent-encoding
811
+ * is normalized the same way as {@link canonicalizePortableUri} does it.
812
+ *
813
+ * Fedify follows at most five hints when dereferencing a portable URI
814
+ * (three for the key ID of an HTTP Signature), so list the preferred
815
+ * gateways first; there is no limit on the number of hints added here.
816
+ *
817
+ * [FEP-ef61]: https://w3id.org/fep/ef61
818
+ *
819
+ * @param portableId The `ap:` or `ap+ef61:` URI. Pass the raw string rather
820
+ * than a `URL` if its path may have `.` or `..` segments,
821
+ * because the `URL` class resolves them.
822
+ * @param gateways The gateways, e.g., the `gateways` of the actor that the
823
+ * URI refers to. Each has to be an HTTP(S) origin with no
824
+ * credentials, path, query, or fragment.
825
+ * @returns The portable URI with the hints, in the same internal `URL` form
826
+ * as {@link parseIri} returns. Use {@link formatIri} to get its
827
+ * canonical string.
828
+ * @throws {TypeError} If the portable ID is not a valid `ap:` or `ap+ef61:`
829
+ * URI, e.g., it is a compatible identifier, which must
830
+ * not have location hints; if its path has `.` or `..`
831
+ * segments, which the `URL` class cannot represent; or
832
+ * if a gateway is invalid.
833
+ * @since 2.4.0
834
+ */
835
+ export function withGatewayHints(
836
+ portableId: string | URL,
837
+ gateways: Iterable<string | URL>,
838
+ ): URL {
839
+ const parts = splitPortableIri(portableId);
840
+ if (typeof gateways === "string") {
841
+ throw new TypeError(
842
+ "The gateways must be an iterable of gateways, not a string.",
843
+ );
844
+ }
845
+ const hints: URL[] = [];
846
+ const seen = new Set<string>();
847
+ for (const gateway of gateways) {
848
+ const url = parseGatewayOrigin(gateway);
849
+ if (url == null) {
850
+ throw new TypeError(
851
+ "FEP-ef61 gateways must be HTTP(S) origins with no credentials, " +
852
+ "path, query, or fragment: " + String(gateway),
853
+ );
854
+ }
855
+ if (seen.has(url.href)) continue;
856
+ seen.add(url.href);
857
+ hints.push(url);
858
+ }
859
+ return replaceGatewayHints(parts, hints);
860
+ }
861
+
862
+ /**
863
+ * Returns a copy of an [FEP-ef61] portable ActivityPub URI without its
864
+ * location hints, i.e., `@gateway` query parameters and the legacy
865
+ * `gateways` parameter. The other query parameters, their order, and the
866
+ * fragment are kept, but percent-encoding is normalized the same way as
867
+ * {@link canonicalizePortableUri} does it.
868
+ *
869
+ * [FEP-ef61]: https://w3id.org/fep/ef61
870
+ *
871
+ * @param portableId The `ap:` or `ap+ef61:` URI. Pass the raw string rather
872
+ * than a `URL` if its path may have `.` or `..` segments,
873
+ * because the `URL` class resolves them.
874
+ * @returns The portable URI without the hints, in the same internal `URL`
875
+ * form as {@link parseIri} returns.
876
+ * @throws {TypeError} If the portable ID is not a valid `ap:` or `ap+ef61:`
877
+ * URI, or if its path has `.` or `..` segments, which
878
+ * the `URL` class cannot represent.
879
+ * @since 2.4.0
880
+ */
881
+ export function withoutGatewayHints(portableId: string | URL): URL {
882
+ return replaceGatewayHints(splitPortableIri(portableId), []);
883
+ }
884
+
885
+ /**
886
+ * Gets the gateways in the `@gateway` location hints of an [FEP-ef61]
887
+ * portable ActivityPub URI, in order. Hints that are not valid gateways,
888
+ * i.e., HTTP(S) origins with no credentials, path, query, or fragment, are
889
+ * skipped, and so are duplicates. The legacy `gateways` parameter is not
890
+ * read.
891
+ *
892
+ * Unlike Fedify's dereferencing, which follows at most five hints, this
893
+ * returns all of them.
894
+ *
895
+ * [FEP-ef61]: https://w3id.org/fep/ef61
896
+ *
897
+ * @param portableId The `ap:` or `ap+ef61:` URI.
898
+ * @returns The gateways, e.g., `https://server1.example/`.
899
+ * @throws {TypeError} If the portable ID is not a valid `ap:` or `ap+ef61:`
900
+ * URI.
901
+ * @since 2.4.0
902
+ */
903
+ export function getGatewayHints(portableId: string | URL): URL[] {
904
+ const { query } = splitPortableIri(portableId);
905
+ return query == null ? [] : parseGatewayHints(query);
906
+ }
907
+
908
+ interface PortableIriParts {
909
+ /** The portable ID as it was given, or the `href` of a `URL`. */
910
+ readonly raw: string;
911
+ /** The parsed portable ID. */
912
+ readonly parsed: URL;
913
+ /** The normalized path. */
914
+ readonly path: string;
915
+ /** The normalized query without `?`, or `null` if there is none. */
916
+ readonly query: string | null;
917
+ /** The normalized fragment with `#`, or an empty string if there is none. */
918
+ readonly fragment: string;
919
+ }
920
+
921
+ function splitPortableIri(portableId: string | URL): PortableIriParts {
922
+ const raw = getRawPortableIri(portableId);
923
+ const match = raw.match(PORTABLE_IRI_PATTERN);
924
+ const parsed = parsePortableIri(raw);
925
+ if (match == null || parsed == null) {
926
+ throw new TypeError("Invalid portable ActivityPub IRI.");
927
+ }
928
+ // Normalize the components before looking at them, as the URL parser would
929
+ // otherwise strip characters such as tabs later, which could turn
930
+ // an unrelated query parameter into a location hint:
931
+ return {
932
+ raw,
933
+ parsed,
934
+ path: normalizePortableComponent(match[3]),
935
+ query: match[4] == null
936
+ ? null
937
+ : normalizePortableComponent(match[4].slice(1)),
938
+ fragment: match[5] == null ? "" : normalizePortableComponent(match[5]),
939
+ };
940
+ }
941
+
942
+ function parseGatewayHints(query: string): URL[] {
943
+ const hints: URL[] = [];
944
+ const seen = new Set<string>();
945
+ // Keep the delimiter, as URLSearchParams would otherwise strip a leading
946
+ // question mark that is part of the first parameter's name:
947
+ for (
948
+ const hint of new URLSearchParams(`?${query}`).getAll(
949
+ GATEWAY_HINT_PARAMETER,
950
+ )
951
+ ) {
952
+ const url = parseGatewayOrigin(hint);
953
+ if (url == null || seen.has(url.href)) continue;
954
+ seen.add(url.href);
955
+ hints.push(url);
956
+ }
957
+ return hints;
958
+ }
959
+
960
+ function replaceGatewayHints(
961
+ parts: PortableIriParts,
962
+ hints: readonly URL[],
963
+ ): URL {
964
+ const pairs = parts.query == null
965
+ ? []
966
+ : parts.query.split("&").filter((pair) =>
967
+ pair !== "" && !isLocationHint(pair)
968
+ );
969
+ for (const hint of hints) {
970
+ pairs.push(`${GATEWAY_HINT_PARAMETER}=${encodeURIComponent(hint.origin)}`);
971
+ }
972
+ const query = pairs.length < 1 ? "" : `?${pairs.join("&")}`;
973
+ const result = parsePortableIri(
974
+ `ap+ef61://${parts.parsed.host}${parts.path}${query}${parts.fragment}`,
975
+ );
976
+ // Guard against URL parser normalization that would silently change
977
+ // the referenced object (e.g., dot segments) or its hints:
978
+ if (
979
+ result == null ||
980
+ canonicalizePortableUri(result.href) !==
981
+ canonicalizePortableUri(parts.raw) ||
982
+ parseGatewayHints(result.search.slice(1)).map((url) => url.href).join(
983
+ " ",
984
+ ) !== hints.map((url) => url.href).join(" ")
985
+ ) {
986
+ throw new TypeError(
987
+ "The portable ActivityPub IRI cannot be represented as a URL without " +
988
+ "changing the object it refers to.",
989
+ );
990
+ }
991
+ return result;
992
+ }
993
+
349
994
  /**
350
995
  * Validates a URL to prevent SSRF attacks.
351
996
  */
@@ -387,13 +1032,45 @@ export async function validatePublicUrl(url: string): Promise<void> {
387
1032
  // and ensure that they are all public:
388
1033
  let addresses: LookupAddress[];
389
1034
  try {
390
- addresses = await lookup(hostname, { all: true });
391
- } catch {
392
- addresses = [];
1035
+ addresses = await dns.lookup(hostname, { all: true });
1036
+ } catch (error) {
1037
+ throw new UrlError("DNS lookup failed", { cause: error, reason: "dns" });
393
1038
  }
394
- for (const { address, family } of addresses) {
1039
+ validateLookupAddresses(addresses);
1040
+ }
1041
+
1042
+ /**
1043
+ * Validates the IP addresses returned by `node:dns.lookup()`.
1044
+ *
1045
+ * Cloudflare Workers' `node:dns` implementation currently maps every record
1046
+ * in a DNS-over-HTTPS `Answer` array—including CNAME records—to a
1047
+ * `LookupAddress`, even though Node.js specifies that the `address` field must
1048
+ * contain an IPv4 or IPv6 literal. See:
1049
+ * https://github.com/cloudflare/workerd/issues/6886
1050
+ *
1051
+ * Work around that bug by ignoring non-IP entries only when the lookup also
1052
+ * returns at least one actual IP address. This remains fail-closed: a result
1053
+ * containing no IP addresses is rejected, and every returned IP address is
1054
+ * still validated and must be public. This workaround can be revisited once
1055
+ * the Workerd issue is fixed in supported Cloudflare Workers runtimes.
1056
+ *
1057
+ * @internal
1058
+ */
1059
+ export function validateLookupAddresses(
1060
+ addresses: readonly LookupAddress[],
1061
+ ): void {
1062
+ let ipAddressCount = 0;
1063
+ for (const { address } of addresses) {
1064
+ const family = isIP(address);
1065
+ if (family === 0) continue;
1066
+ ipAddressCount++;
395
1067
  validatePublicIpAddress(address, family);
396
1068
  }
1069
+ if (ipAddressCount === 0) {
1070
+ throw new UrlError("DNS lookup did not return any IP address", {
1071
+ reason: "dns",
1072
+ });
1073
+ }
397
1074
  }
398
1075
 
399
1076
  function validatePublicIpAddress(address: string, family: number): void {