@fedify/vocab-runtime 2.4.0-dev.2219 → 2.4.0-dev.2233

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 (45) 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 +4 -13
  5. package/dist/internal/portable-dereference.js +2 -11
  6. package/dist/mod.cjs +5 -2
  7. package/dist/mod.d.cts +95 -1
  8. package/dist/mod.d.ts +95 -1
  9. package/dist/mod.js +3 -3
  10. package/dist/tests/{body-BzhhuMI0.mjs → body-BEYUpKvX.mjs} +1 -1
  11. package/dist/tests/{body-GaHFfkaq.cjs → body-DQKTFMsH.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-sGr7kARm.cjs → docloader-DHCYdBXf.cjs} +3 -3
  17. package/dist/tests/{docloader-DiRcdiPp.mjs → docloader-ra1lbBfP.mjs} +3 -3
  18. package/dist/tests/docloader.test.cjs +3 -3
  19. package/dist/tests/docloader.test.mjs +3 -3
  20. package/dist/tests/internal/portable-dereference.test.cjs +16 -15
  21. package/dist/tests/internal/portable-dereference.test.mjs +14 -13
  22. package/dist/tests/{jsonld-cache-DUmuWRwp.mjs → jsonld-cache-BKrsWPCQ.mjs} +1 -1
  23. package/dist/tests/{jsonld-cache-BQMn_ILF.cjs → jsonld-cache-BTgizq9A.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/{portable-media-BERS_tX_.mjs → portable-media-1NjUQari.mjs} +3 -3
  27. package/dist/tests/{portable-media-C_Q9GmqU.cjs → portable-media-DHaAEpaQ.cjs} +3 -3
  28. package/dist/tests/portable-media.test.cjs +1 -1
  29. package/dist/tests/portable-media.test.mjs +1 -1
  30. package/dist/tests/{request-qq1N2cl6.cjs → request-BQlkB8du.cjs} +1 -1
  31. package/dist/tests/{request-CWTG9_Xx.mjs → request-D3elVRyB.mjs} +1 -1
  32. package/dist/tests/request.test.cjs +1 -1
  33. package/dist/tests/request.test.mjs +1 -1
  34. package/dist/tests/{url-DfS-cvwr.mjs → url-Cdu4ASgJ.mjs} +165 -3
  35. package/dist/tests/{url-B8xyTwP-.cjs → url-CtHyzMR4.cjs} +194 -2
  36. package/dist/tests/url.test.cjs +133 -3
  37. package/dist/tests/url.test.mjs +133 -3
  38. package/dist/{url-DDVweCXr.cjs → url-BHfQuRdy.cjs} +194 -2
  39. package/dist/{url-Be3moMor.js → url-DqqDZY9i.js} +165 -3
  40. package/package.json +1 -1
  41. package/src/internal/portable-dereference.test.ts +16 -1
  42. package/src/internal/portable-dereference.ts +3 -20
  43. package/src/mod.ts +3 -0
  44. package/src/url.test.ts +311 -1
  45. package/src/url.ts +236 -8
@@ -219,9 +219,10 @@ function parseAtUri(uri) {
219
219
  }
220
220
  /**
221
221
  * Checks whether the URL is an FEP-ef61 gateway base URI.
222
+ * @since 2.4.0
222
223
  */
223
224
  function isGatewayUrl(url) {
224
- return (url.protocol === "http:" || url.protocol === "https:") && url.username === "" && url.password === "" && url.pathname === "/" && url.search === "" && url.hash === "";
225
+ return (url.protocol === "http:" || url.protocol === "https:") && url.href === `${url.origin}/`;
225
226
  }
226
227
  /**
227
228
  * Parses and validates an FEP-ef61 gateway base URI.
@@ -229,6 +230,7 @@ function isGatewayUrl(url) {
229
230
  * with no credentials, path, query, or fragment. In the
230
231
  * latter case, the message starts with
231
232
  * `Invalid FEP-ef61 gateway:`.
233
+ * @since 2.4.0
232
234
  */
233
235
  function parseGatewayUrl(url) {
234
236
  const parsed = parseIri(url);
@@ -356,7 +358,7 @@ function toCompatibleEf61Id(portableId, gateway) {
356
358
  }
357
359
  function parseCompatibleEf61Gateway(gateway) {
358
360
  const url = gateway instanceof URL ? gateway : typeof gateway === "string" && URL.canParse(gateway) ? new URL(gateway) : null;
359
- if (url == null || url.protocol !== "http:" && url.protocol !== "https:" || url.href !== `${url.origin}/`) throw new TypeError("FEP-ef61 gateways for compatible identifiers must be HTTP(S) origins with no credentials, path, query, or fragment.");
361
+ if (url == null || !isGatewayUrl(url)) throw new TypeError("FEP-ef61 gateways for compatible identifiers must be HTTP(S) origins with no credentials, path, query, or fragment.");
360
362
  return url;
361
363
  }
362
364
  function getRawPortableIri(portableId) {
@@ -382,6 +384,166 @@ function isLocationHint(pair) {
382
384
  }
383
385
  }
384
386
  /**
387
+ * The name of the FEP-ef61 location hint query parameter.
388
+ * @internal
389
+ */
390
+ const GATEWAY_HINT_PARAMETER = "@gateway";
391
+ /**
392
+ * Parses an FEP-ef61 gateway, which has to be an HTTP(S) origin with no
393
+ * credentials, path, query, or fragment.
394
+ * @returns The gateway, or `null` if it is not a valid gateway.
395
+ * @internal
396
+ */
397
+ function parseGatewayOrigin(gateway) {
398
+ let url;
399
+ if (gateway instanceof URL) url = new URL(gateway.href);
400
+ else if (typeof gateway === "string" && URL.canParse(gateway)) url = new URL(gateway);
401
+ else return null;
402
+ return isGatewayUrl(url) ? url : null;
403
+ }
404
+ /**
405
+ * Returns a copy of an [FEP-ef61] portable ActivityPub URI with `@gateway`
406
+ * location hints for the given gateways, which tell consumers where they can
407
+ * retrieve the object. Put hints on *references* to portable actors, e.g.,
408
+ * in `actor`, `attributedTo`, `to`, or `cc`, when constructing an object, as
409
+ * FEP-ef61 recommends:
410
+ *
411
+ * ~~~~ typescript
412
+ * withGatewayHints("ap://did:key:z6Mk.../actor", [
413
+ * "https://server1.example",
414
+ * "https://server2.example",
415
+ * ]);
416
+ * // ap+ef61://did:key:z6Mk.../actor?@gateway=https%3A%2F%2Fserver1.example&@gateway=https%3A%2F%2Fserver2.example
417
+ * ~~~~
418
+ *
419
+ * Do not put hints on an object's own `id`. Hints do not change the
420
+ * identity of a portable URI, since FEP-ef61 drops the query when comparing
421
+ * portable URIs, but implementations that do not canonicalize portable URIs
422
+ * would take a hinted ID for another object. Add hints before signing the
423
+ * object, since its Object Integrity Proof covers its references too.
424
+ *
425
+ * The hints that the URI already has, including the legacy `gateways`
426
+ * parameter, are replaced. Each gateway becomes a `@gateway` query
427
+ * parameter whose value is its URI-encoded origin, e.g.,
428
+ * `@gateway=https%3A%2F%2Fserver1.example`, in the given order after the
429
+ * other query parameters. Duplicate gateways are dropped, and an empty list
430
+ * removes the hints as {@link withoutGatewayHints} does. The other query
431
+ * parameters, their order, and the fragment are kept, but percent-encoding
432
+ * is normalized the same way as {@link canonicalizePortableUri} does it.
433
+ *
434
+ * Fedify follows at most five hints when dereferencing a portable URI
435
+ * (three for the key ID of an HTTP Signature), so list the preferred
436
+ * gateways first; there is no limit on the number of hints added here.
437
+ *
438
+ * [FEP-ef61]: https://w3id.org/fep/ef61
439
+ *
440
+ * @param portableId The `ap:` or `ap+ef61:` URI. Pass the raw string rather
441
+ * than a `URL` if its path may have `.` or `..` segments,
442
+ * because the `URL` class resolves them.
443
+ * @param gateways The gateways, e.g., the `gateways` of the actor that the
444
+ * URI refers to. Each has to be an HTTP(S) origin with no
445
+ * credentials, path, query, or fragment.
446
+ * @returns The portable URI with the hints, in the same internal `URL` form
447
+ * as {@link parseIri} returns. Use {@link formatIri} to get its
448
+ * canonical string.
449
+ * @throws {TypeError} If the portable ID is not a valid `ap:` or `ap+ef61:`
450
+ * URI, e.g., it is a compatible identifier, which must
451
+ * not have location hints; if its path has `.` or `..`
452
+ * segments, which the `URL` class cannot represent; or
453
+ * if a gateway is invalid.
454
+ * @since 2.4.0
455
+ */
456
+ function withGatewayHints(portableId, gateways) {
457
+ const parts = splitPortableIri(portableId);
458
+ if (typeof gateways === "string") throw new TypeError("The gateways must be an iterable of gateways, not a string.");
459
+ const hints = [];
460
+ const seen = /* @__PURE__ */ new Set();
461
+ for (const gateway of gateways) {
462
+ const url = parseGatewayOrigin(gateway);
463
+ if (url == null) throw new TypeError("FEP-ef61 gateways must be HTTP(S) origins with no credentials, path, query, or fragment: " + String(gateway));
464
+ if (seen.has(url.href)) continue;
465
+ seen.add(url.href);
466
+ hints.push(url);
467
+ }
468
+ return replaceGatewayHints(parts, hints);
469
+ }
470
+ /**
471
+ * Returns a copy of an [FEP-ef61] portable ActivityPub URI without its
472
+ * location hints, i.e., `@gateway` query parameters and the legacy
473
+ * `gateways` parameter. The other query parameters, their order, and the
474
+ * fragment are kept, but percent-encoding is normalized the same way as
475
+ * {@link canonicalizePortableUri} does it.
476
+ *
477
+ * [FEP-ef61]: https://w3id.org/fep/ef61
478
+ *
479
+ * @param portableId The `ap:` or `ap+ef61:` URI. Pass the raw string rather
480
+ * than a `URL` if its path may have `.` or `..` segments,
481
+ * because the `URL` class resolves them.
482
+ * @returns The portable URI without the hints, in the same internal `URL`
483
+ * form as {@link parseIri} returns.
484
+ * @throws {TypeError} If the portable ID is not a valid `ap:` or `ap+ef61:`
485
+ * URI, or if its path has `.` or `..` segments, which
486
+ * the `URL` class cannot represent.
487
+ * @since 2.4.0
488
+ */
489
+ function withoutGatewayHints(portableId) {
490
+ return replaceGatewayHints(splitPortableIri(portableId), []);
491
+ }
492
+ /**
493
+ * Gets the gateways in the `@gateway` location hints of an [FEP-ef61]
494
+ * portable ActivityPub URI, in order. Hints that are not valid gateways,
495
+ * i.e., HTTP(S) origins with no credentials, path, query, or fragment, are
496
+ * skipped, and so are duplicates. The legacy `gateways` parameter is not
497
+ * read.
498
+ *
499
+ * Unlike Fedify's dereferencing, which follows at most five hints, this
500
+ * returns all of them.
501
+ *
502
+ * [FEP-ef61]: https://w3id.org/fep/ef61
503
+ *
504
+ * @param portableId The `ap:` or `ap+ef61:` URI.
505
+ * @returns The gateways, e.g., `https://server1.example/`.
506
+ * @throws {TypeError} If the portable ID is not a valid `ap:` or `ap+ef61:`
507
+ * URI.
508
+ * @since 2.4.0
509
+ */
510
+ function getGatewayHints(portableId) {
511
+ const { query } = splitPortableIri(portableId);
512
+ return query == null ? [] : parseGatewayHints(query);
513
+ }
514
+ function splitPortableIri(portableId) {
515
+ const raw = getRawPortableIri(portableId);
516
+ const match = raw.match(PORTABLE_IRI_PATTERN);
517
+ const parsed = parsePortableIri(raw);
518
+ if (match == null || parsed == null) throw new TypeError("Invalid portable ActivityPub IRI.");
519
+ return {
520
+ raw,
521
+ parsed,
522
+ path: normalizePortableComponent(match[3]),
523
+ query: match[4] == null ? null : normalizePortableComponent(match[4].slice(1)),
524
+ fragment: match[5] == null ? "" : normalizePortableComponent(match[5])
525
+ };
526
+ }
527
+ function parseGatewayHints(query) {
528
+ const hints = [];
529
+ const seen = /* @__PURE__ */ new Set();
530
+ for (const hint of new URLSearchParams(`?${query}`).getAll(GATEWAY_HINT_PARAMETER)) {
531
+ const url = parseGatewayOrigin(hint);
532
+ if (url == null || seen.has(url.href)) continue;
533
+ seen.add(url.href);
534
+ hints.push(url);
535
+ }
536
+ return hints;
537
+ }
538
+ function replaceGatewayHints(parts, hints) {
539
+ const pairs = parts.query == null ? [] : parts.query.split("&").filter((pair) => pair !== "" && !isLocationHint(pair));
540
+ for (const hint of hints) pairs.push(`${GATEWAY_HINT_PARAMETER}=${encodeURIComponent(hint.origin)}`);
541
+ const query = pairs.length < 1 ? "" : `?${pairs.join("&")}`;
542
+ const result = parsePortableIri(`ap+ef61://${parts.parsed.host}${parts.path}${query}${parts.fragment}`);
543
+ 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.");
544
+ return result;
545
+ }
546
+ /**
385
547
  * Validates a URL to prevent SSRF attacks.
386
548
  */
387
549
  async function validatePublicUrl(url) {
@@ -577,4 +739,4 @@ function matchesIPv6Prefix(address, prefixWords, prefixLength) {
577
739
  return true;
578
740
  }
579
741
  //#endregion
580
- export { validatePublicUrl 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 };
742
+ export { withoutGatewayHints as S, parseIri as _, expandIPv6Address as a, validatePublicUrl 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, withGatewayHints as x, toCompatibleEf61Id as y };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fedify/vocab-runtime",
3
- "version": "2.4.0-dev.2219+eb70f060",
3
+ "version": "2.4.0-dev.2233+abb4a6b2",
4
4
  "homepage": "https://fedify.dev/",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,7 +1,7 @@
1
1
  import { deepStrictEqual, ok, throws } from "node:assert/strict";
2
2
  import { test } from "node:test";
3
3
  import type { DocumentLoader } from "../docloader.ts";
4
- import { parseIri } from "../url.ts";
4
+ import { parseIri, withGatewayHints } from "../url.ts";
5
5
  import { createScopedContextLoader } from "./jsonld-cache.ts";
6
6
  import {
7
7
  createSnapshotContextLoader,
@@ -55,6 +55,8 @@ test("getPortableGatewayCandidates() reads @gateway hints", () => {
55
55
  "&gateways=https%3A%2F%2Flegacy.example" +
56
56
  "&@gateway=not%20a%20URL" +
57
57
  "&@gateway=https%3A%2F%2Fb.example%2Fpath" +
58
+ "&@gateway=https%3A%2F%2Fquery.example%2F%3F" +
59
+ "&@gateway=https%3A%2F%2Ffragment.example%2F%23" +
58
60
  "&%40gateway=https%3A%2F%2Fb.example" +
59
61
  "&@gateway=https%3A%2F%2Fa.example%2F",
60
62
  ))),
@@ -76,6 +78,19 @@ test("getPortableGatewayCandidates() reads @gateway hints", () => {
76
78
  );
77
79
  });
78
80
 
81
+ test("getPortableGatewayCandidates() reads hints from withGatewayHints()", () => {
82
+ const gateways = Array.from(
83
+ { length: 7 },
84
+ (_, i) => `https://g${i}.example`,
85
+ );
86
+ deepStrictEqual(
87
+ hrefs(getPortableGatewayCandidates(
88
+ withGatewayHints("ap://did:key:z6Mkabc/actor?page=1", gateways),
89
+ )),
90
+ [0, 1, 2, 3, 4].map((i) => `https://g${i}.example/`),
91
+ );
92
+ });
93
+
79
94
  test("createSnapshotContextLoader() does not nest released snapshots", async () => {
80
95
  const calls: string[] = [];
81
96
  const base: DocumentLoader = (url) => {
@@ -21,7 +21,9 @@ import {
21
21
  canonicalizePortableUri,
22
22
  formatIri,
23
23
  fromCompatibleEf61Id,
24
+ GATEWAY_HINT_PARAMETER,
24
25
  haveSameFe34Origin,
26
+ parseGatewayOrigin,
25
27
  parseIri,
26
28
  toCompatibleEf61Id,
27
29
  } from "../url.ts";
@@ -35,8 +37,6 @@ const logger = getLogger(["fedify", "vocab", "gateway"]);
35
37
  */
36
38
  const MAX_GATEWAY_HINTS = 5;
37
39
 
38
- const LOCATION_HINT_PARAMETER = "@gateway";
39
-
40
40
  // The same baseline contexts that Fedify's proof verifier always resolves
41
41
  // from its built-in copies (see getNormalizationContextLoader() in
42
42
  // @fedify/fedify). Serving them identically here keeps the identity check,
@@ -524,7 +524,7 @@ export function getPortableGatewayCandidates(
524
524
  }
525
525
  for (
526
526
  const hint of new URLSearchParams(url.search).getAll(
527
- LOCATION_HINT_PARAMETER,
527
+ GATEWAY_HINT_PARAMETER,
528
528
  )
529
529
  ) {
530
530
  if (candidates.length >= MAX_GATEWAY_HINTS) break;
@@ -541,23 +541,6 @@ export function getPortableGatewayCandidates(
541
541
  return candidates;
542
542
  }
543
543
 
544
- function parseGatewayOrigin(gateway: string | URL): URL | null {
545
- let url: URL;
546
- if (gateway instanceof URL) url = new URL(gateway.href);
547
- else if (typeof gateway === "string" && URL.canParse(gateway)) {
548
- url = new URL(gateway);
549
- } else return null;
550
- // Comparing href with the origin also rejects credentials, a path, and
551
- // query and fragment components, including empty ? and # delimiters.
552
- if (
553
- (url.protocol !== "http:" && url.protocol !== "https:") ||
554
- url.href !== `${url.origin}/`
555
- ) {
556
- return null;
557
- }
558
- return url;
559
- }
560
-
561
544
  /**
562
545
  * Creates a context loader that returns the same context documents for the
563
546
  * whole dereference operation, so that the identity check, the proof
package/src/mod.ts CHANGED
@@ -83,6 +83,7 @@ export {
83
83
  formatIri,
84
84
  fromCompatibleEf61Id,
85
85
  getFe34Origin,
86
+ getGatewayHints,
86
87
  haveSameFe34Origin,
87
88
  haveSameIriOrigin,
88
89
  isGatewayUrl,
@@ -94,4 +95,6 @@ export {
94
95
  toCompatibleEf61Id,
95
96
  UrlError,
96
97
  validatePublicUrl,
98
+ withGatewayHints,
99
+ withoutGatewayHints,
97
100
  } from "./url.ts";
package/src/url.test.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  formatIri,
8
8
  fromCompatibleEf61Id,
9
9
  getFe34Origin,
10
+ getGatewayHints,
10
11
  haveSameFe34Origin,
11
12
  haveSameIriOrigin,
12
13
  isGatewayUrl,
@@ -19,6 +20,8 @@ import {
19
20
  UrlError,
20
21
  validateLookupAddresses,
21
22
  validatePublicUrl,
23
+ withGatewayHints,
24
+ withoutGatewayHints,
22
25
  } from "./url.ts";
23
26
 
24
27
  test("parseIri() accepts portable ActivityPub URI schemes", () => {
@@ -644,7 +647,13 @@ test("parseIri() preserves encoded percent signs while decoding delimiters", ()
644
647
  });
645
648
 
646
649
  test("parseGatewayUrl() accepts only HTTP(S) base URIs", () => {
647
- for (const url of ["https://server.example/", "http://server.example/"]) {
650
+ for (
651
+ const url of [
652
+ "https://server.example",
653
+ "https://server.example/",
654
+ "http://server.example/",
655
+ ]
656
+ ) {
648
657
  deepStrictEqual(parseGatewayUrl(url), new URL(url));
649
658
  ok(isGatewayUrl(new URL(url)));
650
659
  }
@@ -656,7 +665,9 @@ test("parseGatewayUrl() accepts only HTTP(S) base URIs", () => {
656
665
  "https://user@server.example/",
657
666
  "https://server.example/path",
658
667
  "https://server.example/?x=1",
668
+ "https://server.example/?",
659
669
  "https://server.example/#fragment",
670
+ "https://server.example/#",
660
671
  ]
661
672
  ) {
662
673
  // Inboxes tell a malformed gateway from other errors by this prefix:
@@ -668,6 +679,13 @@ test("parseGatewayUrl() accepts only HTTP(S) base URIs", () => {
668
679
  );
669
680
  ok(!isGatewayUrl(new URL(url)));
670
681
  }
682
+
683
+ const mutated = new URL("https://server.example/");
684
+ mutated.search = "?";
685
+ ok(!isGatewayUrl(mutated));
686
+ mutated.search = "";
687
+ mutated.hash = "#";
688
+ ok(!isGatewayUrl(mutated));
671
689
  });
672
690
 
673
691
  test("fromCompatibleEf61Id() converts compatible identifiers", () => {
@@ -1212,3 +1230,295 @@ test("expandIPv6Address()", () => {
1212
1230
  "0064:ff9b:0000:0000:0000:0000:0808:0808",
1213
1231
  );
1214
1232
  });
1233
+
1234
+ test("withGatewayHints() adds @gateway location hints", () => {
1235
+ const expected = "ap+ef61://did:key:z6Mkabc/actor" +
1236
+ "?@gateway=https%3A%2F%2Fserver1.example" +
1237
+ "&@gateway=https%3A%2F%2Fserver2.example";
1238
+ for (
1239
+ const id of [
1240
+ "ap://did:key:z6Mkabc/actor",
1241
+ "ap+ef61://did:key:z6Mkabc/actor",
1242
+ "ap://did%3Akey%3Az6Mkabc/actor",
1243
+ parseIri("ap://did:key:z6Mkabc/actor"),
1244
+ new URL("ap://did%3Akey%3Az6Mkabc/actor"),
1245
+ ]
1246
+ ) {
1247
+ const hinted = withGatewayHints(id, [
1248
+ "https://server1.example",
1249
+ new URL("https://server2.example/"),
1250
+ ]);
1251
+ ok(hinted instanceof URL);
1252
+ strictEqual(
1253
+ hinted.href,
1254
+ "ap+ef61://did%3Akey%3Az6Mkabc/actor" +
1255
+ "?@gateway=https%3A%2F%2Fserver1.example" +
1256
+ "&@gateway=https%3A%2F%2Fserver2.example",
1257
+ );
1258
+ strictEqual(formatIri(hinted), expected);
1259
+ deepStrictEqual(parseIri(formatIri(hinted)), hinted);
1260
+ }
1261
+ });
1262
+
1263
+ test("withGatewayHints() encodes and deduplicates gateway origins", () => {
1264
+ function* gateways(): Generator<string | URL> {
1265
+ yield "https://A.example:443";
1266
+ yield "https://a.example/";
1267
+ yield new URL("https://a.example");
1268
+ yield "https://b.example:8443";
1269
+ yield "http://c.example:80/";
1270
+ yield "https://例え.jp";
1271
+ }
1272
+ strictEqual(
1273
+ formatIri(withGatewayHints("ap://did:key:z6Mkabc/actor", gateways())),
1274
+ "ap+ef61://did:key:z6Mkabc/actor" +
1275
+ "?@gateway=https%3A%2F%2Fa.example" +
1276
+ "&@gateway=https%3A%2F%2Fb.example%3A8443" +
1277
+ "&@gateway=http%3A%2F%2Fc.example" +
1278
+ "&@gateway=https%3A%2F%2Fxn--r8jz45g.jp",
1279
+ );
1280
+ strictEqual(
1281
+ formatIri(
1282
+ withGatewayHints(
1283
+ "ap://did:key:z6Mkabc/actor",
1284
+ new Set(["https://b.example", "https://a.example"]),
1285
+ ),
1286
+ ),
1287
+ "ap+ef61://did:key:z6Mkabc/actor" +
1288
+ "?@gateway=https%3A%2F%2Fb.example&@gateway=https%3A%2F%2Fa.example",
1289
+ );
1290
+ // There is no limit on the number of hints:
1291
+ const many = Array.from(
1292
+ { length: 7 },
1293
+ (_, i) => `https://server${i}.example`,
1294
+ );
1295
+ deepStrictEqual(
1296
+ getGatewayHints(withGatewayHints("ap://did:key:z6Mkabc/actor", many))
1297
+ .map((url) => url.origin),
1298
+ many,
1299
+ );
1300
+ });
1301
+
1302
+ test("withGatewayHints() keeps other query parameters and fragments", () => {
1303
+ strictEqual(
1304
+ formatIri(
1305
+ withGatewayHints(
1306
+ "ap://did:key:z6Mkabc/collection?page=3&maxItems=20&q=a+b%20c%2b",
1307
+ ["https://server.example"],
1308
+ ),
1309
+ ),
1310
+ "ap+ef61://did:key:z6Mkabc/collection?page=3&maxItems=20&q=a+b%20c%2B" +
1311
+ "&@gateway=https%3A%2F%2Fserver.example",
1312
+ );
1313
+ strictEqual(
1314
+ formatIri(
1315
+ withGatewayHints(
1316
+ "ap://did:key:z6Mkabc/actor?x=%26%3D#main-key",
1317
+ ["https://server.example"],
1318
+ ),
1319
+ ),
1320
+ "ap+ef61://did:key:z6Mkabc/actor?x=%26%3D" +
1321
+ "&@gateway=https%3A%2F%2Fserver.example#main-key",
1322
+ );
1323
+ strictEqual(
1324
+ withGatewayHints("ap://did:key:z6Mkabc/actor#", ["https://s.example"])
1325
+ .href,
1326
+ "ap+ef61://did%3Akey%3Az6Mkabc/actor" +
1327
+ "?@gateway=https%3A%2F%2Fs.example#",
1328
+ );
1329
+ });
1330
+
1331
+ test("withGatewayHints() replaces existing location hints", () => {
1332
+ strictEqual(
1333
+ formatIri(
1334
+ withGatewayHints(
1335
+ "ap://did:key:z6Mkabc/collection?@gateway=https%3A%2F%2Fold.example" +
1336
+ "&page=2&%40gateway=https%3A%2F%2Fold2.example&@gateway" +
1337
+ "&gateways=https%3A%2F%2Flegacy.example&%2540gateway=kept",
1338
+ ["https://new.example"],
1339
+ ),
1340
+ ),
1341
+ "ap+ef61://did:key:z6Mkabc/collection?page=2&%2540gateway=kept" +
1342
+ "&@gateway=https%3A%2F%2Fnew.example",
1343
+ );
1344
+ // Characters that the URL parser strips cannot turn into a hint name:
1345
+ for (const char of ["\t", "\n", "\r"]) {
1346
+ const hinted = withGatewayHints(
1347
+ `ap://did:key:z6Mkabc/actor?@gate${char}way=https%3A%2F%2Fevil.example`,
1348
+ ["https://new.example"],
1349
+ );
1350
+ deepStrictEqual(getGatewayHints(hinted).map((url) => url.href), [
1351
+ "https://new.example/",
1352
+ ]);
1353
+ }
1354
+ });
1355
+
1356
+ test("withGatewayHints() with no gateways removes location hints", () => {
1357
+ for (
1358
+ const [input, expected] of [
1359
+ [
1360
+ "ap://did:key:z6Mkabc/actor?@gateway=https%3A%2F%2Fa.example",
1361
+ "ap+ef61://did%3Akey%3Az6Mkabc/actor",
1362
+ ],
1363
+ [
1364
+ "ap://did:key:z6Mkabc/actor?&@gateway=https%3A%2F%2Fa.example&&p=1&",
1365
+ "ap+ef61://did%3Akey%3Az6Mkabc/actor?p=1",
1366
+ ],
1367
+ ["ap://did:key:z6Mkabc/actor?", "ap+ef61://did%3Akey%3Az6Mkabc/actor"],
1368
+ [
1369
+ "ap://did:key:z6Mkabc/actor?gateways=https%3A%2F%2Fa.example#k",
1370
+ "ap+ef61://did%3Akey%3Az6Mkabc/actor#k",
1371
+ ],
1372
+ ]
1373
+ ) {
1374
+ strictEqual(withGatewayHints(input, []).href, expected, input);
1375
+ strictEqual(withoutGatewayHints(input).href, expected, input);
1376
+ }
1377
+ const input = parseIri(
1378
+ "ap://did:key:z6Mkabc/actor?@gateway=https%3A%2F%2Fa.example",
1379
+ );
1380
+ const href = input.href;
1381
+ withoutGatewayHints(input);
1382
+ strictEqual(input.href, href);
1383
+ });
1384
+
1385
+ test("withGatewayHints() rejects non-portable IDs", () => {
1386
+ for (
1387
+ const id of [
1388
+ "https://server.example/.well-known/apgateway/did:key:z6Mkabc/actor",
1389
+ "https://example.com/actor",
1390
+ "did:key:z6Mkabc#z6Mkabc",
1391
+ "ap://did:key:z6Mkabc",
1392
+ "ap://example.com/actor",
1393
+ "ap://did:key:z6Mkabc/actor%zz",
1394
+ new URL("https://example.com/actor"),
1395
+ new URL("ap://did%3Akey%3Az6Mkabc:8080/actor"),
1396
+ new URL("ap://user@did%3Akey%3Az6Mkabc/actor"),
1397
+ ]
1398
+ ) {
1399
+ throws(
1400
+ () => withGatewayHints(id, ["https://server.example"]),
1401
+ TypeError,
1402
+ String(id),
1403
+ );
1404
+ throws(() => withoutGatewayHints(id), TypeError, String(id));
1405
+ throws(() => getGatewayHints(id), TypeError, String(id));
1406
+ }
1407
+ });
1408
+
1409
+ test("withGatewayHints() rejects paths that URLs cannot represent", () => {
1410
+ for (
1411
+ const id of [
1412
+ "ap://did:key:z6Mkabc/a/../actor",
1413
+ "ap://did:key:z6Mkabc/a/./actor",
1414
+ "ap://did:key:z6Mkabc/a/%2e%2e/actor",
1415
+ "ap://did:key:z6Mkabc/a/%2E/actor",
1416
+ ]
1417
+ ) {
1418
+ throws(
1419
+ () => withGatewayHints(id, ["https://server.example"]),
1420
+ TypeError,
1421
+ id,
1422
+ );
1423
+ throws(() => withoutGatewayHints(id), TypeError, id);
1424
+ }
1425
+ // Characters that the URL parser would strip are percent-encoded instead:
1426
+ strictEqual(
1427
+ withoutGatewayHints("ap://did:key:z6Mkabc/a\tb").href,
1428
+ "ap+ef61://did%3Akey%3Az6Mkabc/a%09b",
1429
+ );
1430
+ });
1431
+
1432
+ test("withGatewayHints() rejects invalid gateways", () => {
1433
+ for (
1434
+ const gateway of [
1435
+ "https://server.example/path",
1436
+ "https://server.example/?",
1437
+ "https://server.example/#",
1438
+ "https://server.example/?q=1",
1439
+ "https://user:pass@server.example",
1440
+ "ftp://server.example",
1441
+ "ap://did:key:z6Mkabc/actor",
1442
+ "server.example",
1443
+ new URL("https://server.example/path"),
1444
+ ]
1445
+ ) {
1446
+ throws(
1447
+ () => withGatewayHints("ap://did:key:z6Mkabc/actor", [gateway]),
1448
+ TypeError,
1449
+ String(gateway),
1450
+ );
1451
+ }
1452
+ throws(
1453
+ () =>
1454
+ withGatewayHints(
1455
+ "ap://did:key:z6Mkabc/actor",
1456
+ "https://server.example" as unknown as string[],
1457
+ ),
1458
+ TypeError,
1459
+ );
1460
+ });
1461
+
1462
+ test("getGatewayHints() reads @gateway location hints", () => {
1463
+ deepStrictEqual(
1464
+ getGatewayHints(
1465
+ "ap://did:key:z6Mkabc/actor?@gateway=https%3A%2F%2Fa.example" +
1466
+ "&@gateway=invalid&%40gateway=https%3A%2F%2Fb.example%2F" +
1467
+ "&@gateway=https%3A%2F%2Fa.example%2F" +
1468
+ "&@gateway=https%3A%2F%2Fc.example%2Fpath" +
1469
+ "&gateways=https%3A%2F%2Flegacy.example&page=1#k",
1470
+ ).map((url) => url.href),
1471
+ ["https://a.example/", "https://b.example/"],
1472
+ );
1473
+ deepStrictEqual(getGatewayHints("ap://did:key:z6Mkabc/actor"), []);
1474
+ // A literal question mark is a part of the parameter name:
1475
+ const questioned =
1476
+ "ap://did:key:z6Mkabc/actor??@gateway=https%3A%2F%2Fa.example";
1477
+ deepStrictEqual(getGatewayHints(questioned), []);
1478
+ strictEqual(
1479
+ withoutGatewayHints(questioned).href,
1480
+ "ap+ef61://did%3Akey%3Az6Mkabc/actor??@gateway=https%3A%2F%2Fa.example",
1481
+ );
1482
+ deepStrictEqual(
1483
+ getGatewayHints(withGatewayHints(questioned, ["https://b.example"]))
1484
+ .map((url) => url.href),
1485
+ ["https://b.example/"],
1486
+ );
1487
+ deepStrictEqual(
1488
+ getGatewayHints(
1489
+ "ap://did:key:z6Mkabc/actor?@gate%09way=https%3A%2F%2Fa.example",
1490
+ ),
1491
+ [],
1492
+ );
1493
+ deepStrictEqual(
1494
+ getGatewayHints(
1495
+ "ap://did:key:z6Mkabc/actor?@gate\tway=https%3A%2F%2Fa.example",
1496
+ ),
1497
+ [],
1498
+ );
1499
+ });
1500
+
1501
+ test("withGatewayHints() round-trips with other portable ID helpers", () => {
1502
+ const hinted = withGatewayHints("ap://did:key:z6Mkabc/objects/1#frag", [
1503
+ "https://server1.example",
1504
+ "https://server2.example",
1505
+ ]);
1506
+ strictEqual(
1507
+ canonicalizePortableUri(formatIri(hinted)),
1508
+ "ap+ef61://did:key:z6Mkabc/objects/1#frag",
1509
+ );
1510
+ ok(
1511
+ arePortableUrisEqual(
1512
+ formatIri(hinted),
1513
+ "ap://did:key:z6Mkabc/objects/1#frag",
1514
+ ),
1515
+ );
1516
+ strictEqual(
1517
+ toCompatibleEf61Id(hinted, "https://server1.example").href,
1518
+ "https://server1.example/.well-known/apgateway/did:key:z6Mkabc/objects/1#frag",
1519
+ );
1520
+ strictEqual(
1521
+ formatIri(withoutGatewayHints(hinted)),
1522
+ "ap+ef61://did:key:z6Mkabc/objects/1#frag",
1523
+ );
1524
+ });