@fedify/vocab-runtime 2.4.0-dev.2030 → 2.4.0-dev.2047

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 (51) hide show
  1. package/deno.json +2 -1
  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 +315 -0
  5. package/dist/internal/portable-dereference.d.cts +87 -0
  6. package/dist/internal/portable-dereference.d.ts +87 -0
  7. package/dist/internal/portable-dereference.js +311 -0
  8. package/dist/mod.cjs +11 -3
  9. package/dist/mod.d.cts +75 -1
  10. package/dist/mod.d.ts +75 -1
  11. package/dist/mod.js +10 -4
  12. package/dist/portable-CduElToy.d.ts +52 -0
  13. package/dist/portable-DxF2Ut-Z.d.cts +52 -0
  14. package/dist/tests/{body-B9qsaRLZ.cjs → body-BgDeabVX.cjs} +1 -1
  15. package/dist/tests/{body-BhoBj6zg.mjs → body-D7iDYZxi.mjs} +1 -1
  16. package/dist/tests/body.test.cjs +1 -1
  17. package/dist/tests/body.test.mjs +1 -1
  18. package/dist/tests/decimal.test.cjs +2 -2
  19. package/dist/tests/decimal.test.mjs +2 -2
  20. package/dist/tests/{docloader--w8_lTnA.cjs → docloader-0r0eohuK.cjs} +3 -3
  21. package/dist/tests/{docloader-DKk8OANv.mjs → docloader-DzfRRzKc.mjs} +3 -3
  22. package/dist/tests/docloader.test.cjs +3 -3
  23. package/dist/tests/docloader.test.mjs +3 -3
  24. package/dist/tests/internal/portable-dereference.test.cjs +123 -0
  25. package/dist/tests/internal/portable-dereference.test.d.cts +1 -0
  26. package/dist/tests/internal/portable-dereference.test.d.mts +1 -0
  27. package/dist/tests/internal/portable-dereference.test.mjs +124 -0
  28. package/dist/tests/jsonld-CmDhHNTx.cjs +12 -0
  29. package/dist/tests/jsonld-DweIJ07b.mjs +5 -0
  30. package/dist/tests/jsonld-cache.test.cjs +18 -23
  31. package/dist/tests/jsonld-cache.test.mjs +2 -5
  32. package/dist/tests/{request-Dm_aAoKt.mjs → request-DOyNI0N1.mjs} +8 -2
  33. package/dist/tests/{request-D3Z--Onp.cjs → request-DdT5TslC.cjs} +8 -2
  34. package/dist/tests/request.test.cjs +4 -1
  35. package/dist/tests/request.test.mjs +4 -1
  36. package/dist/{url-i33c0US6.cjs → tests/url-BqlldzT8.cjs} +164 -1
  37. package/dist/tests/{url-Basm1L_c.mjs → url-DgJM9WJn.mjs} +147 -1
  38. package/dist/tests/url.test.cjs +220 -1
  39. package/dist/tests/url.test.mjs +220 -1
  40. package/dist/{url-BHAShw9J.js → url-CEhi4hb4.js} +147 -1
  41. package/dist/{tests/url-CPfMatUL.cjs → url-Cke4irYD.cjs} +155 -2
  42. package/package.json +12 -2
  43. package/src/internal/portable-dereference.test.ts +74 -0
  44. package/src/internal/portable-dereference.ts +426 -0
  45. package/src/mod.ts +6 -0
  46. package/src/portable.ts +52 -0
  47. package/src/request.test.ts +10 -1
  48. package/src/request.ts +9 -1
  49. package/src/url.test.ts +361 -0
  50. package/src/url.ts +235 -0
  51. package/tsdown.config.ts +1 -0
package/src/url.test.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  canonicalizePortableUri,
6
6
  expandIPv6Address,
7
7
  formatIri,
8
+ fromCompatibleEf61Id,
8
9
  getFe34Origin,
9
10
  haveSameFe34Origin,
10
11
  haveSameIriOrigin,
@@ -14,6 +15,7 @@ import {
14
15
  parseGatewayUrl,
15
16
  parseIri,
16
17
  parseJsonLdId,
18
+ toCompatibleEf61Id,
17
19
  UrlError,
18
20
  validateLookupAddresses,
19
21
  validatePublicUrl,
@@ -662,6 +664,365 @@ test("parseGatewayUrl() accepts only HTTP(S) base URIs", () => {
662
664
  }
663
665
  });
664
666
 
667
+ test("fromCompatibleEf61Id() converts compatible identifiers", () => {
668
+ const cases: [string, string][] = [
669
+ [
670
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/objects/1",
671
+ "ap+ef61://did%3Akey%3Az6MkAlice/objects/1",
672
+ ],
673
+ [
674
+ "http://server.example:8080/.well-known/apgateway/did:key:z6MkAlice/actor",
675
+ "ap+ef61://did%3Akey%3Az6MkAlice/actor",
676
+ ],
677
+ [
678
+ "https://server.example/.well-known/apgateway/did%3Akey%3Az6MkAlice/actor",
679
+ "ap+ef61://did%3Akey%3Az6MkAlice/actor",
680
+ ],
681
+ [
682
+ "https://server.example/.well-known/apgateway/DID:key:z6MkAlice/actor",
683
+ "ap+ef61://did%3Akey%3Az6MkAlice/actor",
684
+ ],
685
+ [
686
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/a/b/c",
687
+ "ap+ef61://did%3Akey%3Az6MkAlice/a/b/c",
688
+ ],
689
+ [
690
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/actor#main-key",
691
+ "ap+ef61://did%3Akey%3Az6MkAlice/actor#main-key",
692
+ ],
693
+ [
694
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/actor#",
695
+ "ap+ef61://did%3Akey%3Az6MkAlice/actor#",
696
+ ],
697
+ [
698
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/outbox?page=2",
699
+ "ap+ef61://did%3Akey%3Az6MkAlice/outbox?page=2",
700
+ ],
701
+ [
702
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/a%2Fb",
703
+ "ap+ef61://did%3Akey%3Az6MkAlice/a%2Fb",
704
+ ],
705
+ [
706
+ "https://server.example/.well-known/apgateway/did:web:example.com%3A8080/actor",
707
+ "ap+ef61://did%3Aweb%3Aexample.com%253A8080/actor",
708
+ ],
709
+ [
710
+ "https://server.example/.well-known/apgateway/did:example:a%2525/x",
711
+ "ap+ef61://did%3Aexample%3Aa%2525/x",
712
+ ],
713
+ ];
714
+ for (const [input, expected] of cases) {
715
+ deepStrictEqual(fromCompatibleEf61Id(input)?.href, expected, input);
716
+ deepStrictEqual(
717
+ fromCompatibleEf61Id(new URL(input))?.href,
718
+ expected,
719
+ input,
720
+ );
721
+ }
722
+ const converted = fromCompatibleEf61Id(
723
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/objects/1",
724
+ );
725
+ deepStrictEqual(
726
+ formatIri(converted!),
727
+ "ap+ef61://did:key:z6MkAlice/objects/1",
728
+ );
729
+ deepStrictEqual(
730
+ canonicalizePortableUri(converted!.href),
731
+ "ap+ef61://did:key:z6MkAlice/objects/1",
732
+ );
733
+ ok(
734
+ arePortableUrisEqual(
735
+ fromCompatibleEf61Id(
736
+ "https://a.example/.well-known/apgateway/did:key:z6MkAlice/actor",
737
+ )!.href,
738
+ fromCompatibleEf61Id(
739
+ "https://b.example/.well-known/apgateway/did:key:z6MkAlice/actor",
740
+ )!.href,
741
+ ),
742
+ );
743
+ });
744
+
745
+ test("fromCompatibleEf61Id() returns null for other URLs", () => {
746
+ const cases = [
747
+ "https://server.example/users/alice",
748
+ "https://server.example/.well-known/webfinger?resource=acct:a@b",
749
+ "https://server.example/.well-known/apgateway",
750
+ "https://server.example/.well-known/apgateway/",
751
+ "https://server.example/.well-known/apgateway/hl:zQmdfTbBqBPQ7VNxZEYEj14VmRuZBkqFbiwReogJgS1zR1n",
752
+ "https://server.example/.well-known/apgateway/objects/did:key:z6MkAlice/x",
753
+ "https://server.example/.well-known/apgatewayx/did:key:z6MkAlice/x",
754
+ "https://server.example/ap/did:key:z6MkAlice/actor",
755
+ "https://server.example/?id=/.well-known/apgateway/did:key:z6MkAlice/x",
756
+ "ap://did:key:z6MkAlice/actor",
757
+ "ap+ef61://did:key:z6MkAlice/actor",
758
+ "ftp://server.example/.well-known/apgateway/did:key:z6MkAlice/actor",
759
+ "not a URL",
760
+ "/.well-known/apgateway/did:key:z6MkAlice/actor",
761
+ ];
762
+ for (const input of cases) {
763
+ deepStrictEqual(fromCompatibleEf61Id(input), null, input);
764
+ }
765
+ deepStrictEqual(
766
+ fromCompatibleEf61Id(new URL("ap+ef61://did%3Akey%3Az6MkAlice/actor")),
767
+ null,
768
+ );
769
+ deepStrictEqual(fromCompatibleEf61Id(123 as unknown as string), null);
770
+ });
771
+
772
+ test("fromCompatibleEf61Id() rejects malformed compatible identifiers", () => {
773
+ const cases = [
774
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice",
775
+ "https://server.example/.well-known/apgateway/did:",
776
+ "https://server.example/.well-known/apgateway/did:key/actor",
777
+ "https://server.example/.well-known/apgateway/did:ke%y:z6MkAlice/actor",
778
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/a%zz",
779
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/a#%zz",
780
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/a?x=%zz",
781
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/a?@gateway=https%3A%2F%2Fevil.example",
782
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/a?page=2&%40gateway=x",
783
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/a?@%67ateway=x",
784
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/a?gateways=x",
785
+ // A readable DID authority is percent-decoded once, like parseIri() does,
786
+ // which leaves a bare percent sign here:
787
+ "https://server.example/.well-known/apgateway/did:example:a%25/x",
788
+ "https://user:pass@server.example/.well-known/apgateway/did:key:z6MkAlice/a",
789
+ "https://user@server.example/.well-known/apgateway/did:key:z6MkAlice/a",
790
+ ];
791
+ for (const input of cases) {
792
+ throws(() => fromCompatibleEf61Id(input), TypeError, input);
793
+ }
794
+ });
795
+
796
+ test("toCompatibleEf61Id() converts portable URIs", () => {
797
+ const cases: [string | URL, string | URL, string][] = [
798
+ [
799
+ "ap+ef61://did:key:z6MkAlice/objects/1",
800
+ "https://server.example",
801
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/objects/1",
802
+ ],
803
+ [
804
+ "ap://did:key:z6MkAlice/objects/1",
805
+ "https://server.example/",
806
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/objects/1",
807
+ ],
808
+ [
809
+ "ap://did%3Akey%3Az6MkAlice/actor",
810
+ new URL("http://server.example:8080"),
811
+ "http://server.example:8080/.well-known/apgateway/did:key:z6MkAlice/actor",
812
+ ],
813
+ [
814
+ new URL("ap+ef61://did%3Akey%3Az6MkAlice/actor"),
815
+ "https://server.example",
816
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/actor",
817
+ ],
818
+ [
819
+ new URL("ap://did%3Akey%3Az6MkAlice/actor"),
820
+ "https://SERVER.example",
821
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/actor",
822
+ ],
823
+ [
824
+ "ap://did:key:z6MkAlice/a/b/c",
825
+ "https://server.example",
826
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/a/b/c",
827
+ ],
828
+ [
829
+ "ap://did:key:z6MkAlice/actor#main-key",
830
+ "https://server.example",
831
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/actor#main-key",
832
+ ],
833
+ [
834
+ "ap://did:key:z6MkAlice/actor#",
835
+ "https://server.example",
836
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/actor#",
837
+ ],
838
+ [
839
+ "ap://DID:key:z6MkAlice/actor",
840
+ "https://server.example",
841
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/actor",
842
+ ],
843
+ [
844
+ "ap://did:example:a%2525/x",
845
+ "https://server.example",
846
+ "https://server.example/.well-known/apgateway/did:example:a%2525/x",
847
+ ],
848
+ [
849
+ "ap://did:web:example.com%3A8080/actor",
850
+ "https://server.example",
851
+ "https://server.example/.well-known/apgateway/did:web:example.com%3A8080/actor",
852
+ ],
853
+ [
854
+ "ap://did:key:z6MkAlice/a%2Fb/%252e",
855
+ "https://server.example",
856
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/a%2Fb/%252e",
857
+ ],
858
+ [
859
+ "ap://did:key:z6MkAlice/a\\b c\té/%7euser",
860
+ "https://server.example",
861
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/a%5Cb%20c%09%C3%A9/~user",
862
+ ],
863
+ ];
864
+ for (const [portableId, gateway, expected] of cases) {
865
+ deepStrictEqual(
866
+ toCompatibleEf61Id(portableId, gateway).href,
867
+ expected,
868
+ String(portableId),
869
+ );
870
+ }
871
+ });
872
+
873
+ test("toCompatibleEf61Id() removes location hints from queries", () => {
874
+ const base = "https://server.example/.well-known/apgateway/did:key:z6MkAlice";
875
+ const cases: [string, string][] = [
876
+ [
877
+ "ap://did:key:z6MkAlice/actor?@gateway=https%3A%2F%2Fa.example&@gateway=https%3A%2F%2Fb.example",
878
+ `${base}/actor`,
879
+ ],
880
+ [
881
+ "ap://did:key:z6MkAlice/actor?gateways=https%3A%2F%2Fa.example",
882
+ `${base}/actor`,
883
+ ],
884
+ [
885
+ "ap://did:key:z6MkAlice/actor?%40gateway=https%3A%2F%2Fa.example",
886
+ `${base}/actor`,
887
+ ],
888
+ [
889
+ "ap://did:key:z6MkAlice/actor?@%67ateway=https%3A%2F%2Fa.example",
890
+ `${base}/actor`,
891
+ ],
892
+ [
893
+ "ap://did:key:z6MkAlice/actor?@gateway",
894
+ `${base}/actor`,
895
+ ],
896
+ [
897
+ "ap://did:key:z6MkAlice/c?page=3&@gateway=https%3A%2F%2Fa.example&maxItems=20&page=4",
898
+ `${base}/c?page=3&maxItems=20&page=4`,
899
+ ],
900
+ [
901
+ "ap://did:key:z6MkAlice/c?a=1&&b=2#frag",
902
+ `${base}/c?a=1&&b=2#frag`,
903
+ ],
904
+ ["ap://did:key:z6MkAlice/c?", `${base}/c?`],
905
+ ["ap://did:key:z6MkAlice/c?x='y'", `${base}/c?x=%27y%27`],
906
+ [
907
+ "ap://did:key:z6MkAlice/c?@gate\tway=https%3A%2F%2Fa.example",
908
+ `${base}/c?@gate%09way=https%3A%2F%2Fa.example`,
909
+ ],
910
+ [
911
+ "ap://did:key:z6MkAlice/c?@gate\nway=1&x=\r2",
912
+ `${base}/c?@gate%0Away=1&x=%0D2`,
913
+ ],
914
+ ["ap://did:key:z6MkAlice/c?%ff=1", `${base}/c?%FF=1`],
915
+ ];
916
+ for (const [portableId, expected] of cases) {
917
+ const result = toCompatibleEf61Id(portableId, "https://server.example");
918
+ deepStrictEqual(result.href, expected, portableId);
919
+ ok(!/[?&](%40|@)gateway(=|&|$)/i.test(result.search), portableId);
920
+ }
921
+ });
922
+
923
+ test("toCompatibleEf61Id() rejects invalid portable IDs", () => {
924
+ const cases: (string | URL)[] = [
925
+ "https://server.example/actor",
926
+ "https://server.example/.well-known/apgateway/did:key:z6MkAlice/actor",
927
+ "at://did:plc:example/record",
928
+ "ap://not-a-did/actor",
929
+ "ap://did:key:z6MkAlice",
930
+ "ap://did:key:z6MkAlice/a%zz",
931
+ "ap://did:key:z6MkAlice/a#%zz",
932
+ "ap://did:key:z6MkAlice/a?x=%zz",
933
+ "ap://did:key:z6MkAlice/.",
934
+ "ap://did:key:z6MkAlice/..",
935
+ "ap://did:key:z6MkAlice/a/./b",
936
+ "ap://did:key:z6MkAlice/a/../b",
937
+ "ap://did:key:z6MkAlice/a/%2e/b",
938
+ "ap://did:key:z6MkAlice/a/%2E%2e/b",
939
+ "ap://did:key:z6MkAlice/a/.%2e/b",
940
+ "ap://did:key:z6MkAlice/a/%2e./b",
941
+ new URL("https://server.example/actor"),
942
+ new URL("ap+ef61://user:pass@did%3Akey%3Az6MkAlice/actor"),
943
+ new URL("ap+ef61://did%3Akey%3Az6MkAlice:8080/actor"),
944
+ 123 as unknown as string,
945
+ ];
946
+ for (const portableId of cases) {
947
+ throws(
948
+ () => toCompatibleEf61Id(portableId, "https://server.example"),
949
+ TypeError,
950
+ String(portableId),
951
+ );
952
+ }
953
+ });
954
+
955
+ test("toCompatibleEf61Id() rejects gateways that are not HTTP(S) origins", () => {
956
+ const cases: (string | URL)[] = [
957
+ "ftp://server.example",
958
+ "ap+ef61://did:key:z6MkAlice/actor",
959
+ "https://user:pass@server.example",
960
+ "https://user@server.example",
961
+ "https://server.example/ap",
962
+ "https://server.example/ap/",
963
+ "https://server.example/.well-known/apgateway",
964
+ "https://server.example/?x=1",
965
+ "https://server.example/?",
966
+ "https://server.example/#fragment",
967
+ "https://server.example/#",
968
+ "server.example",
969
+ "",
970
+ new URL("https://server.example/?"),
971
+ new URL("https://server.example/#"),
972
+ new URL("https://server.example/ap"),
973
+ new URL("ftp://server.example/"),
974
+ 123 as unknown as string,
975
+ ];
976
+ for (const gateway of cases) {
977
+ throws(
978
+ () => toCompatibleEf61Id("ap://did:key:z6MkAlice/actor", gateway),
979
+ TypeError,
980
+ String(gateway),
981
+ );
982
+ }
983
+ });
984
+
985
+ test("compatible identifier conversion round-trips", () => {
986
+ const cases = [
987
+ "ap+ef61://did:key:z6MkAlice/objects/1",
988
+ "ap://did:key:z6MkAlice/actor",
989
+ "ap://did%3Akey%3Az6MkAlice/actor/inbox",
990
+ "ap://did:key:z6MkAlice/actor#",
991
+ "ap://did:key:z6MkAlice/actor#main-key",
992
+ "ap://did:key:z6MkAlice/a%2Fb/%252e",
993
+ "ap://did:key:z6MkAlice/a\\b c\té",
994
+ "ap://did:example:a%2525/x",
995
+ "ap://did:web:example.com%3A8080/actor",
996
+ "ap://did:key:z6MkAlice/c?page=3&@gateway=https%3A%2F%2Fa.example",
997
+ ];
998
+ for (const portableId of cases) {
999
+ const compatible = toCompatibleEf61Id(portableId, "https://server.example");
1000
+ const converted = fromCompatibleEf61Id(compatible);
1001
+ ok(converted != null, portableId);
1002
+ deepStrictEqual(
1003
+ canonicalizePortableUri(converted.href),
1004
+ canonicalizePortableUri(portableId),
1005
+ portableId,
1006
+ );
1007
+ }
1008
+ ok(
1009
+ !arePortableUrisEqual(
1010
+ fromCompatibleEf61Id(
1011
+ toCompatibleEf61Id(
1012
+ "ap://did:key:z6MkAlice/actor",
1013
+ "https://server.example",
1014
+ ),
1015
+ )!.href,
1016
+ fromCompatibleEf61Id(
1017
+ toCompatibleEf61Id(
1018
+ "ap://did:key:z6MkAlice/actor#",
1019
+ "https://server.example",
1020
+ ),
1021
+ )!.href,
1022
+ ),
1023
+ );
1024
+ });
1025
+
665
1026
  test("validatePublicUrl()", async () => {
666
1027
  await rejects(() => validatePublicUrl("ftp://localhost"), UrlError);
667
1028
  await rejects(
package/src/url.ts CHANGED
@@ -346,6 +346,241 @@ export function parseGatewayUrl(url: string): URL {
346
346
  return parsed;
347
347
  }
348
348
 
349
+ const COMPATIBLE_ID_PATH_PREFIX = "/.well-known/apgateway/";
350
+ const COMPATIBLE_ID_DID_PATTERN = /^did(?::|%3A)/i;
351
+ // `gateways` is the location hint parameter name used by earlier FEP-ef61
352
+ // revisions; strip it as well for compatibility with older publishers.
353
+ const LOCATION_HINT_PARAMETERS: ReadonlySet<string> = new Set([
354
+ "@gateway",
355
+ "gateways",
356
+ ]);
357
+
358
+ /**
359
+ * Converts an [FEP-ef61] compatible identifier into a portable ActivityPub
360
+ * URI.
361
+ *
362
+ * A compatible identifier is an HTTP(S) URL under a gateway's fixed
363
+ * `/.well-known/apgateway/` path, such as
364
+ * `https://server.example/.well-known/apgateway/did:key:z6Mk.../objects/1`.
365
+ * This function removes the gateway part and returns the corresponding
366
+ * portable URI, e.g., `ap+ef61://did:key:z6Mk.../objects/1`, in the same
367
+ * internal `URL` form that {@link parseIri} produces. The path, query, and
368
+ * fragment are preserved, so the result is a portable URI, not a comparison
369
+ * form; pass its `href` to {@link canonicalizePortableUri} or
370
+ * {@link arePortableUrisEqual} to compare it with other portable URIs.
371
+ *
372
+ * The conversion only reveals the *claimed* portable identifier. Anyone can
373
+ * publish a compatible identifier for any DID on their own server, so the
374
+ * gateway that served it is neither the object's origin nor authorized to act
375
+ * for the DID. Callers must still verify the retrieved document's Object
376
+ * Integrity Proof against the DID, as FEP-ef61 requires, and should keep the
377
+ * original URL if they need the gateway as a retrieval hint.
378
+ *
379
+ * Arbitrary gateway paths are not supported.
380
+ *
381
+ * [FEP-ef61]: https://w3id.org/fep/ef61
382
+ *
383
+ * @param input The URL to convert.
384
+ * @returns The portable ActivityPub URI, or `null` if the input is not an
385
+ * HTTP(S) URL whose path starts with `/.well-known/apgateway/did:`.
386
+ * Other gateway routes, such as the gateway discovery endpoint and
387
+ * hashlink media URLs, also yield `null`.
388
+ * @throws {TypeError} If the input looks like a compatible identifier but is
389
+ * malformed, e.g., it has an invalid DID, no object path,
390
+ * invalid percent-encoding, credentials, or location
391
+ * hints (`@gateway` query parameters, or the legacy
392
+ * `gateways` parameter), which FEP-ef61 forbids in
393
+ * compatible identifiers.
394
+ * @since 2.4.0
395
+ */
396
+ export function fromCompatibleEf61Id(input: string | URL): URL | null {
397
+ return convertCompatibleEf61Id(input)?.url ?? null;
398
+ }
399
+
400
+ function convertCompatibleEf61Id(
401
+ input: string | URL,
402
+ ): { url: URL; iri: string } | null {
403
+ let url: URL;
404
+ if (input instanceof URL) url = input;
405
+ else if (typeof input === "string" && URL.canParse(input)) {
406
+ url = new URL(input);
407
+ } else return null;
408
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
409
+ if (!url.pathname.startsWith(COMPATIBLE_ID_PATH_PREFIX)) return null;
410
+ const tail = url.pathname.slice(COMPATIBLE_ID_PATH_PREFIX.length);
411
+ if (!COMPATIBLE_ID_DID_PATTERN.test(tail)) return null;
412
+ if (url.username !== "" || url.password !== "") {
413
+ throw new TypeError(
414
+ "Invalid FEP-ef61 compatible identifier: credentials are not allowed.",
415
+ );
416
+ }
417
+ // Slice href instead of concatenating pathname, search, and hash, because
418
+ // the latter two drop empty query and fragment delimiters.
419
+ const iri = "ap+ef61://" +
420
+ url.href.slice(url.origin.length + COMPATIBLE_ID_PATH_PREFIX.length);
421
+ try {
422
+ const parsed = parsePortableIri(iri);
423
+ if (parsed == null) throw new TypeError("Not a portable IRI.");
424
+ // parsePortableIri() does not validate path and fragment
425
+ // percent-encoding, but canonicalizePortableUri() does:
426
+ canonicalizePortableUri(iri);
427
+ // canonicalizePortableUri() ignores the query, so validate it here too.
428
+ // Compatible identifiers must not have location hints:
429
+ const query = url.search === ""
430
+ ? []
431
+ : normalizePortableComponent(url.search.slice(1)).split("&");
432
+ if (query.some(isLocationHint)) {
433
+ throw new TypeError("Location hints are not allowed.");
434
+ }
435
+ return { url: parsed, iri };
436
+ } catch (error) {
437
+ if (error instanceof TypeError) {
438
+ throw new TypeError("Invalid FEP-ef61 compatible identifier.", {
439
+ cause: error,
440
+ });
441
+ }
442
+ throw error;
443
+ }
444
+ }
445
+
446
+ /**
447
+ * Converts a portable ActivityPub URI into an [FEP-ef61] compatible
448
+ * identifier, which is an HTTP(S) URL under the gateway's fixed
449
+ * `/.well-known/apgateway/` path.
450
+ *
451
+ * For example, `ap+ef61://did:key:z6Mk.../objects/1` and
452
+ * `https://server.example` yield
453
+ * `https://server.example/.well-known/apgateway/did:key:z6Mk.../objects/1`.
454
+ * Both `ap:` and `ap+ef61:` URIs with decoded or percent-encoded DID
455
+ * authorities are accepted. Publishers should use the first gateway in the
456
+ * actor's `gateways` list, as FEP-ef61 requires.
457
+ *
458
+ * The path and fragment are preserved, with characters that are not allowed
459
+ * in HTTP(S) URLs percent-encoded the same way as
460
+ * {@link canonicalizePortableUri} does. FEP-ef61 location hints (`@gateway`
461
+ * query parameters, and the legacy `gateways` parameter) are removed, since
462
+ * compatible identifiers must not have them; other query parameters are kept
463
+ * in order.
464
+ *
465
+ * Arbitrary gateway paths are not supported.
466
+ *
467
+ * [FEP-ef61]: https://w3id.org/fep/ef61
468
+ *
469
+ * @param portableId The `ap:` or `ap+ef61:` URI to convert.
470
+ * @param gateway The gateway's HTTP(S) origin, e.g., `https://server.example`.
471
+ * @returns The compatible identifier.
472
+ * @throws {TypeError} If the portable ID is not a valid `ap:` or `ap+ef61:`
473
+ * URI, if its path has `.` or `..` segments (which
474
+ * HTTP(S) URLs cannot represent), or if the gateway is
475
+ * not an HTTP(S) origin with no credentials, path, query,
476
+ * or fragment.
477
+ * @since 2.4.0
478
+ */
479
+ export function toCompatibleEf61Id(
480
+ portableId: string | URL,
481
+ gateway: string | URL,
482
+ ): URL {
483
+ const gatewayUrl = parseCompatibleEf61Gateway(gateway);
484
+ const raw = getRawPortableIri(portableId);
485
+ const match = raw.match(PORTABLE_IRI_PATTERN);
486
+ const parsed = parsePortableIri(raw);
487
+ if (match == null || parsed == null) {
488
+ throw new TypeError("Invalid portable ActivityPub IRI.");
489
+ }
490
+ // The parser decodes %25 once in a did:-prefixed authority, so escape
491
+ // percent signs only when that would otherwise change the DID. Other DIDs
492
+ // are kept literal (e.g., did:web:example.com%3A8080) so that gateways
493
+ // which read the path segment as is see the same DID:
494
+ let did = decodePortableAuthority(parsed.host);
495
+ if (/%25/i.test(did)) did = did.replace(/%/g, "%25");
496
+ const path = normalizePortableComponent(match[3]);
497
+ if (path.split("/").some((segment) => segment === "." || segment === "..")) {
498
+ throw new TypeError(
499
+ "FEP-ef61 compatible identifiers cannot represent portable IRI paths " +
500
+ "with dot segments.",
501
+ );
502
+ }
503
+ // Normalize the query before looking for location hints so that characters
504
+ // which the URL parser strips (e.g., tabs) cannot form a hint name later:
505
+ const query = match[4] == null
506
+ ? ""
507
+ : stripLocationHints(normalizePortableComponent(match[4].slice(1)));
508
+ const fragment = match[5] == null ? "" : normalizePortableComponent(match[5]);
509
+ const result = new URL(
510
+ gatewayUrl.origin + COMPATIBLE_ID_PATH_PREFIX + did + path + query +
511
+ fragment,
512
+ );
513
+ // Guard against URL parser normalization that would silently change the
514
+ // identified object or reintroduce location hints (the latter makes
515
+ // convertCompatibleEf61Id() throw):
516
+ const converted = convertCompatibleEf61Id(result);
517
+ if (
518
+ converted == null ||
519
+ canonicalizePortableUri(converted.iri) !== canonicalizePortableUri(raw)
520
+ ) {
521
+ throw new TypeError(
522
+ "The portable ActivityPub IRI cannot be represented as an FEP-ef61 " +
523
+ "compatible identifier.",
524
+ );
525
+ }
526
+ return result;
527
+ }
528
+
529
+ function parseCompatibleEf61Gateway(gateway: string | URL): URL {
530
+ const url = gateway instanceof URL
531
+ ? gateway
532
+ : typeof gateway === "string" && URL.canParse(gateway)
533
+ ? new URL(gateway)
534
+ : null;
535
+ // Comparing href with the origin also rejects credentials, a path, and
536
+ // query and fragment components, including empty ? and # delimiters.
537
+ if (
538
+ url == null || (url.protocol !== "http:" && url.protocol !== "https:") ||
539
+ url.href !== `${url.origin}/`
540
+ ) {
541
+ throw new TypeError(
542
+ "FEP-ef61 gateways for compatible identifiers must be HTTP(S) origins " +
543
+ "with no credentials, path, query, or fragment.",
544
+ );
545
+ }
546
+ return url;
547
+ }
548
+
549
+ function getRawPortableIri(portableId: string | URL): string {
550
+ if (portableId instanceof URL) {
551
+ if (portableId.protocol !== "ap:" && portableId.protocol !== "ap+ef61:") {
552
+ throw new TypeError("Invalid portable ActivityPub IRI.");
553
+ }
554
+ // parseIri() would fold a port into the DID and drop credentials:
555
+ if (
556
+ portableId.username !== "" || portableId.password !== "" ||
557
+ portableId.port !== ""
558
+ ) {
559
+ throw new TypeError("Invalid portable ActivityPub IRI authority.");
560
+ }
561
+ return portableId.href;
562
+ }
563
+ if (typeof portableId !== "string") {
564
+ throw new TypeError("Invalid portable ActivityPub IRI.");
565
+ }
566
+ return portableId;
567
+ }
568
+
569
+ function stripLocationHints(query: string): string {
570
+ const pairs = query.split("&").filter((pair) => !isLocationHint(pair));
571
+ return pairs.length < 1 ? "" : `?${pairs.join("&")}`;
572
+ }
573
+
574
+ function isLocationHint(pair: string): boolean {
575
+ const name = pair.split("=", 1)[0].replace(/\+/g, " ");
576
+ try {
577
+ return LOCATION_HINT_PARAMETERS.has(decodeURIComponent(name));
578
+ } catch (error) {
579
+ if (error instanceof URIError) return false;
580
+ throw error;
581
+ }
582
+ }
583
+
349
584
  /**
350
585
  * Validates a URL to prevent SSRF attacks.
351
586
  */
package/tsdown.config.ts CHANGED
@@ -7,6 +7,7 @@ export default [
7
7
  entry: [
8
8
  "src/mod.ts",
9
9
  "src/internal/jsonld-cache.ts",
10
+ "src/internal/portable-dereference.ts",
10
11
  "src/internal/signed-representation.ts",
11
12
  "src/jsonld.ts",
12
13
  "src/temporal.ts",