@fedify/vocab-runtime 2.4.0-pr.936.41 → 2.5.0-dev.2271

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-CVR1bABr.mjs +130 -0
  26. package/dist/tests/body-T_yQWql3.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-C6yApEts.mjs +373 -0
  42. package/dist/tests/docloader-CJK2w3l-.cjs +396 -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-C4LEXJk4.mjs +148 -0
  70. package/dist/tests/portable-media-D05JJIJg.cjs +153 -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-C-VZ9TLj.cjs} +10 -4
  80. package/dist/tests/{request-C8CaGwtt.mjs → request-Cf-A3DU-.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.test.ts CHANGED
@@ -1,11 +1,13 @@
1
- import { deepStrictEqual, ok, rejects, throws } from "node:assert";
1
+ import { deepStrictEqual, ok, rejects, strictEqual, throws } from "node:assert";
2
2
  import { test } from "node:test";
3
3
  import {
4
4
  arePortableUrisEqual,
5
5
  canonicalizePortableUri,
6
6
  expandIPv6Address,
7
7
  formatIri,
8
+ fromCompatibleEf61Id,
8
9
  getFe34Origin,
10
+ getGatewayHints,
9
11
  haveSameFe34Origin,
10
12
  haveSameIriOrigin,
11
13
  isGatewayUrl,
@@ -14,8 +16,12 @@ import {
14
16
  parseGatewayUrl,
15
17
  parseIri,
16
18
  parseJsonLdId,
19
+ toCompatibleEf61Id,
17
20
  UrlError,
21
+ validateLookupAddresses,
18
22
  validatePublicUrl,
23
+ withGatewayHints,
24
+ withoutGatewayHints,
19
25
  } from "./url.ts";
20
26
 
21
27
  test("parseIri() accepts portable ActivityPub URI schemes", () => {
@@ -34,6 +40,35 @@ test("parseIri() accepts portable ActivityPub URI schemes", () => {
34
40
  }
35
41
  });
36
42
 
43
+ test("portable URI parsing refuses dot segments before URL normalization", () => {
44
+ const base = "ap://did:key:z6Mkabc";
45
+ for (const segment of [".", "..", "%2e", ".%2E", "%2e.", "%2E%2e"]) {
46
+ for (const path of [`/${segment}`, `/a/${segment}/b`, `/a/${segment}`]) {
47
+ const iri = base + path;
48
+ throws(() => parseIri(iri), TypeError, iri);
49
+ throws(() => parseJsonLdId(iri), TypeError, iri);
50
+ throws(() => formatIri(iri), TypeError, iri);
51
+ }
52
+ }
53
+ for (
54
+ const iri of [
55
+ `${base}/a/.\t./b`,
56
+ `${base}/a/..\n/b`,
57
+ ` ${base}/a/../b`,
58
+ `${base}/a/.. `,
59
+ ]
60
+ ) {
61
+ throws(() => parseIri(iri), TypeError, iri);
62
+ }
63
+ for (const path of ["/a/.../b", "/a/.x/b", "/a/x.y/b", "/a/%252e/b"]) {
64
+ deepStrictEqual(
65
+ formatIri(parseIri(base + path)),
66
+ `ap+ef61://did:key:z6Mkabc${path}`,
67
+ );
68
+ }
69
+ throws(() => parseIri("child", `${base}/a/../b`), TypeError);
70
+ });
71
+
37
72
  test("parseIri() normalizes DID scheme and method casing", () => {
38
73
  const cases = [
39
74
  "ap://DID:key:z6Mkabc/actor",
@@ -106,6 +141,19 @@ test("parseIri() resolves relative IRIs against portable string bases", () => {
106
141
  );
107
142
  });
108
143
 
144
+ test("parseIri() rejects lossy compatible ID string bases", () => {
145
+ const base = "https://server.example/.well-known/apgateway/did:key:z6MkAlice";
146
+ for (const segment of [".", "..", "%2e", "%2e%2e"]) {
147
+ const rawBase = `${base}/a/${segment}/b`;
148
+ throws(() => parseIri("child", rawBase), TypeError, rawBase);
149
+ throws(() => parseJsonLdId("child", rawBase), TypeError, rawBase);
150
+ }
151
+ deepStrictEqual(
152
+ parseIri("child", "https://server.example/a/../b"),
153
+ new URL("https://server.example/child"),
154
+ );
155
+ });
156
+
109
157
  test("parseIri() resolves relative IRIs against at:// string bases", () => {
110
158
  deepStrictEqual(
111
159
  parseIri("/record", "at://did:plc:example/collection/item"),
@@ -641,7 +689,13 @@ test("parseIri() preserves encoded percent signs while decoding delimiters", ()
641
689
  });
642
690
 
643
691
  test("parseGatewayUrl() accepts only HTTP(S) base URIs", () => {
644
- for (const url of ["https://server.example/", "http://server.example/"]) {
692
+ for (
693
+ const url of [
694
+ "https://server.example",
695
+ "https://server.example/",
696
+ "http://server.example/",
697
+ ]
698
+ ) {
645
699
  deepStrictEqual(parseGatewayUrl(url), new URL(url));
646
700
  ok(isGatewayUrl(new URL(url)));
647
701
  }
@@ -653,12 +707,456 @@ test("parseGatewayUrl() accepts only HTTP(S) base URIs", () => {
653
707
  "https://user@server.example/",
654
708
  "https://server.example/path",
655
709
  "https://server.example/?x=1",
710
+ "https://server.example/?",
656
711
  "https://server.example/#fragment",
712
+ "https://server.example/#",
657
713
  ]
658
714
  ) {
659
- throws(() => parseGatewayUrl(url), TypeError);
715
+ // Inboxes tell a malformed gateway from other errors by this prefix:
716
+ throws(
717
+ () => parseGatewayUrl(url),
718
+ (error) =>
719
+ error instanceof TypeError &&
720
+ error.message.startsWith("Invalid FEP-ef61 gateway: "),
721
+ );
660
722
  ok(!isGatewayUrl(new URL(url)));
661
723
  }
724
+
725
+ const mutated = new URL("https://server.example/");
726
+ mutated.search = "?";
727
+ ok(!isGatewayUrl(mutated));
728
+ mutated.search = "";
729
+ mutated.hash = "#";
730
+ ok(!isGatewayUrl(mutated));
731
+ });
732
+
733
+ test("fromCompatibleEf61Id() converts compatible identifiers", () => {
734
+ const cases: [string, string][] = [
735
+ [
736
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/objects/1",
737
+ "ap+ef61://did%3Akey%3Az6MkAlice/objects/1",
738
+ ],
739
+ [
740
+ "http://server.example:8080/.well-known/apgateway/did:key:z6MkAlice/actor",
741
+ "ap+ef61://did%3Akey%3Az6MkAlice/actor",
742
+ ],
743
+ [
744
+ "https://server.example/.well-known/apgateway/did%3Akey%3Az6MkAlice/actor",
745
+ "ap+ef61://did%3Akey%3Az6MkAlice/actor",
746
+ ],
747
+ [
748
+ "https://server.example/.well-known/apgateway/DID:key:z6MkAlice/actor",
749
+ "ap+ef61://did%3Akey%3Az6MkAlice/actor",
750
+ ],
751
+ [
752
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/a/b/c",
753
+ "ap+ef61://did%3Akey%3Az6MkAlice/a/b/c",
754
+ ],
755
+ [
756
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/actor#main-key",
757
+ "ap+ef61://did%3Akey%3Az6MkAlice/actor#main-key",
758
+ ],
759
+ [
760
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/actor#",
761
+ "ap+ef61://did%3Akey%3Az6MkAlice/actor#",
762
+ ],
763
+ [
764
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/outbox?page=2",
765
+ "ap+ef61://did%3Akey%3Az6MkAlice/outbox?page=2",
766
+ ],
767
+ [
768
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/a%2Fb",
769
+ "ap+ef61://did%3Akey%3Az6MkAlice/a%2Fb",
770
+ ],
771
+ [
772
+ "https://server.example/.well-known/apgateway/did:web:example.com%3A8080/actor",
773
+ "ap+ef61://did%3Aweb%3Aexample.com%253A8080/actor",
774
+ ],
775
+ [
776
+ "https://server.example/.well-known/apgateway/did:example:a%2525/x",
777
+ "ap+ef61://did%3Aexample%3Aa%2525/x",
778
+ ],
779
+ ];
780
+ for (const [input, expected] of cases) {
781
+ deepStrictEqual(fromCompatibleEf61Id(input)?.href, expected, input);
782
+ deepStrictEqual(
783
+ fromCompatibleEf61Id(new URL(input))?.href,
784
+ expected,
785
+ input,
786
+ );
787
+ }
788
+ const converted = fromCompatibleEf61Id(
789
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/objects/1",
790
+ );
791
+ deepStrictEqual(
792
+ formatIri(converted!),
793
+ "ap+ef61://did:key:z6MkAlice/objects/1",
794
+ );
795
+ deepStrictEqual(
796
+ canonicalizePortableUri(converted!.href),
797
+ "ap+ef61://did:key:z6MkAlice/objects/1",
798
+ );
799
+ ok(
800
+ arePortableUrisEqual(
801
+ fromCompatibleEf61Id(
802
+ "https://a.example/.well-known/apgateway/did:key:z6MkAlice/actor",
803
+ )!.href,
804
+ fromCompatibleEf61Id(
805
+ "https://b.example/.well-known/apgateway/did:key:z6MkAlice/actor",
806
+ )!.href,
807
+ ),
808
+ );
809
+ });
810
+
811
+ test("fromCompatibleEf61Id() returns null for other URLs", () => {
812
+ const cases = [
813
+ "https://server.example/users/alice",
814
+ "https://server.example/.well-known/webfinger?resource=acct:a@b",
815
+ "https://server.example/.well-known/apgateway",
816
+ "https://server.example/.well-known/apgateway/",
817
+ "https://server.example/.well-known/apgateway/hl:zQmdfTbBqBPQ7VNxZEYEj14VmRuZBkqFbiwReogJgS1zR1n",
818
+ "https://server.example/.well-known/apgateway/objects/did:key:z6MkAlice/x",
819
+ "https://server.example/.well-known/apgatewayx/did:key:z6MkAlice/x",
820
+ "https://server.example/ap/did:key:z6MkAlice/actor",
821
+ "https://server.example/?id=/.well-known/apgateway/did:key:z6MkAlice/x",
822
+ "ap://did:key:z6MkAlice/actor",
823
+ "ap+ef61://did:key:z6MkAlice/actor",
824
+ "ftp://server.example/.well-known/apgateway/did:key:z6MkAlice/actor",
825
+ "not a URL",
826
+ "/.well-known/apgateway/did:key:z6MkAlice/actor",
827
+ ];
828
+ for (const input of cases) {
829
+ deepStrictEqual(fromCompatibleEf61Id(input), null, input);
830
+ }
831
+ deepStrictEqual(
832
+ fromCompatibleEf61Id(new URL("ap+ef61://did%3Akey%3Az6MkAlice/actor")),
833
+ null,
834
+ );
835
+ deepStrictEqual(fromCompatibleEf61Id(123 as unknown as string), null);
836
+ });
837
+
838
+ test("fromCompatibleEf61Id() rejects malformed compatible identifiers", () => {
839
+ const cases = [
840
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice",
841
+ "https://server.example/.well-known/apgateway/did:",
842
+ "https://server.example/.well-known/apgateway/did:key/actor",
843
+ "https://server.example/.well-known/apgateway/did:ke%y:z6MkAlice/actor",
844
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/a%zz",
845
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/a#%zz",
846
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/a?x=%zz",
847
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/a?@gateway=https%3A%2F%2Fevil.example",
848
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/a?page=2&%40gateway=x",
849
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/a?@%67ateway=x",
850
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/a?gateways=x",
851
+ // A readable DID authority is percent-decoded once, like parseIri() does,
852
+ // which leaves a bare percent sign here:
853
+ "https://server.example/.well-known/apgateway/did:example:a%25/x",
854
+ "https://user:pass@server.example/.well-known/apgateway/did:key:z6MkAlice/a",
855
+ "https://user@server.example/.well-known/apgateway/did:key:z6MkAlice/a",
856
+ ];
857
+ for (const input of cases) {
858
+ throws(() => fromCompatibleEf61Id(input), TypeError, input);
859
+ }
860
+ });
861
+
862
+ test("fromCompatibleEf61Id() refuses raw paths changed by URL parsing", () => {
863
+ const base = "https://server.example/.well-known/apgateway/did:key:z6MkAlice";
864
+ for (
865
+ const path of [
866
+ "/a/../b",
867
+ "/a/%2e%2E/b",
868
+ "/a/.\t./b",
869
+ "/a/.. ",
870
+ ]
871
+ ) {
872
+ throws(() => fromCompatibleEf61Id(base + path), TypeError, path);
873
+ throws(() => parseIri(base + path), TypeError, path);
874
+ throws(() => formatIri(base + path), TypeError, path);
875
+ }
876
+ for (
877
+ const input of [
878
+ "https:/server.example/.well-known/apgateway/did:key:z6MkAlice/a/../b",
879
+ "https:server.example/.well-known/apgateway/did:key:z6MkAlice/a/../b",
880
+ "https:\\\\server.example\\.well-known\\apgateway\\did:key:z6MkAlice\\a\\..\\b",
881
+ ` ${base}/../../other`,
882
+ ]
883
+ ) {
884
+ throws(() => parseIri(input), TypeError, input);
885
+ throws(() => formatIri(input), TypeError, input);
886
+ }
887
+ throws(
888
+ () => fromCompatibleEf61Id(` ${base}/actor`),
889
+ TypeError,
890
+ );
891
+ throws(
892
+ () =>
893
+ fromCompatibleEf61Id(
894
+ `https://server.example/foo/../.well-known/apgateway/did:key:z6MkAlice/actor`,
895
+ ),
896
+ TypeError,
897
+ );
898
+ deepStrictEqual(
899
+ fromCompatibleEf61Id(`${base}/a/%252e/b`)?.pathname,
900
+ "/a/%252e/b",
901
+ );
902
+ deepStrictEqual(
903
+ fromCompatibleEf61Id(
904
+ "https://server.example/.WELL-KNOWN/apgateway/did:key:z6MkAlice/actor",
905
+ ),
906
+ null,
907
+ );
908
+ });
909
+
910
+ test("toCompatibleEf61Id() converts portable URIs", () => {
911
+ const cases: [string | URL, string | URL, string][] = [
912
+ [
913
+ "ap+ef61://did:key:z6MkAlice/objects/1",
914
+ "https://server.example",
915
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/objects/1",
916
+ ],
917
+ [
918
+ "ap://did:key:z6MkAlice/objects/1",
919
+ "https://server.example/",
920
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/objects/1",
921
+ ],
922
+ [
923
+ "ap://did%3Akey%3Az6MkAlice/actor",
924
+ new URL("http://server.example:8080"),
925
+ "http://server.example:8080/.well-known/apgateway/did:key:z6MkAlice/actor",
926
+ ],
927
+ [
928
+ new URL("ap+ef61://did%3Akey%3Az6MkAlice/actor"),
929
+ "https://server.example",
930
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/actor",
931
+ ],
932
+ [
933
+ new URL("ap://did%3Akey%3Az6MkAlice/actor"),
934
+ "https://SERVER.example",
935
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/actor",
936
+ ],
937
+ [
938
+ "ap://did:key:z6MkAlice/a/b/c",
939
+ "https://server.example",
940
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/a/b/c",
941
+ ],
942
+ [
943
+ "ap://did:key:z6MkAlice/actor#main-key",
944
+ "https://server.example",
945
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/actor#main-key",
946
+ ],
947
+ [
948
+ "ap://did:key:z6MkAlice/actor#",
949
+ "https://server.example",
950
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/actor#",
951
+ ],
952
+ [
953
+ "ap://DID:key:z6MkAlice/actor",
954
+ "https://server.example",
955
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/actor",
956
+ ],
957
+ [
958
+ "ap://did:example:a%2525/x",
959
+ "https://server.example",
960
+ "https://server.example/.well-known/apgateway/did:example:a%2525/x",
961
+ ],
962
+ [
963
+ "ap://did:web:example.com%3A8080/actor",
964
+ "https://server.example",
965
+ "https://server.example/.well-known/apgateway/did:web:example.com%3A8080/actor",
966
+ ],
967
+ [
968
+ "ap://did:key:z6MkAlice/a%2Fb/%252e",
969
+ "https://server.example",
970
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/a%2Fb/%252e",
971
+ ],
972
+ [
973
+ "ap://did:key:z6MkAlice/a\\b c\té/%7euser",
974
+ "https://server.example",
975
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/a%5Cb%20c%09%C3%A9/~user",
976
+ ],
977
+ ];
978
+ for (const [portableId, gateway, expected] of cases) {
979
+ deepStrictEqual(
980
+ toCompatibleEf61Id(portableId, gateway).href,
981
+ expected,
982
+ String(portableId),
983
+ );
984
+ }
985
+ });
986
+
987
+ test("toCompatibleEf61Id() removes location hints from queries", () => {
988
+ const base = "https://server.example/.well-known/apgateway/did:key:z6MkAlice";
989
+ const cases: [string, string][] = [
990
+ [
991
+ "ap://did:key:z6MkAlice/actor?@gateway=https%3A%2F%2Fa.example&@gateway=https%3A%2F%2Fb.example",
992
+ `${base}/actor`,
993
+ ],
994
+ [
995
+ "ap://did:key:z6MkAlice/actor?gateways=https%3A%2F%2Fa.example",
996
+ `${base}/actor`,
997
+ ],
998
+ [
999
+ "ap://did:key:z6MkAlice/actor?%40gateway=https%3A%2F%2Fa.example",
1000
+ `${base}/actor`,
1001
+ ],
1002
+ [
1003
+ "ap://did:key:z6MkAlice/actor?@%67ateway=https%3A%2F%2Fa.example",
1004
+ `${base}/actor`,
1005
+ ],
1006
+ [
1007
+ "ap://did:key:z6MkAlice/actor?@gateway",
1008
+ `${base}/actor`,
1009
+ ],
1010
+ [
1011
+ "ap://did:key:z6MkAlice/c?page=3&@gateway=https%3A%2F%2Fa.example&maxItems=20&page=4",
1012
+ `${base}/c?page=3&maxItems=20&page=4`,
1013
+ ],
1014
+ [
1015
+ "ap://did:key:z6MkAlice/c?a=1&&b=2#frag",
1016
+ `${base}/c?a=1&&b=2#frag`,
1017
+ ],
1018
+ ["ap://did:key:z6MkAlice/c?", `${base}/c?`],
1019
+ ["ap://did:key:z6MkAlice/c?x='y'", `${base}/c?x=%27y%27`],
1020
+ [
1021
+ "ap://did:key:z6MkAlice/c?@gate\tway=https%3A%2F%2Fa.example",
1022
+ `${base}/c?@gate%09way=https%3A%2F%2Fa.example`,
1023
+ ],
1024
+ [
1025
+ "ap://did:key:z6MkAlice/c?@gate\nway=1&x=\r2",
1026
+ `${base}/c?@gate%0Away=1&x=%0D2`,
1027
+ ],
1028
+ ["ap://did:key:z6MkAlice/c?%ff=1", `${base}/c?%FF=1`],
1029
+ ];
1030
+ for (const [portableId, expected] of cases) {
1031
+ const result = toCompatibleEf61Id(portableId, "https://server.example");
1032
+ deepStrictEqual(result.href, expected, portableId);
1033
+ ok(!/[?&](%40|@)gateway(=|&|$)/i.test(result.search), portableId);
1034
+ }
1035
+ });
1036
+
1037
+ test("toCompatibleEf61Id() rejects invalid portable IDs", () => {
1038
+ const cases: (string | URL)[] = [
1039
+ "https://server.example/actor",
1040
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/actor",
1041
+ "at://did:plc:example/record",
1042
+ "ap://not-a-did/actor",
1043
+ "ap://did:key:z6MkAlice",
1044
+ "ap://did:key:z6MkAlice/a%zz",
1045
+ "ap://did:key:z6MkAlice/a#%zz",
1046
+ "ap://did:key:z6MkAlice/a?x=%zz",
1047
+ "ap://did:key:z6MkAlice/.",
1048
+ "ap://did:key:z6MkAlice/..",
1049
+ "ap://did:key:z6MkAlice/a/./b",
1050
+ "ap://did:key:z6MkAlice/a/../b",
1051
+ "ap://did:key:z6MkAlice/a/%2e/b",
1052
+ "ap://did:key:z6MkAlice/a/%2E%2e/b",
1053
+ "ap://did:key:z6MkAlice/a/.%2e/b",
1054
+ "ap://did:key:z6MkAlice/a/%2e./b",
1055
+ new URL("https://server.example/actor"),
1056
+ new URL("ap+ef61://user:pass@did%3Akey%3Az6MkAlice/actor"),
1057
+ new URL("ap+ef61://did%3Akey%3Az6MkAlice:8080/actor"),
1058
+ 123 as unknown as string,
1059
+ ];
1060
+ for (const portableId of cases) {
1061
+ throws(
1062
+ () => toCompatibleEf61Id(portableId, "https://server.example"),
1063
+ TypeError,
1064
+ String(portableId),
1065
+ );
1066
+ }
1067
+ });
1068
+
1069
+ test("toCompatibleEf61Id() rejects gateways that are not HTTP(S) origins", () => {
1070
+ const cases: (string | URL)[] = [
1071
+ "ftp://server.example",
1072
+ "ap+ef61://did:key:z6MkAlice/actor",
1073
+ "https://user:pass@server.example",
1074
+ "https://user@server.example",
1075
+ "https://server.example/ap",
1076
+ "https://server.example/ap/",
1077
+ "https://server.example/.well-known/apgateway",
1078
+ "https://server.example/?x=1",
1079
+ "https://server.example/?",
1080
+ "https://server.example/#fragment",
1081
+ "https://server.example/#",
1082
+ "server.example",
1083
+ "",
1084
+ new URL("https://server.example/?"),
1085
+ new URL("https://server.example/#"),
1086
+ new URL("https://server.example/ap"),
1087
+ new URL("ftp://server.example/"),
1088
+ 123 as unknown as string,
1089
+ ];
1090
+ for (const gateway of cases) {
1091
+ throws(
1092
+ () => toCompatibleEf61Id("ap://did:key:z6MkAlice/actor", gateway),
1093
+ TypeError,
1094
+ String(gateway),
1095
+ );
1096
+ }
1097
+ });
1098
+
1099
+ test("compatible identifier conversion round-trips", () => {
1100
+ const cases = [
1101
+ "ap+ef61://did:key:z6MkAlice/objects/1",
1102
+ "ap://did:key:z6MkAlice/actor",
1103
+ "ap://did%3Akey%3Az6MkAlice/actor/inbox",
1104
+ "ap://did:key:z6MkAlice/actor#",
1105
+ "ap://did:key:z6MkAlice/actor#main-key",
1106
+ "ap://did:key:z6MkAlice/a%2Fb/%252e",
1107
+ "ap://did:key:z6MkAlice/a\\b c\té",
1108
+ "ap://did:example:a%2525/x",
1109
+ "ap://did:web:example.com%3A8080/actor",
1110
+ "ap://did:key:z6MkAlice/c?page=3&@gateway=https%3A%2F%2Fa.example",
1111
+ ];
1112
+ for (const portableId of cases) {
1113
+ const compatible = toCompatibleEf61Id(portableId, "https://server.example");
1114
+ const converted = fromCompatibleEf61Id(compatible);
1115
+ ok(converted != null, portableId);
1116
+ deepStrictEqual(
1117
+ canonicalizePortableUri(converted.href),
1118
+ canonicalizePortableUri(portableId),
1119
+ portableId,
1120
+ );
1121
+ }
1122
+ ok(
1123
+ !arePortableUrisEqual(
1124
+ fromCompatibleEf61Id(
1125
+ toCompatibleEf61Id(
1126
+ "ap://did:key:z6MkAlice/actor",
1127
+ "https://server.example",
1128
+ ),
1129
+ )!.href,
1130
+ fromCompatibleEf61Id(
1131
+ toCompatibleEf61Id(
1132
+ "ap://did:key:z6MkAlice/actor#",
1133
+ "https://server.example",
1134
+ ),
1135
+ )!.href,
1136
+ ),
1137
+ );
1138
+ });
1139
+
1140
+ test("UrlError reason is independent of its cause", () => {
1141
+ const cause = new Error("Underlying failure");
1142
+ strictEqual(new UrlError("Rejected URL").reason, "disallowed");
1143
+ const disallowedError = new UrlError("Rejected URL", { cause });
1144
+ strictEqual(disallowedError.reason, "disallowed");
1145
+ strictEqual(disallowedError.cause, cause);
1146
+ const dnsError = new UrlError("Lookup failed", { reason: "dns", cause });
1147
+ strictEqual(dnsError.reason, "dns");
1148
+ strictEqual(dnsError.cause, cause);
1149
+ });
1150
+
1151
+ test("validatePublicUrl() classifies disallowed URLs", async () => {
1152
+ for (
1153
+ const url of ["ftp://example.com", "https://localhost", "https://127.0.0.1"]
1154
+ ) {
1155
+ await rejects(() => validatePublicUrl(url), {
1156
+ name: "UrlError",
1157
+ reason: "disallowed",
1158
+ });
1159
+ }
662
1160
  });
663
1161
 
664
1162
  test("validatePublicUrl()", async () => {
@@ -718,6 +1216,36 @@ test("validatePublicUrl()", async () => {
718
1216
  await validatePublicUrl("https://[64:ff9b::8.8.8.8]");
719
1217
  });
720
1218
 
1219
+ test("validateLookupAddresses() tolerates Cloudflare Workers CNAME entries", () => {
1220
+ validateLookupAddresses([
1221
+ { address: "app-host.example.net.", family: 4 },
1222
+ { address: "93.184.216.34", family: 4 },
1223
+ { address: "2606:2800:220:1:248:1893:25c8:1946", family: 6 },
1224
+ ]);
1225
+ });
1226
+
1227
+ test("validateLookupAddresses() rejects unsafe or CNAME-only Cloudflare Workers results", () => {
1228
+ throws(
1229
+ () =>
1230
+ validateLookupAddresses([
1231
+ { address: "private-host.example.net.", family: 4 },
1232
+ { address: "127.0.0.1", family: 4 },
1233
+ ]),
1234
+ { name: "UrlError", reason: "disallowed" },
1235
+ );
1236
+ throws(
1237
+ () =>
1238
+ validateLookupAddresses([
1239
+ { address: "app-host.example.net.", family: 4 },
1240
+ ]),
1241
+ { name: "UrlError", reason: "dns" },
1242
+ );
1243
+ throws(() => validateLookupAddresses([]), {
1244
+ name: "UrlError",
1245
+ reason: "dns",
1246
+ });
1247
+ });
1248
+
721
1249
  test("isValidPublicIPv4Address()", () => {
722
1250
  ok(isValidPublicIPv4Address("8.8.8.8")); // Google DNS
723
1251
  ok(!isValidPublicIPv4Address("192.168.1.1")); // private
@@ -792,3 +1320,295 @@ test("expandIPv6Address()", () => {
792
1320
  "0064:ff9b:0000:0000:0000:0000:0808:0808",
793
1321
  );
794
1322
  });
1323
+
1324
+ test("withGatewayHints() adds @gateway location hints", () => {
1325
+ const expected = "ap+ef61://did:key:z6Mkabc/actor" +
1326
+ "?@gateway=https%3A%2F%2Fserver1.example" +
1327
+ "&@gateway=https%3A%2F%2Fserver2.example";
1328
+ for (
1329
+ const id of [
1330
+ "ap://did:key:z6Mkabc/actor",
1331
+ "ap+ef61://did:key:z6Mkabc/actor",
1332
+ "ap://did%3Akey%3Az6Mkabc/actor",
1333
+ parseIri("ap://did:key:z6Mkabc/actor"),
1334
+ new URL("ap://did%3Akey%3Az6Mkabc/actor"),
1335
+ ]
1336
+ ) {
1337
+ const hinted = withGatewayHints(id, [
1338
+ "https://server1.example",
1339
+ new URL("https://server2.example/"),
1340
+ ]);
1341
+ ok(hinted instanceof URL);
1342
+ strictEqual(
1343
+ hinted.href,
1344
+ "ap+ef61://did%3Akey%3Az6Mkabc/actor" +
1345
+ "?@gateway=https%3A%2F%2Fserver1.example" +
1346
+ "&@gateway=https%3A%2F%2Fserver2.example",
1347
+ );
1348
+ strictEqual(formatIri(hinted), expected);
1349
+ deepStrictEqual(parseIri(formatIri(hinted)), hinted);
1350
+ }
1351
+ });
1352
+
1353
+ test("withGatewayHints() encodes and deduplicates gateway origins", () => {
1354
+ function* gateways(): Generator<string | URL> {
1355
+ yield "https://A.example:443";
1356
+ yield "https://a.example/";
1357
+ yield new URL("https://a.example");
1358
+ yield "https://b.example:8443";
1359
+ yield "http://c.example:80/";
1360
+ yield "https://例え.jp";
1361
+ }
1362
+ strictEqual(
1363
+ formatIri(withGatewayHints("ap://did:key:z6Mkabc/actor", gateways())),
1364
+ "ap+ef61://did:key:z6Mkabc/actor" +
1365
+ "?@gateway=https%3A%2F%2Fa.example" +
1366
+ "&@gateway=https%3A%2F%2Fb.example%3A8443" +
1367
+ "&@gateway=http%3A%2F%2Fc.example" +
1368
+ "&@gateway=https%3A%2F%2Fxn--r8jz45g.jp",
1369
+ );
1370
+ strictEqual(
1371
+ formatIri(
1372
+ withGatewayHints(
1373
+ "ap://did:key:z6Mkabc/actor",
1374
+ new Set(["https://b.example", "https://a.example"]),
1375
+ ),
1376
+ ),
1377
+ "ap+ef61://did:key:z6Mkabc/actor" +
1378
+ "?@gateway=https%3A%2F%2Fb.example&@gateway=https%3A%2F%2Fa.example",
1379
+ );
1380
+ // There is no limit on the number of hints:
1381
+ const many = Array.from(
1382
+ { length: 7 },
1383
+ (_, i) => `https://server${i}.example`,
1384
+ );
1385
+ deepStrictEqual(
1386
+ getGatewayHints(withGatewayHints("ap://did:key:z6Mkabc/actor", many))
1387
+ .map((url) => url.origin),
1388
+ many,
1389
+ );
1390
+ });
1391
+
1392
+ test("withGatewayHints() keeps other query parameters and fragments", () => {
1393
+ strictEqual(
1394
+ formatIri(
1395
+ withGatewayHints(
1396
+ "ap://did:key:z6Mkabc/collection?page=3&maxItems=20&q=a+b%20c%2b",
1397
+ ["https://server.example"],
1398
+ ),
1399
+ ),
1400
+ "ap+ef61://did:key:z6Mkabc/collection?page=3&maxItems=20&q=a+b%20c%2B" +
1401
+ "&@gateway=https%3A%2F%2Fserver.example",
1402
+ );
1403
+ strictEqual(
1404
+ formatIri(
1405
+ withGatewayHints(
1406
+ "ap://did:key:z6Mkabc/actor?x=%26%3D#main-key",
1407
+ ["https://server.example"],
1408
+ ),
1409
+ ),
1410
+ "ap+ef61://did:key:z6Mkabc/actor?x=%26%3D" +
1411
+ "&@gateway=https%3A%2F%2Fserver.example#main-key",
1412
+ );
1413
+ strictEqual(
1414
+ withGatewayHints("ap://did:key:z6Mkabc/actor#", ["https://s.example"])
1415
+ .href,
1416
+ "ap+ef61://did%3Akey%3Az6Mkabc/actor" +
1417
+ "?@gateway=https%3A%2F%2Fs.example#",
1418
+ );
1419
+ });
1420
+
1421
+ test("withGatewayHints() replaces existing location hints", () => {
1422
+ strictEqual(
1423
+ formatIri(
1424
+ withGatewayHints(
1425
+ "ap://did:key:z6Mkabc/collection?@gateway=https%3A%2F%2Fold.example" +
1426
+ "&page=2&%40gateway=https%3A%2F%2Fold2.example&@gateway" +
1427
+ "&gateways=https%3A%2F%2Flegacy.example&%2540gateway=kept",
1428
+ ["https://new.example"],
1429
+ ),
1430
+ ),
1431
+ "ap+ef61://did:key:z6Mkabc/collection?page=2&%2540gateway=kept" +
1432
+ "&@gateway=https%3A%2F%2Fnew.example",
1433
+ );
1434
+ // Characters that the URL parser strips cannot turn into a hint name:
1435
+ for (const char of ["\t", "\n", "\r"]) {
1436
+ const hinted = withGatewayHints(
1437
+ `ap://did:key:z6Mkabc/actor?@gate${char}way=https%3A%2F%2Fevil.example`,
1438
+ ["https://new.example"],
1439
+ );
1440
+ deepStrictEqual(getGatewayHints(hinted).map((url) => url.href), [
1441
+ "https://new.example/",
1442
+ ]);
1443
+ }
1444
+ });
1445
+
1446
+ test("withGatewayHints() with no gateways removes location hints", () => {
1447
+ for (
1448
+ const [input, expected] of [
1449
+ [
1450
+ "ap://did:key:z6Mkabc/actor?@gateway=https%3A%2F%2Fa.example",
1451
+ "ap+ef61://did%3Akey%3Az6Mkabc/actor",
1452
+ ],
1453
+ [
1454
+ "ap://did:key:z6Mkabc/actor?&@gateway=https%3A%2F%2Fa.example&&p=1&",
1455
+ "ap+ef61://did%3Akey%3Az6Mkabc/actor?p=1",
1456
+ ],
1457
+ ["ap://did:key:z6Mkabc/actor?", "ap+ef61://did%3Akey%3Az6Mkabc/actor"],
1458
+ [
1459
+ "ap://did:key:z6Mkabc/actor?gateways=https%3A%2F%2Fa.example#k",
1460
+ "ap+ef61://did%3Akey%3Az6Mkabc/actor#k",
1461
+ ],
1462
+ ]
1463
+ ) {
1464
+ strictEqual(withGatewayHints(input, []).href, expected, input);
1465
+ strictEqual(withoutGatewayHints(input).href, expected, input);
1466
+ }
1467
+ const input = parseIri(
1468
+ "ap://did:key:z6Mkabc/actor?@gateway=https%3A%2F%2Fa.example",
1469
+ );
1470
+ const href = input.href;
1471
+ withoutGatewayHints(input);
1472
+ strictEqual(input.href, href);
1473
+ });
1474
+
1475
+ test("withGatewayHints() rejects non-portable IDs", () => {
1476
+ for (
1477
+ const id of [
1478
+ "https://server.example/.well-known/apgateway/did:key:z6Mkabc/actor",
1479
+ "https://example.com/actor",
1480
+ "did:key:z6Mkabc#z6Mkabc",
1481
+ "ap://did:key:z6Mkabc",
1482
+ "ap://example.com/actor",
1483
+ "ap://did:key:z6Mkabc/actor%zz",
1484
+ new URL("https://example.com/actor"),
1485
+ new URL("ap://did%3Akey%3Az6Mkabc:8080/actor"),
1486
+ new URL("ap://user@did%3Akey%3Az6Mkabc/actor"),
1487
+ ]
1488
+ ) {
1489
+ throws(
1490
+ () => withGatewayHints(id, ["https://server.example"]),
1491
+ TypeError,
1492
+ String(id),
1493
+ );
1494
+ throws(() => withoutGatewayHints(id), TypeError, String(id));
1495
+ throws(() => getGatewayHints(id), TypeError, String(id));
1496
+ }
1497
+ });
1498
+
1499
+ test("withGatewayHints() rejects paths that URLs cannot represent", () => {
1500
+ for (
1501
+ const id of [
1502
+ "ap://did:key:z6Mkabc/a/../actor",
1503
+ "ap://did:key:z6Mkabc/a/./actor",
1504
+ "ap://did:key:z6Mkabc/a/%2e%2e/actor",
1505
+ "ap://did:key:z6Mkabc/a/%2E/actor",
1506
+ ]
1507
+ ) {
1508
+ throws(
1509
+ () => withGatewayHints(id, ["https://server.example"]),
1510
+ TypeError,
1511
+ id,
1512
+ );
1513
+ throws(() => withoutGatewayHints(id), TypeError, id);
1514
+ }
1515
+ // Characters that the URL parser would strip are percent-encoded instead:
1516
+ strictEqual(
1517
+ withoutGatewayHints("ap://did:key:z6Mkabc/a\tb").href,
1518
+ "ap+ef61://did%3Akey%3Az6Mkabc/a%09b",
1519
+ );
1520
+ });
1521
+
1522
+ test("withGatewayHints() rejects invalid gateways", () => {
1523
+ for (
1524
+ const gateway of [
1525
+ "https://server.example/path",
1526
+ "https://server.example/?",
1527
+ "https://server.example/#",
1528
+ "https://server.example/?q=1",
1529
+ "https://user:pass@server.example",
1530
+ "ftp://server.example",
1531
+ "ap://did:key:z6Mkabc/actor",
1532
+ "server.example",
1533
+ new URL("https://server.example/path"),
1534
+ ]
1535
+ ) {
1536
+ throws(
1537
+ () => withGatewayHints("ap://did:key:z6Mkabc/actor", [gateway]),
1538
+ TypeError,
1539
+ String(gateway),
1540
+ );
1541
+ }
1542
+ throws(
1543
+ () =>
1544
+ withGatewayHints(
1545
+ "ap://did:key:z6Mkabc/actor",
1546
+ "https://server.example" as unknown as string[],
1547
+ ),
1548
+ TypeError,
1549
+ );
1550
+ });
1551
+
1552
+ test("getGatewayHints() reads @gateway location hints", () => {
1553
+ deepStrictEqual(
1554
+ getGatewayHints(
1555
+ "ap://did:key:z6Mkabc/actor?@gateway=https%3A%2F%2Fa.example" +
1556
+ "&@gateway=invalid&%40gateway=https%3A%2F%2Fb.example%2F" +
1557
+ "&@gateway=https%3A%2F%2Fa.example%2F" +
1558
+ "&@gateway=https%3A%2F%2Fc.example%2Fpath" +
1559
+ "&gateways=https%3A%2F%2Flegacy.example&page=1#k",
1560
+ ).map((url) => url.href),
1561
+ ["https://a.example/", "https://b.example/"],
1562
+ );
1563
+ deepStrictEqual(getGatewayHints("ap://did:key:z6Mkabc/actor"), []);
1564
+ // A literal question mark is a part of the parameter name:
1565
+ const questioned =
1566
+ "ap://did:key:z6Mkabc/actor??@gateway=https%3A%2F%2Fa.example";
1567
+ deepStrictEqual(getGatewayHints(questioned), []);
1568
+ strictEqual(
1569
+ withoutGatewayHints(questioned).href,
1570
+ "ap+ef61://did%3Akey%3Az6Mkabc/actor??@gateway=https%3A%2F%2Fa.example",
1571
+ );
1572
+ deepStrictEqual(
1573
+ getGatewayHints(withGatewayHints(questioned, ["https://b.example"]))
1574
+ .map((url) => url.href),
1575
+ ["https://b.example/"],
1576
+ );
1577
+ deepStrictEqual(
1578
+ getGatewayHints(
1579
+ "ap://did:key:z6Mkabc/actor?@gate%09way=https%3A%2F%2Fa.example",
1580
+ ),
1581
+ [],
1582
+ );
1583
+ deepStrictEqual(
1584
+ getGatewayHints(
1585
+ "ap://did:key:z6Mkabc/actor?@gate\tway=https%3A%2F%2Fa.example",
1586
+ ),
1587
+ [],
1588
+ );
1589
+ });
1590
+
1591
+ test("withGatewayHints() round-trips with other portable ID helpers", () => {
1592
+ const hinted = withGatewayHints("ap://did:key:z6Mkabc/objects/1#frag", [
1593
+ "https://server1.example",
1594
+ "https://server2.example",
1595
+ ]);
1596
+ strictEqual(
1597
+ canonicalizePortableUri(formatIri(hinted)),
1598
+ "ap+ef61://did:key:z6Mkabc/objects/1#frag",
1599
+ );
1600
+ ok(
1601
+ arePortableUrisEqual(
1602
+ formatIri(hinted),
1603
+ "ap://did:key:z6Mkabc/objects/1#frag",
1604
+ ),
1605
+ );
1606
+ strictEqual(
1607
+ toCompatibleEf61Id(hinted, "https://server1.example").href,
1608
+ "https://server1.example/.well-known/apgateway/did:key:z6Mkabc/objects/1#frag",
1609
+ );
1610
+ strictEqual(
1611
+ formatIri(withoutGatewayHints(hinted)),
1612
+ "ap+ef61://did:key:z6Mkabc/objects/1#frag",
1613
+ );
1614
+ });