@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
package/src/url.ts CHANGED
@@ -29,6 +29,56 @@ const INVALID_PERCENT_ENCODING_PATTERN = /%(?![0-9A-Fa-f]{2})/;
29
29
  const PERCENT_ENCODING_PATTERN = /%[0-9A-Fa-f]{2}/g;
30
30
  const DID_SCHEME_PATTERN = /^did:/i;
31
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
+ }
32
82
 
33
83
  /**
34
84
  * Parses a JSON-LD `@id` value as an IRI.
@@ -47,11 +97,18 @@ export function parseJsonLdId(
47
97
 
48
98
  /**
49
99
  * Parses an IRI as a URL, including FEP-ef61 portable ActivityPub IRIs.
100
+ * Portable URI and FEP-ef61 compatible identifier strings whose path contains
101
+ * a `.` or `..` segment, including percent-encoded spellings, throw a
102
+ * `TypeError`: JavaScript `URL` would otherwise identify a different object.
103
+ * This also applies to compatible identifier strings used as relative bases.
104
+ * A `URL` argument may already have lost such segments before this function
105
+ * receives it.
50
106
  */
51
107
  export function parseIri(iri: string | URL, base?: string | URL): URL {
52
108
  if (iri instanceof URL) {
53
109
  return normalizePortableUrl(iri) ?? new URL(iri.href);
54
110
  }
111
+ assertPortablePathCanBeParsed(iri);
55
112
  const portable = parsePortableIri(iri);
56
113
  if (portable != null) return portable;
57
114
  base = normalizeBaseIri(base);
@@ -59,20 +116,25 @@ export function parseIri(iri: string | URL, base?: string | URL): URL {
59
116
  return parseAtUri(iri);
60
117
  }
61
118
  const parsed = new URL(iri, base);
119
+ assertCompatiblePathCanBeParsed(iri, parsed);
62
120
  return normalizePortableUrl(parsed) ?? parsed;
63
121
  }
64
122
 
65
123
  /**
66
124
  * Formats a URL as an IRI, including FEP-ef61 portable ActivityPub IRIs.
125
+ * Portable URI and FEP-ef61 compatible identifier strings with dot segments
126
+ * throw a `TypeError` because their paths cannot be represented by JavaScript
127
+ * `URL` without normalization.
67
128
  */
68
129
  export function formatIri(iri: string | URL): string {
130
+ if (typeof iri === "string") assertPortablePathCanBeParsed(iri);
69
131
  const parsed = parsePortableIri(iri instanceof URL ? iri.href : iri);
70
132
  if (parsed == null) {
71
- return iri instanceof URL
72
- ? iri.href
73
- : URL.canParse(iri)
74
- ? new URL(iri).href
75
- : iri;
133
+ if (iri instanceof URL) return iri.href;
134
+ if (!URL.canParse(iri)) return iri;
135
+ const url = new URL(iri);
136
+ assertCompatiblePathCanBeParsed(iri, url);
137
+ return url.href;
76
138
  }
77
139
  const authority = decodePortableAuthority(parsed.host);
78
140
  return `ap+ef61://${authority}${parsed.pathname}${parsed.search}${parsed.hash}`;
@@ -252,14 +314,18 @@ function parsePortableIri(iri: string): URL | null {
252
314
 
253
315
  function normalizePortableUrl(iri: URL): URL | null {
254
316
  if (iri.protocol !== "ap:" && iri.protocol !== "ap+ef61:") return null;
255
- return parsePortableIri(
256
- `ap+ef61://${iri.host}${iri.pathname}${iri.search}${iri.hash}`,
257
- );
317
+ const raw = `ap+ef61://${iri.host}${iri.pathname}${iri.search}${iri.hash}`;
318
+ assertPortablePathCanBeParsed(raw);
319
+ return parsePortableIri(raw);
258
320
  }
259
321
 
260
322
  function normalizeBaseIri(base?: string | URL): string | URL | undefined {
261
323
  if (base == null) return undefined;
262
324
  if (base instanceof URL) return normalizePortableUrl(base) ?? base;
325
+ assertPortablePathCanBeParsed(base);
326
+ if (URL.canParse(base)) {
327
+ assertCompatiblePathCanBeParsed(base, new URL(base));
328
+ }
263
329
  return parsePortableIri(base) ??
264
330
  (base.startsWith("at://") && !URL.canParse(".", base)
265
331
  ? parseAtUri(base)
@@ -367,6 +433,8 @@ export function parseGatewayUrl(url: string): URL {
367
433
 
368
434
  const COMPATIBLE_ID_PATH_PREFIX = "/.well-known/apgateway/";
369
435
  const COMPATIBLE_ID_DID_PATTERN = /^did(?::|%3A)/i;
436
+ const RAW_COMPATIBLE_ID_PREFIX_PATTERN =
437
+ /^(?:[hH][tT][tT][pP][sS]?):\/\/[^/?#]*\/\.well-known\/apgateway\/(?=[dD][iI][dD](?::|%3[aA]))/;
370
438
  // `gateways` is the location hint parameter name used by earlier FEP-ef61
371
439
  // revisions; strip it as well for compatibility with older publishers.
372
440
  const LOCATION_HINT_PARAMETERS: ReadonlySet<string> = new Set([
@@ -408,8 +476,9 @@ const LOCATION_HINT_PARAMETERS: ReadonlySet<string> = new Set([
408
476
  * malformed, e.g., it has an invalid DID, no object path,
409
477
  * invalid percent-encoding, credentials, or location
410
478
  * hints (`@gateway` query parameters, or the legacy
411
- * `gateways` parameter), which FEP-ef61 forbids in
412
- * compatible identifiers.
479
+ * `gateways` parameter), or its raw string path would
480
+ * change during URL parsing. Already-parsed `URL`
481
+ * arguments cannot reveal segments lost by their parser.
413
482
  * @since 2.4.0
414
483
  */
415
484
  export function fromCompatibleEf61Id(input: string | URL): URL | null {
@@ -420,14 +489,39 @@ function convertCompatibleEf61Id(
420
489
  input: string | URL,
421
490
  ): { url: URL; iri: string } | null {
422
491
  let url: URL;
492
+ const rawPrefix = typeof input === "string"
493
+ ? input.match(RAW_COMPATIBLE_ID_PREFIX_PATTERN)
494
+ : null;
423
495
  if (input instanceof URL) url = input;
424
496
  else if (typeof input === "string" && URL.canParse(input)) {
425
497
  url = new URL(input);
426
498
  } else return null;
427
499
  if (url.protocol !== "http:" && url.protocol !== "https:") return null;
428
- if (!url.pathname.startsWith(COMPATIBLE_ID_PATH_PREFIX)) return null;
500
+ // A raw compatible ID may lose path segments before URL.pathname is read.
501
+ // Likewise, preprocessing must not turn a different raw string into one.
502
+ if (typeof input === "string" && rawPrefix == null) {
503
+ if (
504
+ url.pathname.startsWith(COMPATIBLE_ID_PATH_PREFIX) &&
505
+ COMPATIBLE_ID_DID_PATTERN.test(
506
+ url.pathname.slice(COMPATIBLE_ID_PATH_PREFIX.length),
507
+ )
508
+ ) {
509
+ throw new TypeError("Invalid FEP-ef61 compatible identifier.");
510
+ }
511
+ }
512
+ if (!url.pathname.startsWith(COMPATIBLE_ID_PATH_PREFIX)) {
513
+ if (rawPrefix != null) {
514
+ throw new TypeError("Invalid FEP-ef61 compatible identifier.");
515
+ }
516
+ return null;
517
+ }
429
518
  const tail = url.pathname.slice(COMPATIBLE_ID_PATH_PREFIX.length);
430
- if (!COMPATIBLE_ID_DID_PATTERN.test(tail)) return null;
519
+ if (!COMPATIBLE_ID_DID_PATTERN.test(tail)) {
520
+ if (rawPrefix != null) {
521
+ throw new TypeError("Invalid FEP-ef61 compatible identifier.");
522
+ }
523
+ return null;
524
+ }
431
525
  if (url.username !== "" || url.password !== "") {
432
526
  throw new TypeError(
433
527
  "Invalid FEP-ef61 compatible identifier: credentials are not allowed.",
@@ -440,6 +534,13 @@ function convertCompatibleEf61Id(
440
534
  try {
441
535
  const parsed = parsePortableIri(iri);
442
536
  if (parsed == null) throw new TypeError("Not a portable IRI.");
537
+ if (
538
+ typeof input === "string" && rawPrefix != null &&
539
+ canonicalizePortableUri("ap://" + input.slice(rawPrefix[0].length)) !==
540
+ canonicalizePortableUri(iri)
541
+ ) {
542
+ throw new TypeError("URL parsing changed the portable identifier.");
543
+ }
443
544
  // parsePortableIri() does not validate path and fragment
444
545
  // percent-encoding, but canonicalizePortableUri() does:
445
546
  canonicalizePortableUri(iri);
@@ -490,7 +591,8 @@ function convertCompatibleEf61Id(
490
591
  * @returns The compatible identifier.
491
592
  * @throws {TypeError} If the portable ID is not a valid `ap:` or `ap+ef61:`
492
593
  * URI, if its path has `.` or `..` segments (which
493
- * HTTP(S) URLs cannot represent), or if the gateway is
594
+ * HTTP(S) URLs cannot represent without changing the
595
+ * identified object), or if the gateway is
494
596
  * not an HTTP(S) origin with no credentials, path, query,
495
597
  * or fragment.
496
598
  * @since 2.4.0
@@ -595,6 +697,238 @@ function isLocationHint(pair: string): boolean {
595
697
  }
596
698
  }
597
699
 
700
+ /**
701
+ * The name of the FEP-ef61 location hint query parameter.
702
+ * @internal
703
+ */
704
+ export const GATEWAY_HINT_PARAMETER = "@gateway";
705
+
706
+ /**
707
+ * Parses an FEP-ef61 gateway, which has to be an HTTP(S) origin with no
708
+ * credentials, path, query, or fragment.
709
+ * @returns The gateway, or `null` if it is not a valid gateway.
710
+ * @internal
711
+ */
712
+ export function parseGatewayOrigin(gateway: string | URL): URL | null {
713
+ let url: URL;
714
+ if (gateway instanceof URL) url = new URL(gateway.href);
715
+ else if (typeof gateway === "string" && URL.canParse(gateway)) {
716
+ url = new URL(gateway);
717
+ } else return null;
718
+ return isGatewayUrl(url) ? url : null;
719
+ }
720
+
721
+ /**
722
+ * Returns a copy of an [FEP-ef61] portable ActivityPub URI with `@gateway`
723
+ * location hints for the given gateways, which tell consumers where they can
724
+ * retrieve the object. Put hints on *references* to portable actors, e.g.,
725
+ * in `actor`, `attributedTo`, `to`, or `cc`, when constructing an object, as
726
+ * FEP-ef61 recommends:
727
+ *
728
+ * ~~~~ typescript
729
+ * withGatewayHints("ap://did:key:z6Mk.../actor", [
730
+ * "https://server1.example",
731
+ * "https://server2.example",
732
+ * ]);
733
+ * // ap+ef61://did:key:z6Mk.../actor?@gateway=https%3A%2F%2Fserver1.example&@gateway=https%3A%2F%2Fserver2.example
734
+ * ~~~~
735
+ *
736
+ * Do not put hints on an object's own `id`. Hints do not change the
737
+ * identity of a portable URI, since FEP-ef61 drops the query when comparing
738
+ * portable URIs, but implementations that do not canonicalize portable URIs
739
+ * would take a hinted ID for another object. Add hints before signing the
740
+ * object, since its Object Integrity Proof covers its references too.
741
+ *
742
+ * The hints that the URI already has, including the legacy `gateways`
743
+ * parameter, are replaced. Each gateway becomes a `@gateway` query
744
+ * parameter whose value is its URI-encoded origin, e.g.,
745
+ * `@gateway=https%3A%2F%2Fserver1.example`, in the given order after the
746
+ * other query parameters. Duplicate gateways are dropped, and an empty list
747
+ * removes the hints as {@link withoutGatewayHints} does. The other query
748
+ * parameters, their order, and the fragment are kept, but percent-encoding
749
+ * is normalized the same way as {@link canonicalizePortableUri} does it.
750
+ *
751
+ * Fedify follows at most five hints when dereferencing a portable URI
752
+ * (three for the key ID of an HTTP Signature), so list the preferred
753
+ * gateways first; there is no limit on the number of hints added here.
754
+ *
755
+ * [FEP-ef61]: https://w3id.org/fep/ef61
756
+ *
757
+ * @param portableId The `ap:` or `ap+ef61:` URI. Pass the raw string rather
758
+ * than a `URL` if its path may have `.` or `..` segments,
759
+ * because the `URL` class resolves them.
760
+ * @param gateways The gateways, e.g., the `gateways` of the actor that the
761
+ * URI refers to. Each has to be an HTTP(S) origin with no
762
+ * credentials, path, query, or fragment.
763
+ * @returns The portable URI with the hints, in the same internal `URL` form
764
+ * as {@link parseIri} returns. Use {@link formatIri} to get its
765
+ * canonical string.
766
+ * @throws {TypeError} If the portable ID is not a valid `ap:` or `ap+ef61:`
767
+ * URI, e.g., it is a compatible identifier, which must
768
+ * not have location hints; if its path has `.` or `..`
769
+ * segments, which the `URL` class cannot represent; or
770
+ * if a gateway is invalid.
771
+ * @since 2.4.0
772
+ */
773
+ export function withGatewayHints(
774
+ portableId: string | URL,
775
+ gateways: Iterable<string | URL>,
776
+ ): URL {
777
+ const parts = splitPortableIri(portableId);
778
+ if (typeof gateways === "string") {
779
+ throw new TypeError(
780
+ "The gateways must be an iterable of gateways, not a string.",
781
+ );
782
+ }
783
+ const hints: URL[] = [];
784
+ const seen = new Set<string>();
785
+ for (const gateway of gateways) {
786
+ const url = parseGatewayOrigin(gateway);
787
+ if (url == null) {
788
+ throw new TypeError(
789
+ "FEP-ef61 gateways must be HTTP(S) origins with no credentials, " +
790
+ "path, query, or fragment: " + String(gateway),
791
+ );
792
+ }
793
+ if (seen.has(url.href)) continue;
794
+ seen.add(url.href);
795
+ hints.push(url);
796
+ }
797
+ return replaceGatewayHints(parts, hints);
798
+ }
799
+
800
+ /**
801
+ * Returns a copy of an [FEP-ef61] portable ActivityPub URI without its
802
+ * location hints, i.e., `@gateway` query parameters and the legacy
803
+ * `gateways` parameter. The other query parameters, their order, and the
804
+ * fragment are kept, but percent-encoding is normalized the same way as
805
+ * {@link canonicalizePortableUri} does it.
806
+ *
807
+ * [FEP-ef61]: https://w3id.org/fep/ef61
808
+ *
809
+ * @param portableId The `ap:` or `ap+ef61:` URI. Pass the raw string rather
810
+ * than a `URL` if its path may have `.` or `..` segments,
811
+ * because the `URL` class resolves them.
812
+ * @returns The portable URI without the hints, in the same internal `URL`
813
+ * form as {@link parseIri} returns.
814
+ * @throws {TypeError} If the portable ID is not a valid `ap:` or `ap+ef61:`
815
+ * URI, or if its path has `.` or `..` segments, which
816
+ * the `URL` class cannot represent.
817
+ * @since 2.4.0
818
+ */
819
+ export function withoutGatewayHints(portableId: string | URL): URL {
820
+ return replaceGatewayHints(splitPortableIri(portableId), []);
821
+ }
822
+
823
+ /**
824
+ * Gets the gateways in the `@gateway` location hints of an [FEP-ef61]
825
+ * portable ActivityPub URI, in order. Hints that are not valid gateways,
826
+ * i.e., HTTP(S) origins with no credentials, path, query, or fragment, are
827
+ * skipped, and so are duplicates. The legacy `gateways` parameter is not
828
+ * read.
829
+ *
830
+ * Unlike Fedify's dereferencing, which follows at most five hints, this
831
+ * returns all of them.
832
+ *
833
+ * [FEP-ef61]: https://w3id.org/fep/ef61
834
+ *
835
+ * @param portableId The `ap:` or `ap+ef61:` URI.
836
+ * @returns The gateways, e.g., `https://server1.example/`.
837
+ * @throws {TypeError} If the portable ID is not a valid `ap:` or `ap+ef61:`
838
+ * URI.
839
+ * @since 2.4.0
840
+ */
841
+ export function getGatewayHints(portableId: string | URL): URL[] {
842
+ const { query } = splitPortableIri(portableId);
843
+ return query == null ? [] : parseGatewayHints(query);
844
+ }
845
+
846
+ interface PortableIriParts {
847
+ /** The portable ID as it was given, or the `href` of a `URL`. */
848
+ readonly raw: string;
849
+ /** The parsed portable ID. */
850
+ readonly parsed: URL;
851
+ /** The normalized path. */
852
+ readonly path: string;
853
+ /** The normalized query without `?`, or `null` if there is none. */
854
+ readonly query: string | null;
855
+ /** The normalized fragment with `#`, or an empty string if there is none. */
856
+ readonly fragment: string;
857
+ }
858
+
859
+ function splitPortableIri(portableId: string | URL): PortableIriParts {
860
+ const raw = getRawPortableIri(portableId);
861
+ const match = raw.match(PORTABLE_IRI_PATTERN);
862
+ const parsed = parsePortableIri(raw);
863
+ if (match == null || parsed == null) {
864
+ throw new TypeError("Invalid portable ActivityPub IRI.");
865
+ }
866
+ // Normalize the components before looking at them, as the URL parser would
867
+ // otherwise strip characters such as tabs later, which could turn
868
+ // an unrelated query parameter into a location hint:
869
+ return {
870
+ raw,
871
+ parsed,
872
+ path: normalizePortableComponent(match[3]),
873
+ query: match[4] == null
874
+ ? null
875
+ : normalizePortableComponent(match[4].slice(1)),
876
+ fragment: match[5] == null ? "" : normalizePortableComponent(match[5]),
877
+ };
878
+ }
879
+
880
+ function parseGatewayHints(query: string): URL[] {
881
+ const hints: URL[] = [];
882
+ const seen = new Set<string>();
883
+ // Keep the delimiter, as URLSearchParams would otherwise strip a leading
884
+ // question mark that is part of the first parameter's name:
885
+ for (
886
+ const hint of new URLSearchParams(`?${query}`).getAll(
887
+ GATEWAY_HINT_PARAMETER,
888
+ )
889
+ ) {
890
+ const url = parseGatewayOrigin(hint);
891
+ if (url == null || seen.has(url.href)) continue;
892
+ seen.add(url.href);
893
+ hints.push(url);
894
+ }
895
+ return hints;
896
+ }
897
+
898
+ function replaceGatewayHints(
899
+ parts: PortableIriParts,
900
+ hints: readonly URL[],
901
+ ): URL {
902
+ const pairs = parts.query == null
903
+ ? []
904
+ : parts.query.split("&").filter((pair) =>
905
+ pair !== "" && !isLocationHint(pair)
906
+ );
907
+ for (const hint of hints) {
908
+ pairs.push(`${GATEWAY_HINT_PARAMETER}=${encodeURIComponent(hint.origin)}`);
909
+ }
910
+ const query = pairs.length < 1 ? "" : `?${pairs.join("&")}`;
911
+ const result = parsePortableIri(
912
+ `ap+ef61://${parts.parsed.host}${parts.path}${query}${parts.fragment}`,
913
+ );
914
+ // Guard against URL parser normalization that would silently change
915
+ // the referenced object (e.g., dot segments) or its hints:
916
+ if (
917
+ result == null ||
918
+ canonicalizePortableUri(result.href) !==
919
+ canonicalizePortableUri(parts.raw) ||
920
+ parseGatewayHints(result.search.slice(1)).map((url) => url.href).join(
921
+ " ",
922
+ ) !== hints.map((url) => url.href).join(" ")
923
+ ) {
924
+ throw new TypeError(
925
+ "The portable ActivityPub IRI cannot be represented as a URL without " +
926
+ "changing the object it refers to.",
927
+ );
928
+ }
929
+ return result;
930
+ }
931
+
598
932
  /**
599
933
  * Validates a URL to prevent SSRF attacks.
600
934
  */