@fedify/vocab-runtime 2.4.0-dev.2228 → 2.4.0-dev.2240

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 (57) hide show
  1. package/deno.json +2 -2
  2. package/dist/internal/jsonld-cache.cjs +1 -1
  3. package/dist/internal/jsonld-cache.js +1 -1
  4. package/dist/internal/portable-dereference.cjs +6 -18
  5. package/dist/internal/portable-dereference.js +4 -16
  6. package/dist/mod.cjs +5 -2
  7. package/dist/mod.d.cts +107 -4
  8. package/dist/mod.d.ts +107 -4
  9. package/dist/mod.js +3 -3
  10. package/dist/tests/{body-BmNV5g5S.mjs → body-C00dWiYW.mjs} +1 -1
  11. package/dist/tests/{body-BQZL1aJ1.cjs → body-DHk-vT0I.cjs} +1 -1
  12. package/dist/tests/body.test.cjs +1 -1
  13. package/dist/tests/body.test.mjs +1 -1
  14. package/dist/tests/decimal.test.cjs +3 -3
  15. package/dist/tests/decimal.test.mjs +3 -3
  16. package/dist/tests/{docloader-ButlYQxr.mjs → docloader-Bi982uOa.mjs} +3 -3
  17. package/dist/tests/{docloader-BnuGCXLg.cjs → docloader-gX-8knd3.cjs} +3 -3
  18. package/dist/tests/docloader.test.cjs +59 -65
  19. package/dist/tests/docloader.test.mjs +38 -44
  20. package/dist/tests/internal/portable-dereference.test.cjs +30 -157
  21. package/dist/tests/internal/portable-dereference.test.mjs +13 -140
  22. package/dist/tests/{jsonld-cache-C7mkqjiE.mjs → jsonld-cache-CKfgtxPB.mjs} +1 -1
  23. package/dist/tests/{jsonld-cache-CXA76Xi6.cjs → jsonld-cache-DzxT9IXH.cjs} +1 -1
  24. package/dist/tests/jsonld-cache.test.cjs +2 -2
  25. package/dist/tests/jsonld-cache.test.mjs +2 -2
  26. package/dist/tests/multibase/multibase.test.cjs +7 -7
  27. package/dist/tests/multibase/multibase.test.mjs +5 -5
  28. package/dist/tests/portable-dereference-C_cwGjz6.mjs +132 -0
  29. package/dist/tests/portable-dereference-CyFYTBnr.cjs +149 -0
  30. package/dist/tests/{portable-media-CFrGpz-3.cjs → portable-media-B39cvJGK.cjs} +3 -3
  31. package/dist/tests/{portable-media-xFDDMte7.mjs → portable-media-l0ET9Pq8.mjs} +3 -3
  32. package/dist/tests/portable-media.test.cjs +1 -1
  33. package/dist/tests/portable-media.test.mjs +1 -1
  34. package/dist/tests/portable-workers.test.cjs +36 -0
  35. package/dist/tests/portable-workers.test.d.cts +2 -0
  36. package/dist/tests/portable-workers.test.d.mts +2 -0
  37. package/dist/tests/portable-workers.test.mjs +35 -0
  38. package/dist/tests/{request-2GDEEJng.mjs → request-BRPiDcmG.mjs} +1 -1
  39. package/dist/tests/{request-Cme6ShRu.cjs → request-fo2l2yJS.cjs} +1 -1
  40. package/dist/tests/request.test.cjs +1 -1
  41. package/dist/tests/request.test.mjs +1 -1
  42. package/dist/tests/{url-R9TTZ67B.mjs → url-CWC-NI3a.mjs} +224 -8
  43. package/dist/tests/{url-B6WlNhra.cjs → url-DsMBGqCc.cjs} +253 -7
  44. package/dist/tests/url.test.cjs +192 -1
  45. package/dist/tests/url.test.mjs +192 -1
  46. package/dist/{url-CHAbe3hE.cjs → url-BJMXv2kE.cjs} +253 -7
  47. package/dist/{url-BfguNa6K.js → url-CZm2-7ZO.js} +224 -8
  48. package/package.json +6 -3
  49. package/scripts/test-bun.mjs +17 -0
  50. package/src/docloader.test.ts +82 -74
  51. package/src/internal/portable-dereference.test.ts +14 -1
  52. package/src/internal/portable-dereference.ts +5 -19
  53. package/src/mod.ts +3 -0
  54. package/src/multibase/multibase.test.ts +5 -5
  55. package/src/portable-workers.test.ts +81 -0
  56. package/src/url.test.ts +385 -0
  57. package/src/url.ts +347 -13
@@ -0,0 +1,35 @@
1
+ import { _ as parseIri, i as canonicalizePortableUri, o as formatIri, s as fromCompatibleEf61Id, y as toCompatibleEf61Id } from "./url-CWC-NI3a.mjs";
2
+ import { i as importDidKey, t as exportDidKey } from "./key-C2Db_TAJ.mjs";
3
+ import { n as createHashlink, o as verifyHashlink, t as computeDigestMultibase } from "./digest-COC7xDiQ.mjs";
4
+ import { n as getPortableGatewayCandidates } from "./portable-dereference-C_cwGjz6.mjs";
5
+ import { deepStrictEqual, equal } from "node:assert/strict";
6
+ import { test, testDefinitions } from "@fedify/fixture";
7
+ //#region src/portable-workers.test.ts
8
+ const portableId = "ap://did:key:z6Mkabc/actor";
9
+ test("vocab-runtime portable URI formats on Workers", () => {
10
+ equal(formatIri(parseIri(portableId)), "ap+ef61://did:key:z6Mkabc/actor");
11
+ equal(canonicalizePortableUri(`${portableId}?@gateway=https%3A%2F%2Fa.example`), "ap+ef61://did:key:z6Mkabc/actor");
12
+ });
13
+ test("vocab-runtime compatible IDs on Workers", () => {
14
+ const compatible = toCompatibleEf61Id(portableId, "https://gw.example");
15
+ equal(compatible.href, "https://gw.example/.well-known/apgateway/did:key:z6Mkabc/actor");
16
+ equal(formatIri(fromCompatibleEf61Id(compatible)), "ap+ef61://did:key:z6Mkabc/actor");
17
+ });
18
+ test("vocab-runtime did:key on Workers", async () => {
19
+ const keys = await crypto.subtle.generateKey("Ed25519", true, ["sign", "verify"]);
20
+ const imported = await importDidKey(await exportDidKey(keys.publicKey));
21
+ deepStrictEqual(new Uint8Array(await crypto.subtle.exportKey("raw", imported)), new Uint8Array(await crypto.subtle.exportKey("raw", keys.publicKey)));
22
+ });
23
+ test("vocab-runtime hashlink on Workers", async () => {
24
+ const bytes = new TextEncoder().encode("portable media");
25
+ const digest = await computeDigestMultibase(bytes);
26
+ equal(await verifyHashlink(bytes, createHashlink(digest)), true);
27
+ equal(await verifyHashlink(new TextEncoder().encode("tampered"), createHashlink(digest)), false);
28
+ });
29
+ test("vocab-runtime gateway candidates on Workers", () => {
30
+ const hinted = parseIri(`${portableId}?@gateway=https%3A%2F%2Fhint.example`);
31
+ deepStrictEqual(getPortableGatewayCandidates(hinted, ["https://first.example"]).map((url) => url.href), ["https://first.example/"]);
32
+ deepStrictEqual(getPortableGatewayCandidates(hinted).map((url) => url.href), ["https://hint.example/"]);
33
+ });
34
+ //#endregion
35
+ export { testDefinitions };
@@ -1,7 +1,7 @@
1
1
  import process from "node:process";
2
2
  //#region deno.json
3
3
  var name = "@fedify/vocab-runtime";
4
- var version = "2.4.0-dev.2228+6d017d55";
4
+ var version = "2.4.0-dev.2240+79613fa6";
5
5
  //#endregion
6
6
  //#region src/request.ts
7
7
  /**
@@ -3,7 +3,7 @@ let node_process = require("node:process");
3
3
  node_process = require_rolldown_runtime.__toESM(node_process, 1);
4
4
  //#region deno.json
5
5
  var name = "@fedify/vocab-runtime";
6
- var version = "2.4.0-dev.2228+6d017d55";
6
+ var version = "2.4.0-dev.2240+79613fa6";
7
7
  //#endregion
8
8
  //#region src/request.ts
9
9
  /**
@@ -1,5 +1,5 @@
1
1
  const require_rolldown_runtime = require("./rolldown-runtime-emK7D4bc.cjs");
2
- const require_request = require("./request-Cme6ShRu.cjs");
2
+ const require_request = require("./request-fo2l2yJS.cjs");
3
3
  let node_test = require("node:test");
4
4
  let node_process = require("node:process");
5
5
  node_process = require_rolldown_runtime.__toESM(node_process, 1);
@@ -1,4 +1,4 @@
1
- import { n as createActivityPubRequest, o as version, r as getUserAgent } from "./request-2GDEEJng.mjs";
1
+ import { n as createActivityPubRequest, o as version, r as getUserAgent } from "./request-BRPiDcmG.mjs";
2
2
  import { test } from "node:test";
3
3
  import process from "node:process";
4
4
  import { deepStrictEqual } from "node:assert";
@@ -19,6 +19,26 @@ const INVALID_PERCENT_ENCODING_PATTERN = /%(?![0-9A-Fa-f]{2})/;
19
19
  const PERCENT_ENCODING_PATTERN = /%[0-9A-Fa-f]{2}/g;
20
20
  const DID_SCHEME_PATTERN = /^did:/i;
21
21
  const DID_PATTERN = /^did:[a-z0-9]+:[-A-Za-z0-9._%]+(?::[-A-Za-z0-9._%]+)*$/i;
22
+ const DOT_SEGMENT_PATTERN = /(?:^|\/)(?:\.|%2e){1,2}(?=\/|$)/i;
23
+ function prepareUrlInput(iri) {
24
+ let prepared = iri.replace(/[\t\n\r]/g, "");
25
+ while (prepared.length > 0 && prepared.charCodeAt(0) <= 32) prepared = prepared.slice(1);
26
+ while (prepared.length > 0 && prepared.charCodeAt(prepared.length - 1) <= 32) prepared = prepared.slice(0, -1);
27
+ return prepared;
28
+ }
29
+ function assertPortablePathCanBeParsed(iri) {
30
+ const match = prepareUrlInput(iri).match(PORTABLE_IRI_PATTERN);
31
+ if (match != null && DOT_SEGMENT_PATTERN.test(match[3])) throw new TypeError("Portable ActivityPub IRI paths with dot segments cannot be represented as URLs.");
32
+ }
33
+ function assertCompatiblePathCanBeParsed(raw, parsed) {
34
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return;
35
+ const match = prepareUrlInput(raw).match(/^https?:[\/\\]*[^/?#\\]*([^?#]*)/i);
36
+ if (match == null) return;
37
+ const rawPath = match[1].replace(/\\/g, "/");
38
+ const compatiblePath = parsed.pathname.startsWith(COMPATIBLE_ID_PATH_PREFIX) && COMPATIBLE_ID_DID_PATTERN.test(parsed.pathname.slice(COMPATIBLE_ID_PATH_PREFIX.length));
39
+ const rawCompatiblePath = rawPath.startsWith(COMPATIBLE_ID_PATH_PREFIX) && COMPATIBLE_ID_DID_PATTERN.test(rawPath.slice(COMPATIBLE_ID_PATH_PREFIX.length));
40
+ if ((compatiblePath || rawCompatiblePath) && DOT_SEGMENT_PATTERN.test(rawPath)) throw new TypeError("FEP-ef61 compatible identifier paths with dot segments cannot be represented as URLs.");
41
+ }
22
42
  /**
23
43
  * Parses a JSON-LD `@id` value as an IRI.
24
44
  */
@@ -32,22 +52,40 @@ function parseJsonLdId(id, base) {
32
52
  }
33
53
  /**
34
54
  * Parses an IRI as a URL, including FEP-ef61 portable ActivityPub IRIs.
55
+ * Portable URI and FEP-ef61 compatible identifier strings whose path contains
56
+ * a `.` or `..` segment, including percent-encoded spellings, throw a
57
+ * `TypeError`: JavaScript `URL` would otherwise identify a different object.
58
+ * This also applies to compatible identifier strings used as relative bases.
59
+ * A `URL` argument may already have lost such segments before this function
60
+ * receives it.
35
61
  */
36
62
  function parseIri(iri, base) {
37
63
  if (iri instanceof URL) return normalizePortableUrl(iri) ?? new URL(iri.href);
64
+ assertPortablePathCanBeParsed(iri);
38
65
  const portable = parsePortableIri(iri);
39
66
  if (portable != null) return portable;
40
67
  base = normalizeBaseIri(base);
41
68
  if (!URL.canParse(iri, base) && iri.startsWith("at://")) return parseAtUri(iri);
42
69
  const parsed = new URL(iri, base);
70
+ assertCompatiblePathCanBeParsed(iri, parsed);
43
71
  return normalizePortableUrl(parsed) ?? parsed;
44
72
  }
45
73
  /**
46
74
  * Formats a URL as an IRI, including FEP-ef61 portable ActivityPub IRIs.
75
+ * Portable URI and FEP-ef61 compatible identifier strings with dot segments
76
+ * throw a `TypeError` because their paths cannot be represented by JavaScript
77
+ * `URL` without normalization.
47
78
  */
48
79
  function formatIri(iri) {
80
+ if (typeof iri === "string") assertPortablePathCanBeParsed(iri);
49
81
  const parsed = parsePortableIri(iri instanceof URL ? iri.href : iri);
50
- if (parsed == null) return iri instanceof URL ? iri.href : URL.canParse(iri) ? new URL(iri).href : iri;
82
+ if (parsed == null) {
83
+ if (iri instanceof URL) return iri.href;
84
+ if (!URL.canParse(iri)) return iri;
85
+ const url = new URL(iri);
86
+ assertCompatiblePathCanBeParsed(iri, url);
87
+ return url.href;
88
+ }
51
89
  return `ap+ef61://${decodePortableAuthority(parsed.host)}${parsed.pathname}${parsed.search}${parsed.hash}`;
52
90
  }
53
91
  /**
@@ -167,11 +205,15 @@ function parsePortableIri(iri) {
167
205
  }
168
206
  function normalizePortableUrl(iri) {
169
207
  if (iri.protocol !== "ap:" && iri.protocol !== "ap+ef61:") return null;
170
- return parsePortableIri(`ap+ef61://${iri.host}${iri.pathname}${iri.search}${iri.hash}`);
208
+ const raw = `ap+ef61://${iri.host}${iri.pathname}${iri.search}${iri.hash}`;
209
+ assertPortablePathCanBeParsed(raw);
210
+ return parsePortableIri(raw);
171
211
  }
172
212
  function normalizeBaseIri(base) {
173
213
  if (base == null) return void 0;
174
214
  if (base instanceof URL) return normalizePortableUrl(base) ?? base;
215
+ assertPortablePathCanBeParsed(base);
216
+ if (URL.canParse(base)) assertCompatiblePathCanBeParsed(base, new URL(base));
175
217
  return parsePortableIri(base) ?? (base.startsWith("at://") && !URL.canParse(".", base) ? parseAtUri(base) : base);
176
218
  }
177
219
  function decodePortableAuthority(authority) {
@@ -238,6 +280,7 @@ function parseGatewayUrl(url) {
238
280
  }
239
281
  const COMPATIBLE_ID_PATH_PREFIX = "/.well-known/apgateway/";
240
282
  const COMPATIBLE_ID_DID_PATTERN = /^did(?::|%3A)/i;
283
+ const RAW_COMPATIBLE_ID_PREFIX_PATTERN = /^(?:[hH][tT][tT][pP][sS]?):\/\/[^/?#]*\/\.well-known\/apgateway\/(?=[dD][iI][dD](?::|%3[aA]))/;
241
284
  const LOCATION_HINT_PARAMETERS = /* @__PURE__ */ new Set(["@gateway", "gateways"]);
242
285
  /**
243
286
  * Converts an [FEP-ef61] compatible identifier into a portable ActivityPub
@@ -273,8 +316,9 @@ const LOCATION_HINT_PARAMETERS = /* @__PURE__ */ new Set(["@gateway", "gateways"
273
316
  * malformed, e.g., it has an invalid DID, no object path,
274
317
  * invalid percent-encoding, credentials, or location
275
318
  * hints (`@gateway` query parameters, or the legacy
276
- * `gateways` parameter), which FEP-ef61 forbids in
277
- * compatible identifiers.
319
+ * `gateways` parameter), or its raw string path would
320
+ * change during URL parsing. Already-parsed `URL`
321
+ * arguments cannot reveal segments lost by their parser.
278
322
  * @since 2.4.0
279
323
  */
280
324
  function fromCompatibleEf61Id(input) {
@@ -282,18 +326,29 @@ function fromCompatibleEf61Id(input) {
282
326
  }
283
327
  function convertCompatibleEf61Id(input) {
284
328
  let url;
329
+ const rawPrefix = typeof input === "string" ? input.match(RAW_COMPATIBLE_ID_PREFIX_PATTERN) : null;
285
330
  if (input instanceof URL) url = input;
286
331
  else if (typeof input === "string" && URL.canParse(input)) url = new URL(input);
287
332
  else return null;
288
333
  if (url.protocol !== "http:" && url.protocol !== "https:") return null;
289
- if (!url.pathname.startsWith(COMPATIBLE_ID_PATH_PREFIX)) return null;
334
+ if (typeof input === "string" && rawPrefix == null) {
335
+ if (url.pathname.startsWith(COMPATIBLE_ID_PATH_PREFIX) && COMPATIBLE_ID_DID_PATTERN.test(url.pathname.slice(23))) throw new TypeError("Invalid FEP-ef61 compatible identifier.");
336
+ }
337
+ if (!url.pathname.startsWith(COMPATIBLE_ID_PATH_PREFIX)) {
338
+ if (rawPrefix != null) throw new TypeError("Invalid FEP-ef61 compatible identifier.");
339
+ return null;
340
+ }
290
341
  const tail = url.pathname.slice(23);
291
- if (!COMPATIBLE_ID_DID_PATTERN.test(tail)) return null;
342
+ if (!COMPATIBLE_ID_DID_PATTERN.test(tail)) {
343
+ if (rawPrefix != null) throw new TypeError("Invalid FEP-ef61 compatible identifier.");
344
+ return null;
345
+ }
292
346
  if (url.username !== "" || url.password !== "") throw new TypeError("Invalid FEP-ef61 compatible identifier: credentials are not allowed.");
293
347
  const iri = "ap+ef61://" + url.href.slice(url.origin.length + 23);
294
348
  try {
295
349
  const parsed = parsePortableIri(iri);
296
350
  if (parsed == null) throw new TypeError("Not a portable IRI.");
351
+ if (typeof input === "string" && rawPrefix != null && canonicalizePortableUri("ap://" + input.slice(rawPrefix[0].length)) !== canonicalizePortableUri(iri)) throw new TypeError("URL parsing changed the portable identifier.");
297
352
  canonicalizePortableUri(iri);
298
353
  if ((url.search === "" ? [] : normalizePortableComponent(url.search.slice(1)).split("&")).some(isLocationHint)) throw new TypeError("Location hints are not allowed.");
299
354
  return {
@@ -333,7 +388,8 @@ function convertCompatibleEf61Id(input) {
333
388
  * @returns The compatible identifier.
334
389
  * @throws {TypeError} If the portable ID is not a valid `ap:` or `ap+ef61:`
335
390
  * URI, if its path has `.` or `..` segments (which
336
- * HTTP(S) URLs cannot represent), or if the gateway is
391
+ * HTTP(S) URLs cannot represent without changing the
392
+ * identified object), or if the gateway is
337
393
  * not an HTTP(S) origin with no credentials, path, query,
338
394
  * or fragment.
339
395
  * @since 2.4.0
@@ -383,6 +439,166 @@ function isLocationHint(pair) {
383
439
  }
384
440
  }
385
441
  /**
442
+ * The name of the FEP-ef61 location hint query parameter.
443
+ * @internal
444
+ */
445
+ const GATEWAY_HINT_PARAMETER = "@gateway";
446
+ /**
447
+ * Parses an FEP-ef61 gateway, which has to be an HTTP(S) origin with no
448
+ * credentials, path, query, or fragment.
449
+ * @returns The gateway, or `null` if it is not a valid gateway.
450
+ * @internal
451
+ */
452
+ function parseGatewayOrigin(gateway) {
453
+ let url;
454
+ if (gateway instanceof URL) url = new URL(gateway.href);
455
+ else if (typeof gateway === "string" && URL.canParse(gateway)) url = new URL(gateway);
456
+ else return null;
457
+ return isGatewayUrl(url) ? url : null;
458
+ }
459
+ /**
460
+ * Returns a copy of an [FEP-ef61] portable ActivityPub URI with `@gateway`
461
+ * location hints for the given gateways, which tell consumers where they can
462
+ * retrieve the object. Put hints on *references* to portable actors, e.g.,
463
+ * in `actor`, `attributedTo`, `to`, or `cc`, when constructing an object, as
464
+ * FEP-ef61 recommends:
465
+ *
466
+ * ~~~~ typescript
467
+ * withGatewayHints("ap://did:key:z6Mk.../actor", [
468
+ * "https://server1.example",
469
+ * "https://server2.example",
470
+ * ]);
471
+ * // ap+ef61://did:key:z6Mk.../actor?@gateway=https%3A%2F%2Fserver1.example&@gateway=https%3A%2F%2Fserver2.example
472
+ * ~~~~
473
+ *
474
+ * Do not put hints on an object's own `id`. Hints do not change the
475
+ * identity of a portable URI, since FEP-ef61 drops the query when comparing
476
+ * portable URIs, but implementations that do not canonicalize portable URIs
477
+ * would take a hinted ID for another object. Add hints before signing the
478
+ * object, since its Object Integrity Proof covers its references too.
479
+ *
480
+ * The hints that the URI already has, including the legacy `gateways`
481
+ * parameter, are replaced. Each gateway becomes a `@gateway` query
482
+ * parameter whose value is its URI-encoded origin, e.g.,
483
+ * `@gateway=https%3A%2F%2Fserver1.example`, in the given order after the
484
+ * other query parameters. Duplicate gateways are dropped, and an empty list
485
+ * removes the hints as {@link withoutGatewayHints} does. The other query
486
+ * parameters, their order, and the fragment are kept, but percent-encoding
487
+ * is normalized the same way as {@link canonicalizePortableUri} does it.
488
+ *
489
+ * Fedify follows at most five hints when dereferencing a portable URI
490
+ * (three for the key ID of an HTTP Signature), so list the preferred
491
+ * gateways first; there is no limit on the number of hints added here.
492
+ *
493
+ * [FEP-ef61]: https://w3id.org/fep/ef61
494
+ *
495
+ * @param portableId The `ap:` or `ap+ef61:` URI. Pass the raw string rather
496
+ * than a `URL` if its path may have `.` or `..` segments,
497
+ * because the `URL` class resolves them.
498
+ * @param gateways The gateways, e.g., the `gateways` of the actor that the
499
+ * URI refers to. Each has to be an HTTP(S) origin with no
500
+ * credentials, path, query, or fragment.
501
+ * @returns The portable URI with the hints, in the same internal `URL` form
502
+ * as {@link parseIri} returns. Use {@link formatIri} to get its
503
+ * canonical string.
504
+ * @throws {TypeError} If the portable ID is not a valid `ap:` or `ap+ef61:`
505
+ * URI, e.g., it is a compatible identifier, which must
506
+ * not have location hints; if its path has `.` or `..`
507
+ * segments, which the `URL` class cannot represent; or
508
+ * if a gateway is invalid.
509
+ * @since 2.4.0
510
+ */
511
+ function withGatewayHints(portableId, gateways) {
512
+ const parts = splitPortableIri(portableId);
513
+ if (typeof gateways === "string") throw new TypeError("The gateways must be an iterable of gateways, not a string.");
514
+ const hints = [];
515
+ const seen = /* @__PURE__ */ new Set();
516
+ for (const gateway of gateways) {
517
+ const url = parseGatewayOrigin(gateway);
518
+ if (url == null) throw new TypeError("FEP-ef61 gateways must be HTTP(S) origins with no credentials, path, query, or fragment: " + String(gateway));
519
+ if (seen.has(url.href)) continue;
520
+ seen.add(url.href);
521
+ hints.push(url);
522
+ }
523
+ return replaceGatewayHints(parts, hints);
524
+ }
525
+ /**
526
+ * Returns a copy of an [FEP-ef61] portable ActivityPub URI without its
527
+ * location hints, i.e., `@gateway` query parameters and the legacy
528
+ * `gateways` parameter. The other query parameters, their order, and the
529
+ * fragment are kept, but percent-encoding is normalized the same way as
530
+ * {@link canonicalizePortableUri} does it.
531
+ *
532
+ * [FEP-ef61]: https://w3id.org/fep/ef61
533
+ *
534
+ * @param portableId The `ap:` or `ap+ef61:` URI. Pass the raw string rather
535
+ * than a `URL` if its path may have `.` or `..` segments,
536
+ * because the `URL` class resolves them.
537
+ * @returns The portable URI without the hints, in the same internal `URL`
538
+ * form as {@link parseIri} returns.
539
+ * @throws {TypeError} If the portable ID is not a valid `ap:` or `ap+ef61:`
540
+ * URI, or if its path has `.` or `..` segments, which
541
+ * the `URL` class cannot represent.
542
+ * @since 2.4.0
543
+ */
544
+ function withoutGatewayHints(portableId) {
545
+ return replaceGatewayHints(splitPortableIri(portableId), []);
546
+ }
547
+ /**
548
+ * Gets the gateways in the `@gateway` location hints of an [FEP-ef61]
549
+ * portable ActivityPub URI, in order. Hints that are not valid gateways,
550
+ * i.e., HTTP(S) origins with no credentials, path, query, or fragment, are
551
+ * skipped, and so are duplicates. The legacy `gateways` parameter is not
552
+ * read.
553
+ *
554
+ * Unlike Fedify's dereferencing, which follows at most five hints, this
555
+ * returns all of them.
556
+ *
557
+ * [FEP-ef61]: https://w3id.org/fep/ef61
558
+ *
559
+ * @param portableId The `ap:` or `ap+ef61:` URI.
560
+ * @returns The gateways, e.g., `https://server1.example/`.
561
+ * @throws {TypeError} If the portable ID is not a valid `ap:` or `ap+ef61:`
562
+ * URI.
563
+ * @since 2.4.0
564
+ */
565
+ function getGatewayHints(portableId) {
566
+ const { query } = splitPortableIri(portableId);
567
+ return query == null ? [] : parseGatewayHints(query);
568
+ }
569
+ function splitPortableIri(portableId) {
570
+ const raw = getRawPortableIri(portableId);
571
+ const match = raw.match(PORTABLE_IRI_PATTERN);
572
+ const parsed = parsePortableIri(raw);
573
+ if (match == null || parsed == null) throw new TypeError("Invalid portable ActivityPub IRI.");
574
+ return {
575
+ raw,
576
+ parsed,
577
+ path: normalizePortableComponent(match[3]),
578
+ query: match[4] == null ? null : normalizePortableComponent(match[4].slice(1)),
579
+ fragment: match[5] == null ? "" : normalizePortableComponent(match[5])
580
+ };
581
+ }
582
+ function parseGatewayHints(query) {
583
+ const hints = [];
584
+ const seen = /* @__PURE__ */ new Set();
585
+ for (const hint of new URLSearchParams(`?${query}`).getAll(GATEWAY_HINT_PARAMETER)) {
586
+ const url = parseGatewayOrigin(hint);
587
+ if (url == null || seen.has(url.href)) continue;
588
+ seen.add(url.href);
589
+ hints.push(url);
590
+ }
591
+ return hints;
592
+ }
593
+ function replaceGatewayHints(parts, hints) {
594
+ const pairs = parts.query == null ? [] : parts.query.split("&").filter((pair) => pair !== "" && !isLocationHint(pair));
595
+ for (const hint of hints) pairs.push(`${GATEWAY_HINT_PARAMETER}=${encodeURIComponent(hint.origin)}`);
596
+ const query = pairs.length < 1 ? "" : `?${pairs.join("&")}`;
597
+ const result = parsePortableIri(`ap+ef61://${parts.parsed.host}${parts.path}${query}${parts.fragment}`);
598
+ if (result == null || canonicalizePortableUri(result.href) !== canonicalizePortableUri(parts.raw) || parseGatewayHints(result.search.slice(1)).map((url) => url.href).join(" ") !== hints.map((url) => url.href).join(" ")) throw new TypeError("The portable ActivityPub IRI cannot be represented as a URL without changing the object it refers to.");
599
+ return result;
600
+ }
601
+ /**
386
602
  * Validates a URL to prevent SSRF attacks.
387
603
  */
388
604
  async function validatePublicUrl(url) {
@@ -578,4 +794,4 @@ function matchesIPv6Prefix(address, prefixWords, prefixLength) {
578
794
  return true;
579
795
  }
580
796
  //#endregion
581
- export { validateLookupAddresses as _, formatIri as a, haveSameFe34Origin as c, isValidPublicIPv4Address as d, isValidPublicIPv6Address as f, toCompatibleEf61Id as g, parseJsonLdId as h, expandIPv6Address as i, haveSameIriOrigin as l, parseIri as m, arePortableUrisEqual as n, fromCompatibleEf61Id as o, parseGatewayUrl as p, canonicalizePortableUri as r, getFe34Origin as s, UrlError as t, isGatewayUrl as u, validatePublicUrl as v };
797
+ export { withoutGatewayHints as C, withGatewayHints as S, parseIri as _, expandIPv6Address as a, validateLookupAddresses as b, getFe34Origin as c, haveSameIriOrigin as d, isGatewayUrl as f, parseGatewayUrl as g, parseGatewayOrigin as h, canonicalizePortableUri as i, getGatewayHints as l, isValidPublicIPv6Address as m, UrlError as n, formatIri as o, isValidPublicIPv4Address as p, arePortableUrisEqual as r, fromCompatibleEf61Id as s, GATEWAY_HINT_PARAMETER as t, haveSameFe34Origin as u, parseJsonLdId as v, validatePublicUrl as x, toCompatibleEf61Id as y };