@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,1535 @@
1
+ import { Temporal } from "temporal-polyfill";
2
+ import "urlpattern-polyfill";
3
+ globalThis.addEventListener = () => {};
4
+ import { t as esm_default } from "../esm-BHXXx4Wx.mjs";
5
+ import { t as MemoryKvStore } from "../kv-DCHkaJ9-.mjs";
6
+ import { t as assertEquals } from "../assert_equals-Z6IcEbWU.mjs";
7
+ import { t as assertFalse } from "../assert_false-CPdRnc3E.mjs";
8
+ import { t as assertThrows } from "../assert_throws-BYITOvPO.mjs";
9
+ import "../std__assert-DvMDRPes.mjs";
10
+ import { t as assert } from "../assert-D1Eafmd_.mjs";
11
+ import { p as signJsonLd } from "../ld-CzaP-hJl.mjs";
12
+ import { a as signObject } from "../proof-Dxy6ByqI.mjs";
13
+ import { l as signRequest, p as verifyRequestDetailed } from "../http-siciWkmx.mjs";
14
+ import { a as rsaPrivateKey3, c as rsaPublicKey3, i as rsaPrivateKey2, n as ed25519PrivateKey, r as ed25519PublicKey, s as rsaPublicKey2 } from "../keys-DIJNHW-Y.mjs";
15
+ import { _ as coversDelivery, o as createFederation, v as forwardPortableInboxActivity, y as resolvePortableInboxForwardingOptions } from "../middleware-D7_LO-Vs.mjs";
16
+ import { Create, CryptographicKey, Follow, Note, Person, Tombstone } from "@fedify/vocab";
17
+ import { exportDidKey, formatIri, parseIri } from "@fedify/vocab-runtime";
18
+ import { mockDocumentLoader, test } from "@fedify/fixture";
19
+ import { encodeBase64 } from "byte-encodings/base64";
20
+ //#region src/federation/portable-inbox.test.ts
21
+ const did = await exportDidKey(ed25519PublicKey.publicKey);
22
+ const keyId = new URL(`${did}#${did.substring(8)}`);
23
+ const otherKeyPair = await crypto.subtle.generateKey("Ed25519", true, ["sign", "verify"]);
24
+ const otherDid = await exportDidKey(otherKeyPair.publicKey);
25
+ const LOCAL = "https://example.com";
26
+ const GATEWAY2 = "https://example.org";
27
+ const GATEWAY3 = "https://example.net";
28
+ function inboxUrl(authority = did, path = "/users/alice/inbox", origin = LOCAL) {
29
+ return `${origin}/.well-known/apgateway/${authority}${path}`;
30
+ }
31
+ function post(url, body, headers = {}) {
32
+ return new Request(url, {
33
+ method: "POST",
34
+ headers: {
35
+ "Content-Type": "application/activity+json",
36
+ ...headers
37
+ },
38
+ body: JSON.stringify(body)
39
+ });
40
+ }
41
+ var RecordingQueue = class {
42
+ messages = [];
43
+ failFor;
44
+ enqueue(message) {
45
+ if (this.failFor != null && message.type === "outbox" && message.inbox.startsWith(this.failFor)) return Promise.reject(/* @__PURE__ */ new Error("Enqueue failed."));
46
+ this.messages.push(message);
47
+ return Promise.resolve();
48
+ }
49
+ listen() {
50
+ return Promise.resolve();
51
+ }
52
+ get outbox() {
53
+ return this.messages.filter((m) => m.type === "outbox");
54
+ }
55
+ };
56
+ function setup({ gateways = [
57
+ LOCAL,
58
+ GATEWAY2,
59
+ GATEWAY3
60
+ ], kv = new MemoryKvStore(), queue, options = {}, actor, listenerError = false, keyPairs = true, aliceKeyPairs = [], mapPortableActorId, onSharedInboxKey } = {}) {
61
+ const federation = createFederation({
62
+ kv,
63
+ ...options.allowPrivateAddress ? {} : {
64
+ documentLoaderFactory: () => mockDocumentLoader,
65
+ contextLoaderFactory: () => mockDocumentLoader
66
+ },
67
+ manuallyStartQueue: true,
68
+ ...queue == null ? {} : { queue: { outbox: queue } },
69
+ ...options
70
+ });
71
+ const received = [];
72
+ const actorCallbacks = federation.setActorDispatcher("/users/{identifier}", actor ?? ((ctx, identifier) => {
73
+ if (identifier === "ordinary") return new Person({
74
+ id: ctx.getActorUri(identifier),
75
+ inbox: ctx.getInboxUri(identifier)
76
+ });
77
+ if (identifier !== "alice") return null;
78
+ return new Person({
79
+ id: parseIri(`ap+ef61://${did}/users/alice`),
80
+ inbox: ctx.getPortableInboxUri(identifier, did),
81
+ gateways: gateways.map((g) => new URL(g))
82
+ });
83
+ }));
84
+ if (keyPairs) actorCallbacks.setKeyPairsDispatcher((_ctx, identifier) => identifier === "ordinary" ? [{
85
+ privateKey: rsaPrivateKey2,
86
+ publicKey: rsaPublicKey2.publicKey
87
+ }] : identifier === "alice" ? aliceKeyPairs : []);
88
+ if (mapPortableActorId != null) actorCallbacks.mapPortableActorId((_ctx, identifier) => mapPortableActorId(identifier));
89
+ const reports = [];
90
+ const inboxListeners = federation.setInboxListeners("/users/{identifier}/inbox", "/inbox").onRequestFinished((_ctx, report) => {
91
+ reports.push(report);
92
+ });
93
+ if (onSharedInboxKey != null) inboxListeners.setSharedKeyDispatcher(() => {
94
+ onSharedInboxKey();
95
+ return null;
96
+ });
97
+ inboxListeners.on(Follow, (ctx, activity) => {
98
+ received.push({
99
+ activity,
100
+ recipient: ctx.recipient
101
+ });
102
+ if (listenerError) throw new Error("Listener failed.");
103
+ }).on(Create, (ctx, activity) => {
104
+ received.push({
105
+ activity,
106
+ recipient: ctx.recipient
107
+ });
108
+ });
109
+ return {
110
+ federation,
111
+ received,
112
+ kv,
113
+ queue,
114
+ reports
115
+ };
116
+ }
117
+ let activityCounter = 0;
118
+ async function signedFollow(authority = did, privateKey = ed25519PrivateKey, signingKeyId = keyId) {
119
+ return await (await signObject(new Follow({
120
+ id: parseIri(`ap+ef61://${authority}/follows/${++activityCounter}`),
121
+ actor: parseIri(`ap+ef61://${authority}/users/bob`),
122
+ object: parseIri(`ap+ef61://${did}/users/alice`)
123
+ }), privateKey, signingKeyId, { contextLoader: mockDocumentLoader })).toJsonLd({ contextLoader: mockDocumentLoader });
124
+ }
125
+ test("Federation.fetch() delivers to portable inboxes", async (t) => {
126
+ const queue = new RecordingQueue();
127
+ const { federation, received } = setup({ queue });
128
+ const json = await signedFollow();
129
+ await t.step("routes to the existing inbox listeners", async () => {
130
+ assertEquals((await federation.fetch(post(inboxUrl(), json), { contextData: void 0 })).status, 202);
131
+ assertEquals(received.length, 1);
132
+ assertEquals(received[0].recipient, "alice");
133
+ assert(received[0].activity instanceof Follow);
134
+ });
135
+ await t.step("forwards the activity to the other gateways", () => {
136
+ assertEquals(queue.outbox.map((m) => m.inbox).sort(), [inboxUrl(did, "/users/alice/inbox", GATEWAY3), inboxUrl(did, "/users/alice/inbox", GATEWAY2)]);
137
+ for (const message of queue.outbox) {
138
+ assertEquals(message.keys, []);
139
+ assertEquals(message.activity, json);
140
+ assertFalse(message.sharedInbox);
141
+ }
142
+ });
143
+ await t.step("neither forwards nor dispatches duplicates", async () => {
144
+ assertEquals((await federation.fetch(post(inboxUrl(), json), { contextData: void 0 })).status, 202);
145
+ assertEquals(received.length, 1);
146
+ assertEquals(queue.outbox.length, 2);
147
+ });
148
+ await t.step("accepts ap: and percent-encoded DIDs", async () => {
149
+ assertEquals((await federation.fetch(post(inboxUrl(did.replaceAll(":", "%3A")), await signedFollow()), { contextData: void 0 })).status, 202);
150
+ assertEquals(received.length, 2);
151
+ });
152
+ });
153
+ test("Federation.fetch() delivers to portable inboxes without key pairs", async () => {
154
+ const queue = new RecordingQueue();
155
+ const { federation, received } = setup({
156
+ queue,
157
+ keyPairs: false
158
+ });
159
+ assertEquals((await federation.fetch(post(inboxUrl(), await signedFollow()), { contextData: void 0 })).status, 202);
160
+ assertEquals(received.length, 1);
161
+ assertEquals(queue.outbox.length, 2);
162
+ });
163
+ test("Federation.fetch() refuses portable inbox deliveries it does not accept", async (t) => {
164
+ const { federation, received } = setup({ gateways: [GATEWAY2] });
165
+ const json = await signedFollow();
166
+ await t.step("this server is not a gateway of the actor", async () => {
167
+ assertEquals((await federation.fetch(post(inboxUrl(), json), { contextData: void 0 })).status, 404);
168
+ });
169
+ const accepting = setup();
170
+ const cases = [
171
+ ["unknown actor", inboxUrl(did, "/users/unknown/inbox")],
172
+ ["non-portable actor", inboxUrl(did, "/users/ordinary/inbox")],
173
+ ["DID mismatch", inboxUrl(otherDid)],
174
+ ["shared inbox", inboxUrl(did, "/inbox")],
175
+ ["actor path", inboxUrl(did, "/users/alice")],
176
+ ["unregistered path", inboxUrl(did, "/unknown/alice/inbox")]
177
+ ];
178
+ for (const [name, url] of cases) await t.step(name, async () => {
179
+ assertEquals((await accepting.federation.fetch(post(url, json), { contextData: void 0 })).status, 404);
180
+ });
181
+ await t.step("inbox mismatch", async () => {
182
+ const { federation } = setup({ actor: () => new Person({
183
+ id: parseIri(`ap+ef61://${did}/users/alice`),
184
+ inbox: parseIri(`ap+ef61://${did}/users/alice/other-inbox`),
185
+ gateways: [new URL(LOCAL)]
186
+ }) });
187
+ assertEquals((await federation.fetch(post(inboxUrl(), json), { contextData: void 0 })).status, 404);
188
+ });
189
+ await t.step("tombstone", async () => {
190
+ const { federation } = setup({ actor: () => new Tombstone({ id: parseIri(`ap+ef61://${did}/users/alice`) }) });
191
+ assertEquals((await federation.fetch(post(inboxUrl(), json), { contextData: void 0 })).status, 404);
192
+ });
193
+ await t.step("malformed DID", async () => {
194
+ assertEquals((await accepting.federation.fetch(post(`${LOCAL}/.well-known/apgateway/did:key:/users/alice/inbox`, json), { contextData: void 0 })).status, 400);
195
+ });
196
+ await t.step("listeners are not called", () => {
197
+ assertEquals(received.length, 0);
198
+ assertEquals(accepting.received.length, 0);
199
+ });
200
+ });
201
+ test("onRequestFinished retains crypto success when a portable activity's DID mismatches", async () => {
202
+ const { federation, reports, received } = setup();
203
+ const signingKeyId = new URL(`${otherDid}#${otherDid.substring(8)}`);
204
+ const activity = await signObject(new Follow({
205
+ id: parseIri(`ap+ef61://${did}/follows/mismatched-report`),
206
+ actor: parseIri(`ap+ef61://${otherDid}/users/bob`),
207
+ object: parseIri(`ap+ef61://${did}/users/alice`)
208
+ }), otherKeyPair.privateKey, signingKeyId, { contextLoader: mockDocumentLoader });
209
+ assertEquals((await federation.fetch(post(inboxUrl(), await activity.toJsonLd({ contextLoader: mockDocumentLoader })), { contextData: void 0 })).status, 401);
210
+ assertEquals(received.length, 0);
211
+ assertEquals(reports.length, 1);
212
+ const report = reports[0];
213
+ assert(report.authentication.status === "rejected");
214
+ assert(report.authentication.reason.type === "proofPolicy");
215
+ assertEquals(report.authentication.reason.policy, "portableActivity");
216
+ assertEquals(report.authentication.reason.detail?.type, "verificationMethodMismatch");
217
+ assert(report.attempts.some((attempt) => attempt.mechanism === "objectIntegrity" && attempt.checks.some((check) => check.status === "verified")));
218
+ assert(report.attempts.some((attempt) => attempt.status === "rejected" && attempt.reason.type === "proofPolicy" && attempt.reason.reason.type === "verificationMethodMismatch"));
219
+ assertEquals(report.outcome, {
220
+ type: "response",
221
+ status: 401,
222
+ disposition: "rejected",
223
+ reason: "authentication"
224
+ });
225
+ });
226
+ test("Federation.fetch() applies the proof policy to portable inbox deliveries", async (t) => {
227
+ const queue = new RecordingQueue();
228
+ const { federation, received } = setup({ queue });
229
+ await t.step("unsigned portable activity", async () => {
230
+ const json = await new Follow({
231
+ id: parseIri(`ap+ef61://${did}/follows/unsigned`),
232
+ actor: parseIri(`ap+ef61://${did}/users/bob`),
233
+ object: parseIri(`ap+ef61://${did}/users/alice`)
234
+ }).toJsonLd({ contextLoader: mockDocumentLoader });
235
+ assertEquals((await federation.fetch(post(inboxUrl(), json), { contextData: void 0 })).status, 401);
236
+ });
237
+ await t.step("proof made by another DID", async () => {
238
+ const json = await signedFollow(did, otherKeyPair.privateKey, new URL(`${otherDid}#${otherDid.substring(8)}`));
239
+ assertEquals((await federation.fetch(post(inboxUrl(), json), { contextData: void 0 })).status, 401);
240
+ });
241
+ await t.step("unsigned portable object in a signed activity", async () => {
242
+ const json = await (await signObject(new Create({
243
+ id: parseIri(`ap+ef61://${did}/creates/1`),
244
+ actor: parseIri(`ap+ef61://${did}/users/bob`),
245
+ object: new Note({
246
+ id: parseIri(`ap+ef61://${did}/notes/1`),
247
+ content: "Hello"
248
+ })
249
+ }), ed25519PrivateKey, keyId, { contextLoader: mockDocumentLoader })).toJsonLd({ contextLoader: mockDocumentLoader });
250
+ assertEquals((await federation.fetch(post(inboxUrl(), json), { contextData: void 0 })).status, 401);
251
+ });
252
+ await t.step("nothing is dispatched or forwarded", () => {
253
+ assertEquals(received.length, 0);
254
+ assertEquals(queue.outbox.length, 0);
255
+ });
256
+ });
257
+ test("Federation.fetch() does not forward activities authenticated only by HTTP Signatures", async () => {
258
+ const queue = new RecordingQueue();
259
+ const { federation, received } = setup({ queue });
260
+ const activity = {
261
+ "@context": "https://www.w3.org/ns/activitystreams",
262
+ id: "https://example.com/follows/http",
263
+ type: "Follow",
264
+ actor: "https://example.com/person2",
265
+ object: formatIri(parseIri(`ap+ef61://${did}/users/alice`))
266
+ };
267
+ const request = await signRequest(post(inboxUrl(), activity), rsaPrivateKey3, new URL("https://example.com/person2#key3"));
268
+ assertEquals((await federation.fetch(request, { contextData: void 0 })).status, 202);
269
+ assertEquals(received.length, 1);
270
+ assertEquals(queue.outbox.length, 0);
271
+ });
272
+ test("Federation.fetch() forwards activities authenticated by Linked Data Signatures", async () => {
273
+ const queue = new RecordingQueue();
274
+ const { federation, received } = setup({ queue });
275
+ const activity = await signJsonLd({
276
+ "@context": "https://www.w3.org/ns/activitystreams",
277
+ id: "https://example.com/follows/lds",
278
+ type: "Follow",
279
+ actor: "https://example.com/person2",
280
+ object: formatIri(parseIri(`ap+ef61://${did}/users/alice`))
281
+ }, rsaPrivateKey3, rsaPublicKey3.id, { contextLoader: mockDocumentLoader });
282
+ assertEquals((await federation.fetch(post(inboxUrl(), activity), { contextData: void 0 })).status, 202);
283
+ assertEquals(received.length, 1);
284
+ assertEquals(queue.outbox.length, 2);
285
+ for (const message of queue.outbox) assertEquals(message.activity, activity);
286
+ });
287
+ test("Federation.fetch() does not forward portable inbox deliveries without verification", async () => {
288
+ const queue = new RecordingQueue();
289
+ const { federation, received } = setup({
290
+ queue,
291
+ options: { skipSignatureVerification: true }
292
+ });
293
+ assertEquals((await federation.fetch(post(inboxUrl(), await signedFollow()), { contextData: void 0 })).status, 202);
294
+ assertEquals(received.length, 1);
295
+ assertEquals(queue.outbox.length, 0);
296
+ });
297
+ test("Federation.fetch() does not forward portable inbox deliveries whose listener failed", async () => {
298
+ const queue = new RecordingQueue();
299
+ const { federation } = setup({
300
+ queue,
301
+ listenerError: true
302
+ });
303
+ const json = await signedFollow();
304
+ let response = await federation.fetch(post(inboxUrl(), json), { contextData: void 0 });
305
+ assertEquals(response.status, 500);
306
+ assertEquals(queue.outbox.length, 0);
307
+ response = await federation.fetch(post(inboxUrl(), json), { contextData: void 0 });
308
+ assertEquals(response.status, 500);
309
+ assertEquals(queue.outbox.length, 0);
310
+ });
311
+ test("Federation.fetch() forwards each portable inbox delivery at most once", async (t) => {
312
+ await t.step("concurrent deliveries", async () => {
313
+ const queue = new RecordingQueue();
314
+ const { federation } = setup({ queue });
315
+ const json = await signedFollow();
316
+ const responses = await Promise.all([
317
+ federation.fetch(post(inboxUrl(), json), { contextData: void 0 }),
318
+ federation.fetch(post(inboxUrl(), json), { contextData: void 0 }),
319
+ federation.fetch(post(inboxUrl(), json), { contextData: void 0 })
320
+ ]);
321
+ for (const response of responses) assertEquals(response.status, 202);
322
+ assertEquals(queue.outbox.map((m) => m.inbox).sort(), [inboxUrl(did, "/users/alice/inbox", GATEWAY3), inboxUrl(did, "/users/alice/inbox", GATEWAY2)]);
323
+ });
324
+ await t.step("failed hand-offs are not retried", async () => {
325
+ const queue = new RecordingQueue();
326
+ queue.failFor = GATEWAY2;
327
+ const { federation, received } = setup({ queue });
328
+ const json = await signedFollow();
329
+ let response = await federation.fetch(post(inboxUrl(), json), { contextData: void 0 });
330
+ assertEquals(response.status, 202);
331
+ assertEquals(queue.outbox.map((m) => m.inbox), [inboxUrl(did, "/users/alice/inbox", GATEWAY3)]);
332
+ queue.failFor = void 0;
333
+ response = await federation.fetch(post(inboxUrl(), json), { contextData: void 0 });
334
+ assertEquals(response.status, 202);
335
+ assertEquals(queue.outbox.length, 1);
336
+ assertEquals(received.length, 1);
337
+ });
338
+ await t.step("a delivery forwarded back is not forwarded again", async () => {
339
+ const queue = new RecordingQueue();
340
+ const local = setup({
341
+ queue,
342
+ kv: new MemoryKvStore()
343
+ });
344
+ const json = await signedFollow();
345
+ await local.federation.fetch(post(inboxUrl(), json), { contextData: void 0 });
346
+ assertEquals(queue.outbox.length, 2);
347
+ assertEquals((await local.federation.fetch(post(inboxUrl(), json), { contextData: void 0 })).status, 202);
348
+ assertEquals(queue.outbox.length, 2);
349
+ assertEquals(local.received.length, 1);
350
+ });
351
+ await t.step("without KvStore.cas()", async () => {
352
+ const queue = new RecordingQueue();
353
+ const memory = new MemoryKvStore();
354
+ const { federation, received } = setup({
355
+ queue,
356
+ kv: {
357
+ get: (key) => memory.get(key),
358
+ set: (key, value, options) => memory.set(key, value, options),
359
+ delete: (key) => memory.delete(key),
360
+ list: (prefix) => memory.list(prefix)
361
+ }
362
+ });
363
+ assertEquals((await federation.fetch(post(inboxUrl(), await signedFollow()), { contextData: void 0 })).status, 202);
364
+ assertEquals(received.length, 1);
365
+ assertEquals(queue.outbox.length, 0);
366
+ });
367
+ await t.step("configured origin", async () => {
368
+ const queue = new RecordingQueue();
369
+ const { federation } = setup({
370
+ queue,
371
+ gateways: [
372
+ GATEWAY2,
373
+ LOCAL,
374
+ GATEWAY3
375
+ ],
376
+ options: { origin: GATEWAY2 }
377
+ });
378
+ assertEquals((await federation.fetch(post(inboxUrl(), await signedFollow()), { contextData: void 0 })).status, 202);
379
+ assertEquals(queue.outbox.map((m) => m.inbox), [inboxUrl(did, "/users/alice/inbox", GATEWAY3)]);
380
+ });
381
+ await t.step("gateway cap", async () => {
382
+ const queue = new RecordingQueue();
383
+ const gateways = [LOCAL];
384
+ for (let i = 0; i < 15; i++) gateways.push(`https://gw${i}.example.net`);
385
+ const { federation } = setup({
386
+ queue,
387
+ gateways
388
+ });
389
+ assertEquals((await federation.fetch(post(inboxUrl(), await signedFollow()), { contextData: void 0 })).status, 202);
390
+ assertEquals(queue.outbox.length, 10);
391
+ });
392
+ });
393
+ test("FederationOptions.portableInboxForwarding", async (t) => {
394
+ await t.step("maxTargets", async () => {
395
+ const queue = new RecordingQueue();
396
+ const { federation } = setup({
397
+ queue,
398
+ options: { portableInboxForwarding: { maxTargets: 1 } }
399
+ });
400
+ assertEquals((await federation.fetch(post(inboxUrl(), await signedFollow()), { contextData: void 0 })).status, 202);
401
+ assertEquals(queue.outbox.map((m) => m.inbox), [inboxUrl(did, "/users/alice/inbox", GATEWAY2)]);
402
+ });
403
+ await t.step("maxTargets: 0 turns off forwarding", async () => {
404
+ const queue = new RecordingQueue();
405
+ const { federation, received } = setup({
406
+ queue,
407
+ options: { portableInboxForwarding: { maxTargets: 0 } }
408
+ });
409
+ assertEquals((await federation.fetch(post(inboxUrl(), await signedFollow()), { contextData: void 0 })).status, 202);
410
+ assertEquals(received.length, 1);
411
+ assertEquals(queue.outbox.length, 0);
412
+ });
413
+ await t.step("ttl", async () => {
414
+ const queue = new RecordingQueue();
415
+ const memory = new MemoryKvStore();
416
+ const claimTtls = [];
417
+ const { federation } = setup({
418
+ queue,
419
+ kv: {
420
+ get: (key) => memory.get(key),
421
+ set: (key, value, options) => memory.set(key, value, options),
422
+ delete: (key) => memory.delete(key),
423
+ list: (prefix) => memory.list(prefix),
424
+ cas: (key, expected, value, options) => {
425
+ if (key[1] === "portableInboxForwarding") claimTtls.push(options?.ttl);
426
+ return memory.cas(key, expected, value, options);
427
+ }
428
+ },
429
+ options: { portableInboxForwarding: { ttl: Temporal.Duration.from({ days: 7 }) } }
430
+ });
431
+ assertEquals((await federation.fetch(post(inboxUrl(), await signedFollow()), { contextData: void 0 })).status, 202);
432
+ assertEquals(queue.outbox.length, 2);
433
+ assertEquals(claimTtls.map((ttl) => ttl?.total("day")), [7, 7]);
434
+ });
435
+ await t.step("longest deadline", () => {
436
+ assertEquals(resolvePortableInboxForwardingOptions({ deadline: { milliseconds: 2 ** 31 - 1 } }).deadline.total("millisecond"), 2 ** 31 - 1);
437
+ });
438
+ await t.step("defaults", () => {
439
+ const resolved = resolvePortableInboxForwardingOptions();
440
+ assertEquals(resolved.maxTargets, 10);
441
+ assertEquals(resolved.ttl.total("day"), 30);
442
+ assertEquals(resolved.deadline.total("second"), 10);
443
+ });
444
+ await t.step("invalid values", () => {
445
+ for (const portableInboxForwarding of [
446
+ { maxTargets: -1 },
447
+ { maxTargets: 1.5 },
448
+ { maxTargets: NaN },
449
+ { ttl: { seconds: 0 } },
450
+ { ttl: { weeks: 1 } },
451
+ { ttl: { months: 1 } },
452
+ { deadline: { seconds: -1 } },
453
+ { deadline: { years: 1 } },
454
+ { deadline: { milliseconds: 2 ** 31 } }
455
+ ]) assertThrows(() => setup({ options: { portableInboxForwarding } }), RangeError);
456
+ });
457
+ });
458
+ test("Federation.fetch() forwards portable inbox deliveries immediately without a queue", async () => {
459
+ esm_default.spyGlobal();
460
+ const requests = [];
461
+ esm_default.post(`begin:${GATEWAY2}/`, async (callLog) => {
462
+ const request = callLog.request;
463
+ requests.push({
464
+ url: request.url,
465
+ headers: new Headers(request.headers),
466
+ body: await request.json()
467
+ });
468
+ return new Response(null, { status: 202 });
469
+ });
470
+ esm_default.post(`begin:${GATEWAY3}/`, () => {
471
+ throw new TypeError("Connection refused.");
472
+ });
473
+ try {
474
+ const { federation, received } = setup({ options: { allowPrivateAddress: true } });
475
+ const json = await signedFollow();
476
+ let response = await federation.fetch(post(inboxUrl(), json), { contextData: void 0 });
477
+ assertEquals(response.status, 202);
478
+ assertEquals(received.length, 1);
479
+ assertEquals(requests.length, 1);
480
+ assertEquals(requests[0].url, inboxUrl(did, "/users/alice/inbox", GATEWAY2));
481
+ assertFalse(requests[0].headers.has("Signature"));
482
+ assertFalse(requests[0].headers.has("Signature-Input"));
483
+ assertEquals(requests[0].body, json);
484
+ response = await federation.fetch(post(inboxUrl(), json), { contextData: void 0 });
485
+ assertEquals(response.status, 202);
486
+ assertEquals(requests.length, 1);
487
+ assertEquals(esm_default.callHistory.calls(`begin:${GATEWAY3}/`).length, 1);
488
+ } finally {
489
+ esm_default.hardReset();
490
+ }
491
+ });
492
+ test("Federation.fetch() forwards portable inbox deliveries when it enqueues them", async () => {
493
+ const queue = new RecordingQueue();
494
+ const inboxQueue = new RecordingQueue();
495
+ let sharedInboxKeyCalls = 0;
496
+ const { federation, received } = setup({
497
+ queue,
498
+ keyPairs: false,
499
+ onSharedInboxKey: () => sharedInboxKeyCalls++,
500
+ options: { queue: {
501
+ inbox: inboxQueue,
502
+ outbox: queue
503
+ } }
504
+ });
505
+ assertEquals((await federation.fetch(post(inboxUrl(), await signedFollow()), { contextData: void 0 })).status, 202);
506
+ assertEquals(received.length, 0);
507
+ assertEquals(inboxQueue.messages.length, 1);
508
+ assertEquals(inboxQueue.messages[0].type, "inbox");
509
+ assertEquals(queue.outbox.length, 2);
510
+ await federation.processQueuedTask(void 0, inboxQueue.messages[0]);
511
+ assertEquals(received.length, 1);
512
+ assertEquals(received[0].recipient, "alice");
513
+ assertEquals(sharedInboxKeyCalls, 0);
514
+ });
515
+ test("forwardPortableInboxActivity() does not wait for slow gateways", async () => {
516
+ esm_default.spyGlobal();
517
+ let release = () => {};
518
+ const pending = new Promise((resolve) => release = resolve);
519
+ esm_default.post(`begin:${GATEWAY2}/`, async () => {
520
+ await pending;
521
+ throw new TypeError("Connection reset.");
522
+ });
523
+ try {
524
+ const kv = new MemoryKvStore();
525
+ const activityId = parseIri(`ap+ef61://${did}/follows/slow`);
526
+ const parameters = {
527
+ recipient: {
528
+ actorId: parseIri(`ap+ef61://${did}/users/alice`),
529
+ inboxId: parseIri(`ap+ef61://${did}/users/alice/inbox`),
530
+ canonicalInboxId: `ap://${did}/users/alice/inbox`,
531
+ gateways: [new URL(LOCAL), new URL(GATEWAY2)]
532
+ },
533
+ activity: { id: formatIri(activityId) },
534
+ activityId,
535
+ activityType: "https://www.w3.org/ns/activitystreams#Follow",
536
+ excludedOrigins: [LOCAL],
537
+ baseUrl: LOCAL,
538
+ kv,
539
+ kvPrefix: ["_fedify", "portableInboxForwarding"],
540
+ options: resolvePortableInboxForwardingOptions({ deadline: { milliseconds: 10 } }),
541
+ allowPrivateAddress: true
542
+ };
543
+ const started = performance.now();
544
+ const forwarded = await forwardPortableInboxActivity(parameters);
545
+ assert(performance.now() - started < 5e3);
546
+ assertEquals(forwarded.map((u) => u.href), [inboxUrl(did, "/users/alice/inbox", GATEWAY2)]);
547
+ release();
548
+ await new Promise((resolve) => setTimeout(resolve, 10));
549
+ assertEquals(await forwardPortableInboxActivity(parameters), []);
550
+ assertEquals(esm_default.callHistory.calls().length, 1);
551
+ } finally {
552
+ esm_default.hardReset();
553
+ }
554
+ });
555
+ function compatibleAlice(path = "/users/alice", { authority = did, origin = LOCAL, gateways = [
556
+ LOCAL,
557
+ GATEWAY2,
558
+ GATEWAY3
559
+ ] } = {}) {
560
+ return {
561
+ id: new URL(`${origin}/.well-known/apgateway/${authority}${path}`),
562
+ inbox: new URL(inboxUrl(authority, `${path}/inbox`, origin)),
563
+ gateways: gateways.map((g) => new URL(g))
564
+ };
565
+ }
566
+ test("Federation.fetch() delivers to portable inboxes of compatible-ID actors", async (t) => {
567
+ const apAlice = parseIri(`ap+ef61://${did}/users/alice`);
568
+ const apInbox = parseIri(`ap+ef61://${did}/users/alice/inbox`);
569
+ const cases = [
570
+ [
571
+ "compatible actor and inbox",
572
+ compatibleAlice(),
573
+ ""
574
+ ],
575
+ [
576
+ "on another gateway",
577
+ compatibleAlice(void 0, { origin: GATEWAY2 }),
578
+ ""
579
+ ],
580
+ [
581
+ "compatible actor and ap: inbox",
582
+ {
583
+ ...compatibleAlice(),
584
+ inbox: apInbox
585
+ },
586
+ ""
587
+ ],
588
+ [
589
+ "ap: actor and compatible inbox",
590
+ {
591
+ ...compatibleAlice(),
592
+ id: apAlice
593
+ },
594
+ ""
595
+ ],
596
+ [
597
+ "compatible inbox with a query",
598
+ {
599
+ ...compatibleAlice(),
600
+ inbox: new URL(inboxUrl() + "?page=1")
601
+ },
602
+ "?page=1"
603
+ ]
604
+ ];
605
+ for (const [name, alice, query] of cases) await t.step(name, async () => {
606
+ const queue = new RecordingQueue();
607
+ const { federation, received } = setup({
608
+ queue,
609
+ actor: (_ctx, identifier) => identifier === "alice" ? new Person(alice) : null
610
+ });
611
+ assertEquals((await federation.fetch(post(inboxUrl(), await signedFollow()), { contextData: void 0 })).status, 202);
612
+ assertEquals(received.length, 1);
613
+ assertEquals(received[0].recipient, "alice");
614
+ assertEquals(queue.outbox.map((m) => m.inbox).sort(), [inboxUrl(did, "/users/alice/inbox", GATEWAY3) + query, inboxUrl(did, "/users/alice/inbox", GATEWAY2) + query]);
615
+ });
616
+ const json = await signedFollow();
617
+ const rejections = [
618
+ ["DID mismatch", new Person(compatibleAlice(void 0, { authority: otherDid }))],
619
+ ["inbox mismatch", new Person({
620
+ ...compatibleAlice(),
621
+ inbox: new URL(inboxUrl(did, "/users/alice/other-inbox"))
622
+ })],
623
+ ["this server is not a gateway of the actor", new Person(compatibleAlice(void 0, { gateways: [GATEWAY2] }))],
624
+ ["malformed actor ID", new Person({
625
+ ...compatibleAlice(),
626
+ id: new URL(compatibleAlice().id.href + "?@gateway=https%3A%2F%2Fexample.com")
627
+ })],
628
+ ["malformed inbox ID", new Person({
629
+ ...compatibleAlice(),
630
+ inbox: new URL(inboxUrl() + "?@gateway=https%3A%2F%2Fexample.com")
631
+ })]
632
+ ];
633
+ for (const [name, actor] of rejections) await t.step(name, async () => {
634
+ const { federation, received } = setup({ actor: (_ctx, identifier) => identifier === "alice" ? actor : null });
635
+ assertEquals((await federation.fetch(post(inboxUrl(), json), { contextData: void 0 })).status, 404);
636
+ assertEquals(received.length, 0);
637
+ });
638
+ });
639
+ test("forwardPortableInboxActivity() deduplicates compatible activity IDs", async () => {
640
+ const kv = new MemoryKvStore();
641
+ const queue = new RecordingQueue();
642
+ const forward = (activityId) => forwardPortableInboxActivity({
643
+ recipient: {
644
+ actorId: compatibleAlice().id,
645
+ inboxId: parseIri(`ap+ef61://${did}/users/alice/inbox`),
646
+ canonicalInboxId: `ap+ef61://${did}/users/alice/inbox`,
647
+ gateways: [new URL(LOCAL), new URL(GATEWAY2)]
648
+ },
649
+ activity: { id: activityId.href },
650
+ activityId,
651
+ activityType: "https://www.w3.org/ns/activitystreams#Follow",
652
+ excludedOrigins: [LOCAL],
653
+ baseUrl: LOCAL,
654
+ kv,
655
+ kvPrefix: ["_fedify", "portableInboxForwarding"],
656
+ outboxQueue: queue
657
+ });
658
+ const path = `${did}/follows/compatible`;
659
+ assertEquals((await forward(new URL(`${LOCAL}/.well-known/apgateway/${path}`))).length, 1);
660
+ for (const id of [
661
+ new URL(`${GATEWAY2}/.well-known/apgateway/${path}`),
662
+ new URL(`${LOCAL}/.well-known/apgateway/${path}?x=1`),
663
+ parseIri(`ap://${path}`)
664
+ ]) assertEquals(await forward(id), [], id.href);
665
+ assertEquals(queue.outbox.length, 1);
666
+ });
667
+ const aliceId = parseIri(`ap+ef61://${did}/users/alice`);
668
+ const rsaGatewayKeyPair = await crypto.subtle.generateKey({
669
+ name: "RSASSA-PKCS1-v1_5",
670
+ modulusLength: 2048,
671
+ publicExponent: new Uint8Array([
672
+ 1,
673
+ 0,
674
+ 1
675
+ ]),
676
+ hash: "SHA-256"
677
+ }, true, ["sign", "verify"]);
678
+ function compatibleAliceId(origin = LOCAL) {
679
+ return `${origin}/.well-known/apgateway/${did}/users/alice`;
680
+ }
681
+ function mapAlice(identifier) {
682
+ return identifier === "alice" ? aliceId : null;
683
+ }
684
+ function signatureKeyId(headers) {
685
+ return headers.get("Signature-Input")?.match(/keyid="([^"]+)"/)?.[1] ?? headers.get("Signature")?.match(/keyId="([^"]+)"/)?.[1];
686
+ }
687
+ /**
688
+ * Makes a document loader that serves Alice's actor document as the gateway
689
+ * at {@link LOCAL} does: signed by her DID, and embedding the gateway keys in
690
+ * its `assertionMethod`.
691
+ */
692
+ async function createGatewayLoader(keyPairs, origin = LOCAL) {
693
+ const { federation } = setup({
694
+ aliceKeyPairs: keyPairs,
695
+ mapPortableActorId: mapAlice
696
+ });
697
+ const keys = await federation.createContext(new URL(origin)).getActorKeyPairs("alice");
698
+ const document = await (await signObject(new Person({
699
+ id: aliceId,
700
+ inbox: parseIri(`${aliceId.href}/inbox`),
701
+ gateways: [
702
+ new URL(LOCAL),
703
+ new URL(GATEWAY2),
704
+ new URL(GATEWAY3)
705
+ ],
706
+ assertionMethods: keys.map((k) => k.multikey)
707
+ }), ed25519PrivateKey, keyId, { contextLoader: mockDocumentLoader })).toJsonLd({ contextLoader: mockDocumentLoader });
708
+ return (url) => {
709
+ if (url.replace(/#.*$/, "") !== compatibleAliceId(origin)) return mockDocumentLoader(url);
710
+ return Promise.resolve({
711
+ contextUrl: null,
712
+ documentUrl: url,
713
+ document: structuredClone(document)
714
+ });
715
+ };
716
+ }
717
+ function toRequest({ url, headers, body }) {
718
+ return new Request(url, {
719
+ method: "POST",
720
+ headers,
721
+ body
722
+ });
723
+ }
724
+ async function assertSignedBy(request, expectedKeyId, documentLoader) {
725
+ assertEquals(signatureKeyId(request.headers), expectedKeyId);
726
+ const result = await verifyRequestDetailed(toRequest(request), {
727
+ documentLoader,
728
+ contextLoader: mockDocumentLoader
729
+ });
730
+ assert(result.verified, JSON.stringify(result));
731
+ assertEquals(result.key.id?.href, expectedKeyId);
732
+ }
733
+ /** Records the requests made to the gateways other than {@link LOCAL}. */
734
+ function captureGatewayRequests(respond = () => new Response(null, { status: 202 })) {
735
+ const requests = [];
736
+ esm_default.spyGlobal();
737
+ for (const gateway of [GATEWAY2, GATEWAY3]) esm_default.post(`begin:${gateway}/`, async (callLog) => {
738
+ const request = callLog.request;
739
+ const captured = {
740
+ url: request.url,
741
+ headers: new Headers(request.headers),
742
+ body: await request.text()
743
+ };
744
+ requests.push(captured);
745
+ return respond(captured);
746
+ });
747
+ return requests;
748
+ }
749
+ test("Federation.fetch() signs forwarded portable inbox deliveries with gateway keys", async (t) => {
750
+ const keyOrders = [[
751
+ "RSA key first",
752
+ [rsaGatewayKeyPair, otherKeyPair],
753
+ "#main-key"
754
+ ], [
755
+ "Ed25519 key first",
756
+ [otherKeyPair, rsaGatewayKeyPair],
757
+ "#key-2"
758
+ ]];
759
+ for (const [name, aliceKeyPairs, fragment] of keyOrders) await t.step(name, async () => {
760
+ const requests = captureGatewayRequests();
761
+ try {
762
+ const kv = new MemoryKvStore();
763
+ const { federation, received } = setup({
764
+ kv,
765
+ aliceKeyPairs,
766
+ mapPortableActorId: mapAlice,
767
+ options: { allowPrivateAddress: true }
768
+ });
769
+ const json = await signedFollow();
770
+ assertEquals((await federation.fetch(post(inboxUrl(), json), { contextData: void 0 })).status, 202);
771
+ assertEquals(received.length, 1);
772
+ assertEquals(requests.map((r) => r.url).sort(), [inboxUrl(did, "/users/alice/inbox", GATEWAY3), inboxUrl(did, "/users/alice/inbox", GATEWAY2)]);
773
+ const loader = await createGatewayLoader(aliceKeyPairs);
774
+ for (const request of requests) {
775
+ await assertSignedBy(request, compatibleAliceId() + fragment, loader);
776
+ assertEquals(JSON.parse(request.body), json);
777
+ }
778
+ assertEquals(await kv.get([
779
+ "_fedify",
780
+ "httpMessageSignaturesSpec",
781
+ GATEWAY2
782
+ ]), "rfc9421");
783
+ } finally {
784
+ esm_default.hardReset();
785
+ }
786
+ });
787
+ });
788
+ test("Federation.fetch() signs queued forwarded portable inbox deliveries with gateway keys", async () => {
789
+ const queue = new RecordingQueue();
790
+ const aliceKeyPairs = [otherKeyPair, rsaGatewayKeyPair];
791
+ const { federation } = setup({
792
+ queue,
793
+ aliceKeyPairs,
794
+ mapPortableActorId: mapAlice,
795
+ options: { allowPrivateAddress: true }
796
+ });
797
+ assertEquals((await federation.fetch(post(inboxUrl(), await signedFollow()), { contextData: void 0 })).status, 202);
798
+ assertEquals(queue.outbox.length, 2);
799
+ const expectedKeyId = `${compatibleAliceId()}#key-2`;
800
+ for (const message of queue.outbox) {
801
+ assertEquals(message.keys.map((k) => k.keyId), [expectedKeyId]);
802
+ assertEquals(message.keys[0].privateKey.kty, "RSA");
803
+ }
804
+ const requests = captureGatewayRequests();
805
+ try {
806
+ for (const message of queue.outbox) await federation.processQueuedTask(void 0, message);
807
+ } finally {
808
+ esm_default.hardReset();
809
+ }
810
+ assertEquals(requests.length, 2);
811
+ const loader = await createGatewayLoader(aliceKeyPairs);
812
+ for (const request of requests) await assertSignedBy(request, expectedKeyId, loader);
813
+ });
814
+ test("Federation.fetch() forwards portable inbox deliveries unsigned without gateway keys", async (t) => {
815
+ const otherActorId = parseIri(`ap+ef61://${otherDid}/users/alice`);
816
+ const cases = [
817
+ ["without a mapper", { aliceKeyPairs: [rsaGatewayKeyPair] }],
818
+ ["the mapper returns null", {
819
+ aliceKeyPairs: [rsaGatewayKeyPair],
820
+ mapPortableActorId: () => null
821
+ }],
822
+ ["the mapper returns another actor", {
823
+ aliceKeyPairs: [rsaGatewayKeyPair],
824
+ mapPortableActorId: () => otherActorId
825
+ }],
826
+ ["the mapper returns another actor of the same DID", {
827
+ aliceKeyPairs: [rsaGatewayKeyPair],
828
+ mapPortableActorId: () => parseIri(`ap+ef61://${did}/users/bob`)
829
+ }],
830
+ ["without a key pairs dispatcher", {
831
+ keyPairs: false,
832
+ mapPortableActorId: mapAlice
833
+ }],
834
+ ["without key pairs", { mapPortableActorId: mapAlice }],
835
+ ["without an RSA key", {
836
+ aliceKeyPairs: [otherKeyPair],
837
+ mapPortableActorId: mapAlice
838
+ }]
839
+ ];
840
+ for (const [name, options] of cases) await t.step(name, async () => {
841
+ const queue = new RecordingQueue();
842
+ const { federation, received } = setup({
843
+ ...options,
844
+ queue
845
+ });
846
+ assertEquals((await federation.fetch(post(inboxUrl(), await signedFollow()), { contextData: void 0 })).status, 202);
847
+ assertEquals(received.length, 1);
848
+ assertEquals(queue.outbox.length, 2);
849
+ for (const message of queue.outbox) assertEquals(message.keys, []);
850
+ });
851
+ });
852
+ test("Federation.fetch() signs forwarded portable inbox deliveries if the mapper returns an equivalent ID", async (t) => {
853
+ const ids = [
854
+ ["ap: URI", parseIri(`ap://${did}/users/alice`)],
855
+ ["percent-encoded DID", parseIri(`ap+ef61://${did.replaceAll(":", "%3A")}/users/alice`)],
856
+ ["compatible identifier", new URL(compatibleAliceId(GATEWAY2))]
857
+ ];
858
+ for (const [name, id] of ids) await t.step(name, async () => {
859
+ const queue = new RecordingQueue();
860
+ const { federation } = setup({
861
+ queue,
862
+ aliceKeyPairs: [rsaGatewayKeyPair],
863
+ mapPortableActorId: (identifier) => identifier === "alice" ? id : null
864
+ });
865
+ assertEquals((await federation.fetch(post(inboxUrl(), await signedFollow()), { contextData: void 0 })).status, 202);
866
+ assertEquals(queue.outbox.length, 2);
867
+ for (const message of queue.outbox) assertEquals(message.keys.map((k) => k.keyId), [`${compatibleAliceId()}#main-key`]);
868
+ });
869
+ });
870
+ test("Federation.fetch() signs forwarded portable inbox deliveries with the gateway keys of the canonical origin", async () => {
871
+ const queue = new RecordingQueue();
872
+ const { federation } = setup({
873
+ queue,
874
+ gateways: [
875
+ GATEWAY2,
876
+ LOCAL,
877
+ GATEWAY3
878
+ ],
879
+ aliceKeyPairs: [rsaGatewayKeyPair],
880
+ mapPortableActorId: mapAlice,
881
+ options: { origin: GATEWAY2 }
882
+ });
883
+ assertEquals((await federation.fetch(post(inboxUrl(), await signedFollow()), { contextData: void 0 })).status, 202);
884
+ assertEquals(queue.outbox.length, 1);
885
+ assertEquals(queue.outbox[0].keys.map((k) => k.keyId), [`${compatibleAliceId(GATEWAY2)}#main-key`]);
886
+ });
887
+ test("Federation.fetch() double-knocks forwarded portable inbox deliveries", async (t) => {
888
+ await t.step("firstKnock", async () => {
889
+ const requests = captureGatewayRequests();
890
+ try {
891
+ const { federation } = setup({
892
+ aliceKeyPairs: [rsaGatewayKeyPair],
893
+ mapPortableActorId: mapAlice,
894
+ options: {
895
+ allowPrivateAddress: true,
896
+ firstKnock: "draft-cavage-http-signatures-12"
897
+ }
898
+ });
899
+ assertEquals((await federation.fetch(post(inboxUrl(), await signedFollow()), { contextData: void 0 })).status, 202);
900
+ assertEquals(requests.length, 2);
901
+ for (const request of requests) {
902
+ assert(request.headers.has("Signature"));
903
+ assertFalse(request.headers.has("Signature-Input"));
904
+ }
905
+ } finally {
906
+ esm_default.hardReset();
907
+ }
908
+ });
909
+ await t.step("falls back to the other spec", async () => {
910
+ const requests = captureGatewayRequests((request) => new Response(null, { status: request.headers.has("Signature-Input") ? 401 : 202 }));
911
+ try {
912
+ const kv = new MemoryKvStore();
913
+ const { federation } = setup({
914
+ kv,
915
+ aliceKeyPairs: [rsaGatewayKeyPair],
916
+ mapPortableActorId: mapAlice,
917
+ options: { allowPrivateAddress: true }
918
+ });
919
+ assertEquals((await federation.fetch(post(inboxUrl(), await signedFollow()), { contextData: void 0 })).status, 202);
920
+ assertEquals(requests.length, 4);
921
+ const loader = await createGatewayLoader([rsaGatewayKeyPair]);
922
+ for (const request of requests) {
923
+ if (request.headers.has("Signature-Input")) continue;
924
+ await assertSignedBy(request, `${compatibleAliceId()}#main-key`, loader);
925
+ }
926
+ assertEquals(await kv.get([
927
+ "_fedify",
928
+ "httpMessageSignaturesSpec",
929
+ GATEWAY2
930
+ ]), "draft-cavage-http-signatures-12");
931
+ } finally {
932
+ esm_default.hardReset();
933
+ }
934
+ });
935
+ });
936
+ test("A Fedify gateway accepts signed forwarded portable inbox deliveries", async () => {
937
+ const requests = captureGatewayRequests();
938
+ let forwarded;
939
+ try {
940
+ const { federation } = setup({
941
+ aliceKeyPairs: [rsaGatewayKeyPair],
942
+ mapPortableActorId: mapAlice,
943
+ options: { allowPrivateAddress: true }
944
+ });
945
+ assertEquals((await federation.fetch(post(inboxUrl(), await signedFollow()), { contextData: void 0 })).status, 202);
946
+ forwarded = requests.find((r) => r.url.startsWith(`${GATEWAY2}/`));
947
+ } finally {
948
+ esm_default.hardReset();
949
+ }
950
+ await assertSignedBy(forwarded, `${compatibleAliceId()}#main-key`, await createGatewayLoader([rsaGatewayKeyPair]));
951
+ const queue = new RecordingQueue();
952
+ const loader = await createGatewayLoader([rsaGatewayKeyPair]);
953
+ const { federation, received } = setup({
954
+ queue,
955
+ options: {
956
+ origin: GATEWAY2,
957
+ documentLoaderFactory: () => loader
958
+ }
959
+ });
960
+ assertEquals((await federation.fetch(toRequest(forwarded), { contextData: void 0 })).status, 202);
961
+ assertEquals(received.length, 1);
962
+ assertEquals(received[0].recipient, "alice");
963
+ assertEquals(queue.outbox.map((m) => m.inbox), [inboxUrl(did, "/users/alice/inbox", GATEWAY3)]);
964
+ });
965
+ /** Records the URLs that a document loader is asked for. */
966
+ function recordLoader(loader) {
967
+ const urls = [];
968
+ return {
969
+ urls,
970
+ loader: (url, options) => {
971
+ urls.push(url);
972
+ return loader(url, options);
973
+ }
974
+ };
975
+ }
976
+ const gateway2KeyId = new URL(`${compatibleAliceId(GATEWAY2)}#main-key`);
977
+ /** Signs a delivery to Alice's inbox on {@link LOCAL}. */
978
+ async function signDelivery(body, { keyId = gateway2KeyId, privateKey = rsaGatewayKeyPair.privateKey, spec = "draft-cavage-http-signatures-12", components, label, headers } = {}) {
979
+ const signed = await signRequest(post(inboxUrl(), body, headers), privateKey, keyId, {
980
+ spec,
981
+ ...components == null ? {} : { body: null },
982
+ rfc9421: {
983
+ label,
984
+ components: components?.map((value) => ({
985
+ value,
986
+ params: {}
987
+ }))
988
+ }
989
+ });
990
+ return new Request(signed.url, {
991
+ method: "POST",
992
+ headers: signed.headers,
993
+ body: JSON.stringify(body)
994
+ });
995
+ }
996
+ async function deliverToGateway(request, { loader, kv, maxTargets, maxHttpSignatures }) {
997
+ const queue = new RecordingQueue();
998
+ const { federation, received, kv: store } = setup({
999
+ queue,
1000
+ kv,
1001
+ options: {
1002
+ documentLoaderFactory: () => loader,
1003
+ maxHttpSignatures,
1004
+ ...maxTargets == null ? {} : { portableInboxForwarding: { maxTargets } }
1005
+ }
1006
+ });
1007
+ const response = await federation.fetch(request, { contextData: void 0 });
1008
+ assert(received.length <= 1);
1009
+ return {
1010
+ response,
1011
+ queue,
1012
+ kv: store
1013
+ };
1014
+ }
1015
+ function forwardedGateways(queue) {
1016
+ return queue.outbox.map((m) => new URL(m.inbox).origin).sort();
1017
+ }
1018
+ test("Federation.fetch() does not forward portable inbox deliveries back to the gateway that forwarded them", async (t) => {
1019
+ const gatewayLoader = await createGatewayLoader([rsaGatewayKeyPair], GATEWAY2);
1020
+ for (const spec of ["draft-cavage-http-signatures-12", "rfc9421"]) await t.step(spec, async () => {
1021
+ const { loader, urls } = recordLoader(gatewayLoader);
1022
+ const json = await signedFollow();
1023
+ const { response, queue, kv } = await deliverToGateway(await signDelivery(json, { spec }), { loader });
1024
+ assertEquals(response.status, 202);
1025
+ assertEquals(forwardedGateways(queue), [GATEWAY3]);
1026
+ assertEquals(urls.filter((u) => u.startsWith(GATEWAY2)), [gateway2KeyId.href]);
1027
+ const { queue: queue2 } = await deliverToGateway(post(inboxUrl(), json), {
1028
+ loader,
1029
+ kv
1030
+ });
1031
+ assertEquals(queue2.outbox, []);
1032
+ });
1033
+ await t.step("identified before the targets are capped", async () => {
1034
+ const { queue } = await deliverToGateway(await signDelivery(await signedFollow()), {
1035
+ loader: gatewayLoader,
1036
+ maxTargets: 1
1037
+ });
1038
+ assertEquals(forwardedGateways(queue), [GATEWAY3]);
1039
+ });
1040
+ await t.step("with an unrelated signature first", async () => {
1041
+ const { loader, urls } = recordLoader(gatewayLoader);
1042
+ const json = await signedFollow();
1043
+ const unrelated = await signDelivery(json, {
1044
+ spec: "rfc9421",
1045
+ keyId: new URL("https://unrelated.example/key"),
1046
+ privateKey: rsaPrivateKey3,
1047
+ label: "sig1"
1048
+ });
1049
+ const gateway = await signDelivery(json, {
1050
+ spec: "rfc9421",
1051
+ label: "sig2"
1052
+ });
1053
+ const headers = new Headers(gateway.headers);
1054
+ for (const name of ["Signature-Input", "Signature"]) headers.set(name, `${unrelated.headers.get(name)}, ${gateway.headers.get(name)}`);
1055
+ const { queue } = await deliverToGateway(new Request(gateway.url, {
1056
+ method: "POST",
1057
+ headers,
1058
+ body: JSON.stringify(json)
1059
+ }), { loader });
1060
+ assertEquals(forwardedGateways(queue), [GATEWAY3]);
1061
+ assertFalse(urls.some((u) => u.startsWith("https://unrelated.example/")));
1062
+ });
1063
+ await t.step("with the gateway's signature beyond maxHttpSignatures", async () => {
1064
+ const json = await signedFollow();
1065
+ const unrelated = await signDelivery(json, {
1066
+ spec: "rfc9421",
1067
+ keyId: new URL("https://unrelated.example/key"),
1068
+ privateKey: rsaPrivateKey3,
1069
+ label: "sig1"
1070
+ });
1071
+ const gateway = await signDelivery(json, {
1072
+ spec: "rfc9421",
1073
+ label: "sig2"
1074
+ });
1075
+ const headers = new Headers(gateway.headers);
1076
+ for (const name of ["Signature-Input", "Signature"]) headers.set(name, `${unrelated.headers.get(name)}, ${gateway.headers.get(name)}`);
1077
+ const request = () => new Request(gateway.url, {
1078
+ method: "POST",
1079
+ headers,
1080
+ body: JSON.stringify(json)
1081
+ });
1082
+ for (const [maxHttpSignatures, expected] of [[2, [GATEWAY3]], [1, [GATEWAY2, GATEWAY3]]]) {
1083
+ const { loader, urls } = recordLoader(gatewayLoader);
1084
+ const { queue } = await deliverToGateway(request(), {
1085
+ loader,
1086
+ maxHttpSignatures
1087
+ });
1088
+ assertEquals(forwardedGateways(queue), [...expected].sort());
1089
+ assertEquals(urls.some((u) => u.startsWith(GATEWAY2)), maxHttpSignatures > 1);
1090
+ }
1091
+ });
1092
+ await t.step("activity authenticated by a Linked Data Signature", async () => {
1093
+ const { queue } = await deliverToGateway(await signDelivery(await signJsonLd({
1094
+ "@context": "https://www.w3.org/ns/activitystreams",
1095
+ id: `https://example.com/follows/lds-${++activityCounter}`,
1096
+ type: "Follow",
1097
+ actor: "https://example.com/person2",
1098
+ object: formatIri(aliceId)
1099
+ }, rsaPrivateKey3, rsaPublicKey3.id, { contextLoader: mockDocumentLoader })), { loader: gatewayLoader });
1100
+ assertEquals(forwardedGateways(queue), [GATEWAY3]);
1101
+ });
1102
+ });
1103
+ test("Federation.fetch() forwards portable inbox deliveries whose sending gateway is not identified", async (t) => {
1104
+ const gatewayLoader = await createGatewayLoader([rsaGatewayKeyPair], GATEWAY2);
1105
+ const bobKeyId = new URL(`${GATEWAY2}/.well-known/apgateway/${did}/users/bob#main-key`);
1106
+ const compatibleId = new URL(compatibleAliceId(GATEWAY2));
1107
+ const unsignedActor = await new Person({
1108
+ id: compatibleId,
1109
+ inbox: new URL(`${compatibleId.href}/inbox`),
1110
+ publicKey: rsaPublicKey2.clone({
1111
+ id: gateway2KeyId,
1112
+ owner: compatibleId
1113
+ })
1114
+ }).toJsonLd({ contextLoader: mockDocumentLoader });
1115
+ const unsignedLoader = (url) => {
1116
+ if (url.replace(/#.*$/, "") !== compatibleId.href) return mockDocumentLoader(url);
1117
+ return Promise.resolve({
1118
+ contextUrl: null,
1119
+ documentUrl: url,
1120
+ document: structuredClone(unsignedActor)
1121
+ });
1122
+ };
1123
+ const json = await signedFollow();
1124
+ const contentDigest = `sha-256=:${encodeBase64(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(json))))}:`;
1125
+ const cases = [
1126
+ {
1127
+ name: "unsigned",
1128
+ unsigned: true,
1129
+ fetches: false
1130
+ },
1131
+ {
1132
+ name: "invalid signature",
1133
+ sign: { privateKey: rsaPrivateKey2 },
1134
+ fetches: true
1135
+ },
1136
+ {
1137
+ name: "unknown key",
1138
+ loader: mockDocumentLoader,
1139
+ fetches: true
1140
+ },
1141
+ {
1142
+ name: "gateway not in gateways",
1143
+ sign: { keyId: new URL(`https://unlisted.example/.well-known/apgateway/${did}/users/alice#main-key`) },
1144
+ fetches: false
1145
+ },
1146
+ {
1147
+ name: "key of another actor",
1148
+ sign: { keyId: bobKeyId },
1149
+ fetches: false
1150
+ },
1151
+ {
1152
+ name: "key at an ap: key ID",
1153
+ sign: { keyId: parseIri(`ap+ef61://${did}/users/alice?@gateway=${encodeURIComponent(GATEWAY2)}#main-key`) },
1154
+ fetches: false
1155
+ },
1156
+ {
1157
+ name: "content-digest not covered",
1158
+ sign: {
1159
+ spec: "rfc9421",
1160
+ components: ["@method", "@target-uri"]
1161
+ },
1162
+ fetches: false
1163
+ },
1164
+ {
1165
+ name: "target URI not covered",
1166
+ sign: {
1167
+ spec: "rfc9421",
1168
+ components: [
1169
+ "@method",
1170
+ "@authority",
1171
+ "@path",
1172
+ "content-digest"
1173
+ ],
1174
+ headers: { "Content-Digest": contentDigest }
1175
+ },
1176
+ fetches: false
1177
+ },
1178
+ {
1179
+ name: "non-canonical spelling of content-digest",
1180
+ sign: {
1181
+ spec: "rfc9421",
1182
+ components: [
1183
+ "@method",
1184
+ "@target-uri",
1185
+ "Content-Digest"
1186
+ ],
1187
+ headers: { "Content-Digest": "sha-256=:AAAA:" }
1188
+ },
1189
+ fetches: false
1190
+ },
1191
+ {
1192
+ name: "key not vouched for by the DID",
1193
+ sign: { privateKey: rsaPrivateKey2 },
1194
+ loader: unsignedLoader,
1195
+ fetches: true
1196
+ }
1197
+ ];
1198
+ for (const { name, sign, unsigned, loader, fetches } of cases) await t.step(name, async () => {
1199
+ const recorded = recordLoader(loader ?? gatewayLoader);
1200
+ const { response, queue } = await deliverToGateway(unsigned ? post(inboxUrl(), json) : await signDelivery(json, sign), { loader: recorded.loader });
1201
+ assertEquals(response.status, 202);
1202
+ assertEquals(forwardedGateways(queue), [GATEWAY3, GATEWAY2].sort());
1203
+ assertEquals(recorded.urls.some((u) => u.includes("/.well-known/apgateway/")), fetches);
1204
+ });
1205
+ await t.step("already forwarded to the gateway", async () => {
1206
+ const recorded = recordLoader(gatewayLoader);
1207
+ const kv = new MemoryKvStore();
1208
+ const json = await signedFollow();
1209
+ const { queue } = await deliverToGateway(post(inboxUrl(), json), {
1210
+ loader: recorded.loader,
1211
+ kv
1212
+ });
1213
+ assertEquals(forwardedGateways(queue), [GATEWAY3, GATEWAY2].sort());
1214
+ const { queue: queue2 } = await deliverToGateway(await signDelivery(json), {
1215
+ loader: recorded.loader,
1216
+ kv
1217
+ });
1218
+ assertEquals(queue2.outbox, []);
1219
+ assertEquals(recorded.urls.filter((u) => u.startsWith(GATEWAY2)), []);
1220
+ });
1221
+ });
1222
+ function forwardingParameters(overrides = {}) {
1223
+ const activityId = parseIri(`ap+ef61://${did}/follows/${++activityCounter}`);
1224
+ return {
1225
+ recipient: {
1226
+ actorId: aliceId,
1227
+ inboxId: parseIri(`ap+ef61://${did}/users/alice/inbox`),
1228
+ canonicalInboxId: `ap://${did}/users/alice/inbox`,
1229
+ gateways: [new URL(LOCAL), new URL(GATEWAY2)]
1230
+ },
1231
+ activity: { id: formatIri(activityId) },
1232
+ activityId,
1233
+ activityType: "https://www.w3.org/ns/activitystreams#Follow",
1234
+ excludedOrigins: [LOCAL],
1235
+ baseUrl: LOCAL,
1236
+ kv: new MemoryKvStore(),
1237
+ kvPrefix: ["_fedify", "portableInboxForwarding"],
1238
+ allowPrivateAddress: true,
1239
+ ...overrides
1240
+ };
1241
+ }
1242
+ const gatewayKey = {
1243
+ keyId: new URL(`${compatibleAliceId()}#main-key`),
1244
+ privateKey: rsaGatewayKeyPair.privateKey
1245
+ };
1246
+ test("forwardPortableInboxActivity() gets keys only when it forwards", async () => {
1247
+ let calls = 0;
1248
+ const parameters = forwardingParameters({
1249
+ outboxQueue: new RecordingQueue(),
1250
+ getKeys: () => {
1251
+ calls++;
1252
+ return Promise.resolve([gatewayKey]);
1253
+ }
1254
+ });
1255
+ assertEquals((await forwardPortableInboxActivity(parameters)).length, 1);
1256
+ assertEquals(calls, 1);
1257
+ assertEquals(await forwardPortableInboxActivity(parameters), []);
1258
+ assertEquals(calls, 1);
1259
+ });
1260
+ test("forwardPortableInboxActivity() forwards unsigned if it cannot get keys", async (t) => {
1261
+ const nonExtractable = await crypto.subtle.generateKey({
1262
+ name: "RSASSA-PKCS1-v1_5",
1263
+ modulusLength: 2048,
1264
+ publicExponent: new Uint8Array([
1265
+ 1,
1266
+ 0,
1267
+ 1
1268
+ ]),
1269
+ hash: "SHA-256"
1270
+ }, false, ["sign", "verify"]);
1271
+ const getKeysCases = [["getKeys() rejects", () => Promise.reject(/* @__PURE__ */ new Error("Unavailable."))], ["the key is not extractable", () => Promise.resolve([{
1272
+ keyId: gatewayKey.keyId,
1273
+ privateKey: nonExtractable.privateKey
1274
+ }])]];
1275
+ for (const [name, getKeys] of getKeysCases) {
1276
+ await t.step(`${name} (queued)`, async () => {
1277
+ const queue = new RecordingQueue();
1278
+ assertEquals((await forwardPortableInboxActivity(forwardingParameters({
1279
+ outboxQueue: queue,
1280
+ getKeys
1281
+ }))).length, 1);
1282
+ assertEquals(queue.outbox.length, 1);
1283
+ assertEquals(queue.outbox[0].keys, []);
1284
+ });
1285
+ await t.step(`${name} (immediate)`, async () => {
1286
+ const requests = captureGatewayRequests();
1287
+ try {
1288
+ assertEquals((await forwardPortableInboxActivity(forwardingParameters({ getKeys }))).length, 1);
1289
+ } finally {
1290
+ esm_default.hardReset();
1291
+ }
1292
+ assertEquals(requests.length, 1);
1293
+ assertEquals(signatureKeyId(requests[0].headers), void 0);
1294
+ });
1295
+ }
1296
+ });
1297
+ test("forwardPortableInboxActivity() does not wait for slow keys", async () => {
1298
+ let release = () => {};
1299
+ const pending = new Promise((resolve) => release = resolve);
1300
+ const requests = captureGatewayRequests();
1301
+ try {
1302
+ const started = performance.now();
1303
+ const forwarded = await forwardPortableInboxActivity(forwardingParameters({
1304
+ options: resolvePortableInboxForwardingOptions({ deadline: { milliseconds: 10 } }),
1305
+ getKeys: async () => {
1306
+ await pending;
1307
+ throw new Error("Unavailable.");
1308
+ }
1309
+ }));
1310
+ assert(performance.now() - started < 5e3);
1311
+ assertEquals(forwarded.length, 1);
1312
+ assertEquals(requests.length, 0);
1313
+ release();
1314
+ for (let i = 0; i < 100 && requests.length < 1; i++) await new Promise((resolve) => setTimeout(resolve, 10));
1315
+ assertEquals(requests.length, 1);
1316
+ assertEquals(signatureKeyId(requests[0].headers), void 0);
1317
+ } finally {
1318
+ esm_default.hardReset();
1319
+ }
1320
+ });
1321
+ function gatewaySignature(keyId, verify) {
1322
+ return {
1323
+ keyId,
1324
+ coversDelivery: true,
1325
+ verify
1326
+ };
1327
+ }
1328
+ function gatewayKeyOf(keyId, owner = aliceId) {
1329
+ return new CryptographicKey({
1330
+ id: keyId,
1331
+ owner,
1332
+ publicKey: rsaGatewayKeyPair.publicKey
1333
+ });
1334
+ }
1335
+ test("forwardPortableInboxActivity() skips the gateway that forwarded the activity", async (t) => {
1336
+ const recipient = {
1337
+ actorId: parseIri(`ap://${did}/users/alice`),
1338
+ inboxId: parseIri(`ap+ef61://${did}/users/alice/inbox`),
1339
+ canonicalInboxId: `ap://${did}/users/alice/inbox`,
1340
+ gateways: [
1341
+ new URL(LOCAL),
1342
+ new URL(GATEWAY3),
1343
+ new URL(GATEWAY2)
1344
+ ]
1345
+ };
1346
+ const owners = [["identified", aliceId], ["identified by a key of a compatible identifier", new URL(compatibleAliceId())]];
1347
+ for (const [name, owner] of owners) await t.step(name, async () => {
1348
+ const queue = new RecordingQueue();
1349
+ assertEquals((await forwardPortableInboxActivity(forwardingParameters({
1350
+ recipient,
1351
+ outboxQueue: queue,
1352
+ signatures: [gatewaySignature(gateway2KeyId, () => Promise.resolve(gatewayKeyOf(gateway2KeyId, owner)))]
1353
+ }))).map((u) => u.origin), [GATEWAY3]);
1354
+ });
1355
+ const notIdentified = [
1356
+ ["verify() rejects", [gatewaySignature(gateway2KeyId, () => Promise.reject(/* @__PURE__ */ new Error("Failed.")))]],
1357
+ ["verify() throws", [gatewaySignature(gateway2KeyId, () => {
1358
+ throw new Error("Failed.");
1359
+ })]],
1360
+ ["not verified", [gatewaySignature(gateway2KeyId, () => Promise.resolve(null))]],
1361
+ ["the key is not owned by a portable actor", [gatewaySignature(gateway2KeyId, () => Promise.resolve(gatewayKeyOf(gateway2KeyId, new URL(`${GATEWAY2}/users/alice`))))]],
1362
+ ["the key is owned by another actor", [gatewaySignature(gateway2KeyId, () => Promise.resolve(gatewayKeyOf(gateway2KeyId, parseIri(`ap://${did}/users/bob`))))]],
1363
+ ["another key is verified", [gatewaySignature(gateway2KeyId, () => Promise.resolve(gatewayKeyOf(new URL(`${compatibleAliceId(GATEWAY3)}#main-key`))))]],
1364
+ ["the signature does not cover the delivery", [{
1365
+ ...gatewaySignature(gateway2KeyId, () => Promise.resolve(gatewayKeyOf(gateway2KeyId))),
1366
+ coversDelivery: false
1367
+ }]]
1368
+ ];
1369
+ for (const [name, signatures] of notIdentified) await t.step(name, async () => {
1370
+ const queue = new RecordingQueue();
1371
+ assertEquals((await forwardPortableInboxActivity(forwardingParameters({
1372
+ recipient,
1373
+ outboxQueue: queue,
1374
+ signatures
1375
+ }))).map((u) => u.origin), [GATEWAY3, GATEWAY2]);
1376
+ });
1377
+ await t.step("verified only if it may save a request", async (t) => {
1378
+ const cases = [
1379
+ ["forwarding is turned off", { options: resolvePortableInboxForwardingOptions({ maxTargets: 0 }) }],
1380
+ ["no compare-and-swap", { kv: {
1381
+ ...new MemoryKvStore(),
1382
+ cas: void 0
1383
+ } }],
1384
+ ["this server", { excludedOrigins: [LOCAL, GATEWAY2] }]
1385
+ ];
1386
+ for (const [name, overrides] of cases) await t.step(name, async () => {
1387
+ let calls = 0;
1388
+ await forwardPortableInboxActivity(forwardingParameters({
1389
+ recipient,
1390
+ outboxQueue: new RecordingQueue(),
1391
+ signatures: [gatewaySignature(gateway2KeyId, () => {
1392
+ calls++;
1393
+ return Promise.resolve(gatewayKeyOf(gateway2KeyId));
1394
+ })],
1395
+ ...overrides
1396
+ }));
1397
+ assertEquals(calls, 0);
1398
+ });
1399
+ });
1400
+ await t.step("verification does not finish in time", async () => {
1401
+ let release = () => {};
1402
+ const pending = new Promise((resolve) => release = resolve);
1403
+ const kv = new MemoryKvStore();
1404
+ const parameters = forwardingParameters({
1405
+ recipient,
1406
+ kv,
1407
+ outboxQueue: new RecordingQueue(),
1408
+ options: resolvePortableInboxForwardingOptions({
1409
+ maxTargets: 1,
1410
+ deadline: { milliseconds: 10 }
1411
+ }),
1412
+ signatures: [gatewaySignature(gateway2KeyId, () => pending)]
1413
+ });
1414
+ const started = performance.now();
1415
+ const forwarded = await forwardPortableInboxActivity(parameters);
1416
+ assert(performance.now() - started < 5e3);
1417
+ assertEquals(forwarded.map((u) => u.origin), [GATEWAY3]);
1418
+ release(gatewayKeyOf(gateway2KeyId));
1419
+ await new Promise((resolve) => setTimeout(resolve, 20));
1420
+ const claimed = [];
1421
+ for await (const { key } of kv.list(parameters.kvPrefix)) claimed.push(String(key.at(-1)));
1422
+ assertEquals(claimed, [GATEWAY3]);
1423
+ });
1424
+ });
1425
+ test("coversDelivery()", () => {
1426
+ const signature = (label, ...names) => ({
1427
+ keyId: gateway2KeyId,
1428
+ label,
1429
+ components: names.map((value) => ({
1430
+ value,
1431
+ params: {}
1432
+ }))
1433
+ });
1434
+ assert(coversDelivery(signature("sig1", "@method", "@target-uri", "@authority", "content-digest")));
1435
+ assert(coversDelivery(signature(null, "(request-target)", "host", "date", "digest")));
1436
+ assertFalse(coversDelivery(signature("sig1", "@method", "@target-uri")));
1437
+ assertFalse(coversDelivery(signature("sig1", "@method", "@target-uri", "Content-Digest")));
1438
+ assertFalse(coversDelivery(signature("sig1", "@method", "@authority", "@path", "content-digest")));
1439
+ assertFalse(coversDelivery(signature(null, "(request-target)", "digest")));
1440
+ assertFalse(coversDelivery({
1441
+ keyId: gateway2KeyId,
1442
+ label: "sig1",
1443
+ components: [
1444
+ {
1445
+ value: "@method",
1446
+ params: {}
1447
+ },
1448
+ {
1449
+ value: "@target-uri",
1450
+ params: {}
1451
+ },
1452
+ {
1453
+ value: "content-digest",
1454
+ params: { req: true }
1455
+ }
1456
+ ]
1457
+ }));
1458
+ });
1459
+ test("Context.getPortableInboxUri()", async (t) => {
1460
+ const { federation } = setup();
1461
+ await t.step("Context", () => {
1462
+ const ctx = federation.createContext(new URL(LOCAL), void 0);
1463
+ const uri = ctx.getPortableInboxUri("alice", did);
1464
+ assertEquals(uri.protocol, "ap+ef61:");
1465
+ assertEquals(formatIri(uri), `ap+ef61://${did}/users/alice/inbox`);
1466
+ assertThrows(() => ctx.getPortableInboxUri("alice", "not-a-did"), TypeError);
1467
+ });
1468
+ await t.step("RequestContext without portable request", () => {
1469
+ const ctx = federation.createContext(new Request(LOCAL), void 0);
1470
+ assertThrows(() => ctx.getPortableInboxUri("alice"), TypeError);
1471
+ assertEquals(formatIri(ctx.getPortableInboxUri("alice", did)), `ap+ef61://${did}/users/alice/inbox`);
1472
+ });
1473
+ });
1474
+ test("Federation.fetch() serves and delivers to one portable actor dispatcher", async () => {
1475
+ const { federation, received } = setup({ actor: async (ctx, identifier) => {
1476
+ if (identifier !== "alice") return null;
1477
+ if (ctx.portableRequest != null && ctx.portableRequest.authority !== did) return null;
1478
+ return await signObject(new Person({
1479
+ id: ctx.getPortableActorUri(identifier, did),
1480
+ inbox: ctx.getPortableInboxUri(identifier, did),
1481
+ gateways: [new URL(LOCAL)]
1482
+ }), ed25519PrivateKey, keyId, { contextLoader: mockDocumentLoader });
1483
+ } });
1484
+ assertEquals((await federation.fetch(post(inboxUrl(), await signedFollow()), { contextData: void 0 })).status, 202);
1485
+ assertEquals(received.length, 1);
1486
+ assertEquals(received[0].recipient, "alice");
1487
+ const get = (authority) => federation.fetch(new Request(inboxUrl(authority, "/users/alice"), { headers: { Accept: "application/activity+json" } }), { contextData: void 0 });
1488
+ const actor = await get(did);
1489
+ assertEquals(actor.status, 200);
1490
+ const json = await actor.json();
1491
+ assertEquals(json.id, `ap+ef61://${did}/users/alice`);
1492
+ assertEquals(json.inbox, `ap+ef61://${did}/users/alice/inbox`);
1493
+ assertEquals((await get(otherDid)).status, 404);
1494
+ });
1495
+ test("Federation.fetch() keeps ordinary inbox deliveries unchanged", async () => {
1496
+ const queue = new RecordingQueue();
1497
+ const { federation, received } = setup({ queue });
1498
+ assertEquals((await federation.fetch(post(`${LOCAL}/users/alice/inbox`, await signedFollow()), { contextData: void 0 })).status, 202);
1499
+ assertEquals(received.length, 1);
1500
+ assertEquals(queue.outbox.length, 0);
1501
+ });
1502
+ test("onRequestFinished covers portable ingress and recipient lookup failures", async () => {
1503
+ const { federation, reports } = setup({ options: { portableInboxForwarding: { maxTargets: 0 } } });
1504
+ assertEquals((await federation.fetch(post(inboxUrl(), await signedFollow()), { contextData: void 0 })).status, 202);
1505
+ assertEquals(reports.length, 1);
1506
+ assertEquals(reports[0].inbox, {
1507
+ kind: "portable",
1508
+ recipient: "alice"
1509
+ });
1510
+ assertEquals(reports[0].authentication.status, "verified");
1511
+ assert(reports[0].attempts.some((attempt) => attempt.subject.pointer === "" && attempt.status === "verified"));
1512
+ const absent = setup({ actor: () => null });
1513
+ assertEquals((await absent.federation.fetch(post(inboxUrl(), {}), { contextData: void 0 })).status, 404);
1514
+ assertEquals(absent.reports[0].payload, { status: "unavailable" });
1515
+ assertEquals(absent.reports[0].authentication, { status: "notDetermined" });
1516
+ const failure = /* @__PURE__ */ new Error("recipient lookup failed");
1517
+ const broken = setup({ actor: () => {
1518
+ throw failure;
1519
+ } });
1520
+ let thrown;
1521
+ try {
1522
+ await broken.federation.fetch(post(inboxUrl(), {}), { contextData: void 0 });
1523
+ } catch (error) {
1524
+ thrown = error;
1525
+ }
1526
+ assertEquals(thrown, failure);
1527
+ assertEquals(broken.reports.length, 1);
1528
+ assertEquals(broken.reports[0].outcome, {
1529
+ type: "exception",
1530
+ stage: "prepare",
1531
+ error: failure
1532
+ });
1533
+ });
1534
+ //#endregion
1535
+ export {};