@fedify/fedify 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 (244) hide show
  1. package/README.md +50 -39
  2. package/dist/{assert-CJ1KXcvv.mjs → assert-D1Eafmd_.mjs} +1 -1
  3. package/dist/{assert_equals-BV7oLMxm.mjs → assert_equals-Z6IcEbWU.mjs} +2 -28
  4. package/dist/assert_exists-cffSpJ7X.mjs +28 -0
  5. package/dist/assert_false-CPdRnc3E.mjs +23 -0
  6. package/dist/assert_greater_or_equal-BlafOF4-.mjs +31 -0
  7. package/dist/{assert_instance_of-Q9KTBnrf.mjs → assert_instance_of-mrXWWNY-.mjs} +1 -1
  8. package/dist/{assert_not_equals-EPJPoxuf.mjs → assert_not_equals-CpPK2t89.mjs} +2 -1
  9. package/dist/assert_rejects-CTptdYck.mjs +39 -0
  10. package/dist/{assert_strict_equals-Ec20AUwa.mjs → assert_strict_equals-CYvBmkpC.mjs} +2 -1
  11. package/dist/{assert_throws-NiFfqbnH.mjs → assert_throws-BYITOvPO.mjs} +2 -1
  12. package/dist/assertion_error-B-cx0fFA.mjs +31 -0
  13. package/dist/audience-CHQ-FlLD.mjs +95 -0
  14. package/dist/{builder-DonuxRl_.mjs → builder-Bda6LK7A.mjs} +169 -14
  15. package/dist/{circuit-breaker-CBGyz5F7.mjs → circuit-breaker-XJ53md4S.mjs} +224 -17
  16. package/dist/circuit-breaker-test-utils-DW6YC_Pe.mjs +14 -0
  17. package/dist/{client-CjF5IYQS.mjs → client-CEnNz8O7.mjs} +144 -8
  18. package/dist/{codec-BBga-FWI.mjs → codec-C6rTrpIp.mjs} +26 -3
  19. package/dist/{collection-BIhlldqK.mjs → collection-CnnP1UAv.mjs} +1 -1
  20. package/dist/compat/mod.cjs +1 -1
  21. package/dist/compat/mod.d.cts +1 -1
  22. package/dist/compat/mod.d.ts +1 -1
  23. package/dist/compat/mod.js +1 -1
  24. package/dist/compat/outgoing-jsonld.test.mjs +71 -3
  25. package/dist/compat/public-audience.test.mjs +14 -5
  26. package/dist/compat/secured-document.test.d.mts +2 -0
  27. package/dist/compat/secured-document.test.mjs +58 -0
  28. package/dist/compat/transformers.test.mjs +5 -5
  29. package/dist/compound-proof-B-9mXxFW.mjs +989 -0
  30. package/dist/{context-Zii1f5xS.d.ts → context-7NyiqIcg.d.ts} +1600 -25
  31. package/dist/{context-BJXxPElm.mjs → context-8Tu9kea0.mjs} +21 -63
  32. package/dist/{context-lxX6I7as.d.cts → context-tsYPJ2Ew.d.cts} +1600 -25
  33. package/dist/{deno-BHzKn9Ig.mjs → deno-oR5aJKpU.mjs} +1 -1
  34. package/dist/dist-DJQM4u7i.mjs +189 -0
  35. package/dist/docloader-DBoYbvYW.mjs +87 -0
  36. package/dist/{esm-yYQfPhIY.mjs → esm-BHXXx4Wx.mjs} +13 -6
  37. package/dist/federation/audience.test.d.mts +2 -0
  38. package/dist/federation/audience.test.mjs +130 -0
  39. package/dist/federation/builder.test.mjs +54 -5
  40. package/dist/federation/circuit-breaker.test.mjs +838 -7
  41. package/dist/federation/collection.test.mjs +3 -3
  42. package/dist/federation/compound-fanout.test.d.mts +2 -0
  43. package/dist/federation/compound-fanout.test.mjs +228 -0
  44. package/dist/federation/handler-compound-proof.test.d.mts +2 -0
  45. package/dist/federation/handler-compound-proof.test.mjs +384 -0
  46. package/dist/federation/handler.test.mjs +385 -15
  47. package/dist/federation/hashlink-media.test.d.mts +2 -0
  48. package/dist/federation/hashlink-media.test.mjs +422 -0
  49. package/dist/federation/idempotency.test.mjs +6 -6
  50. package/dist/federation/inbox-report.test.d.mts +2 -0
  51. package/dist/federation/inbox-report.test.mjs +475 -0
  52. package/dist/federation/inbox.test.mjs +3 -3
  53. package/dist/federation/keycache.test.mjs +215 -14
  54. package/dist/federation/kv.test.mjs +3 -3
  55. package/dist/federation/metrics.test.mjs +5 -5
  56. package/dist/federation/middleware.test.mjs +967 -35
  57. package/dist/federation/mod.cjs +18 -261
  58. package/dist/federation/mod.d.cts +5 -5
  59. package/dist/federation/mod.d.ts +5 -5
  60. package/dist/federation/mod.js +279 -1
  61. package/dist/federation/mq.test.mjs +123 -47
  62. package/dist/federation/negotiation.test.mjs +4 -4
  63. package/dist/federation/outgoing-proof.test.d.mts +2 -0
  64. package/dist/federation/outgoing-proof.test.mjs +931 -0
  65. package/dist/federation/portable-actor.test.d.mts +2 -0
  66. package/dist/federation/portable-actor.test.mjs +496 -0
  67. package/dist/federation/portable-chain.test.d.mts +2 -0
  68. package/dist/federation/portable-chain.test.mjs +370 -0
  69. package/dist/federation/portable-collection.test.d.mts +2 -0
  70. package/dist/federation/portable-collection.test.mjs +832 -0
  71. package/dist/federation/portable-delivery.test.d.mts +2 -0
  72. package/dist/federation/portable-delivery.test.mjs +746 -0
  73. package/dist/federation/portable-gateway-keys.test.d.mts +2 -0
  74. package/dist/federation/portable-gateway-keys.test.mjs +686 -0
  75. package/dist/federation/portable-inbox.test.d.mts +2 -0
  76. package/dist/federation/portable-inbox.test.mjs +1535 -0
  77. package/dist/federation/portable-parse-uri.test.d.mts +2 -0
  78. package/dist/federation/portable-parse-uri.test.mjs +348 -0
  79. package/dist/federation/portable-route-activity.test.d.mts +2 -0
  80. package/dist/federation/portable-route-activity.test.mjs +259 -0
  81. package/dist/federation/portable.test.d.mts +2 -0
  82. package/dist/federation/portable.test.mjs +1072 -0
  83. package/dist/federation/retry.test.mjs +4 -3
  84. package/dist/federation/router.test.mjs +9 -159
  85. package/dist/federation/send.test.mjs +245 -27
  86. package/dist/federation/tasks/codec.test.mjs +4 -4
  87. package/dist/federation/tasks/enqueue.test.mjs +42 -4
  88. package/dist/federation/tasks/tasks.test.mjs +7 -6
  89. package/dist/federation/temporal.test.mjs +4 -4
  90. package/dist/federation/webfinger.test.mjs +275 -12
  91. package/dist/{middleware-FrSMAYEx.cjs → federation-CkZiAcWY.cjs} +8640 -3722
  92. package/dist/http-B5JT115n.cjs +10630 -0
  93. package/dist/http-DydWCB7R.js +6527 -0
  94. package/dist/{http-CPVMUgsW.mjs → http-siciWkmx.mjs} +397 -168
  95. package/dist/key-BspPiXqn.mjs +951 -0
  96. package/dist/key-C1nv7_jD.d.ts +1125 -0
  97. package/dist/key-DYxuyd-s.d.cts +1125 -0
  98. package/dist/key-owner-evidence-APB6NpSk.mjs +108 -0
  99. package/dist/keycache-Da8A8mZa.mjs +200 -0
  100. package/dist/{kv-D6hNiMTK.d.ts → kv-B2mzK5tl.d.cts} +3 -2
  101. package/dist/{kv-gJ8LYbxX.d.cts → kv-B2mzK5tl.d.ts} +3 -2
  102. package/dist/kv-DBRWVx3Q.mjs +104 -0
  103. package/dist/{kv-cache-KyGqJqSp.mjs → kv-cache-BF1hnW65.mjs} +1 -1
  104. package/dist/{kv-cache-5bnX3-Jq.js → kv-cache-CTQr1tbn.js} +54 -16
  105. package/dist/{kv-cache-DQ29C4XL.cjs → kv-cache-DCac1nEU.cjs} +55 -19
  106. package/dist/{ld-Cj9F_5AP.mjs → ld-CzaP-hJl.mjs} +44 -12
  107. package/dist/map-local-context-conflict-DOYEEJ3u.mjs +204 -0
  108. package/dist/map-local-create-note-B0SeXJms.mjs +214 -0
  109. package/dist/{metrics-DMk-nTxI.mjs → metrics-CYg9yxBL.mjs} +3 -2
  110. package/dist/metrics-DVKYY6K1.d.cts +36 -0
  111. package/dist/metrics-DoRtI0qU.d.ts +36 -0
  112. package/dist/{middleware-Y-kpsp6S.js → middleware-CsDDXKuL.js} +5493 -1103
  113. package/dist/{middleware-CvwTUgnP.mjs → middleware-D7_LO-Vs.mjs} +3157 -272
  114. package/dist/{middleware-CEctxBN6.mjs → middleware-DWjrfpcz.mjs} +1 -1
  115. package/dist/{mod-gOlPDuT8.d.cts → mod-BojP2Bw3.d.cts} +1 -1
  116. package/dist/mod-CavhAgLw.d.cts +320 -0
  117. package/dist/mod-CtpF2BU5.d.ts +320 -0
  118. package/dist/{mod-BtEaIEcD.d.cts → mod-DEZXwV6J.d.ts} +10 -4
  119. package/dist/{mod-UMmveiO0.d.ts → mod-Dw-nPxQh.d.cts} +10 -4
  120. package/dist/{mod-DLc2Ot6f.d.ts → mod-Xj5P4s4d.d.ts} +1 -1
  121. package/dist/mod.cjs +40 -39
  122. package/dist/mod.d.cts +10 -11
  123. package/dist/mod.d.ts +10 -11
  124. package/dist/mod.js +9 -9
  125. package/dist/{mq-llHKivST.mjs → mq-Dm1Wmtm-.mjs} +6 -0
  126. package/dist/nodeinfo/client.test.mjs +139 -4
  127. package/dist/nodeinfo/handler.test.mjs +5 -5
  128. package/dist/nodeinfo/mod.cjs +1 -1
  129. package/dist/nodeinfo/mod.d.cts +1 -1
  130. package/dist/nodeinfo/mod.d.ts +1 -1
  131. package/dist/nodeinfo/mod.js +1 -1
  132. package/dist/nodeinfo/types.test.mjs +4 -4
  133. package/dist/otel/exporter.test.mjs +3 -3
  134. package/dist/otel/mod.cjs +0 -1
  135. package/dist/otel/mod.d.cts +1 -1
  136. package/dist/otel/mod.d.ts +1 -1
  137. package/dist/{outgoing-jsonld-URuqsICz.mjs → outgoing-jsonld-4IJqPYos.mjs} +12 -10
  138. package/dist/{owner-DEvZuyOE.d.cts → owner-BF0L9cMt.d.cts} +11 -0
  139. package/dist/{owner-CnngXDNJ.d.ts → owner-BSzr5-I8.d.ts} +11 -0
  140. package/dist/owner-DYgfTFHy.mjs +267 -0
  141. package/dist/portable-collection-BpsVsRov.js +670 -0
  142. package/dist/portable-collection-C74P1vtC.mjs +418 -0
  143. package/dist/portable-collection-DZd2bm7M.cjs +686 -0
  144. package/dist/portable-key-BIMLrAP4.mjs +549 -0
  145. package/dist/portable-key-id-Ca30-hOa.mjs +248 -0
  146. package/dist/proof-Dxy6ByqI.mjs +1299 -0
  147. package/dist/{public-audience-Cr5MsnMq.mjs → public-audience--klBIOk1.mjs} +15 -10
  148. package/dist/router-u5O5KCQr.mjs +156 -0
  149. package/dist/runtime/mod.cjs +1 -1
  150. package/dist/runtime/mod.js +1 -1
  151. package/dist/secured-document-CxzZZ9nL.mjs +35 -0
  152. package/dist/send-C4dDpAxm.mjs +495 -0
  153. package/dist/sig/accept.test.mjs +1 -1
  154. package/dist/sig/compound-proof-discovery.test.d.mts +2 -0
  155. package/dist/sig/compound-proof-discovery.test.mjs +331 -0
  156. package/dist/sig/compound-proof-producer.test.d.mts +2 -0
  157. package/dist/sig/compound-proof-producer.test.mjs +107 -0
  158. package/dist/sig/compound-proof-verification.test.d.mts +2 -0
  159. package/dist/sig/compound-proof-verification.test.mjs +833 -0
  160. package/dist/sig/compound-proof.test.d.mts +2 -0
  161. package/dist/sig/compound-proof.test.mjs +411 -0
  162. package/dist/sig/http.test.mjs +702 -31
  163. package/dist/sig/key-owner-evidence.test.d.mts +2 -0
  164. package/dist/sig/key-owner-evidence.test.mjs +100 -0
  165. package/dist/sig/key.test.mjs +220 -8
  166. package/dist/sig/ld.test.mjs +65 -7
  167. package/dist/sig/mitra-receive-interop.test.d.mts +2 -0
  168. package/dist/sig/mitra-receive-interop.test.mjs +260 -0
  169. package/dist/sig/mitra-send-interop.test.d.mts +2 -0
  170. package/dist/sig/mitra-send-interop.test.mjs +461 -0
  171. package/dist/sig/mod.cjs +18 -16
  172. package/dist/sig/mod.d.cts +4 -4
  173. package/dist/sig/mod.d.ts +4 -4
  174. package/dist/sig/mod.js +3 -3
  175. package/dist/sig/owner.test.mjs +108 -9
  176. package/dist/sig/portable-collection.test.d.mts +2 -0
  177. package/dist/sig/portable-collection.test.mjs +697 -0
  178. package/dist/sig/portable-interop.test.d.mts +2 -0
  179. package/dist/sig/portable-interop.test.mjs +156 -0
  180. package/dist/sig/portable-key-id.test.d.mts +2 -0
  181. package/dist/sig/portable-key-id.test.mjs +67 -0
  182. package/dist/sig/portable-key.test.d.mts +2 -0
  183. package/dist/sig/portable-key.test.mjs +1439 -0
  184. package/dist/sig/proof.test.mjs +1753 -16
  185. package/dist/sig/tootik-request-interop.test.d.mts +2 -0
  186. package/dist/sig/tootik-request-interop.test.mjs +190 -0
  187. package/dist/sig/tootik-send-interop.test.d.mts +2 -0
  188. package/dist/sig/tootik-send-interop.test.mjs +331 -0
  189. package/dist/sig/verification.test.d.mts +2 -0
  190. package/dist/sig/verification.test.mjs +310 -0
  191. package/dist/std__assert-DvMDRPes.mjs +8 -0
  192. package/dist/{testing-CO7ftO8A.mjs → tasks-IG3pHfvZ.mjs} +2 -2
  193. package/dist/{temporal-fsXO5IvD.mjs → temporal-D0KJI4NF.mjs} +3 -3
  194. package/dist/testing/context.test.d.mts +2 -0
  195. package/dist/testing/context.test.mjs +19 -0
  196. package/dist/testing/kv.test.d.mts +2 -0
  197. package/dist/testing/kv.test.mjs +101 -0
  198. package/dist/testing/mod.d.mts +1617 -48
  199. package/dist/testing/mod.mjs +6 -3
  200. package/dist/testing-t8xDeJZ5.mjs +8 -0
  201. package/dist/{transformers-DGwl-hD1.cjs → transformers-BalCrSsr.cjs} +0 -1
  202. package/dist/{types-DC1pNgJA.js → types-_S2_MeLF.js} +144 -8
  203. package/dist/{types-CPym83zc.cjs → types-poIao40f.cjs} +160 -7
  204. package/dist/utils/docloader.test.mjs +530 -8
  205. package/dist/utils/kv-cache.test.mjs +2 -2
  206. package/dist/utils/mod.cjs +1 -1
  207. package/dist/utils/mod.d.cts +1 -1
  208. package/dist/utils/mod.d.ts +1 -1
  209. package/dist/utils/mod.js +1 -1
  210. package/dist/verification-C3954hQh.mjs +5 -0
  211. package/dist/verification-Dpfcw8ml.mjs +105 -0
  212. package/dist/vocab/cjs.test.mjs +1 -1
  213. package/package.json +8 -8
  214. package/dist/assert_rejects-EgstcQy5.mjs +0 -82
  215. package/dist/docloader-e1khNUKy.mjs +0 -49
  216. package/dist/http-CsmQ-BQ4.js +0 -2483
  217. package/dist/http-VyDTd4G3.d.cts +0 -553
  218. package/dist/http-hlIjmewS.cjs +0 -2694
  219. package/dist/http-lf8Hsd91.d.ts +0 -553
  220. package/dist/key-DfPsX8hv.mjs +0 -453
  221. package/dist/keycache-_DHqz5z_.mjs +0 -97
  222. package/dist/metrics-CoUJLQhZ.d.cts +0 -276
  223. package/dist/metrics-Dc4jR-IC.d.ts +0 -276
  224. package/dist/mod-B0hW12_O.d.cts +0 -307
  225. package/dist/mod-COIAjwRS.d.ts +0 -307
  226. package/dist/owner-Br9YYzn_.mjs +0 -120
  227. package/dist/proof-B4ngoaNQ.mjs +0 -316
  228. package/dist/proof-B6E0XUXJ.js +0 -1415
  229. package/dist/proof-NN4xCQGL.cjs +0 -1562
  230. package/dist/send-CxHKUNzv.mjs +0 -239
  231. package/dist/std__assert-B2NnY9bq.mjs +0 -79
  232. package/dist/temporal-polyfill-BCdTHexd.cjs +0 -3565
  233. /package/dist/{accept-8PiolDP7.mjs → accept-CPVqki78.mjs} +0 -0
  234. /package/dist/{activity-listener-BtTSxZWA.mjs → activity-listener-C-BSbKI7.mjs} +0 -0
  235. /package/dist/{client-CAM_bQXx.d.cts → client-D-XP_Ccb.d.cts} +0 -0
  236. /package/dist/{client-CSddvgWN.d.ts → client-D-XP_Ccb.d.ts} +0 -0
  237. /package/dist/{keys-Dbyd7bhl.mjs → keys-DIJNHW-Y.mjs} +0 -0
  238. /package/dist/{kv-CXBAok3k.mjs → kv-DCHkaJ9-.mjs} +0 -0
  239. /package/dist/{negotiation-RuwrPFqi.mjs → negotiation-CCocWT_N.mjs} +0 -0
  240. /package/dist/{retry-DgFqeS5k.mjs → retry-zX5xw5e0.mjs} +0 -0
  241. /package/dist/{chunk-D5IdezsZ.js → rolldown-runtime-BC-Eo5Mj.js} +0 -0
  242. /package/dist/{chunk-Czpeue4M.cjs → rolldown-runtime-Dmm3t92W.cjs} +0 -0
  243. /package/dist/{transformers-DXze6FUB.js → transformers-DAtXl_Kc.js} +0 -0
  244. /package/dist/{types-CrtdVuDP.mjs → types-DE_LV1JB.mjs} +0 -0
@@ -0,0 +1,1072 @@
1
+ import { Temporal } from "temporal-polyfill";
2
+ import "urlpattern-polyfill";
3
+ globalThis.addEventListener = () => {};
4
+ import { t as MemoryKvStore } from "../kv-DCHkaJ9-.mjs";
5
+ import { t as assertEquals } from "../assert_equals-Z6IcEbWU.mjs";
6
+ import { t as assertInstanceOf } from "../assert_instance_of-mrXWWNY-.mjs";
7
+ import { t as assertThrows } from "../assert_throws-BYITOvPO.mjs";
8
+ import "../std__assert-DvMDRPes.mjs";
9
+ import { t as assertStrictEquals } from "../assert_strict_equals-CYvBmkpC.mjs";
10
+ import { t as assert } from "../assert-D1Eafmd_.mjs";
11
+ import { a as signObject, c as verifyPortableObjectProof } from "../proof-Dxy6ByqI.mjs";
12
+ import { l as signRequest } from "../http-siciWkmx.mjs";
13
+ import { a as rsaPrivateKey3, c as rsaPublicKey3, i as rsaPrivateKey2, n as ed25519PrivateKey, r as ed25519PublicKey, s as rsaPublicKey2 } from "../keys-DIJNHW-Y.mjs";
14
+ import { b as PORTABLE_OBJECT_CONTENT_TYPE, o as createFederation } from "../middleware-D7_LO-Vs.mjs";
15
+ import { configure, reset } from "@logtape/logtape";
16
+ import { Collection, Create, Note, Object as Object$1, PUBLIC_COLLECTION, Person, Tombstone, lookupObject } from "@fedify/vocab";
17
+ import { exportDidKey, formatIri, parseIri, toCompatibleEf61Id } from "@fedify/vocab-runtime";
18
+ import { mockDocumentLoader, test } from "@fedify/fixture";
19
+ //#region src/federation/portable.test.ts
20
+ const did = await exportDidKey(ed25519PublicKey.publicKey);
21
+ const keyId = new URL(`${did}#${did.substring(8)}`);
22
+ const otherKeyPair = await crypto.subtle.generateKey("Ed25519", true, ["sign", "verify"]);
23
+ const otherDid = await exportDidKey(otherKeyPair.publicKey);
24
+ const otherKeyId = new URL(`${otherDid}#${otherDid.substring(8)}`);
25
+ const ACCEPT = "application/activity+json";
26
+ function gatewayUrl(path, authority = did) {
27
+ return `https://example.com/.well-known/apgateway/${authority}${path}`;
28
+ }
29
+ function gatewayRequest(path, init = {}) {
30
+ const { authority, ...rest } = init;
31
+ return new Request(gatewayUrl(path, authority), {
32
+ ...rest,
33
+ headers: {
34
+ Accept: ACCEPT,
35
+ ...rest.headers
36
+ }
37
+ });
38
+ }
39
+ function createTestFederation() {
40
+ return createFederation({
41
+ kv: new MemoryKvStore(),
42
+ documentLoaderFactory: () => mockDocumentLoader,
43
+ contextLoaderFactory: () => mockDocumentLoader
44
+ });
45
+ }
46
+ async function sign(object, privateKey = ed25519PrivateKey, key = keyId) {
47
+ return await signObject(object, privateKey, key, { contextLoader: mockDocumentLoader });
48
+ }
49
+ async function signedNote(path, authority = did, content = "Hello") {
50
+ return await sign(new Note({
51
+ id: parseIri(`ap+ef61://${authority}${path}`),
52
+ attribution: parseIri(`ap+ef61://${authority}/actor`),
53
+ to: PUBLIC_COLLECTION,
54
+ content
55
+ }));
56
+ }
57
+ test("Federation.fetch() serves portable objects through object dispatchers", async (t) => {
58
+ const federation = createTestFederation();
59
+ const calls = [];
60
+ federation.setObjectDispatcher(Note, "/users/{userId}/notes/{noteId}", async (ctx, values) => {
61
+ calls.push({
62
+ values,
63
+ portableRequest: ctx.portableRequest
64
+ });
65
+ if (values.noteId === "missing") return null;
66
+ if (values.noteId === "https") return new Note({
67
+ id: ctx.getObjectUri(Note, values),
68
+ content: "Not portable"
69
+ });
70
+ if (values.noteId === "ap") return await sign(new Note({
71
+ id: parseIri(`ap://${did}/users/${values.userId}/notes/ap`),
72
+ to: PUBLIC_COLLECTION,
73
+ content: "Hello"
74
+ }));
75
+ if (values.noteId === "ignores-authority") return await signedNote(`/users/${values.userId}/notes/${values.noteId}`);
76
+ if (ctx.portableRequest?.authority !== did) return null;
77
+ return await sign(new Note({
78
+ id: ctx.getPortableObjectUri(Note, values),
79
+ attribution: parseIri(`ap+ef61://${did}/actor`),
80
+ to: PUBLIC_COLLECTION,
81
+ content: `Note ${values.noteId} by ${values.userId}`
82
+ }));
83
+ });
84
+ federation.setActorDispatcher("/actors/{identifier}", (ctx, identifier) => new Person({ id: ctx.getActorUri(identifier) }));
85
+ await t.step("serves a portable object", async () => {
86
+ calls.length = 0;
87
+ const response = await federation.fetch(gatewayRequest("/users/alice/notes/123"), { contextData: void 0 });
88
+ assertEquals(response.status, 200);
89
+ assertEquals(response.headers.get("Content-Type"), PORTABLE_OBJECT_CONTENT_TYPE);
90
+ assertEquals(response.headers.get("Vary"), "Accept");
91
+ const json = await response.json();
92
+ assertEquals(json.id, `ap+ef61://${did}/users/alice/notes/123`);
93
+ assertEquals(json.content, "Note 123 by alice");
94
+ assertEquals((await verifyPortableObjectProof(json, { contextLoader: mockDocumentLoader })).verified, true);
95
+ assertEquals(calls.length, 1);
96
+ assertEquals(calls[0].values, {
97
+ userId: "alice",
98
+ noteId: "123"
99
+ });
100
+ assertEquals(calls[0].portableRequest?.authority, did);
101
+ assertEquals(formatIri(calls[0].portableRequest.id), `ap+ef61://${did}/users/alice/notes/123`);
102
+ });
103
+ await t.step("accepts a percent-encoded DID authority", async () => {
104
+ calls.length = 0;
105
+ assertEquals((await federation.fetch(gatewayRequest("/users/alice/notes/123", { authority: did.replaceAll(":", "%3A") }), { contextData: void 0 })).status, 200);
106
+ assertEquals(calls[0].portableRequest?.authority, did);
107
+ });
108
+ await t.step("ignores the query, including location hints", async () => {
109
+ assertEquals((await federation.fetch(gatewayRequest("/users/alice/notes/123?@gateway=https%3A%2F%2Fexample.com&x=%ZZ"), { contextData: void 0 })).status, 200);
110
+ });
111
+ await t.step("accepts an ap: ID returned by the dispatcher", async () => {
112
+ assertEquals((await federation.fetch(gatewayRequest("/users/alice/notes/ap"), { contextData: void 0 })).status, 200);
113
+ });
114
+ await t.step("responds to HEAD without a body", async () => {
115
+ const response = await federation.fetch(gatewayRequest("/users/alice/notes/123", { method: "HEAD" }), { contextData: void 0 });
116
+ assertEquals(response.status, 200);
117
+ assertEquals(response.headers.get("Content-Type"), PORTABLE_OBJECT_CONTENT_TYPE);
118
+ assertEquals(response.body, null);
119
+ });
120
+ await t.step("responds with 404 for objects it does not serve", async () => {
121
+ for (const request of [
122
+ gatewayRequest("/users/alice/notes/missing"),
123
+ gatewayRequest("/users/alice/notes/https"),
124
+ gatewayRequest("/users/alice/notes/123", { authority: otherDid }),
125
+ gatewayRequest("/users/alice/notes/ignores-authority", { authority: otherDid }),
126
+ gatewayRequest("/users/alice/posts/123"),
127
+ gatewayRequest("/actors/alice")
128
+ ]) assertEquals((await federation.fetch(request, { contextData: void 0 })).status, 404, request.url);
129
+ });
130
+ await t.step("responds with 400 for malformed portable IDs", async () => {
131
+ for (const path of [
132
+ `/.well-known/apgateway/${did}`,
133
+ "/.well-known/apgateway/did:key:/users/alice/notes/123",
134
+ `/.well-known/apgateway/${did}/users/alice/notes/%ZZ`
135
+ ]) assertEquals((await federation.fetch(new Request(`https://example.com${path}`, { headers: { Accept: ACCEPT } }), { contextData: void 0 })).status, 400, path);
136
+ });
137
+ await t.step("leaves other gateway requests to onNotFound()", async () => {
138
+ for (const request of [
139
+ new Request("https://example.com/.well-known/apgateway", { headers: { Accept: ACCEPT } }),
140
+ new Request("https://example.com/.well-known/apgateway/hl:zQmdfTbBqBPQ7VNxZEYEj14VmRuZBkqFbiwReogJgS1zR1n"),
141
+ gatewayRequest("/users/alice/notes/123", {
142
+ method: "POST",
143
+ body: "{}"
144
+ })
145
+ ]) {
146
+ let notFound = false;
147
+ assertEquals((await federation.fetch(request, {
148
+ contextData: void 0,
149
+ onNotFound() {
150
+ notFound = true;
151
+ return new Response("Not found", { status: 404 });
152
+ }
153
+ })).status, 404);
154
+ assertEquals(notFound, true, request.url);
155
+ }
156
+ });
157
+ await t.step("responds to HEAD without a body on errors", async () => {
158
+ for (const [request, status] of [[gatewayRequest("/users/alice/notes/missing", { method: "HEAD" }), 404], [gatewayRequest("/users/alice/notes/123", {
159
+ method: "HEAD",
160
+ headers: { Accept: "text/html" }
161
+ }), 406]]) {
162
+ const response = await federation.fetch(request, { contextData: void 0 });
163
+ assertEquals(response.status, status);
164
+ assertEquals(response.body, null);
165
+ }
166
+ });
167
+ await t.step("responds with 406 without a JSON-LD Accept header", async () => {
168
+ assertEquals((await federation.fetch(gatewayRequest("/users/alice/notes/123", { headers: { Accept: "text/html" } }), { contextData: void 0 })).status, 406);
169
+ });
170
+ await t.step("keeps ordinary object requests unchanged", async () => {
171
+ const response = await federation.fetch(new Request("https://example.com/users/alice/notes/https", { headers: { Accept: ACCEPT } }), { contextData: void 0 });
172
+ assertEquals(response.status, 200);
173
+ assertEquals(response.headers.get("Content-Type"), "application/activity+json");
174
+ assertEquals((await response.json()).id, "https://example.com/users/alice/notes/https");
175
+ });
176
+ });
177
+ test("Federation.fetch() applies the FEP-ef61 proof policy to portable objects", async (t) => {
178
+ const federation = createTestFederation();
179
+ const objects = {
180
+ "valid": () => signedNote("/objects/valid"),
181
+ "unsigned": () => Promise.resolve(new Note({ id: parseIri(`ap+ef61://${did}/objects/unsigned`) })),
182
+ "wrong-did": () => sign(new Note({ id: parseIri(`ap+ef61://${did}/objects/wrong-did`) }), otherKeyPair.privateKey, otherKeyId),
183
+ "tampered": async () => (await signedNote("/objects/tampered")).clone({ content: "Tampered" }),
184
+ "unsigned-collection": () => Promise.resolve(new Collection({
185
+ id: parseIri(`ap+ef61://${did}/objects/unsigned-collection`),
186
+ totalItems: 0
187
+ })),
188
+ "aliased-collection": () => Collection.fromJsonLd({
189
+ "@context": ["https://www.w3.org/ns/activitystreams", { "identifier": "@id" }],
190
+ type: "Collection",
191
+ identifier: `ap+ef61://${did}/objects/aliased-collection`,
192
+ totalItems: 0
193
+ }, {
194
+ contextLoader: mockDocumentLoader,
195
+ documentLoader: mockDocumentLoader
196
+ }),
197
+ "tampered-collection": async () => (await sign(new Collection({
198
+ id: parseIri(`ap+ef61://${did}/objects/tampered-collection`),
199
+ totalItems: 0
200
+ }))).clone({ totalItems: 1 }),
201
+ "activity": () => sign(new Create({
202
+ id: parseIri(`ap+ef61://${did}/objects/activity`),
203
+ actor: parseIri(`ap+ef61://${did}/actor`),
204
+ object: parseIri(`ap+ef61://${did}/objects/valid`)
205
+ })),
206
+ "actor": () => sign(new Person({
207
+ id: parseIri(`ap+ef61://${did}/objects/actor`),
208
+ inbox: parseIri(`ap+ef61://${did}/objects/actor/inbox`),
209
+ outbox: parseIri(`ap+ef61://${did}/objects/actor/outbox`),
210
+ gateways: [new URL("https://example.com/")]
211
+ })),
212
+ "mutated-id": async () => {
213
+ const signed = await signedNote("/objects/original");
214
+ const object = await Note.fromJsonLd(await signed.toJsonLd({ contextLoader: mockDocumentLoader }), {
215
+ contextLoader: mockDocumentLoader,
216
+ documentLoader: mockDocumentLoader
217
+ });
218
+ object.id.pathname = "/objects/mutated-id";
219
+ return object;
220
+ },
221
+ "actor-without-gateways": () => sign(new Person({
222
+ id: parseIri(`ap+ef61://${did}/objects/actor-without-gateways`),
223
+ inbox: parseIri(`ap+ef61://${did}/objects/actor-without-gateways/inbox`),
224
+ outbox: parseIri(`ap+ef61://${did}/objects/actor-without-gateways/outbox`)
225
+ })),
226
+ "unsigned-actor": () => Promise.resolve(new Person({
227
+ id: parseIri(`ap+ef61://${did}/objects/unsigned-actor`),
228
+ inbox: parseIri(`ap+ef61://${did}/objects/unsigned-actor/inbox`),
229
+ outbox: parseIri(`ap+ef61://${did}/objects/unsigned-actor/outbox`),
230
+ gateways: [new URL("https://example.com/")]
231
+ }))
232
+ };
233
+ federation.setObjectDispatcher(Object$1, "/objects/{id}", (_ctx, { id }) => objects[id]?.() ?? null).authorize(() => true);
234
+ for (const [id, status] of globalThis.Object.entries({
235
+ "valid": 200,
236
+ "unsigned": 500,
237
+ "wrong-did": 500,
238
+ "tampered": 500,
239
+ "unsigned-collection": 200,
240
+ "aliased-collection": 200,
241
+ "tampered-collection": 500,
242
+ "activity": 200,
243
+ "actor": 200,
244
+ "mutated-id": 404,
245
+ "actor-without-gateways": 500,
246
+ "unsigned-actor": 500
247
+ })) await t.step(`${id} → ${status}`, async () => {
248
+ const response = await federation.fetch(gatewayRequest(`/objects/${id}`), { contextData: void 0 });
249
+ assertEquals(response.status, status);
250
+ if (status === 500) assertEquals(await response.text(), "Internal server error.");
251
+ });
252
+ await t.step("responds to HEAD without a body on failure", async () => {
253
+ const response = await federation.fetch(gatewayRequest("/objects/unsigned", { method: "HEAD" }), { contextData: void 0 });
254
+ assertEquals(response.status, 500);
255
+ assertEquals(response.body, null);
256
+ });
257
+ });
258
+ test("Federation.fetch() serves tombstones of portable objects", async (t) => {
259
+ const federation = createTestFederation();
260
+ const compatible = (path) => new URL(`https://example.com/.well-known/apgateway/${did}${path}`);
261
+ const tombstoneFromJsonLd = (path, properties) => Tombstone.fromJsonLd({
262
+ "@context": "https://www.w3.org/ns/activitystreams",
263
+ type: "Tombstone",
264
+ id: `ap+ef61://${did}${path}`,
265
+ ...properties
266
+ }, {
267
+ contextLoader: mockDocumentLoader,
268
+ documentLoader: mockDocumentLoader
269
+ });
270
+ const tombstones = {
271
+ "signed": () => sign(new Tombstone({
272
+ id: parseIri(`ap+ef61://${did}/notes/signed`),
273
+ formerType: Note,
274
+ deleted: Temporal.Instant.from("2026-01-01T00:00:00Z")
275
+ })),
276
+ "compatible": () => sign(new Tombstone({
277
+ id: compatible("/notes/compatible"),
278
+ formerType: Note
279
+ })),
280
+ "signed-collection": async () => sign(await tombstoneFromJsonLd("/notes/signed-collection", { totalItems: 0 })),
281
+ "unsigned": () => Promise.resolve(new Tombstone({
282
+ id: parseIri(`ap+ef61://${did}/notes/unsigned`),
283
+ formerType: Note
284
+ })),
285
+ "wrong-did": () => sign(new Tombstone({ id: parseIri(`ap+ef61://${did}/notes/wrong-did`) }), otherKeyPair.privateKey, otherKeyId),
286
+ "tampered": async () => (await sign(new Tombstone({ id: parseIri(`ap+ef61://${did}/notes/tampered`) }))).clone({ formerType: Note }),
287
+ "unsigned-collection": () => tombstoneFromJsonLd("/notes/unsigned-collection", { totalItems: 0 }),
288
+ "link": () => tombstoneFromJsonLd("/notes/link", { href: "https://example.com/" }),
289
+ "mismatched": () => sign(new Tombstone({ id: parseIri(`ap+ef61://${did}/notes/other`) })),
290
+ "mutated-id": async () => {
291
+ const signed = await sign(new Tombstone({ id: parseIri(`ap+ef61://${did}/notes/original`) }));
292
+ const tombstone = await Tombstone.fromJsonLd(await signed.toJsonLd({ contextLoader: mockDocumentLoader }), {
293
+ contextLoader: mockDocumentLoader,
294
+ documentLoader: mockDocumentLoader
295
+ });
296
+ tombstone.id.pathname = tombstone.id.pathname.replace(/original$/, "mutated-id");
297
+ return tombstone;
298
+ }
299
+ };
300
+ federation.setObjectDispatcher(Note, "/notes/{id}", (_ctx, { id }) => tombstones[id]?.() ?? null).authorize((ctx) => ctx.request.headers.get("X-Deny") == null);
301
+ for (const [id, reason] of globalThis.Object.entries({
302
+ "unsigned": "missingProof",
303
+ "wrong-did": "verificationMethodMismatch",
304
+ "tampered": "invalidProof",
305
+ "unsigned-collection": "unsecuredCollection",
306
+ "link": "unsupportedObjectType"
307
+ })) {
308
+ const result = await verifyPortableObjectProof(await (await tombstones[id]()).toJsonLd({ contextLoader: mockDocumentLoader }), { contextLoader: mockDocumentLoader });
309
+ assert(!result.verified, id);
310
+ assertEquals(result.reason.type, reason, id);
311
+ }
312
+ for (const [id, status] of globalThis.Object.entries({
313
+ "signed": 410,
314
+ "compatible": 410,
315
+ "signed-collection": 410,
316
+ "unsigned": 404,
317
+ "wrong-did": 500,
318
+ "tampered": 500,
319
+ "unsigned-collection": 500,
320
+ "link": 500,
321
+ "mismatched": 404,
322
+ "mutated-id": 404
323
+ })) await t.step(`${id} → ${status}`, async () => {
324
+ let notFound = false;
325
+ const response = await federation.fetch(gatewayRequest(`/notes/${id}`), {
326
+ contextData: void 0,
327
+ onNotFound() {
328
+ notFound = true;
329
+ return new Response("Not found", { status: 404 });
330
+ }
331
+ });
332
+ assertEquals(response.status, status);
333
+ assertEquals(notFound, status === 404);
334
+ if (status === 410) {
335
+ assertEquals(response.headers.get("Content-Type"), PORTABLE_OBJECT_CONTENT_TYPE);
336
+ assertEquals(response.headers.get("Vary"), "Accept");
337
+ const body = await response.json();
338
+ assertEquals(body.type, "Tombstone");
339
+ assert((await verifyPortableObjectProof(body, { contextLoader: mockDocumentLoader })).verified);
340
+ } else if (status === 500) assertEquals(await response.text(), "Internal server error.");
341
+ });
342
+ await t.step("serves the tombstone as the dispatcher returns it", async () => {
343
+ let response = await federation.fetch(gatewayRequest("/notes/signed"), { contextData: void 0 });
344
+ let body = await response.json();
345
+ assertEquals(body.id, `ap+ef61://${did}/notes/signed`);
346
+ assertEquals(body.formerType, "as:Note");
347
+ assertEquals(body.deleted, "2026-01-01T00:00:00Z");
348
+ response = await federation.fetch(gatewayRequest("/notes/compatible"), { contextData: void 0 });
349
+ body = await response.json();
350
+ assertEquals(body.id, compatible("/notes/compatible").href);
351
+ });
352
+ await t.step("responds to HEAD without a body", async () => {
353
+ for (const [id, status] of [["signed", 410], ["unsigned", 404]]) {
354
+ const response = await federation.fetch(gatewayRequest(`/notes/${id}`, { method: "HEAD" }), { contextData: void 0 });
355
+ assertEquals(response.status, status, id);
356
+ assertEquals(response.body, null, id);
357
+ if (status === 410) {
358
+ assertEquals(response.headers.get("Content-Type"), PORTABLE_OBJECT_CONTENT_TYPE);
359
+ assertEquals(response.headers.get("Vary"), "Accept");
360
+ }
361
+ }
362
+ });
363
+ await t.step("authorizes before serving a tombstone", async () => {
364
+ for (const id of ["signed", "unsigned"]) {
365
+ let unauthorized = false;
366
+ assertEquals((await federation.fetch(gatewayRequest(`/notes/${id}`, { headers: { "X-Deny": "1" } }), {
367
+ contextData: void 0,
368
+ onUnauthorized() {
369
+ unauthorized = true;
370
+ return new Response("Unauthorized", { status: 401 });
371
+ }
372
+ })).status, 401, id);
373
+ assertEquals(unauthorized, true, id);
374
+ }
375
+ });
376
+ await t.step("does not warn about tombstones with compatible IDs", async () => {
377
+ assertEquals((await captureWarnings(async () => {
378
+ assertEquals((await federation.fetch(gatewayRequest("/notes/compatible"), { contextData: void 0 })).status, 410);
379
+ })).filter((record) => record.category[2] === "object"), []);
380
+ });
381
+ });
382
+ test("Federation.fetch() authorizes portable object requests", async (t) => {
383
+ const federation = createTestFederation();
384
+ let dispatched = 0;
385
+ federation.setObjectDispatcher(Note, "/notes/{id}", (ctx, values) => {
386
+ dispatched++;
387
+ if (values.id === "unsigned") return new Note({ id: ctx.getPortableObjectUri(Note, values) });
388
+ return signedNote(`/notes/${values.id}`);
389
+ }).authorize(async (ctx) => {
390
+ assertEquals(ctx.portableRequest?.authority, did);
391
+ return (await ctx.getSignedKeyOwner())?.id?.href === "https://example.com/person2";
392
+ });
393
+ await t.step("allows a signed request by the audience", async () => {
394
+ const request = await signRequest(gatewayRequest("/notes/1"), rsaPrivateKey3, rsaPublicKey3.id);
395
+ assertEquals((await federation.fetch(request, { contextData: void 0 })).status, 200);
396
+ });
397
+ await t.step("denies an unsigned request", async () => {
398
+ assertEquals((await federation.fetch(gatewayRequest("/notes/1"), { contextData: void 0 })).status, 401);
399
+ });
400
+ await t.step("denies a request signed by someone else", async () => {
401
+ const request = await signRequest(gatewayRequest("/notes/1"), rsaPrivateKey2, rsaPublicKey2.id);
402
+ assertEquals((await federation.fetch(request, { contextData: void 0 })).status, 401);
403
+ });
404
+ await t.step("denies a request whose target was tampered with", async () => {
405
+ const signed = await signRequest(gatewayRequest("/notes/1"), rsaPrivateKey3, rsaPublicKey3.id);
406
+ assertEquals((await federation.fetch(new Request(gatewayUrl("/notes/2"), { headers: signed.headers }), { contextData: void 0 })).status, 401);
407
+ });
408
+ await t.step("authorizes before checking proofs", async () => {
409
+ dispatched = 0;
410
+ assertEquals((await federation.fetch(gatewayRequest("/notes/unsigned"), { contextData: void 0 })).status, 401);
411
+ assertEquals(dispatched, 1);
412
+ });
413
+ });
414
+ test("Federation.fetch() serves only public portable objects by default", async (t) => {
415
+ const federation = createTestFederation();
416
+ const person2 = new URL("https://example.com/person2");
417
+ const note = (id, values = {}) => sign(new Note({
418
+ id: parseIri(`ap+ef61://${did}/objects/${id}`),
419
+ ...values
420
+ }));
421
+ const objects = {
422
+ "to": () => note("to", { to: PUBLIC_COLLECTION }),
423
+ "cc": () => note("cc", {
424
+ to: person2,
425
+ cc: PUBLIC_COLLECTION
426
+ }),
427
+ "bto": () => note("bto", { bto: PUBLIC_COLLECTION }),
428
+ "bcc": () => note("bcc", { bcc: PUBLIC_COLLECTION }),
429
+ "audience": () => note("audience", { audience: PUBLIC_COLLECTION }),
430
+ "unaddressed": () => note("unaddressed"),
431
+ "direct": () => note("direct", { to: person2 }),
432
+ "followers": () => note("followers", { cc: new URL("https://example.com/followers") }),
433
+ "wrong-did": () => sign(new Note({
434
+ id: parseIri(`ap+ef61://${did}/objects/wrong-did`),
435
+ to: person2
436
+ }), otherKeyPair.privateKey, otherKeyId),
437
+ "public-create-of-private-note": async () => await sign(new Create({
438
+ id: parseIri(`ap+ef61://${did}/objects/public-create-of-private-note`),
439
+ actor: parseIri(`ap+ef61://${did}/actor`),
440
+ to: PUBLIC_COLLECTION,
441
+ object: await note("private-note", { to: person2 })
442
+ })),
443
+ "private-create-of-public-note": async () => await sign(new Create({
444
+ id: parseIri(`ap+ef61://${did}/objects/private-create-of-public-note`),
445
+ actor: parseIri(`ap+ef61://${did}/actor`),
446
+ object: await note("public-note", { to: PUBLIC_COLLECTION })
447
+ })),
448
+ "collection": () => Promise.resolve(new Collection({
449
+ id: parseIri(`ap+ef61://${did}/objects/collection`),
450
+ totalItems: 0
451
+ })),
452
+ "public-collection": () => Promise.resolve(new Collection({
453
+ id: parseIri(`ap+ef61://${did}/objects/public-collection`),
454
+ to: PUBLIC_COLLECTION,
455
+ totalItems: 0
456
+ })),
457
+ "tombstone": () => sign(new Tombstone({ id: parseIri(`ap+ef61://${did}/objects/tombstone`) })),
458
+ "public-tombstone": () => sign(new Tombstone({
459
+ id: parseIri(`ap+ef61://${did}/objects/public-tombstone`),
460
+ to: PUBLIC_COLLECTION
461
+ })),
462
+ "actor": () => sign(new Person({
463
+ id: parseIri(`ap+ef61://${did}/objects/actor`),
464
+ inbox: parseIri(`ap+ef61://${did}/objects/actor/inbox`),
465
+ outbox: parseIri(`ap+ef61://${did}/objects/actor/outbox`),
466
+ gateways: [new URL("https://example.com/")]
467
+ })),
468
+ "mutated-audience": async () => {
469
+ const signed = await note("mutated-audience", { to: person2 });
470
+ const object = await Note.fromJsonLd(await signed.toJsonLd({ contextLoader: mockDocumentLoader }), {
471
+ contextLoader: mockDocumentLoader,
472
+ documentLoader: mockDocumentLoader
473
+ });
474
+ object.toIds[0].href = PUBLIC_COLLECTION.href;
475
+ return object;
476
+ }
477
+ };
478
+ let dispatched = 0;
479
+ federation.setObjectDispatcher(Object$1, "/objects/{id}", (ctx, { id }) => {
480
+ dispatched++;
481
+ if (ctx.portableRequest == null) return new Note({
482
+ id: ctx.getObjectUri(Object$1, { id }),
483
+ to: person2
484
+ });
485
+ return objects[id]?.() ?? null;
486
+ });
487
+ for (const [id, status] of globalThis.Object.entries({
488
+ "to": 200,
489
+ "cc": 200,
490
+ "bto": 200,
491
+ "bcc": 200,
492
+ "audience": 200,
493
+ "unaddressed": 404,
494
+ "direct": 404,
495
+ "followers": 404,
496
+ "wrong-did": 404,
497
+ "public-create-of-private-note": 200,
498
+ "private-create-of-public-note": 404,
499
+ "collection": 404,
500
+ "public-collection": 200,
501
+ "tombstone": 404,
502
+ "public-tombstone": 410,
503
+ "actor": 200,
504
+ "mutated-audience": 404
505
+ })) await t.step(`${id} → ${status}`, async () => {
506
+ for (const method of ["GET", "HEAD"]) {
507
+ dispatched = 0;
508
+ let response;
509
+ const records = await captureWarnings(async () => {
510
+ response = await federation.fetch(gatewayRequest(`/objects/${id}`, { method }), { contextData: void 0 });
511
+ });
512
+ assertEquals(response?.status, status, method);
513
+ assertEquals(dispatched, 1);
514
+ assertEquals(response?.headers.get("Cache-Control"), null);
515
+ assertEquals(records.filter((r) => r.level === "error").map((r) => r.rawMessage), []);
516
+ if (method === "HEAD") assertEquals(response?.body, null);
517
+ }
518
+ });
519
+ await t.step("responds to a denied request with onNotFound()", async () => {
520
+ let notFound = false;
521
+ assertEquals((await federation.fetch(gatewayRequest("/objects/direct"), {
522
+ contextData: void 0,
523
+ onNotFound() {
524
+ notFound = true;
525
+ return new Response("Not found", { status: 404 });
526
+ }
527
+ })).status, 404);
528
+ assertEquals(notFound, true);
529
+ });
530
+ await t.step("keeps ordinary object requests unchanged", async () => {
531
+ assertEquals((await federation.fetch(new Request("https://example.com/objects/direct", { headers: { Accept: ACCEPT } }), { contextData: void 0 })).status, 200);
532
+ });
533
+ });
534
+ test("Federation.fetch() serves non-public portable objects to their audience", async (t) => {
535
+ const federation = createTestFederation();
536
+ const person2 = new URL("https://example.com/person2");
537
+ const followers = new URL("https://example.com/users/alice/followers");
538
+ const notes = {
539
+ direct: sign(new Note({
540
+ id: parseIri(`ap+ef61://${did}/notes/direct`),
541
+ to: person2
542
+ })),
543
+ followers: sign(new Note({
544
+ id: parseIri(`ap+ef61://${did}/notes/followers`),
545
+ cc: followers
546
+ })),
547
+ public: signedNote("/notes/public"),
548
+ other: sign(new Note({
549
+ id: parseIri(`ap+ef61://${did}/notes/other`),
550
+ to: new URL("https://example.com/other")
551
+ }))
552
+ };
553
+ federation.setObjectDispatcher(Note, "/notes/{id}", (_ctx, values) => notes[values.id] ?? null).authorize(async (ctx, values) => {
554
+ const note = await notes[values.id];
555
+ return note != null && await ctx.isSignedByAudience(note, { isMember: (addressee, actor) => addressee.href === followers.href && actor.id?.href === person2.href });
556
+ });
557
+ const signed = async (id, key = "key3") => await signRequest(gatewayRequest(`/notes/${id}`), key === "key3" ? rsaPrivateKey3 : rsaPrivateKey2, key === "key3" ? rsaPublicKey3.id : rsaPublicKey2.id);
558
+ await t.step("serves a request signed by an addressee", async () => {
559
+ for (const id of ["direct", "followers"]) {
560
+ const response = await federation.fetch(await signed(id), { contextData: void 0 });
561
+ assertEquals(response.status, 200, id);
562
+ assertEquals(response.headers.get("Cache-Control"), "private", id);
563
+ assertEquals((await response.json()).id, `ap+ef61://${did}/notes/${id}`);
564
+ }
565
+ });
566
+ await t.step("denies a request by anyone else", async () => {
567
+ for (const request of [
568
+ gatewayRequest("/notes/direct"),
569
+ await signed("direct", "key2"),
570
+ await signed("followers", "key2"),
571
+ await signed("other")
572
+ ]) assertEquals((await federation.fetch(request, { contextData: void 0 })).status, 401, request.url);
573
+ });
574
+ await t.step("serves a public object to anyone", async () => {
575
+ const response = await federation.fetch(gatewayRequest("/notes/public"), { contextData: void 0 });
576
+ assertEquals(response.status, 200);
577
+ assertEquals(response.headers.get("Cache-Control"), "private");
578
+ });
579
+ await t.step("serves anything if the predicate allows it", async () => {
580
+ const permissive = createTestFederation();
581
+ permissive.setObjectDispatcher(Note, "/notes/{id}", (_ctx, values) => notes[values.id] ?? null).authorize(() => true);
582
+ const response = await permissive.fetch(gatewayRequest("/notes/direct"), { contextData: void 0 });
583
+ assertEquals(response.status, 200);
584
+ assertEquals(response.headers.get("Cache-Control"), "private");
585
+ });
586
+ });
587
+ test("Federation.fetch() serves portable objects with compatible IDs", async (t) => {
588
+ const federation = createTestFederation();
589
+ const compatible = (path, authority = did, origin = "example.com") => new URL(`https://${origin}/.well-known/apgateway/${authority}${path}`);
590
+ const objects = {
591
+ "compatible": () => sign(new Note({
592
+ to: PUBLIC_COLLECTION,
593
+ id: compatible("/objects/compatible")
594
+ })),
595
+ "other-gateway": () => sign(new Note({
596
+ to: PUBLIC_COLLECTION,
597
+ id: compatible("/objects/other-gateway", did, "other.example")
598
+ })),
599
+ "percent-encoded": () => sign(new Note({
600
+ to: PUBLIC_COLLECTION,
601
+ id: compatible("/objects/percent-encoded", did.replaceAll(":", "%3A"))
602
+ })),
603
+ "other-did": () => sign(new Note({
604
+ to: PUBLIC_COLLECTION,
605
+ id: compatible("/objects/other-did", otherDid)
606
+ }), otherKeyPair.privateKey, otherKeyId),
607
+ "other-path": () => sign(new Note({
608
+ to: PUBLIC_COLLECTION,
609
+ id: compatible("/objects/else")
610
+ })),
611
+ "fragment": () => sign(new Note({
612
+ to: PUBLIC_COLLECTION,
613
+ id: compatible("/objects/fragment#note")
614
+ })),
615
+ "malformed": () => sign(new Note({
616
+ to: PUBLIC_COLLECTION,
617
+ id: new URL(compatible("/objects/malformed").href + "?@gateway=https%3A%2F%2Fexample.com")
618
+ })),
619
+ "ordinary": () => sign(new Note({
620
+ to: PUBLIC_COLLECTION,
621
+ id: new URL("https://example.com/objects/ordinary")
622
+ })),
623
+ "unsigned": () => Promise.resolve(new Note({
624
+ to: PUBLIC_COLLECTION,
625
+ id: compatible("/objects/unsigned")
626
+ })),
627
+ "wrong-did": () => sign(new Note({
628
+ to: PUBLIC_COLLECTION,
629
+ id: compatible("/objects/wrong-did")
630
+ }), otherKeyPair.privateKey, otherKeyId),
631
+ "mutated-id": async () => {
632
+ const signed = await sign(new Note({
633
+ to: PUBLIC_COLLECTION,
634
+ id: compatible("/objects/original")
635
+ }));
636
+ const object = await Note.fromJsonLd(await signed.toJsonLd({ contextLoader: mockDocumentLoader }), {
637
+ contextLoader: mockDocumentLoader,
638
+ documentLoader: mockDocumentLoader
639
+ });
640
+ object.id.pathname = object.id.pathname.replace(/original$/, "mutated-id");
641
+ return object;
642
+ },
643
+ "actor": () => sign(new Person({
644
+ id: compatible("/objects/actor"),
645
+ inbox: compatible("/objects/actor/inbox"),
646
+ outbox: compatible("/objects/actor/outbox"),
647
+ gateways: [new URL("https://example.com/")]
648
+ }))
649
+ };
650
+ federation.setObjectDispatcher(Object$1, "/objects/{id}", (_ctx, { id }) => objects[id]?.() ?? null);
651
+ const expected = {
652
+ "compatible": 200,
653
+ "other-gateway": 200,
654
+ "percent-encoded": 200,
655
+ "other-did": 404,
656
+ "other-path": 404,
657
+ "fragment": 404,
658
+ "malformed": 404,
659
+ "ordinary": 404,
660
+ "unsigned": 500,
661
+ "wrong-did": 500,
662
+ "mutated-id": 404,
663
+ "actor": 200
664
+ };
665
+ for (const host of ["example.com", "other.example"]) for (const [id, status] of globalThis.Object.entries(expected)) await t.step(`${id} on ${host} → ${status}`, async () => {
666
+ const response = await federation.fetch(new Request(`https://${host}/.well-known/apgateway/${did}/objects/${id}`, { headers: { Accept: ACCEPT } }), { contextData: void 0 });
667
+ assertEquals(response.status, status);
668
+ if (status !== 200) return;
669
+ assertEquals(response.headers.get("Content-Type"), PORTABLE_OBJECT_CONTENT_TYPE);
670
+ assertEquals((await response.json()).id, (await objects[id]()).id?.href);
671
+ });
672
+ await t.step("authorizes compatible-ID objects on any gateway", async () => {
673
+ const authorized = createTestFederation();
674
+ authorized.setObjectDispatcher(Note, "/notes/{id}", (_ctx, values) => sign(new Note({ id: compatible(`/notes/${values.id}`) }))).authorize(async (ctx) => {
675
+ return (await ctx.getSignedKeyOwner())?.id?.href === "https://example.com/person2";
676
+ });
677
+ for (const host of ["example.com", "other.example"]) {
678
+ const url = `https://${host}/.well-known/apgateway/${did}/notes/1`;
679
+ const request = new Request(url, { headers: { Accept: ACCEPT } });
680
+ let response = await authorized.fetch(request, { contextData: void 0 });
681
+ assertEquals(response.status, 401, host);
682
+ response = await authorized.fetch(await signRequest(new Request(url, { headers: { Accept: ACCEPT } }), rsaPrivateKey3, rsaPublicKey3.id), { contextData: void 0 });
683
+ assertEquals(response.status, 200, host);
684
+ }
685
+ });
686
+ });
687
+ test("Federation.fetch() lets application routes shadow the gateway", async () => {
688
+ const federation = createTestFederation();
689
+ const paths = [];
690
+ federation.setObjectDispatcher(Note, "/{+path}", (_ctx, { path }) => {
691
+ paths.push(path);
692
+ return null;
693
+ });
694
+ assertEquals((await federation.fetch(gatewayRequest("/notes/1"), { contextData: void 0 })).status, 404);
695
+ assertEquals(paths, [`.well-known/apgateway/${did}/notes/1`]);
696
+ });
697
+ test("Federation.fetch() compares portable IDs with the requested path", async () => {
698
+ const federation = createTestFederation();
699
+ federation.setObjectDispatcher(Note, "/notes/{id}", async (ctx, values) => {
700
+ if (values.id === "a/b") return await sign(new Note({
701
+ id: ctx.getPortableObjectUri(Note, values),
702
+ to: PUBLIC_COLLECTION
703
+ }));
704
+ return await signedNote(`/notes/${values.id}`);
705
+ });
706
+ let response = await federation.fetch(gatewayRequest("/notes/a%2Fb"), { contextData: void 0 });
707
+ assertEquals(response.status, 200);
708
+ assertEquals((await response.json()).id, `ap+ef61://${did}/notes/a%2Fb`);
709
+ const tolerant = createFederation({
710
+ kv: new MemoryKvStore(),
711
+ documentLoaderFactory: () => mockDocumentLoader,
712
+ contextLoaderFactory: () => mockDocumentLoader,
713
+ trailingSlashInsensitive: true
714
+ });
715
+ tolerant.setObjectDispatcher(Note, "/notes/{id}", (_ctx, values) => signedNote(`/notes/${values.id}`));
716
+ response = await tolerant.fetch(gatewayRequest("/notes/1/"), { contextData: void 0 });
717
+ assertEquals(response.status, 404);
718
+ });
719
+ test("RequestContext.portableRequest", async (t) => {
720
+ const federation = createTestFederation();
721
+ const contexts = [];
722
+ federation.setObjectDispatcher(Tombstone, "/tombstones/{id}", (ctx) => {
723
+ contexts.push(ctx);
724
+ return null;
725
+ });
726
+ federation.setObjectDispatcher(Note, "/notes/{id}", async (ctx, values) => {
727
+ contexts.push(ctx);
728
+ ctx.portableRequest.id.pathname = "/notes/other";
729
+ await ctx.getObject(Tombstone, { id: values.id });
730
+ contexts.push(ctx.clone(void 0));
731
+ return await sign(new Note({
732
+ id: ctx.getPortableObjectUri(Note, values),
733
+ to: PUBLIC_COLLECTION
734
+ }));
735
+ });
736
+ assertEquals((await federation.fetch(gatewayRequest("/notes/1"), { contextData: void 0 })).status, 200);
737
+ await t.step("is frozen and returns a new URL each time", () => {
738
+ const [ctx] = contexts;
739
+ const portableRequest = ctx.portableRequest;
740
+ assertEquals(globalThis.Object.isFrozen(portableRequest), true);
741
+ assertEquals(formatIri(portableRequest.id), `ap+ef61://${did}/notes/1`);
742
+ assertInstanceOf(portableRequest.id, URL);
743
+ const first = portableRequest.id;
744
+ const second = portableRequest.id;
745
+ assertEquals(first === second, false);
746
+ });
747
+ await t.step("is kept by derived contexts", () => {
748
+ assertEquals(contexts.length, 3);
749
+ assertStrictEquals(contexts[1].portableRequest, contexts[0].portableRequest);
750
+ assertStrictEquals(contexts[2].portableRequest, contexts[0].portableRequest);
751
+ });
752
+ await t.step("is undefined for ordinary requests", () => {
753
+ assertEquals(federation.createContext(new Request("https://example.com/notes/1"), void 0).portableRequest, void 0);
754
+ });
755
+ });
756
+ test("Context.getPortableObjectUri()", async (t) => {
757
+ const federation = createTestFederation();
758
+ federation.setObjectDispatcher(Note, "/users/{userId}/notes/{noteId}", () => null);
759
+ const ctx = federation.createContext(new URL("https://example.com/"), void 0);
760
+ const values = {
761
+ userId: "alice",
762
+ noteId: "123"
763
+ };
764
+ await t.step("builds a portable ID from the object path", () => {
765
+ const uri = ctx.getPortableObjectUri(Note, values, did);
766
+ assertInstanceOf(uri, URL);
767
+ assertEquals(uri.protocol, "ap+ef61:");
768
+ assertEquals(formatIri(uri), `ap+ef61://${did}/users/alice/notes/123`);
769
+ assertEquals(ctx.getObjectUri(Note, values), new URL("https://example.com/users/alice/notes/123"));
770
+ });
771
+ await t.step("requires a bare DID authority", () => {
772
+ for (const authority of [
773
+ `${did}/extra`,
774
+ `${did}?query`,
775
+ `${did}#fragment`,
776
+ "did:key:",
777
+ "https://example.com",
778
+ ""
779
+ ]) assertThrows(() => ctx.getPortableObjectUri(Note, values, authority), TypeError, void 0, authority);
780
+ });
781
+ await t.step("requires an authority outside gateway requests", () => {
782
+ const requestCtx = federation.createContext(new Request("https://example.com/"), void 0);
783
+ assertThrows(() => requestCtx.getPortableObjectUri(Note, values), TypeError);
784
+ assertEquals(formatIri(requestCtx.getPortableObjectUri(Note, values, did)), `ap+ef61://${did}/users/alice/notes/123`);
785
+ });
786
+ await t.step("validates values like getObjectUri()", () => {
787
+ assertThrows(() => ctx.getPortableObjectUri(Note, { userId: "alice" }, did), TypeError);
788
+ assertThrows(() => ctx.getPortableObjectUri(Person, values, did));
789
+ });
790
+ });
791
+ test("Federation.fetch() serves portable actors through WebFinger", async () => {
792
+ const federation = createTestFederation();
793
+ const getActor = async (ctx, name) => {
794
+ if (name !== "alice") return null;
795
+ return await sign(new Person({
796
+ id: ctx.getPortableObjectUri(Person, { name }, did),
797
+ preferredUsername: name,
798
+ gateways: [new URL("https://example.com")]
799
+ }));
800
+ };
801
+ federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => getActor(ctx, identifier)).mapHandle((_ctx, username) => username);
802
+ federation.setObjectDispatcher(Person, "/actors/{name}", (ctx, values) => ctx.portableRequest?.authority === did ? getActor(ctx, values.name) : null);
803
+ const webFinger = await federation.fetch(new Request("https://example.com/.well-known/webfinger?resource=acct:alice@example.com"), { contextData: void 0 });
804
+ assertEquals(webFinger.status, 200);
805
+ const jrd = await webFinger.json();
806
+ const compatibleId = gatewayUrl("/actors/alice");
807
+ assertEquals(jrd.subject, "acct:alice@example.com");
808
+ assertEquals(jrd.links?.[0], {
809
+ rel: "self",
810
+ href: compatibleId,
811
+ type: "application/activity+json"
812
+ });
813
+ const documentLoader = async (url) => {
814
+ const response = await federation.fetch(new Request(url, { headers: { Accept: ACCEPT } }), { contextData: void 0 });
815
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${url}`);
816
+ return {
817
+ contextUrl: null,
818
+ documentUrl: url,
819
+ document: await response.json()
820
+ };
821
+ };
822
+ const actor = await lookupObject(compatibleId, {
823
+ documentLoader,
824
+ contextLoader: mockDocumentLoader,
825
+ verifyPortableObject: verifyPortableObjectProof
826
+ });
827
+ assertInstanceOf(actor, Person);
828
+ assertEquals(formatIri(actor.id), `ap+ef61://${did}/actors/alice`);
829
+ assertEquals(actor.preferredUsername, "alice");
830
+ const ctx = federation.createContext(new URL("https://example.com/"), void 0);
831
+ assertInstanceOf(await ctx.lookupObject(compatibleId, { documentLoader }), Person);
832
+ assertInstanceOf(await ctx.lookupObject(`ap://${did}/actors/alice`, {
833
+ documentLoader,
834
+ gateways: ["https://example.com"]
835
+ }), Person);
836
+ const unsigned = {
837
+ "@context": "https://www.w3.org/ns/activitystreams",
838
+ id: `ap://${did}/actors/alice`,
839
+ type: "Person"
840
+ };
841
+ assertEquals(await ctx.lookupObject(compatibleId, { documentLoader: (url) => Promise.resolve({
842
+ contextUrl: null,
843
+ documentUrl: url,
844
+ document: unsigned
845
+ }) }), null);
846
+ });
847
+ /**
848
+ * Captures Fedify's warnings logged while `run` executes.
849
+ */
850
+ async function captureWarnings(run) {
851
+ const records = [];
852
+ await reset();
853
+ try {
854
+ await configure({
855
+ sinks: { buffer: (record) => records.push(record) },
856
+ filters: {},
857
+ loggers: [{
858
+ category: ["logtape", "meta"],
859
+ sinks: []
860
+ }, {
861
+ category: [],
862
+ sinks: ["buffer"],
863
+ lowestLevel: "warning"
864
+ }]
865
+ });
866
+ await run();
867
+ } finally {
868
+ await reset();
869
+ }
870
+ return records.filter((record) => record.category[0] === "fedify");
871
+ }
872
+ test("object responses warn about compatible IDs off the owner's first gateway", async () => {
873
+ const federation = createTestFederation();
874
+ const ownerId = parseIri(`ap://${did}/users/alice`);
875
+ const gateways = [new URL("https://primary.example"), new URL("https://secondary.example")];
876
+ const secondary = await sign(new Note({
877
+ id: toCompatibleEf61Id(parseIri(`ap://${did}/notes/secondary`), gateways[1]),
878
+ attribution: ownerId,
879
+ to: PUBLIC_COLLECTION,
880
+ content: "Hello"
881
+ }));
882
+ let actorCalls = 0;
883
+ let actorLookupFails = false;
884
+ federation.setActorDispatcher("/users/{identifier}", (_ctx, identifier) => {
885
+ actorCalls++;
886
+ if (actorLookupFails) throw new Error("Actor storage unavailable");
887
+ return identifier === "alice" ? new Person({
888
+ id: ownerId,
889
+ gateways
890
+ }) : null;
891
+ });
892
+ federation.setObjectDispatcher(Note, "/notes/{id}", async (_ctx, values) => {
893
+ if (values.id === "secondary") return secondary;
894
+ const note = new Note({
895
+ id: values.id === "portable" ? parseIri(`ap://${did}/notes/portable`) : values.id === "ordinary" ? new URL("https://example.com/notes/ordinary") : toCompatibleEf61Id(parseIri(`ap://${did}/notes/${values.id}`), values.id === "secondary" || values.id === "retained" ? gateways[1] : gateways[0]),
896
+ attribution: ownerId,
897
+ to: PUBLIC_COLLECTION,
898
+ content: "Hello"
899
+ });
900
+ if (values.id === "retained") {
901
+ const raw = await (await sign(note)).toJsonLd({
902
+ format: "compact",
903
+ contextLoader: mockDocumentLoader
904
+ });
905
+ const parsed = await Note.fromJsonLd(raw, {
906
+ contextLoader: mockDocumentLoader,
907
+ documentLoader: mockDocumentLoader
908
+ });
909
+ parsed.id.hostname = "primary.example";
910
+ return parsed;
911
+ }
912
+ return note;
913
+ });
914
+ federation.setObjectDispatcher(Create, "/activities/{id}", (_ctx, values) => new Create({
915
+ id: toCompatibleEf61Id(parseIri(`ap://${did}/activities/${values.id}`), gateways[1]),
916
+ actor: ownerId
917
+ }));
918
+ for (const [id, expected] of [
919
+ ["secondary", 1],
920
+ ["retained", 1],
921
+ ["primary", 0],
922
+ ["portable", 0],
923
+ ["ordinary", 0]
924
+ ]) assertEquals((await captureWarnings(async () => {
925
+ const response = await federation.fetch(new Request(`https://example.com/notes/${id}`, { headers: { Accept: ACCEPT } }), { contextData: void 0 });
926
+ assertEquals(response.status, 200);
927
+ const body = await response.json();
928
+ assertEquals(body.type, "Note");
929
+ if (id === "retained") assertEquals(body.id, `https://secondary.example/.well-known/apgateway/${did}/notes/retained`);
930
+ })).filter((r) => r.properties.kind === "object").length, expected, id);
931
+ assertEquals((await captureWarnings(async () => {
932
+ assertEquals((await federation.fetch(new Request("https://example.com/activities/1", { headers: { Accept: ACCEPT } }), { contextData: void 0 })).status, 200);
933
+ })).filter((r) => r.properties.kind === "activity").length, 1);
934
+ for (const id of ["secondary", "retained"]) assertEquals((await captureWarnings(async () => {
935
+ assertEquals((await federation.fetch(gatewayRequest(`/notes/${id}`), { contextData: void 0 })).status, 200);
936
+ })).filter((r) => r.properties.kind === "object").length, 1);
937
+ actorLookupFails = true;
938
+ const expectedBody = await secondary.toJsonLd({
939
+ format: "compact",
940
+ contextLoader: mockDocumentLoader
941
+ });
942
+ for (const request of [new Request("https://example.com/notes/secondary", { headers: { Accept: ACCEPT } }), gatewayRequest("/notes/secondary")]) assertEquals((await captureWarnings(async () => {
943
+ const response = await federation.fetch(request, { contextData: void 0 });
944
+ assertEquals(response.status, 200);
945
+ assertEquals(await response.json(), expectedBody);
946
+ })).filter((r) => r.properties.kind != null), []);
947
+ assertEquals(actorCalls, 8);
948
+ });
949
+ test("Federation.fetch() serves compatible-ID actors", async (t) => {
950
+ const federation = createTestFederation();
951
+ const actorGateways = {
952
+ alice: ["https://example.com"],
953
+ secondary: ["https://primary.example", "https://example.com"],
954
+ nogateway: [],
955
+ malformed: ["https://example.com"]
956
+ };
957
+ const getActor = async (ctx, name) => {
958
+ const gateways = actorGateways[name];
959
+ if (gateways == null) return null;
960
+ const id = toCompatibleEf61Id(ctx.getPortableObjectUri(Person, { name }, did), "https://example.com");
961
+ if (name === "malformed") id.search = "?@gateway=https%3A%2F%2Fexample.com";
962
+ return await sign(new Person({
963
+ id,
964
+ preferredUsername: name,
965
+ inbox: new URL(`${id.href}/inbox`),
966
+ followers: toCompatibleEf61Id(ctx.getPortableFollowersUri(name, did), "https://example.com"),
967
+ gateways: gateways.map((g) => new URL(g))
968
+ }));
969
+ };
970
+ federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => getActor(ctx, identifier)).mapHandle((_ctx, username) => username).mapAlias((_ctx, resource) => {
971
+ const match = resource.href.match(/\/actors\/([^/?#]+)$/);
972
+ return match == null || !resource.href.startsWith(gatewayUrl("/")) ? null : { identifier: match[1] };
973
+ });
974
+ federation.setFollowersDispatcher("/users/{identifier}/followers", () => ({ items: [] }));
975
+ federation.setObjectDispatcher(Person, "/actors/{name}", (ctx, values) => ctx.portableRequest?.authority === did ? getActor(ctx, values.name) : null);
976
+ const compatibleId = gatewayUrl("/actors/alice");
977
+ await t.step("WebFinger", async () => {
978
+ let records = [];
979
+ records = await captureWarnings(async () => {
980
+ for (const resource of ["acct:alice@example.com", compatibleId]) {
981
+ const response = await federation.fetch(new Request(`https://example.com/.well-known/webfinger?resource=${encodeURIComponent(resource)}`), { contextData: void 0 });
982
+ assertEquals(response.status, 200, resource);
983
+ const jrd = await response.json();
984
+ assertEquals(jrd.subject, "acct:alice@example.com", resource);
985
+ assertEquals(jrd.links?.[0], {
986
+ rel: "self",
987
+ href: compatibleId,
988
+ type: "application/activity+json"
989
+ });
990
+ }
991
+ });
992
+ assertEquals(records.map((r) => r.rawMessage), []);
993
+ });
994
+ await t.step("gateway", async () => {
995
+ const response = await federation.fetch(new Request(compatibleId, { headers: { Accept: ACCEPT } }), { contextData: void 0 });
996
+ assertEquals(response.status, 200);
997
+ const json = await response.json();
998
+ assertEquals(json.id, compatibleId);
999
+ const actor = await lookupObject(compatibleId, {
1000
+ documentLoader: (url) => Promise.resolve({
1001
+ contextUrl: null,
1002
+ documentUrl: url,
1003
+ document: json
1004
+ }),
1005
+ contextLoader: mockDocumentLoader,
1006
+ verifyPortableObject: verifyPortableObjectProof
1007
+ });
1008
+ assertInstanceOf(actor, Person);
1009
+ assertEquals(actor.id?.href, compatibleId);
1010
+ });
1011
+ await t.step("warns if the ID is not on the first gateway", async () => {
1012
+ assertEquals((await captureWarnings(async () => {
1013
+ const response = await federation.fetch(new Request("https://example.com/.well-known/webfinger?resource=acct:secondary@example.com"), { contextData: void 0 });
1014
+ assertEquals(response.status, 200);
1015
+ const jrd = await response.json();
1016
+ assertEquals(jrd.subject, "acct:secondary@primary.example");
1017
+ assertEquals(jrd.links?.[0].href, gatewayUrl("/actors/secondary"));
1018
+ })).map((r) => r.properties.gateway), ["https://primary.example"]);
1019
+ });
1020
+ await t.step("warns if the actor has no gateways", async () => {
1021
+ const records = await captureWarnings(async () => {
1022
+ assertEquals((await federation.fetch(new Request("https://example.com/users/nogateway", { headers: { Accept: ACCEPT } }), { contextData: void 0 })).status, 200);
1023
+ });
1024
+ assertEquals(records.map((r) => r.properties.actorId), [gatewayUrl("/actors/nogateway")]);
1025
+ assert(String(records[0].rawMessage).includes("gateways property"));
1026
+ });
1027
+ await t.step("warns if the ID is a malformed compatible identifier", async () => {
1028
+ const records = await captureWarnings(async () => {
1029
+ assertEquals((await federation.fetch(new Request("https://example.com/users/malformed", { headers: { Accept: ACCEPT } }), { contextData: void 0 })).status, 200);
1030
+ });
1031
+ assertEquals(records.length, 1);
1032
+ assert(String(records[0].rawMessage).includes("malformed"));
1033
+ });
1034
+ });
1035
+ test("Federation.fetch() warns about portable actors without valid gateways", async (t) => {
1036
+ const federation = createTestFederation();
1037
+ const actors = {
1038
+ valid: (ctx) => new Person({
1039
+ id: ctx.getPortableObjectUri(Person, { name: "valid" }, did),
1040
+ gateways: [new URL("https://example.com"), new URL("https://other.example/")]
1041
+ }),
1042
+ empty: (ctx) => new Person({ id: ctx.getPortableObjectUri(Person, { name: "empty" }, did) }),
1043
+ mutated: (ctx) => {
1044
+ const actor = new Person({
1045
+ id: ctx.getPortableObjectUri(Person, { name: "mutated" }, did),
1046
+ gateways: [new URL("https://example.com"), new URL("https://other.example/")]
1047
+ });
1048
+ actor.gateways[1].pathname = "/gateway";
1049
+ return actor;
1050
+ },
1051
+ https: (ctx) => new Person({ id: ctx.getActorUri("https") }),
1052
+ tombstone: (ctx) => new Tombstone({ id: ctx.getPortableObjectUri(Person, { name: "tombstone" }, did) })
1053
+ };
1054
+ federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => actors[identifier]?.(ctx) ?? null);
1055
+ federation.setObjectDispatcher(Person, "/actors/{name}", () => null);
1056
+ const expected = {
1057
+ valid: [],
1058
+ empty: [`ap+ef61://${did}/actors/empty`],
1059
+ mutated: [`ap+ef61://${did}/actors/mutated`],
1060
+ https: [],
1061
+ tombstone: []
1062
+ };
1063
+ for (const [identifier, actorIds] of globalThis.Object.entries(expected)) await t.step(identifier, async () => {
1064
+ const actorRecords = (await captureWarnings(async () => {
1065
+ await federation.fetch(new Request(`https://example.com/users/${identifier}`, { headers: { Accept: ACCEPT } }), { contextData: void 0 });
1066
+ })).filter((r) => r.category.join(".") === "fedify.federation.actor");
1067
+ assertEquals(actorRecords.map((r) => r.properties.actorId), actorIds.map((id) => parseIri(id).href));
1068
+ for (const record of actorRecords) assert(String(record.rawMessage).includes("gateways property"));
1069
+ });
1070
+ });
1071
+ //#endregion
1072
+ export {};