@fedify/fedify 2.3.0-dev.994 → 2.3.0-pr.809.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/README.md +16 -7
  2. package/dist/{assert-DikXweDx.mjs → assert-OguE97r2.mjs} +1 -1
  3. package/dist/{assert_instance_of-C4Ri6VuN.mjs → assert_instance_of-DBC5X09g.mjs} +1 -1
  4. package/dist/{assert_not_equals--wG9hV7u.mjs → assert_not_equals-DkVK8oqV.mjs} +1 -1
  5. package/dist/{assert_rejects-B-qJtC9Z.mjs → assert_rejects-DN60FHPX.mjs} +28 -3
  6. package/dist/{assert_strict_equals-Dmjbg-bA.mjs → assert_strict_equals-XEgZAlrj.mjs} +1 -1
  7. package/dist/{assert_throws-4NwKEy2q.mjs → assert_throws-BOkhLGYc.mjs} +1 -1
  8. package/dist/{builder-yAlpiIqP.mjs → builder-DSZ7FZmg.mjs} +96 -42
  9. package/dist/{chunk-nlSIicah.js → chunk-CRNNMoPX.js} +2 -2
  10. package/dist/circuit-breaker-CSWsyoef.mjs +337 -0
  11. package/dist/{client-z-8dc-e1.d.cts → client-CAM_bQXx.d.cts} +1 -0
  12. package/dist/{client-AtlibPOU.d.ts → client-CSddvgWN.d.ts} +1 -2
  13. package/dist/compat/mod.d.cts +2 -1
  14. package/dist/compat/mod.d.ts +2 -3
  15. package/dist/compat/mod.js +3 -3
  16. package/dist/compat/outgoing-jsonld.test.mjs +4 -4
  17. package/dist/compat/public-audience.test.mjs +4 -4
  18. package/dist/compat/transformers.test.mjs +5 -5
  19. package/dist/{context-BzH2-ajs.d.ts → context-BBVLF7lx.d.cts} +342 -269
  20. package/dist/{context-DJGagtNd.d.cts → context-BU6jSQdo.d.ts} +344 -266
  21. package/dist/{context-Dk_tacqz.mjs → context-DVoTs_wM.mjs} +64 -5
  22. package/dist/{deno-XBVlKnOX.mjs → deno-DCdM7d93.mjs} +1 -1
  23. package/dist/{docloader-DPp-X6gk.mjs → docloader-B7mVwA5_.mjs} +4 -4
  24. package/dist/{esm-DVILvP5e.mjs → esm-vrlUxr60.mjs} +4 -7
  25. package/dist/federation/builder.test.mjs +163 -11
  26. package/dist/federation/circuit-breaker.test.d.mts +2 -0
  27. package/dist/federation/circuit-breaker.test.mjs +446 -0
  28. package/dist/federation/collection.test.mjs +3 -3
  29. package/dist/federation/handler.test.mjs +1770 -29
  30. package/dist/federation/idempotency.test.mjs +5 -5
  31. package/dist/federation/inbox.test.mjs +4 -4
  32. package/dist/federation/keycache.test.mjs +5 -5
  33. package/dist/federation/kv.test.mjs +2 -2
  34. package/dist/federation/metrics.test.d.mts +2 -0
  35. package/dist/federation/metrics.test.mjs +634 -0
  36. package/dist/federation/middleware.test.mjs +4036 -146
  37. package/dist/federation/mod.cjs +195 -14
  38. package/dist/federation/mod.d.cts +6 -4
  39. package/dist/federation/mod.d.ts +6 -6
  40. package/dist/federation/mod.js +192 -14
  41. package/dist/federation/mq.test.mjs +176 -15
  42. package/dist/federation/negotiation.test.mjs +4 -4
  43. package/dist/federation/retry.test.mjs +3 -3
  44. package/dist/federation/router.test.mjs +190 -9
  45. package/dist/federation/send.test.mjs +153 -15
  46. package/dist/federation/temporal.test.d.mts +2 -0
  47. package/dist/federation/temporal.test.mjs +71 -0
  48. package/dist/federation/webfinger.test.mjs +150 -5
  49. package/dist/{http-CSlLA54v.mjs → http-BufbkLuW.mjs} +146 -47
  50. package/dist/{http-B4rrzxtg.js → http-Dh18nwJg.js} +994 -64
  51. package/dist/{http-aQzN9Ayi.d.ts → http-VyDTd4G3.d.cts} +15 -3
  52. package/dist/{http-BrGqJDXL.cjs → http-gOiudB3g.cjs} +1099 -61
  53. package/dist/{http-CrGuipxe.d.cts → http-lf8Hsd91.d.ts} +15 -1
  54. package/dist/{key-Bzx--sHD.mjs → key-DZdKLJXT.mjs} +43 -18
  55. package/dist/{kv-CbLNp3zQ.d.cts → kv-D6hNiMTK.d.ts} +1 -0
  56. package/dist/{kv-cache-DI-Sk7-I.cjs → kv-cache-BuK_nlpY.cjs} +20 -3
  57. package/dist/{kv-cache-U__xU4qR.mjs → kv-cache-C7SQnkGI.mjs} +21 -3
  58. package/dist/{kv-cache-EMIqoIuB.js → kv-cache-DNDSb6Po.js} +20 -3
  59. package/dist/{kv-GFYnFoOl.d.ts → kv-gJ8LYbxX.d.cts} +1 -3
  60. package/dist/ld-0Lsznacf.mjs +626 -0
  61. package/dist/metrics-BHeWd24f.mjs +815 -0
  62. package/dist/{middleware-zjZ6AFCW.mjs → middleware-BNANvDh2.mjs} +1 -1
  63. package/dist/{middleware-nidH7VZH.js → middleware-BgzRkDcm.js} +2260 -556
  64. package/dist/{middleware-An4DjES4.cjs → middleware-D-iES1nQ.cjs} +2318 -623
  65. package/dist/{middleware-K1g6bEkV.mjs → middleware-EV6J_eAc.mjs} +1639 -373
  66. package/dist/{mod-CR8soWa9.d.ts → mod-B0hW12_O.d.cts} +26 -4
  67. package/dist/mod-C0F6kvgS.d.cts +182 -0
  68. package/dist/{mod-Cr3f-ACa.d.cts → mod-COIAjwRS.d.ts} +26 -2
  69. package/dist/{mod-CMEbIaNh.d.cts → mod-DFvNJcNb.d.ts} +56 -4
  70. package/dist/mod-vPYVoa5n.d.ts +182 -0
  71. package/dist/{mod-CLgIXe9w.d.ts → mod-yvIXFAEi.d.cts} +56 -6
  72. package/dist/mod.cjs +9 -6
  73. package/dist/mod.d.cts +12 -10
  74. package/dist/mod.d.ts +12 -12
  75. package/dist/mod.js +11 -11
  76. package/dist/mq-D-nlpY04.d.ts +208 -0
  77. package/dist/mq-D8uSFzxe.d.cts +208 -0
  78. package/dist/{negotiation-SQvQgUqe.mjs → negotiation-DDstyBvc.mjs} +29 -0
  79. package/dist/nodeinfo/client.test.mjs +4 -4
  80. package/dist/nodeinfo/handler.test.mjs +4 -4
  81. package/dist/nodeinfo/mod.d.cts +2 -1
  82. package/dist/nodeinfo/mod.d.ts +2 -3
  83. package/dist/nodeinfo/mod.js +3 -3
  84. package/dist/nodeinfo/types.test.mjs +3 -3
  85. package/dist/otel/exporter.test.mjs +28 -25
  86. package/dist/otel/mod.cjs +6 -5
  87. package/dist/otel/mod.d.cts +5 -3
  88. package/dist/otel/mod.d.ts +5 -5
  89. package/dist/otel/mod.js +8 -7
  90. package/dist/{outgoing-jsonld-CNmZLixq.mjs → outgoing-jsonld-L_DbOaFe.mjs} +2 -2
  91. package/dist/{owner-C-tqfvxn.mjs → owner-Cl-1cAR8.mjs} +2 -2
  92. package/dist/{owner-CptqhsOy.d.cts → owner-CnngXDNJ.d.ts} +2 -1
  93. package/dist/{owner-74ARJ5TL.d.ts → owner-DEvZuyOE.d.cts} +2 -3
  94. package/dist/{proof-Bbbwf8_x.js → proof-CyXndf6-.js} +378 -15
  95. package/dist/{proof-BlmRPzrK.mjs → proof-Dnz8jtiQ.mjs} +26 -8
  96. package/dist/{proof-BW89QMVB.cjs → proof-tz35unOj.cjs} +432 -15
  97. package/dist/runtime/mod.d.cts +1 -0
  98. package/dist/runtime/mod.d.ts +1 -2
  99. package/dist/runtime/mod.js +3 -3
  100. package/dist/{send-05pJLcb6.mjs → send-DIbBYN5P.mjs} +72 -26
  101. package/dist/sig/http.test.mjs +356 -33
  102. package/dist/sig/key.test.mjs +103 -6
  103. package/dist/sig/ld.test.mjs +696 -7
  104. package/dist/sig/mod.cjs +2 -2
  105. package/dist/sig/mod.d.cts +4 -3
  106. package/dist/sig/mod.d.ts +4 -5
  107. package/dist/sig/mod.js +4 -4
  108. package/dist/sig/owner.test.mjs +6 -6
  109. package/dist/sig/proof.test.mjs +169 -8
  110. package/dist/{std__assert-CRDpx_HF.mjs → std__assert-BBjXFNOb.mjs} +5 -30
  111. package/dist/temporal-BEwob1Vg.mjs +95 -0
  112. package/dist/testing/mod.d.mts +110 -10
  113. package/dist/testing/mod.mjs +1 -2
  114. package/dist/{transformers-ve6e2xcg.js → transformers-BGMIq1cs.js} +2 -2
  115. package/dist/{types-hvL8ElAs.js → types-CAY3OdLq.js} +2 -2
  116. package/dist/utils/docloader.test.mjs +6 -6
  117. package/dist/utils/kv-cache.test.mjs +67 -2
  118. package/dist/utils/mod.cjs +1 -1
  119. package/dist/utils/mod.d.cts +2 -1
  120. package/dist/utils/mod.d.ts +2 -3
  121. package/dist/utils/mod.js +3 -3
  122. package/dist/vocab/cjs.test.mjs +1 -1
  123. package/dist/vocab/mod.d.cts +1 -0
  124. package/dist/vocab/mod.d.ts +1 -2
  125. package/dist/vocab/mod.js +2 -2
  126. package/package.json +16 -16
  127. package/dist/ld-DR78beiv.mjs +0 -279
  128. package/dist/middleware-BFBaR0mF.cjs +0 -4
  129. package/dist/mod-2d12ffz3.d.ts +0 -64
  130. package/dist/mod-D35TRn09.d.cts +0 -62
  131. package/dist/router-CrMLXoOr.mjs +0 -114
  132. package/dist/{activity-listener-ell7W1s9.mjs → activity-listener-tztVvlNb.mjs} +0 -0
  133. package/dist/{assert_equals-Ew3jOFa3.mjs → assert_equals-C-ZRDbaf.mjs} +0 -0
  134. package/dist/{client-D_1QpnWt.mjs → client-ByXmQhYD.mjs} +1 -1
  135. package/dist/{collection-D-HqUuA2.mjs → collection-Cc3DVAhE.mjs} +0 -0
  136. package/dist/{keycache-EGATflN-.mjs → keycache-BeU0LCII.mjs} +0 -0
  137. package/dist/{public-audience-DYFHzm_c.mjs → public-audience-Cvbr2Gzt.mjs} +1 -1
  138. /package/dist/{retry-bMXBL97A.mjs → retry-CXg_MBI-.mjs} +0 -0
@@ -1,30 +1,34 @@
1
1
  import { Temporal } from "@js-temporal/polyfill";
2
2
  import "urlpattern-polyfill";
3
3
  globalThis.addEventListener = () => {};
4
- import { t as esm_default } from "../esm-DVILvP5e.mjs";
5
- import { n as RouterError } from "../router-CrMLXoOr.mjs";
6
- import { t as assertEquals } from "../assert_equals-Ew3jOFa3.mjs";
7
- import "../std__assert-CRDpx_HF.mjs";
8
- import { n as assertFalse, t as assertRejects } from "../assert_rejects-B-qJtC9Z.mjs";
9
- import { t as assertInstanceOf } from "../assert_instance_of-C4Ri6VuN.mjs";
10
- import { t as assertThrows } from "../assert_throws-4NwKEy2q.mjs";
11
- import { t as assertNotEquals } from "../assert_not_equals--wG9hV7u.mjs";
12
- import { t as assertStrictEquals } from "../assert_strict_equals-Dmjbg-bA.mjs";
13
- import { t as assert } from "../assert-DikXweDx.mjs";
14
- import { l as verifyRequest, s as signRequest } from "../http-CSlLA54v.mjs";
4
+ import { t as esm_default } from "../esm-vrlUxr60.mjs";
5
+ import { t as assertEquals } from "../assert_equals-C-ZRDbaf.mjs";
6
+ import { r as assertExists } from "../std__assert-BBjXFNOb.mjs";
7
+ import { r as assertFalse, t as assertRejects } from "../assert_rejects-DN60FHPX.mjs";
8
+ import { t as assertInstanceOf } from "../assert_instance_of-DBC5X09g.mjs";
9
+ import { t as assertThrows } from "../assert_throws-BOkhLGYc.mjs";
10
+ import { t as assertNotEquals } from "../assert_not_equals-DkVK8oqV.mjs";
11
+ import { t as assertStrictEquals } from "../assert_strict_equals-XEgZAlrj.mjs";
12
+ import { t as assert } from "../assert-OguE97r2.mjs";
13
+ import { g as recordInboxActivity } from "../metrics-BHeWd24f.mjs";
14
+ import { l as verifyRequest, s as signRequest } from "../http-BufbkLuW.mjs";
15
15
  import { a as rsaPrivateKey3, c as rsaPublicKey3, i as rsaPrivateKey2, n as ed25519PrivateKey, r as ed25519PublicKey, s as rsaPublicKey2, t as ed25519Multikey } from "../keys-DGu1NFwu.mjs";
16
- import { t as getAuthenticatedDocumentLoader } from "../docloader-DPp-X6gk.mjs";
17
- import { a as signJsonLd, o as verifyJsonLd, r as detachSignature } from "../ld-DR78beiv.mjs";
18
- import { t as doesActorOwnKey } from "../owner-C-tqfvxn.mjs";
19
- import { i as verifyObject, r as signObject } from "../proof-BlmRPzrK.mjs";
16
+ import { t as getAuthenticatedDocumentLoader } from "../docloader-B7mVwA5_.mjs";
17
+ import { a as compactJsonLd, h as verifyJsonLd, p as signJsonLd, s as detachSignature } from "../ld-0Lsznacf.mjs";
18
+ import { t as doesActorOwnKey } from "../owner-Cl-1cAR8.mjs";
19
+ import { i as verifyObject, r as signObject } from "../proof-Dnz8jtiQ.mjs";
20
20
  import { t as MemoryKvStore } from "../kv-rV3vodCc.mjs";
21
- import { i as KvSpecDeterminer, n as FederationImpl, o as createFederation, r as InboxContextImpl, t as ContextImpl } from "../middleware-K1g6bEkV.mjs";
22
- import { createTestTracerProvider, mockDocumentLoader, test } from "@fedify/fixture";
21
+ import { g as handleBenchmarkTrigger, i as KvSpecDeterminer, n as FederationImpl, o as createFederation, r as InboxContextImpl, t as ContextImpl } from "../middleware-EV6J_eAc.mjs";
22
+ import { t as CircuitBreaker } from "../circuit-breaker-CSWsyoef.mjs";
23
+ import { configure, reset } from "@logtape/logtape";
24
+ import { RouterError } from "@fedify/uri-template";
23
25
  import * as vocab from "@fedify/vocab";
24
- import { getTypeId, lookupObject } from "@fedify/vocab";
26
+ import { Create, Offer, Person, getTypeId, lookupObject } from "@fedify/vocab";
27
+ import { SpanStatusCode, metrics } from "@opentelemetry/api";
28
+ import { createTestMeterProvider, createTestTracerProvider, mockDocumentLoader, test } from "@fedify/fixture";
25
29
  import { FetchError, getDocumentLoader } from "@fedify/vocab-runtime";
26
- import { configure, reset } from "@logtape/logtape";
27
30
  import serialize from "json-canon";
31
+ import { DataPointType, MeterProvider as MeterProvider$1, MetricReader } from "@opentelemetry/sdk-metrics";
28
32
  //#region ../fixture/src/fixtures/example.com/create.json
29
33
  var id$2 = "https://example.com/create";
30
34
  var create_default = {
@@ -96,6 +100,14 @@ async function withLogtapeLock(fn) {
96
100
  logtapeLock = run.then(() => void 0, () => void 0);
97
101
  return await run;
98
102
  }
103
+ var TestMetricReader = class extends MetricReader {
104
+ onShutdown() {
105
+ return Promise.resolve();
106
+ }
107
+ onForceFlush() {
108
+ return Promise.resolve();
109
+ }
110
+ };
99
111
  test("createFederation()", async (t) => {
100
112
  const kv = new MemoryKvStore();
101
113
  await t.step("allowPrivateAddress", () => {
@@ -110,6 +122,104 @@ test("createFederation()", async (t) => {
110
122
  allowPrivateAddress: true
111
123
  }), TypeError);
112
124
  });
125
+ await t.step("benchmarkMode applies cooperative benchmark defaults", () => {
126
+ const federation = createFederation({
127
+ kv,
128
+ benchmarkMode: true
129
+ });
130
+ assertInstanceOf(federation, FederationImpl);
131
+ assertEquals(federation.allowPrivateAddress, true);
132
+ assertEquals(federation.signatureTimeWindow, false);
133
+ });
134
+ await t.step("benchmarkMode preserves explicit option overrides", () => {
135
+ const federation = createFederation({
136
+ kv,
137
+ benchmarkMode: true,
138
+ allowPrivateAddress: false,
139
+ signatureTimeWindow: { minutes: 10 }
140
+ });
141
+ assertInstanceOf(federation, FederationImpl);
142
+ assertEquals(federation.allowPrivateAddress, false);
143
+ assertEquals(federation.signatureTimeWindow, { minutes: 10 });
144
+ });
145
+ await t.step("benchmarkMode tolerates calendar time windows", () => {
146
+ const federation = createFederation({
147
+ kv,
148
+ benchmarkMode: true,
149
+ signatureTimeWindow: { months: 1 }
150
+ });
151
+ assertInstanceOf(federation, FederationImpl);
152
+ assertEquals(federation.signatureTimeWindow, { months: 1 });
153
+ });
154
+ await t.step("benchmarkMode leaves custom loader factories alone", () => {
155
+ const federation = createFederation({
156
+ kv,
157
+ benchmarkMode: true,
158
+ documentLoaderFactory: () => mockDocumentLoader,
159
+ contextLoaderFactory: () => mockDocumentLoader,
160
+ authenticatedDocumentLoaderFactory: () => mockDocumentLoader
161
+ });
162
+ assertInstanceOf(federation, FederationImpl);
163
+ assertEquals(federation.allowPrivateAddress, false);
164
+ });
165
+ await t.step("benchmarkMode keeps private-address default with auth loader only", () => {
166
+ const federation = createFederation({
167
+ kv,
168
+ benchmarkMode: true,
169
+ authenticatedDocumentLoaderFactory: () => mockDocumentLoader
170
+ });
171
+ assertInstanceOf(federation, FederationImpl);
172
+ assertEquals(federation.allowPrivateAddress, true);
173
+ });
174
+ await t.step("benchmarkMode rejects an explicit meterProvider", () => {
175
+ const [meterProvider] = createTestMeterProvider();
176
+ assertThrows(() => createFederation({
177
+ kv,
178
+ benchmarkMode: true,
179
+ meterProvider
180
+ }), TypeError, "benchmarkMode requires Fedify to own the meterProvider");
181
+ });
182
+ await t.step("benchmarkMode warns that benchmark-only relaxations are on", async () => {
183
+ await withLogtapeLock(async () => {
184
+ const records = [];
185
+ await reset();
186
+ try {
187
+ await configure({
188
+ sinks: { test(record) {
189
+ records.push(record);
190
+ } },
191
+ loggers: [{
192
+ category: [
193
+ "fedify",
194
+ "federation",
195
+ "benchmark"
196
+ ],
197
+ lowestLevel: "warning",
198
+ sinks: ["test"]
199
+ }]
200
+ });
201
+ createFederation({
202
+ kv,
203
+ benchmarkMode: true
204
+ });
205
+ assertEquals(records.length, 1);
206
+ assertEquals(records[0].level, "warning");
207
+ assertEquals(records[0].rawMessage, "Fedify benchmarkMode is enabled; private address checks disabled (allowPrivateAddress=true); HTTP Signature time window disabled (signatureTimeWindow=false). Benchmark endpoints are active and must not be used in production.");
208
+ assertEquals(records[0].properties.relaxations, [{
209
+ protection: "private_address_checks",
210
+ effect: "disabled",
211
+ effectiveValue: true
212
+ }, {
213
+ protection: "http_signature_time_window",
214
+ effect: "disabled",
215
+ effectiveValue: false,
216
+ secureDefaultSeconds: 3600
217
+ }]);
218
+ } finally {
219
+ await reset();
220
+ }
221
+ });
222
+ });
113
223
  await t.step("origin", () => {
114
224
  const f = createFederation({
115
225
  kv,
@@ -203,6 +313,319 @@ test("createFederation()", async (t) => {
203
313
  }), TypeError);
204
314
  });
205
315
  });
316
+ test("benchmarkMode stats endpoint", async (t) => {
317
+ await t.step("is absent when benchmarkMode is off", async () => {
318
+ assertEquals((await createFederation({ kv: new MemoryKvStore() }).fetch(new Request("https://example.com/.well-known/fedify/bench/stats"), { contextData: void 0 })).status, 404);
319
+ });
320
+ await t.step("returns a v1 in-process metrics snapshot", async () => {
321
+ const federation = createFederation({
322
+ kv: new MemoryKvStore(),
323
+ benchmarkMode: true,
324
+ queue: {
325
+ enqueue() {
326
+ return Promise.resolve();
327
+ },
328
+ listen() {
329
+ return Promise.resolve();
330
+ },
331
+ getDepth() {
332
+ return Promise.resolve({
333
+ queued: 3,
334
+ ready: 2,
335
+ delayed: 1
336
+ });
337
+ }
338
+ }
339
+ });
340
+ recordInboxActivity(federation.meterProvider, "processed", vocab.Create.typeId.href);
341
+ const response = await federation.fetch(new Request("https://example.com/.well-known/fedify/bench/stats"), { contextData: void 0 });
342
+ assertEquals(response.status, 200);
343
+ assertEquals(response.headers.get("Content-Type"), "application/json");
344
+ const body = await response.json();
345
+ assertEquals(body.version, 1);
346
+ assertEquals(body.source, "server");
347
+ assertEquals(Number.isNaN(Date.parse(body.generatedAt)), false);
348
+ const metrics = body.scopeMetrics.flatMap((scope) => scope.metrics);
349
+ assertExists(metrics.find((metric) => metric.name === "activitypub.inbox.activity"));
350
+ const queueDepth = metrics.find((metric) => metric.name === "fedify.queue.depth");
351
+ assertExists(queueDepth);
352
+ assertEquals(queueDepth.dataPointType, "gauge");
353
+ assertEquals(queueDepth.dataPoints.map((point) => ({
354
+ state: point.attributes["fedify.queue.depth.state"],
355
+ role: point.attributes["fedify.queue.role"],
356
+ value: point.value
357
+ })).sort((a, b) => String(a.state).localeCompare(String(b.state))), [
358
+ {
359
+ state: "delayed",
360
+ role: "shared",
361
+ value: 1
362
+ },
363
+ {
364
+ state: "queued",
365
+ role: "shared",
366
+ value: 3
367
+ },
368
+ {
369
+ state: "ready",
370
+ role: "shared",
371
+ value: 2
372
+ }
373
+ ]);
374
+ });
375
+ });
376
+ test("createFederation() registers queue depth for regular metrics", async () => {
377
+ const reader = new TestMetricReader();
378
+ const meterProvider = new MeterProvider$1({ readers: [reader] });
379
+ try {
380
+ createFederation({
381
+ kv: new MemoryKvStore(),
382
+ meterProvider,
383
+ queue: {
384
+ enqueue() {
385
+ return Promise.resolve();
386
+ },
387
+ listen() {
388
+ return Promise.resolve();
389
+ },
390
+ getDepth() {
391
+ return Promise.resolve({
392
+ queued: 5,
393
+ ready: 4,
394
+ delayed: 3
395
+ });
396
+ }
397
+ }
398
+ });
399
+ const queueDepth = (await reader.collect()).resourceMetrics.scopeMetrics.flatMap((scope) => scope.metrics).find((metric) => metric.descriptor.name === "fedify.queue.depth");
400
+ assertExists(queueDepth);
401
+ assertEquals(queueDepth.dataPointType, DataPointType.GAUGE);
402
+ assertEquals(queueDepth.dataPoints.map((point) => ({
403
+ state: point.attributes["fedify.queue.depth.state"],
404
+ role: point.attributes["fedify.queue.role"],
405
+ value: point.value
406
+ })).sort((a, b) => String(a.state).localeCompare(String(b.state))), [
407
+ {
408
+ state: "delayed",
409
+ role: "shared",
410
+ value: 3
411
+ },
412
+ {
413
+ state: "queued",
414
+ role: "shared",
415
+ value: 5
416
+ },
417
+ {
418
+ state: "ready",
419
+ role: "shared",
420
+ value: 4
421
+ }
422
+ ]);
423
+ } finally {
424
+ await meterProvider.shutdown();
425
+ }
426
+ });
427
+ test("createFederation() registers queue depth after global meterProvider is set", async () => {
428
+ metrics.disable();
429
+ const federation = createFederation({
430
+ kv: new MemoryKvStore(),
431
+ queue: {
432
+ enqueue() {
433
+ return Promise.resolve();
434
+ },
435
+ listen() {
436
+ return Promise.resolve();
437
+ },
438
+ getDepth() {
439
+ return Promise.resolve({ queued: 8 });
440
+ }
441
+ }
442
+ });
443
+ const reader = new TestMetricReader();
444
+ const meterProvider = new MeterProvider$1({ readers: [reader] });
445
+ try {
446
+ metrics.setGlobalMeterProvider(meterProvider);
447
+ federation.meterProvider;
448
+ const queueDepth = (await reader.collect()).resourceMetrics.scopeMetrics.flatMap((scope) => scope.metrics).find((metric) => metric.descriptor.name === "fedify.queue.depth");
449
+ assertExists(queueDepth);
450
+ assertEquals(queueDepth.dataPointType, DataPointType.GAUGE);
451
+ assertEquals(queueDepth.dataPoints.map((point) => ({
452
+ state: point.attributes["fedify.queue.depth.state"],
453
+ role: point.attributes["fedify.queue.role"],
454
+ value: point.value
455
+ })), [{
456
+ state: "queued",
457
+ role: "shared",
458
+ value: 8
459
+ }]);
460
+ } finally {
461
+ metrics.disable();
462
+ await meterProvider.shutdown();
463
+ }
464
+ });
465
+ test("createFederation() distinguishes queue depth series per federation", async () => {
466
+ const reader = new TestMetricReader();
467
+ const meterProvider = new MeterProvider$1({ readers: [reader] });
468
+ try {
469
+ const createQueue = (queued) => ({
470
+ enqueue() {
471
+ return Promise.resolve();
472
+ },
473
+ listen() {
474
+ return Promise.resolve();
475
+ },
476
+ getDepth() {
477
+ return Promise.resolve({ queued });
478
+ }
479
+ });
480
+ createFederation({
481
+ kv: new MemoryKvStore(),
482
+ meterProvider,
483
+ queue: createQueue(1)
484
+ });
485
+ createFederation({
486
+ kv: new MemoryKvStore(),
487
+ meterProvider,
488
+ queue: createQueue(2)
489
+ });
490
+ const queueDepth = (await reader.collect()).resourceMetrics.scopeMetrics.flatMap((scope) => scope.metrics).find((metric) => metric.descriptor.name === "fedify.queue.depth");
491
+ assertExists(queueDepth);
492
+ const queuedPoints = queueDepth.dataPoints.filter((point) => point.attributes["fedify.queue.depth.state"] === "queued");
493
+ assertEquals(queuedPoints.map((point) => point.value).sort(), [1, 2]);
494
+ const instanceIds = queuedPoints.map((point) => point.attributes["fedify.federation.instance_id"]);
495
+ assertEquals(instanceIds.every((id) => typeof id === "string"), true);
496
+ assertEquals(new Set(instanceIds).size, 2);
497
+ } finally {
498
+ await meterProvider.shutdown();
499
+ }
500
+ });
501
+ test("benchmarkMode trigger endpoint", async (t) => {
502
+ const createTriggerTarget = (options = {}) => {
503
+ const messages = [];
504
+ const federation = createFederation({
505
+ kv: new MemoryKvStore(),
506
+ benchmarkMode: {
507
+ triggerSinks: ["https://sink.example/inbox"],
508
+ allowUnsafeTriggerRecipients: options.allowUnsafeTriggerRecipients
509
+ },
510
+ contextLoaderFactory: () => mockDocumentLoader,
511
+ queue: { outbox: {
512
+ enqueue(message) {
513
+ messages.push(message);
514
+ return Promise.resolve();
515
+ },
516
+ listen() {
517
+ return Promise.resolve();
518
+ }
519
+ } }
520
+ });
521
+ federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => new vocab.Person({
522
+ id: ctx.getActorUri(identifier),
523
+ inbox: ctx.getInboxUri(identifier)
524
+ })).setKeyPairsDispatcher(() => [{
525
+ privateKey: rsaPrivateKey2,
526
+ publicKey: rsaPublicKey2.publicKey
527
+ }]);
528
+ return {
529
+ federation,
530
+ messages
531
+ };
532
+ };
533
+ const createTriggerBody = async (options = {}) => ({
534
+ sender: { identifier: "alice" },
535
+ sinks: options.sinks,
536
+ recipients: options.recipients ?? [{
537
+ "@context": "https://www.w3.org/ns/activitystreams",
538
+ type: "Service",
539
+ id: "https://sink.example/actors/bob",
540
+ inbox: options.recipientInbox ?? "https://sink.example/inbox"
541
+ }],
542
+ activity: await new vocab.Create({
543
+ id: new URL("https://example.com/activities/bench-1"),
544
+ actor: new URL("https://example.com/users/alice"),
545
+ object: new vocab.Note({
546
+ id: new URL("https://example.com/notes/bench-1"),
547
+ attribution: new URL("https://example.com/users/alice"),
548
+ content: "benchmark"
549
+ })
550
+ }).toJsonLd({ contextLoader: mockDocumentLoader }),
551
+ allowUnsafeRecipients: options.allowUnsafeRecipients
552
+ });
553
+ await t.step("is absent when benchmarkMode is off", async () => {
554
+ assertEquals((await createFederation({ kv: new MemoryKvStore() }).fetch(new Request("https://example.com/.well-known/fedify/bench/trigger", { method: "POST" }), { contextData: void 0 })).status, 404);
555
+ });
556
+ await t.step("rejects unreadable JSON request bodies", async () => {
557
+ const response = await handleBenchmarkTrigger({
558
+ method: "POST",
559
+ json() {
560
+ throw new TypeError("body is unavailable");
561
+ }
562
+ }, {});
563
+ assertEquals(response.status, 400);
564
+ assertEquals(await response.json(), { error: "Invalid JSON request body." });
565
+ });
566
+ await t.step("rejects empty recipient lists", async () => {
567
+ const { federation, messages } = createTriggerTarget();
568
+ const response = await federation.fetch(new Request("https://example.com/.well-known/fedify/bench/trigger", {
569
+ method: "POST",
570
+ headers: { "Content-Type": "application/json" },
571
+ body: JSON.stringify(await createTriggerBody({ recipients: [] }))
572
+ }), { contextData: void 0 });
573
+ assertEquals(response.status, 400);
574
+ assertEquals(await response.json(), { error: "No valid recipient inboxes found. The recipients list must not be empty." });
575
+ assertEquals(messages, []);
576
+ });
577
+ await t.step("rejects recipients outside configured trigger sinks", async () => {
578
+ const { federation, messages } = createTriggerTarget();
579
+ assertEquals((await federation.fetch(new Request("https://example.com/.well-known/fedify/bench/trigger", {
580
+ method: "POST",
581
+ headers: { "Content-Type": "application/json" },
582
+ body: JSON.stringify(await createTriggerBody({ recipientInbox: "https://not-a-sink.example/inbox" }))
583
+ }), { contextData: void 0 })).status, 403);
584
+ assertEquals(messages, []);
585
+ });
586
+ await t.step("does not trust request-provided trigger sinks or bypasses", async () => {
587
+ const { federation, messages } = createTriggerTarget();
588
+ assertEquals((await federation.fetch(new Request("https://example.com/.well-known/fedify/bench/trigger", {
589
+ method: "POST",
590
+ headers: { "Content-Type": "application/json" },
591
+ body: JSON.stringify(await createTriggerBody({
592
+ recipientInbox: "https://not-a-sink.example/inbox",
593
+ sinks: ["https://not-a-sink.example/inbox"],
594
+ allowUnsafeRecipients: true
595
+ }))
596
+ }), { contextData: void 0 })).status, 403);
597
+ assertEquals(messages, []);
598
+ });
599
+ await t.step("allows unsafe recipients only with a server override", async () => {
600
+ const { federation, messages } = createTriggerTarget({ allowUnsafeTriggerRecipients: true });
601
+ assertEquals((await federation.fetch(new Request("https://example.com/.well-known/fedify/bench/trigger", {
602
+ method: "POST",
603
+ headers: { "Content-Type": "application/json" },
604
+ body: JSON.stringify(await createTriggerBody({ recipientInbox: "https://not-a-sink.example/inbox" }))
605
+ }), { contextData: void 0 })).status, 202);
606
+ assertEquals(messages.length, 1);
607
+ assertEquals(messages[0].inbox, "https://not-a-sink.example/inbox");
608
+ });
609
+ await t.step("sends the activity to explicit sink recipients", async () => {
610
+ const { federation, messages } = createTriggerTarget();
611
+ const response = await federation.fetch(new Request("https://example.com/.well-known/fedify/bench/trigger", {
612
+ method: "POST",
613
+ headers: { "Content-Type": "application/json" },
614
+ body: JSON.stringify(await createTriggerBody())
615
+ }), { contextData: void 0 });
616
+ assertEquals(response.status, 202);
617
+ const body = await response.json();
618
+ assertEquals(body.version, 1);
619
+ assertEquals(body.activityId, "https://example.com/activities/bench-1");
620
+ assertEquals(body.queueCorrelationId, "https://example.com/activities/bench-1");
621
+ assertEquals(body.recipientCount, 1);
622
+ assertEquals(body.inboxCount, 1);
623
+ assertEquals(messages.length, 1);
624
+ assertEquals(messages[0].type, "outbox");
625
+ assertEquals(messages[0].activityId, body.queueCorrelationId);
626
+ assertEquals(messages[0].inbox, "https://sink.example/inbox");
627
+ });
628
+ });
206
629
  test({
207
630
  name: "Federation.createContext()",
208
631
  permissions: {
@@ -270,7 +693,8 @@ test({
270
693
  }));
271
694
  ctx = federation.createContext(new URL("https://example.com/"), 123);
272
695
  assertEquals(ctx.getNodeInfoUri(), new URL("https://example.com/nodeinfo/2.1"));
273
- federation.setActorDispatcher("/users/{identifier}", () => new vocab.Person({})).setKeyPairsDispatcher(() => [{
696
+ assertThrows(() => createFederation({ kv: new MemoryKvStore() }).setActorDispatcher("/users/{identifier}", () => null).mapActorAlias("/actor/{id}", "instance"), RouterError, "Path for actor alias must have no variables.");
697
+ federation.setActorDispatcher("/users/{identifier}", () => new vocab.Person({})).mapActorAlias("/bot", "bot").setKeyPairsDispatcher(() => [{
274
698
  privateKey: rsaPrivateKey2,
275
699
  publicKey: rsaPublicKey2.publicKey
276
700
  }, {
@@ -279,11 +703,16 @@ test({
279
703
  }]).mapHandle((_, username) => username === "HANDLE" ? "handle" : null);
280
704
  ctx = federation.createContext(new URL("https://example.com/"), 123);
281
705
  assertEquals(ctx.getActorUri("handle"), new URL("https://example.com/users/handle"));
706
+ assertEquals(ctx.getActorUri("bot"), new URL("https://example.com/bot"));
282
707
  assertEquals(ctx.parseUri(new URL("https://example.com/")), null);
283
708
  assertEquals(ctx.parseUri(new URL("https://example.com/users/handle")), {
284
709
  type: "actor",
285
710
  identifier: "handle"
286
711
  });
712
+ assertEquals(ctx.parseUri(new URL("https://example.com/bot")), {
713
+ type: "actor",
714
+ identifier: "bot"
715
+ });
287
716
  assertEquals(ctx.parseUri(null), null);
288
717
  assertEquals(await ctx.getActorKeyPairs("handle"), [{
289
718
  keyId: new URL("https://example.com/users/handle#main-key"),
@@ -775,7 +1204,7 @@ test("Federation.fetch()", async (t) => {
775
1204
  inbox: ctx.getInboxUri(identifier),
776
1205
  preferredUsername: identifier
777
1206
  });
778
- }).setKeyPairsDispatcher(() => {
1207
+ }).mapActorAlias("/bot", "bot").setKeyPairsDispatcher(() => {
779
1208
  return [{
780
1209
  privateKey: rsaPrivateKey2,
781
1210
  publicKey: rsaPublicKey2.publicKey
@@ -799,6 +1228,32 @@ test("Federation.fetch()", async (t) => {
799
1228
  assertEquals(dispatches, []);
800
1229
  assertEquals(response.status, 406);
801
1230
  });
1231
+ await t.step("GET actor alias", async () => {
1232
+ const { federation, dispatches } = createTestContext();
1233
+ const response = await federation.fetch(new Request("https://example.com/bot", {
1234
+ method: "GET",
1235
+ headers: { "Accept": "application/activity+json" }
1236
+ }), { contextData: void 0 });
1237
+ assertEquals(dispatches, ["bot"]);
1238
+ assertEquals(response.status, 200);
1239
+ const body = await response.json();
1240
+ assertEquals(body.id, "https://example.com/bot");
1241
+ assertEquals(body.preferredUsername, "bot");
1242
+ });
1243
+ await t.step("WebFinger for actor alias", async () => {
1244
+ const { federation } = createTestContext();
1245
+ const response = await federation.fetch(new Request("https://example.com/.well-known/webfinger?resource=acct:bot@example.com"), { contextData: void 0 });
1246
+ assertEquals(response.status, 200);
1247
+ const body = await response.json();
1248
+ assertEquals(body.subject, "acct:bot@example.com");
1249
+ assertExists(body.links);
1250
+ assert(Array.isArray(body.links));
1251
+ const selfLink = body.links.find((l) => l.rel === "self");
1252
+ assertExists(selfLink);
1253
+ assertEquals(selfLink.href, "https://example.com/bot");
1254
+ assertExists(body.aliases);
1255
+ assert(body.aliases.includes("https://example.com/bot"));
1256
+ });
802
1257
  await t.step("POST with application/json", async () => {
803
1258
  const { federation, inbox } = createTestContext();
804
1259
  const request = await signRequest(new Request("https://example.com/users/json/inbox", {
@@ -905,6 +1360,18 @@ test("Federation.fetch()", async (t) => {
905
1360
  assertEquals(inbox, []);
906
1361
  assertEquals(response.status, 404);
907
1362
  });
1363
+ await t.step("empty identifier segment is Not Found, dispatcher not invoked", async () => {
1364
+ const { federation, dispatches } = createTestContext();
1365
+ assertEquals((await federation.fetch(new Request("https://example.com/users/", {
1366
+ method: "GET",
1367
+ headers: { "Accept": "application/activity+json" }
1368
+ }), { contextData: void 0 })).status, 404);
1369
+ assertEquals((await federation.fetch(new Request("https://example.com/users//inbox", {
1370
+ method: "POST",
1371
+ headers: { "accept": "application/ld+json" }
1372
+ }), { contextData: void 0 })).status, 404);
1373
+ assertEquals(dispatches.includes(""), false);
1374
+ });
908
1375
  await t.step("onNotAcceptable with GET", async () => {
909
1376
  const { federation } = createTestContext();
910
1377
  let notAcceptableCalled = false;
@@ -924,6 +1391,213 @@ test("Federation.fetch()", async (t) => {
924
1391
  });
925
1392
  esm_default.hardReset();
926
1393
  });
1394
+ test("Federation.fetch() records HTTP server request metrics", async (t) => {
1395
+ const createTestContext = () => {
1396
+ const kv = new MemoryKvStore();
1397
+ const [meterProvider, recorder] = createTestMeterProvider();
1398
+ const federation = createFederation({
1399
+ kv,
1400
+ meterProvider,
1401
+ documentLoaderFactory: () => mockDocumentLoader
1402
+ });
1403
+ federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
1404
+ if (identifier === "boom") throw new Error("explosion in actor dispatcher");
1405
+ return new vocab.Person({
1406
+ id: ctx.getActorUri(identifier),
1407
+ inbox: ctx.getInboxUri(identifier),
1408
+ preferredUsername: identifier
1409
+ });
1410
+ });
1411
+ federation.setNodeInfoDispatcher("/nodeinfo/2.1", () => ({
1412
+ software: {
1413
+ name: "example",
1414
+ version: "1.0.0"
1415
+ },
1416
+ protocols: ["activitypub"],
1417
+ usage: {
1418
+ users: {},
1419
+ localPosts: 0,
1420
+ localComments: 0
1421
+ }
1422
+ }));
1423
+ federation.setFollowersDispatcher("/users/{identifier}/followers", () => ({ items: [] }));
1424
+ federation.setCollectionDispatcher("custom-collection", vocab.Object, "/users/{identifier}/custom/{id}", () => ({ items: [] }));
1425
+ federation.setInboxListeners("/users/{identifier}/inbox", "/inbox");
1426
+ return {
1427
+ federation,
1428
+ recorder
1429
+ };
1430
+ };
1431
+ await t.step("records a successful actor request", async () => {
1432
+ const { federation, recorder } = createTestContext();
1433
+ assertEquals((await federation.fetch(new Request("https://example.com/users/alice", {
1434
+ method: "GET",
1435
+ headers: { "Accept": "application/activity+json" }
1436
+ }), { contextData: void 0 })).status, 200);
1437
+ const counts = recorder.getMeasurements("fedify.http.server.request.count");
1438
+ assertEquals(counts.length, 1);
1439
+ assertEquals(counts[0].type, "counter");
1440
+ assertEquals(counts[0].value, 1);
1441
+ assertEquals(counts[0].attributes["http.request.method"], "GET");
1442
+ assertEquals(counts[0].attributes["fedify.endpoint"], "actor");
1443
+ assertEquals(counts[0].attributes["http.response.status_code"], 200);
1444
+ assertEquals(counts[0].attributes["fedify.route.template"], "/users/{identifier}");
1445
+ const durations = recorder.getMeasurements("fedify.http.server.request.duration");
1446
+ assertEquals(durations.length, 1);
1447
+ assertEquals(durations[0].type, "histogram");
1448
+ assert(durations[0].value >= 0);
1449
+ assertEquals(durations[0].attributes["fedify.endpoint"], "actor");
1450
+ assertEquals(durations[0].attributes["http.response.status_code"], 200);
1451
+ assertEquals(durations[0].attributes["fedify.route.template"], "/users/{identifier}");
1452
+ });
1453
+ await t.step("records WebFinger requests", async () => {
1454
+ const { federation, recorder } = createTestContext();
1455
+ assertEquals((await federation.fetch(new Request("https://example.com/.well-known/webfinger?resource=acct:alice@example.com"), { contextData: void 0 })).status, 200);
1456
+ const counts = recorder.getMeasurements("fedify.http.server.request.count");
1457
+ assertEquals(counts.length, 1);
1458
+ assertEquals(counts[0].attributes["fedify.endpoint"], "webfinger");
1459
+ assertEquals(counts[0].attributes["fedify.route.template"], "/.well-known/webfinger");
1460
+ assertEquals(counts[0].attributes["http.response.status_code"], 200);
1461
+ });
1462
+ await t.step("records NodeInfo JRD requests", async () => {
1463
+ const { federation, recorder } = createTestContext();
1464
+ assertEquals((await federation.fetch(new Request("https://example.com/.well-known/nodeinfo"), { contextData: void 0 })).status, 200);
1465
+ const counts = recorder.getMeasurements("fedify.http.server.request.count");
1466
+ assertEquals(counts.length, 1);
1467
+ assertEquals(counts[0].attributes["fedify.endpoint"], "nodeinfo");
1468
+ assertEquals(counts[0].attributes["fedify.route.template"], "/.well-known/nodeinfo");
1469
+ });
1470
+ await t.step("records NodeInfo dispatcher requests", async () => {
1471
+ const { federation, recorder } = createTestContext();
1472
+ assertEquals((await federation.fetch(new Request("https://example.com/nodeinfo/2.1"), { contextData: void 0 })).status, 200);
1473
+ const counts = recorder.getMeasurements("fedify.http.server.request.count");
1474
+ assertEquals(counts.length, 1);
1475
+ assertEquals(counts[0].attributes["fedify.endpoint"], "nodeinfo");
1476
+ assertEquals(counts[0].attributes["fedify.route.template"], "/nodeinfo/2.1");
1477
+ });
1478
+ await t.step("records 404 not_found for unmatched paths", async () => {
1479
+ const { federation, recorder } = createTestContext();
1480
+ assertEquals((await federation.fetch(new Request("https://example.com/no/such/path"), { contextData: void 0 })).status, 404);
1481
+ const counts = recorder.getMeasurements("fedify.http.server.request.count");
1482
+ assertEquals(counts.length, 1);
1483
+ assertEquals(counts[0].attributes["fedify.endpoint"], "not_found");
1484
+ assertEquals(counts[0].attributes["http.response.status_code"], 404);
1485
+ assertEquals(counts[0].attributes["fedify.route.template"], void 0);
1486
+ });
1487
+ await t.step("records 406 not_acceptable when JSON-LD Accept missing", async () => {
1488
+ const { federation, recorder } = createTestContext();
1489
+ assertEquals((await federation.fetch(new Request("https://example.com/users/alice", {
1490
+ method: "GET",
1491
+ headers: { "Accept": "text/html" }
1492
+ }), { contextData: void 0 })).status, 406);
1493
+ const counts = recorder.getMeasurements("fedify.http.server.request.count");
1494
+ assertEquals(counts.length, 1);
1495
+ assertEquals(counts[0].attributes["fedify.endpoint"], "not_acceptable");
1496
+ assertEquals(counts[0].attributes["http.response.status_code"], 406);
1497
+ assertEquals(counts[0].attributes["fedify.route.template"], "/users/{identifier}");
1498
+ });
1499
+ await t.step("records collection metrics for not_acceptable collection requests", async () => {
1500
+ const { federation, recorder } = createTestContext();
1501
+ assertEquals((await federation.fetch(new Request("https://example.com/users/alice/followers", {
1502
+ method: "GET",
1503
+ headers: { "Accept": "text/html" }
1504
+ }), { contextData: void 0 })).status, 406);
1505
+ const requests = recorder.getMeasurements("activitypub.collection.request");
1506
+ assertEquals(requests.length, 1);
1507
+ assertEquals(requests[0].attributes["activitypub.collection.kind"], "followers");
1508
+ assertEquals(requests[0].attributes["activitypub.collection.page"], false);
1509
+ assertEquals(requests[0].attributes["fedify.collection.dispatcher"], "built_in");
1510
+ assertEquals(requests[0].attributes["activitypub.collection.result"], "not_acceptable");
1511
+ assertEquals(requests[0].attributes["http.response.status_code"], 406);
1512
+ });
1513
+ await t.step("records thrown errors after classification with the matched endpoint", async () => {
1514
+ const { federation, recorder } = createTestContext();
1515
+ await assertRejects(() => federation.fetch(new Request("https://example.com/users/boom", {
1516
+ method: "GET",
1517
+ headers: { "Accept": "application/activity+json" }
1518
+ }), { contextData: void 0 }), Error, "explosion");
1519
+ const counts = recorder.getMeasurements("fedify.http.server.request.count");
1520
+ assertEquals(counts.length, 1);
1521
+ assertEquals(counts[0].attributes["fedify.endpoint"], "actor");
1522
+ assertEquals(counts[0].attributes["http.response.status_code"], void 0);
1523
+ assertEquals(counts[0].attributes["fedify.route.template"], "/users/{identifier}");
1524
+ const durations = recorder.getMeasurements("fedify.http.server.request.duration");
1525
+ assertEquals(durations.length, 1);
1526
+ assertEquals(durations[0].attributes["fedify.endpoint"], "actor");
1527
+ });
1528
+ await t.step("collapses user-defined collection dispatchers to endpoint=collection", async () => {
1529
+ const { federation, recorder } = createTestContext();
1530
+ assertEquals((await federation.fetch(new Request("https://example.com/users/alice/custom/1", {
1531
+ method: "GET",
1532
+ headers: { "Accept": "application/activity+json" }
1533
+ }), { contextData: void 0 })).status, 200);
1534
+ const counts = recorder.getMeasurements("fedify.http.server.request.count");
1535
+ assertEquals(counts.length, 1);
1536
+ assertEquals(counts[0].attributes["fedify.endpoint"], "collection");
1537
+ assertEquals(counts[0].attributes["fedify.route.template"], "/users/{identifier}/custom/{id}");
1538
+ });
1539
+ await t.step("records followers as endpoint=followers", async () => {
1540
+ const { federation, recorder } = createTestContext();
1541
+ assertEquals((await federation.fetch(new Request("https://example.com/users/alice/followers", {
1542
+ method: "GET",
1543
+ headers: { "Accept": "application/activity+json" }
1544
+ }), { contextData: void 0 })).status, 200);
1545
+ const counts = recorder.getMeasurements("fedify.http.server.request.count");
1546
+ assertEquals(counts.length, 1);
1547
+ assertEquals(counts[0].attributes["fedify.endpoint"], "followers");
1548
+ assertEquals(counts[0].attributes["fedify.route.template"], "/users/{identifier}/followers");
1549
+ });
1550
+ await t.step("records sharedInbox as endpoint=shared_inbox", async () => {
1551
+ const kv = new MemoryKvStore();
1552
+ const [meterProvider, recorder] = createTestMeterProvider();
1553
+ const federation = createFederation({
1554
+ kv,
1555
+ meterProvider,
1556
+ documentLoaderFactory: () => mockDocumentLoader
1557
+ });
1558
+ federation.setInboxListeners("/users/{identifier}/inbox", "/inbox");
1559
+ assert((await federation.fetch(new Request("https://example.com/inbox", {
1560
+ method: "POST",
1561
+ headers: { "accept": "application/ld+json" }
1562
+ }), { contextData: void 0 })).status >= 400);
1563
+ const counts = recorder.getMeasurements("fedify.http.server.request.count");
1564
+ assertEquals(counts.length, 1);
1565
+ assertEquals(counts[0].attributes["fedify.endpoint"], "shared_inbox");
1566
+ assertEquals(counts[0].attributes["fedify.route.template"], "/inbox");
1567
+ assertEquals(counts[0].attributes["http.request.method"], "POST");
1568
+ });
1569
+ await t.step("normalizes unknown HTTP methods to _OTHER for cardinality control", async () => {
1570
+ const { federation, recorder } = createTestContext();
1571
+ assert((await federation.fetch(new Request("https://example.com/users/alice", {
1572
+ method: "PROPFIND",
1573
+ headers: { "Accept": "application/activity+json" }
1574
+ }), { contextData: void 0 })).status >= 100);
1575
+ const counts = recorder.getMeasurements("fedify.http.server.request.count");
1576
+ assertEquals(counts.length, 1);
1577
+ assertEquals(counts[0].attributes["http.request.method"], "_OTHER");
1578
+ });
1579
+ await t.step("preserves QUERY as a known HTTP method", async () => {
1580
+ const { federation, recorder } = createTestContext();
1581
+ assert((await federation.fetch(new Request("https://example.com/users/alice", {
1582
+ method: "QUERY",
1583
+ headers: { "Accept": "application/activity+json" }
1584
+ }), { contextData: void 0 })).status >= 100);
1585
+ const counts = recorder.getMeasurements("fedify.http.server.request.count");
1586
+ assertEquals(counts.length, 1);
1587
+ assertEquals(counts[0].attributes["http.request.method"], "QUERY");
1588
+ });
1589
+ await t.step("uses the global meter provider when none is configured", async () => {
1590
+ const federation = createFederation({
1591
+ kv: new MemoryKvStore(),
1592
+ documentLoaderFactory: () => mockDocumentLoader
1593
+ });
1594
+ federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => new vocab.Person({ id: ctx.getActorUri(identifier) }));
1595
+ assertEquals((await federation.fetch(new Request("https://example.com/users/alice", {
1596
+ method: "GET",
1597
+ headers: { "Accept": "application/activity+json" }
1598
+ }), { contextData: void 0 })).status, 200);
1599
+ });
1600
+ });
927
1601
  test("Federation.setInboxListeners()", async (t) => {
928
1602
  const kv = new MemoryKvStore();
929
1603
  esm_default.spyGlobal();
@@ -1693,19 +2367,22 @@ test("Federation.setOutboxListeners()", async (t) => {
1693
2367
  };
1694
2368
  let listenCalled = false;
1695
2369
  const enqueued = [];
2370
+ const queue = {
2371
+ enqueue(message) {
2372
+ enqueued.push(message);
2373
+ return Promise.resolve();
2374
+ },
2375
+ listen() {
2376
+ listenCalled = true;
2377
+ return Promise.resolve();
2378
+ }
2379
+ };
2380
+ const [meterProvider, recorder] = createTestMeterProvider();
1696
2381
  const federation = new FederationImpl({
1697
2382
  kv,
1698
2383
  contextLoaderFactory: () => mockDocumentLoader,
1699
- queue: {
1700
- enqueue(message) {
1701
- enqueued.push(message);
1702
- return Promise.resolve();
1703
- },
1704
- listen() {
1705
- listenCalled = true;
1706
- return Promise.resolve();
1707
- }
1708
- }
2384
+ meterProvider,
2385
+ queue
1709
2386
  });
1710
2387
  federation.setActorDispatcher("/users/{identifier}", (_ctx, identifier) => identifier === "john" ? new vocab.Person({}) : null).setKeyPairsDispatcher(() => [{
1711
2388
  privateKey: rsaPrivateKey2,
@@ -1731,7 +2408,59 @@ test("Federation.setOutboxListeners()", async (t) => {
1731
2408
  assertEquals(enqueued.length, 1);
1732
2409
  assertEquals(enqueued[0].type, "outbox");
1733
2410
  assertEquals(enqueued[0].actorIds, ["https://remote.example/users/alice"]);
2411
+ const enqueuedMetrics = recorder.getMeasurements("fedify.queue.task.enqueued");
2412
+ assertEquals(enqueuedMetrics.length, 1);
2413
+ assertEquals(enqueuedMetrics[0].attributes["fedify.queue.role"], "outbox");
2414
+ assertEquals(enqueuedMetrics[0].attributes["fedify.queue.task.attempt"], 0);
2415
+ });
2416
+ });
2417
+ test("Federation.fetch() preserves original LD-signed payload for InboxContextImpl.activity", async () => {
2418
+ const remoteContextUrl = "https://remote.example/contexts/ext";
2419
+ const sourceContextLoader = async (resource) => {
2420
+ const url = new URL(resource).href;
2421
+ if (url === remoteContextUrl) return {
2422
+ contextUrl: null,
2423
+ documentUrl: url,
2424
+ document: { "@context": { ext: "https://example.com/ext" } }
2425
+ };
2426
+ return await mockDocumentLoader(url);
2427
+ };
2428
+ const federation = createFederation({
2429
+ kv: new MemoryKvStore(),
2430
+ documentLoaderFactory: () => mockDocumentLoader,
2431
+ contextLoaderFactory: () => sourceContextLoader
1734
2432
  });
2433
+ federation.setActorDispatcher("/users/{identifier}", (_ctx, identifier) => identifier === "someone" ? new Person({}) : null);
2434
+ let receivedRaw = null;
2435
+ let receivedTyped = null;
2436
+ federation.setInboxListeners("/users/{identifier}/inbox", "/inbox").on(Create, (ctx, activity) => {
2437
+ receivedRaw = ctx.activity;
2438
+ receivedTyped = activity;
2439
+ });
2440
+ const signed = await signJsonLd({
2441
+ "@context": [remoteContextUrl, "https://www.w3.org/ns/activitystreams"],
2442
+ id: "https://example.com/activities/preserve-raw",
2443
+ type: "Create",
2444
+ actor: "https://example.com/person2",
2445
+ ext: "preserve-me",
2446
+ object: {
2447
+ id: "https://example.com/notes/preserve-raw",
2448
+ type: "Note",
2449
+ attributedTo: "https://example.com/person2",
2450
+ content: "Hello, world!"
2451
+ }
2452
+ }, rsaPrivateKey3, rsaPublicKey3.id, { contextLoader: sourceContextLoader });
2453
+ const response = await federation.fetch(new Request("https://example.com/inbox", {
2454
+ method: "POST",
2455
+ headers: { "Content-Type": "application/activity+json" },
2456
+ body: JSON.stringify(signed)
2457
+ }), { contextData: void 0 });
2458
+ assertEquals([response.status, await response.text()], [202, ""]);
2459
+ assertEquals(receivedRaw, signed);
2460
+ assertNotEquals(receivedRaw, await compactJsonLd(signed, sourceContextLoader));
2461
+ const delivered = receivedTyped;
2462
+ assert(delivered != null);
2463
+ assertEquals(delivered.id?.href, "https://example.com/activities/preserve-raw");
1735
2464
  });
1736
2465
  test("Federation.setInboxDispatcher()", async (t) => {
1737
2466
  const kv = new MemoryKvStore();
@@ -2054,32 +2783,2798 @@ test("FederationImpl.processQueuedTask()", async (t) => {
2054
2783
  attempt: 1
2055
2784
  }]);
2056
2785
  });
2057
- });
2058
- test("FederationImpl.processQueuedTask() permanent failure", async (t) => {
2059
- esm_default.spyGlobal();
2786
+ await t.step("records activitypub.outbox.activity retry on transient failure", async () => {
2787
+ const kv = new MemoryKvStore();
2788
+ const [meterProvider, recorder] = createTestMeterProvider();
2789
+ await new FederationImpl({
2790
+ kv,
2791
+ meterProvider,
2792
+ queue: {
2793
+ enqueue(_message, _options) {
2794
+ return Promise.resolve();
2795
+ },
2796
+ listen(_handler, _options) {
2797
+ return Promise.resolve();
2798
+ }
2799
+ }
2800
+ }).processQueuedTask(void 0, {
2801
+ type: "outbox",
2802
+ id: crypto.randomUUID(),
2803
+ baseUrl: "https://example.com",
2804
+ keys: [],
2805
+ activity: {
2806
+ "@context": "https://www.w3.org/ns/activitystreams",
2807
+ type: "Create",
2808
+ actor: "https://example.com/users/alice",
2809
+ object: {
2810
+ type: "Note",
2811
+ content: "test"
2812
+ }
2813
+ },
2814
+ activityType: "https://www.w3.org/ns/activitystreams#Create",
2815
+ inbox: "https://invalid-domain-that-does-not-exist.example/inbox",
2816
+ sharedInbox: false,
2817
+ started: (/* @__PURE__ */ new Date()).toISOString(),
2818
+ attempt: 0,
2819
+ headers: {},
2820
+ traceContext: {}
2821
+ });
2822
+ const outboxLifecycle = recorder.getMeasurements("activitypub.outbox.activity");
2823
+ assertEquals(outboxLifecycle.length, 1);
2824
+ assertEquals(outboxLifecycle[0].type, "counter");
2825
+ assertEquals(outboxLifecycle[0].attributes["activitypub.processing.result"], "retried");
2826
+ assertEquals(outboxLifecycle[0].attributes["activitypub.activity.type"], "https://www.w3.org/ns/activitystreams#Create");
2827
+ });
2828
+ await t.step("records activitypub.outbox.activity abandoned when retry policy gives up", async () => {
2829
+ const kv = new MemoryKvStore();
2830
+ const [meterProvider, recorder] = createTestMeterProvider();
2831
+ await new FederationImpl({
2832
+ kv,
2833
+ meterProvider,
2834
+ queue: {
2835
+ enqueue(_message, _options) {
2836
+ return Promise.resolve();
2837
+ },
2838
+ listen(_handler, _options) {
2839
+ return Promise.resolve();
2840
+ }
2841
+ },
2842
+ outboxRetryPolicy: () => null
2843
+ }).processQueuedTask(void 0, {
2844
+ type: "outbox",
2845
+ id: crypto.randomUUID(),
2846
+ baseUrl: "https://example.com",
2847
+ keys: [],
2848
+ activity: {
2849
+ "@context": "https://www.w3.org/ns/activitystreams",
2850
+ type: "Follow",
2851
+ actor: "https://example.com/users/alice",
2852
+ object: "https://remote.example/users/bob"
2853
+ },
2854
+ activityType: "https://www.w3.org/ns/activitystreams#Follow",
2855
+ inbox: "https://invalid-domain-that-does-not-exist.example/inbox",
2856
+ sharedInbox: false,
2857
+ started: (/* @__PURE__ */ new Date()).toISOString(),
2858
+ attempt: 0,
2859
+ headers: {},
2860
+ traceContext: {}
2861
+ });
2862
+ const outboxLifecycle = recorder.getMeasurements("activitypub.outbox.activity");
2863
+ assertEquals(outboxLifecycle.length, 1);
2864
+ assertEquals(outboxLifecycle[0].type, "counter");
2865
+ assertEquals(outboxLifecycle[0].attributes["activitypub.processing.result"], "abandoned");
2866
+ assertEquals(outboxLifecycle[0].attributes["activitypub.activity.type"], "https://www.w3.org/ns/activitystreams#Follow");
2867
+ });
2868
+ await t.step("records activitypub.inbox.activity processed on successful queued dispatch", async () => {
2869
+ const kv = new MemoryKvStore();
2870
+ const [meterProvider, recorder] = createTestMeterProvider();
2871
+ const federation = new FederationImpl({
2872
+ kv,
2873
+ meterProvider,
2874
+ queue: {
2875
+ enqueue(_message, _options) {
2876
+ return Promise.resolve();
2877
+ },
2878
+ listen(_handler, _options) {
2879
+ return Promise.resolve();
2880
+ }
2881
+ }
2882
+ });
2883
+ federation.setInboxListeners("/users/{identifier}/inbox", "/inbox").on(vocab.Create, () => {});
2884
+ await federation.processQueuedTask(void 0, {
2885
+ type: "inbox",
2886
+ id: crypto.randomUUID(),
2887
+ baseUrl: "https://example.com",
2888
+ activity: {
2889
+ "@context": "https://www.w3.org/ns/activitystreams",
2890
+ type: "Create",
2891
+ id: "https://example.com/activities/queued-processed",
2892
+ actor: "https://remote.example/users/alice",
2893
+ object: {
2894
+ type: "Note",
2895
+ content: "Hello world"
2896
+ }
2897
+ },
2898
+ started: (/* @__PURE__ */ new Date()).toISOString(),
2899
+ attempt: 0,
2900
+ identifier: null,
2901
+ traceContext: {}
2902
+ });
2903
+ const inboxLifecycle = recorder.getMeasurements("activitypub.inbox.activity");
2904
+ assertEquals(inboxLifecycle.length, 1);
2905
+ assertEquals(inboxLifecycle[0].type, "counter");
2906
+ assertEquals(inboxLifecycle[0].attributes["activitypub.processing.result"], "processed");
2907
+ assertEquals(inboxLifecycle[0].attributes["activitypub.activity.type"], "https://www.w3.org/ns/activitystreams#Create");
2908
+ });
2909
+ await t.step("records activitypub.inbox.activity retried on transient listener failure", async () => {
2910
+ const kv = new MemoryKvStore();
2911
+ const [meterProvider, recorder] = createTestMeterProvider();
2912
+ const federation = new FederationImpl({
2913
+ kv,
2914
+ meterProvider,
2915
+ queue: {
2916
+ enqueue(_message, _options) {
2917
+ return Promise.resolve();
2918
+ },
2919
+ listen(_handler, _options) {
2920
+ return Promise.resolve();
2921
+ }
2922
+ }
2923
+ });
2924
+ federation.setInboxListeners("/users/{identifier}/inbox", "/inbox").on(vocab.Create, () => {
2925
+ throw new Error("Intended error for testing");
2926
+ });
2927
+ await federation.processQueuedTask(void 0, {
2928
+ type: "inbox",
2929
+ id: crypto.randomUUID(),
2930
+ baseUrl: "https://example.com",
2931
+ activity: {
2932
+ "@context": "https://www.w3.org/ns/activitystreams",
2933
+ type: "Create",
2934
+ id: "https://example.com/activities/queued-retried",
2935
+ actor: "https://remote.example/users/alice",
2936
+ object: {
2937
+ type: "Note",
2938
+ content: "Hello world"
2939
+ }
2940
+ },
2941
+ started: (/* @__PURE__ */ new Date()).toISOString(),
2942
+ attempt: 0,
2943
+ identifier: null,
2944
+ traceContext: {}
2945
+ });
2946
+ const inboxLifecycle = recorder.getMeasurements("activitypub.inbox.activity");
2947
+ assertEquals(inboxLifecycle.length, 1);
2948
+ assertEquals(inboxLifecycle[0].attributes["activitypub.processing.result"], "retried");
2949
+ assertEquals(inboxLifecycle[0].attributes["activitypub.activity.type"], "https://www.w3.org/ns/activitystreams#Create");
2950
+ });
2951
+ await t.step("records activitypub.inbox.activity abandoned when retry policy gives up", async () => {
2952
+ const kv = new MemoryKvStore();
2953
+ const [meterProvider, recorder] = createTestMeterProvider();
2954
+ const federation = new FederationImpl({
2955
+ kv,
2956
+ meterProvider,
2957
+ queue: {
2958
+ enqueue(_message, _options) {
2959
+ return Promise.resolve();
2960
+ },
2961
+ listen(_handler, _options) {
2962
+ return Promise.resolve();
2963
+ }
2964
+ },
2965
+ inboxRetryPolicy: () => null
2966
+ });
2967
+ federation.setInboxListeners("/users/{identifier}/inbox", "/inbox").on(vocab.Create, () => {
2968
+ throw new Error("Intended error for testing");
2969
+ });
2970
+ await federation.processQueuedTask(void 0, {
2971
+ type: "inbox",
2972
+ id: crypto.randomUUID(),
2973
+ baseUrl: "https://example.com",
2974
+ activity: {
2975
+ "@context": "https://www.w3.org/ns/activitystreams",
2976
+ type: "Create",
2977
+ id: "https://example.com/activities/queued-abandoned",
2978
+ actor: "https://remote.example/users/alice",
2979
+ object: {
2980
+ type: "Note",
2981
+ content: "Hello world"
2982
+ }
2983
+ },
2984
+ started: (/* @__PURE__ */ new Date()).toISOString(),
2985
+ attempt: 0,
2986
+ identifier: null,
2987
+ traceContext: {}
2988
+ });
2989
+ const inboxLifecycle = recorder.getMeasurements("activitypub.inbox.activity");
2990
+ assertEquals(inboxLifecycle.length, 1);
2991
+ assertEquals(inboxLifecycle[0].attributes["activitypub.processing.result"], "abandoned");
2992
+ assertEquals(inboxLifecycle[0].attributes["activitypub.activity.type"], "https://www.w3.org/ns/activitystreams#Create");
2993
+ });
2994
+ await t.step("records queued inbox processing duration", async () => {
2995
+ const kv = new MemoryKvStore();
2996
+ const [meterProvider, recorder] = createTestMeterProvider();
2997
+ const federation = new FederationImpl({
2998
+ kv,
2999
+ meterProvider,
3000
+ queue: {
3001
+ enqueue(_message, _options) {
3002
+ return Promise.resolve();
3003
+ },
3004
+ listen(_handler, _options) {
3005
+ return Promise.resolve();
3006
+ }
3007
+ }
3008
+ });
3009
+ let handled = false;
3010
+ federation.setInboxListeners("/users/{identifier}/inbox", "/inbox").on(vocab.Create, () => {
3011
+ handled = true;
3012
+ });
3013
+ await federation.processQueuedTask(void 0, {
3014
+ type: "inbox",
3015
+ id: crypto.randomUUID(),
3016
+ baseUrl: "https://example.com",
3017
+ activity: {
3018
+ "@context": "https://www.w3.org/ns/activitystreams",
3019
+ type: "Create",
3020
+ id: "https://remote.example/activities/1",
3021
+ actor: "https://remote.example/users/alice",
3022
+ object: {
3023
+ type: "Note",
3024
+ content: "Hello world"
3025
+ }
3026
+ },
3027
+ started: (/* @__PURE__ */ new Date()).toISOString(),
3028
+ attempt: 0,
3029
+ identifier: null,
3030
+ traceContext: {}
3031
+ });
3032
+ assert(handled);
3033
+ const durations = recorder.getMeasurements("activitypub.inbox.processing_duration");
3034
+ assertEquals(durations.length, 1);
3035
+ assertEquals(durations[0].attributes["activitypub.activity.type"], "https://www.w3.org/ns/activitystreams#Create");
3036
+ const started = recorder.getMeasurements("fedify.queue.task.started");
3037
+ assertEquals(started.length, 1);
3038
+ assertEquals(started[0].attributes["fedify.queue.role"], "inbox");
3039
+ const completed = recorder.getMeasurements("fedify.queue.task.completed");
3040
+ assertEquals(completed.length, 1);
3041
+ assertEquals(completed[0].attributes["fedify.queue.role"], "inbox");
3042
+ assertEquals(completed[0].attributes["fedify.queue.task.result"], "completed");
3043
+ assertEquals(completed[0].attributes["activitypub.activity.type"], "https://www.w3.org/ns/activitystreams#Create");
3044
+ assertEquals(recorder.getMeasurements("fedify.queue.task.failed").length, 0);
3045
+ const taskDurations = recorder.getMeasurements("fedify.queue.task.duration");
3046
+ assertEquals(taskDurations.length, 1);
3047
+ assertEquals(taskDurations[0].type, "histogram");
3048
+ assertEquals(taskDurations[0].attributes["fedify.queue.role"], "inbox");
3049
+ assertEquals(taskDurations[0].attributes["fedify.queue.task.result"], "completed");
3050
+ const inFlight = recorder.getMeasurements("fedify.queue.task.in_flight");
3051
+ assertEquals(inFlight.length, 2);
3052
+ assertEquals(inFlight[0].type, "upDownCounter");
3053
+ assertEquals(inFlight[0].value, 1);
3054
+ assertEquals(inFlight[1].value, -1);
3055
+ assertEquals(inFlight[0].attributes, inFlight[1].attributes);
3056
+ assertEquals(inFlight[0].attributes["fedify.queue.role"], "inbox");
3057
+ assertEquals(inFlight[0].attributes["activitypub.activity.type"], void 0);
3058
+ });
3059
+ await t.step("with restrictive context loader and normalized LD-signed inbox activity", async () => {
3060
+ const remoteContextUrl = "https://remote.example/contexts/ext";
3061
+ const sourceContextLoader = async (resource) => {
3062
+ const url = new URL(resource).href;
3063
+ if (url === remoteContextUrl) return {
3064
+ contextUrl: null,
3065
+ documentUrl: url,
3066
+ document: { "@context": { ext: "https://example.com/ext" } }
3067
+ };
3068
+ return await mockDocumentLoader(url);
3069
+ };
3070
+ const restrictiveContextLoader = async (resource) => {
3071
+ const url = new URL(resource).href;
3072
+ if (url === "https://www.w3.org/ns/activitystreams" || url === "https://w3id.org/identity/v1") return await mockDocumentLoader(url);
3073
+ throw new Error(`Unexpected context: ${url}`);
3074
+ };
3075
+ const kv = new MemoryKvStore();
3076
+ let receivedCount = 0;
3077
+ let received = null;
3078
+ let receivedRaw = null;
3079
+ const federation = new FederationImpl({
3080
+ kv,
3081
+ contextLoaderFactory: () => restrictiveContextLoader
3082
+ });
3083
+ federation.setInboxListeners("/users/{identifier}/inbox", "/inbox").on(Create, (ctx, activity) => {
3084
+ receivedCount++;
3085
+ receivedRaw = ctx.activity;
3086
+ received = activity;
3087
+ });
3088
+ const signed = await signJsonLd({
3089
+ "@context": [remoteContextUrl, "https://www.w3.org/ns/activitystreams"],
3090
+ id: "https://remote.example/activities/1",
3091
+ type: "Create",
3092
+ actor: "https://remote.example/users/alice",
3093
+ ext: "preserve-me",
3094
+ object: {
3095
+ id: "https://remote.example/notes/1",
3096
+ type: "Note",
3097
+ attributedTo: "https://remote.example/users/alice",
3098
+ content: "Hello, world!"
3099
+ }
3100
+ }, rsaPrivateKey3, rsaPublicKey3.id, { contextLoader: sourceContextLoader });
3101
+ const normalizedActivity = await compactJsonLd(signed, sourceContextLoader);
3102
+ const messageId = crypto.randomUUID();
3103
+ await federation.processQueuedTask(void 0, {
3104
+ type: "inbox",
3105
+ id: messageId,
3106
+ baseUrl: "https://example.com",
3107
+ activity: signed,
3108
+ normalizedActivity,
3109
+ started: (/* @__PURE__ */ new Date()).toISOString(),
3110
+ attempt: 0,
3111
+ identifier: null,
3112
+ traceContext: {}
3113
+ });
3114
+ const delivered = received;
3115
+ assert(delivered != null);
3116
+ const deliveredCreate = delivered;
3117
+ assertInstanceOf(deliveredCreate, Create);
3118
+ assertEquals(deliveredCreate.id?.href, "https://remote.example/activities/1");
3119
+ assertEquals(receivedRaw, signed);
3120
+ await federation.processQueuedTask(void 0, {
3121
+ type: "inbox",
3122
+ id: messageId,
3123
+ baseUrl: "https://example.com",
3124
+ activity: signed,
3125
+ normalizedActivity,
3126
+ started: (/* @__PURE__ */ new Date()).toISOString(),
3127
+ attempt: 0,
3128
+ identifier: null,
3129
+ traceContext: {}
3130
+ });
3131
+ assertEquals(receivedCount, 1);
3132
+ });
3133
+ await t.step("cached normalizedActivity is rechecked for unsafe JSON-LD keywords", async () => {
3134
+ const queuedMessages = [];
3135
+ const queue = {
3136
+ enqueue(message, _options) {
3137
+ queuedMessages.push(message);
3138
+ return Promise.resolve();
3139
+ },
3140
+ listen(_handler, _options) {
3141
+ return Promise.resolve();
3142
+ }
3143
+ };
3144
+ const kv = new MemoryKvStore();
3145
+ let receivedCount = 0;
3146
+ let errorCount = 0;
3147
+ const federation = new FederationImpl({
3148
+ kv,
3149
+ queue
3150
+ });
3151
+ federation.setInboxListeners("/users/{identifier}/inbox", "/inbox").on(Create, () => {
3152
+ receivedCount++;
3153
+ }).onError(() => {
3154
+ errorCount++;
3155
+ });
3156
+ const signed = await signJsonLd({
3157
+ "@context": "https://www.w3.org/ns/activitystreams",
3158
+ id: "https://remote.example/activities/unsafe-normalized-cache",
3159
+ type: "Create",
3160
+ actor: "https://remote.example/users/alice",
3161
+ object: {
3162
+ id: "https://remote.example/notes/unsafe-normalized-cache",
3163
+ type: "Note",
3164
+ attributedTo: "https://remote.example/users/alice",
3165
+ content: "Hello from unsafe normalized cache"
3166
+ }
3167
+ }, rsaPrivateKey3, rsaPublicKey3.id, { contextLoader: mockDocumentLoader });
3168
+ const normalizedActivity = await compactJsonLd(signed, mockDocumentLoader);
3169
+ const tamperedNormalizedActivity = {
3170
+ ...normalizedActivity,
3171
+ signature: {
3172
+ ...normalizedActivity.signature,
3173
+ "@included": [{
3174
+ id: "https://remote.example/activities/inside-signature",
3175
+ type: "Undo"
3176
+ }]
3177
+ }
3178
+ };
3179
+ await federation.processQueuedTask(void 0, {
3180
+ type: "inbox",
3181
+ id: crypto.randomUUID(),
3182
+ baseUrl: "https://example.com",
3183
+ activity: signed,
3184
+ normalizedActivity: tamperedNormalizedActivity,
3185
+ ldSignatureVerified: false,
3186
+ started: (/* @__PURE__ */ new Date()).toISOString(),
3187
+ attempt: 0,
3188
+ identifier: null,
3189
+ traceContext: {}
3190
+ });
3191
+ assertEquals(receivedCount, 0);
3192
+ assertEquals(errorCount, 1);
3193
+ assertEquals(queuedMessages, []);
3194
+ });
3195
+ await t.step("old queued LDS inbox messages without normalizedActivity still work", async () => {
3196
+ const restrictiveContextLoader = async (resource) => {
3197
+ const url = new URL(resource).href;
3198
+ if (url === "https://www.w3.org/ns/activitystreams" || url === "https://w3id.org/identity/v1") return await mockDocumentLoader(url);
3199
+ throw new Error(`Unexpected context: ${url}`);
3200
+ };
3201
+ const kv = new MemoryKvStore();
3202
+ let received = null;
3203
+ const federation = new FederationImpl({
3204
+ kv,
3205
+ contextLoaderFactory: () => restrictiveContextLoader
3206
+ });
3207
+ federation.setInboxListeners("/users/{identifier}/inbox", "/inbox").on(Create, (_ctx, activity) => {
3208
+ received = activity;
3209
+ });
3210
+ const compacted = await compactJsonLd(await signJsonLd({
3211
+ "@context": "https://www.w3.org/ns/activitystreams",
3212
+ id: "https://remote.example/activities/legacy",
3213
+ type: "Create",
3214
+ actor: "https://remote.example/users/alice",
3215
+ object: {
3216
+ id: "https://remote.example/notes/legacy",
3217
+ type: "Note",
3218
+ attributedTo: "https://remote.example/users/alice",
3219
+ content: "Hello from legacy queue"
3220
+ }
3221
+ }, rsaPrivateKey3, rsaPublicKey3.id, { contextLoader: mockDocumentLoader }), restrictiveContextLoader);
3222
+ await federation.processQueuedTask(void 0, {
3223
+ type: "inbox",
3224
+ id: crypto.randomUUID(),
3225
+ baseUrl: "https://example.com",
3226
+ activity: compacted,
3227
+ started: (/* @__PURE__ */ new Date()).toISOString(),
3228
+ attempt: 0,
3229
+ identifier: null,
3230
+ traceContext: {}
3231
+ });
3232
+ assert(received != null);
3233
+ assertEquals(received.id?.href, "https://remote.example/activities/legacy");
3234
+ });
3235
+ await t.step("queued signature-bearing non-LDS inbox messages keep parse-time normalization contexts", async () => {
3236
+ const signingContextLoader = async (resource) => {
3237
+ const url = new URL(resource).href;
3238
+ if (url === "https://www.w3.org/ns/activitystreams" || url === "https://w3id.org/identity/v1" || url === "https://w3id.org/security/v1" || url === "https://w3id.org/security/data-integrity/v1") return await mockDocumentLoader(url);
3239
+ throw new Error(`Unexpected context: ${url}`);
3240
+ };
3241
+ const processingContextLoader = async (resource) => {
3242
+ const url = new URL(resource).href;
3243
+ if (url === "https://w3id.org/identity/v1" || url === "https://w3id.org/security/v1" || url === "https://w3id.org/security/data-integrity/v1") throw new Error("queued non-LDS signed payloads should parse with the normalization loader's built-in signature contexts");
3244
+ return await signingContextLoader(resource);
3245
+ };
3246
+ const kv = new MemoryKvStore();
3247
+ let received = null;
3248
+ let receivedRaw = null;
3249
+ const federation = new FederationImpl({
3250
+ kv,
3251
+ contextLoaderFactory: () => processingContextLoader
3252
+ });
3253
+ federation.setInboxListeners("/users/{identifier}/inbox", "/inbox").on(Create, (ctx, activity) => {
3254
+ receivedRaw = ctx.activity;
3255
+ received = activity;
3256
+ });
3257
+ const signed = await signJsonLd({
3258
+ "@context": ["https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1"],
3259
+ id: "https://remote.example/activities/non-lds-queued-signature",
3260
+ type: "Create",
3261
+ actor: "https://remote.example/users/alice",
3262
+ object: {
3263
+ id: "https://remote.example/notes/non-lds-queued-signature",
3264
+ type: "Note",
3265
+ attributedTo: "https://remote.example/users/alice",
3266
+ content: "Hello from non-LDS queued signature"
3267
+ }
3268
+ }, rsaPrivateKey3, rsaPublicKey3.id, { contextLoader: signingContextLoader });
3269
+ const signedPayload = signed;
3270
+ assert(Array.isArray(signedPayload["@context"]) && signedPayload["@context"].includes("https://w3id.org/security/v1"));
3271
+ await federation.processQueuedTask(void 0, {
3272
+ type: "inbox",
3273
+ id: crypto.randomUUID(),
3274
+ baseUrl: "https://example.com",
3275
+ activity: signed,
3276
+ ldSignatureVerified: false,
3277
+ started: (/* @__PURE__ */ new Date()).toISOString(),
3278
+ attempt: 0,
3279
+ identifier: null,
3280
+ traceContext: {}
3281
+ });
3282
+ if (received == null) throw new Error("Inbox activity not delivered.");
3283
+ assertEquals(received.id?.href, "https://remote.example/activities/non-lds-queued-signature");
3284
+ assertEquals(receivedRaw, signed);
3285
+ });
3286
+ await t.step("queued signature-bearing non-LDS inbox messages reuse normalizedActivity for custom contexts", async () => {
3287
+ const remoteContextUrl = "https://remote.example/contexts/ext";
3288
+ const sourceContextLoader = async (resource) => {
3289
+ const url = new URL(resource).href;
3290
+ if (url === remoteContextUrl) return {
3291
+ contextUrl: null,
3292
+ documentUrl: url,
3293
+ document: { "@context": { ext: "https://example.com/ext" } }
3294
+ };
3295
+ return await mockDocumentLoader(url);
3296
+ };
3297
+ const restrictiveContextLoader = async (resource) => {
3298
+ const url = new URL(resource).href;
3299
+ if (url === "https://www.w3.org/ns/activitystreams" || url === "https://w3id.org/identity/v1") return await mockDocumentLoader(url);
3300
+ throw new Error(`Unexpected context: ${url}`);
3301
+ };
3302
+ const kv = new MemoryKvStore();
3303
+ let received = null;
3304
+ let receivedRaw = null;
3305
+ const federation = new FederationImpl({
3306
+ kv,
3307
+ contextLoaderFactory: () => restrictiveContextLoader
3308
+ });
3309
+ federation.setInboxListeners("/users/{identifier}/inbox", "/inbox").on(Create, (ctx, activity) => {
3310
+ receivedRaw = ctx.activity;
3311
+ received = activity;
3312
+ });
3313
+ const unsignedBody = {
3314
+ "@context": [
3315
+ remoteContextUrl,
3316
+ "https://www.w3.org/ns/activitystreams",
3317
+ "https://w3id.org/security/v1"
3318
+ ],
3319
+ id: "https://remote.example/activities/non-lds-queued-custom-context",
3320
+ type: "Create",
3321
+ actor: "https://remote.example/users/alice",
3322
+ ext: "preserve-me",
3323
+ object: {
3324
+ id: "https://remote.example/notes/non-lds-queued-custom-context",
3325
+ type: "Note",
3326
+ attributedTo: "https://remote.example/users/alice",
3327
+ content: "Hello from non-LDS queued custom context"
3328
+ },
3329
+ signature: {
3330
+ type: "RsaSignature2017",
3331
+ creator: "not a url",
3332
+ created: "2024-09-12T16:50:46Z",
3333
+ signatureValue: "Zm9v"
3334
+ }
3335
+ };
3336
+ const normalizedActivity = await compactJsonLd(unsignedBody, sourceContextLoader);
3337
+ await federation.processQueuedTask(void 0, {
3338
+ type: "inbox",
3339
+ id: crypto.randomUUID(),
3340
+ baseUrl: "https://example.com",
3341
+ activity: unsignedBody,
3342
+ normalizedActivity,
3343
+ ldSignatureVerified: false,
3344
+ started: (/* @__PURE__ */ new Date()).toISOString(),
3345
+ attempt: 0,
3346
+ identifier: null,
3347
+ traceContext: {}
3348
+ });
3349
+ if (received == null) throw new Error("Inbox activity not delivered.");
3350
+ assertEquals(received.id?.href, "https://remote.example/activities/non-lds-queued-custom-context");
3351
+ assertEquals(receivedRaw, unsignedBody);
3352
+ });
3353
+ await t.step("legacy raw LDS inbox messages without normalizedActivity retry through worker error handling", async () => {
3354
+ const remoteContextUrl = "https://remote.example/contexts/ext";
3355
+ const restrictiveContextLoader = async (resource) => {
3356
+ const url = new URL(resource).href;
3357
+ if (url === "https://www.w3.org/ns/activitystreams" || url === "https://w3id.org/identity/v1") return await mockDocumentLoader(url);
3358
+ throw new Error(`Unexpected context: ${url}`);
3359
+ };
3360
+ const queue = {
3361
+ enqueue(message, _options) {
3362
+ queuedMessages.push(message);
3363
+ return Promise.resolve();
3364
+ },
3365
+ listen(_handler, _options) {
3366
+ return Promise.resolve();
3367
+ }
3368
+ };
3369
+ const kv = new MemoryKvStore();
3370
+ const queuedMessages = [];
3371
+ let errorCount = 0;
3372
+ const federation = new FederationImpl({
3373
+ kv,
3374
+ queue,
3375
+ contextLoaderFactory: () => restrictiveContextLoader
3376
+ });
3377
+ federation.setInboxListeners("/users/{identifier}/inbox", "/inbox").on(Create, () => {
3378
+ throw new Error("listener should not run");
3379
+ }).onError(() => {
3380
+ errorCount++;
3381
+ });
3382
+ const signed = await signJsonLd({
3383
+ "@context": [remoteContextUrl, "https://www.w3.org/ns/activitystreams"],
3384
+ id: "https://remote.example/activities/legacy-raw",
3385
+ type: "Create",
3386
+ actor: "https://remote.example/users/alice",
3387
+ ext: "preserve-me",
3388
+ object: {
3389
+ id: "https://remote.example/notes/legacy-raw",
3390
+ type: "Note",
3391
+ attributedTo: "https://remote.example/users/alice",
3392
+ content: "Hello from raw legacy queue"
3393
+ }
3394
+ }, rsaPrivateKey3, rsaPublicKey3.id, { contextLoader: async (resource) => {
3395
+ const url = new URL(resource).href;
3396
+ if (url === remoteContextUrl) return {
3397
+ contextUrl: null,
3398
+ documentUrl: url,
3399
+ document: { "@context": { ext: "https://example.com/ext" } }
3400
+ };
3401
+ return await mockDocumentLoader(url);
3402
+ } });
3403
+ const inboxMessage = {
3404
+ type: "inbox",
3405
+ id: crypto.randomUUID(),
3406
+ baseUrl: "https://example.com",
3407
+ activity: signed,
3408
+ started: (/* @__PURE__ */ new Date()).toISOString(),
3409
+ attempt: 0,
3410
+ identifier: null,
3411
+ traceContext: {}
3412
+ };
3413
+ await federation.processQueuedTask(void 0, inboxMessage);
3414
+ assertEquals(errorCount, 1);
3415
+ assertEquals(queuedMessages.length, 1);
3416
+ const retried = queuedMessages[0];
3417
+ assertEquals(retried.attempt, 1);
3418
+ assertEquals(retried.activity, inboxMessage.activity);
3419
+ });
3420
+ await t.step("without inbox queue retriable inbox parse failures bubble to caller", async () => {
3421
+ const remoteContextUrl = "https://remote.example/contexts/ext";
3422
+ const sourceContextLoader = async (resource) => {
3423
+ const url = new URL(resource).href;
3424
+ if (url === remoteContextUrl) return {
3425
+ contextUrl: null,
3426
+ documentUrl: url,
3427
+ document: { "@context": { ext: "https://example.com/ext" } }
3428
+ };
3429
+ return await mockDocumentLoader(url);
3430
+ };
3431
+ let errorCount = 0;
3432
+ const federation = new FederationImpl({
3433
+ kv: new MemoryKvStore(),
3434
+ contextLoaderFactory: () => async (resource) => {
3435
+ const url = new URL(resource).href;
3436
+ if (url === "https://www.w3.org/ns/activitystreams" || url === "https://w3id.org/identity/v1") return await mockDocumentLoader(url);
3437
+ if (url === remoteContextUrl) throw new Error(`Transient remote context failure: ${url}`);
3438
+ throw new Error(`Unexpected context: ${url}`);
3439
+ }
3440
+ });
3441
+ federation.setInboxListeners("/users/{identifier}/inbox", "/inbox").on(Create, () => {
3442
+ throw new Error("listener should not run");
3443
+ }).onError(() => {
3444
+ errorCount++;
3445
+ });
3446
+ const signed = await signJsonLd({
3447
+ "@context": [remoteContextUrl, "https://www.w3.org/ns/activitystreams"],
3448
+ id: "https://remote.example/activities/manual-retry",
3449
+ type: "Create",
3450
+ actor: "https://remote.example/users/alice",
3451
+ ext: "preserve-me",
3452
+ object: {
3453
+ id: "https://remote.example/notes/manual-retry",
3454
+ type: "Note",
3455
+ attributedTo: "https://remote.example/users/alice",
3456
+ content: "Hello from manual retry queue"
3457
+ }
3458
+ }, rsaPrivateKey3, rsaPublicKey3.id, { contextLoader: sourceContextLoader });
3459
+ await assertRejects(() => federation.processQueuedTask(void 0, {
3460
+ type: "inbox",
3461
+ id: crypto.randomUUID(),
3462
+ baseUrl: "https://example.com",
3463
+ activity: signed,
3464
+ started: (/* @__PURE__ */ new Date()).toISOString(),
3465
+ attempt: 0,
3466
+ identifier: null,
3467
+ traceContext: {}
3468
+ }), Error);
3469
+ assertEquals(errorCount, 1);
3470
+ });
3471
+ await t.step("legacy raw LDS inbox messages with transient InvalidUrl failures retry", async () => {
3472
+ const remoteContextUrl = "https://remote.example/contexts/ext";
3473
+ const queue = {
3474
+ enqueue(message, _options) {
3475
+ queuedMessages.push(message);
3476
+ return Promise.resolve();
3477
+ },
3478
+ listen(_handler, _options) {
3479
+ return Promise.resolve();
3480
+ }
3481
+ };
3482
+ const kv = new MemoryKvStore();
3483
+ const queuedMessages = [];
3484
+ let errorCount = 0;
3485
+ const federation = new FederationImpl({
3486
+ kv,
3487
+ queue,
3488
+ contextLoaderFactory: () => async (resource) => {
3489
+ const url = new URL(resource).href;
3490
+ if (url === "https://www.w3.org/ns/activitystreams" || url === "https://w3id.org/identity/v1") return await mockDocumentLoader(url);
3491
+ if (url === remoteContextUrl) {
3492
+ const error = /* @__PURE__ */ new Error(`Transient remote context failure: ${url}`);
3493
+ error.name = "jsonld.InvalidUrl";
3494
+ error.details = {
3495
+ code: "loading remote context failed",
3496
+ url
3497
+ };
3498
+ throw error;
3499
+ }
3500
+ throw new Error(`Unexpected context: ${url}`);
3501
+ }
3502
+ });
3503
+ federation.setInboxListeners("/users/{identifier}/inbox", "/inbox").on(Create, () => {
3504
+ throw new Error("listener should not run");
3505
+ }).onError(() => {
3506
+ errorCount++;
3507
+ });
3508
+ const signed = await signJsonLd({
3509
+ "@context": [remoteContextUrl, "https://www.w3.org/ns/activitystreams"],
3510
+ id: "https://remote.example/activities/legacy-invalid-context",
3511
+ type: "Create",
3512
+ actor: "https://remote.example/users/alice",
3513
+ ext: "preserve-me",
3514
+ object: {
3515
+ id: "https://remote.example/notes/legacy-invalid-context",
3516
+ type: "Note",
3517
+ attributedTo: "https://remote.example/users/alice",
3518
+ content: "Hello from invalid legacy queue"
3519
+ }
3520
+ }, rsaPrivateKey3, rsaPublicKey3.id, { contextLoader: async (resource) => {
3521
+ const url = new URL(resource).href;
3522
+ if (url === remoteContextUrl) return {
3523
+ contextUrl: null,
3524
+ documentUrl: url,
3525
+ document: { "@context": { ext: "https://example.com/ext" } }
3526
+ };
3527
+ return await mockDocumentLoader(url);
3528
+ } });
3529
+ const inboxMessage = {
3530
+ type: "inbox",
3531
+ id: crypto.randomUUID(),
3532
+ baseUrl: "https://example.com",
3533
+ activity: signed,
3534
+ started: (/* @__PURE__ */ new Date()).toISOString(),
3535
+ attempt: 0,
3536
+ identifier: null,
3537
+ traceContext: {}
3538
+ };
3539
+ await federation.processQueuedTask(void 0, inboxMessage);
3540
+ assertEquals(errorCount, 1);
3541
+ assertEquals(queuedMessages.length, 1);
3542
+ const retried = queuedMessages[0];
3543
+ assertEquals(retried.attempt, 1);
3544
+ assertEquals(retried.activity, inboxMessage.activity);
3545
+ });
3546
+ await t.step("legacy raw LDS inbox messages with opaque context ids retry", async () => {
3547
+ const queue = {
3548
+ enqueue(message, _options) {
3549
+ queuedMessages.push(message);
3550
+ return Promise.resolve();
3551
+ },
3552
+ listen(_handler, _options) {
3553
+ return Promise.resolve();
3554
+ }
3555
+ };
3556
+ const kv = new MemoryKvStore();
3557
+ const queuedMessages = [];
3558
+ let errorCount = 0;
3559
+ const federation = new FederationImpl({
3560
+ kv,
3561
+ queue,
3562
+ contextLoaderFactory: () => async (resource) => {
3563
+ if (resource === "app-context") {
3564
+ const error = /* @__PURE__ */ new Error(`Opaque context backend is unavailable: ${resource}`);
3565
+ error.name = "jsonld.InvalidUrl";
3566
+ error.details = {
3567
+ code: "loading remote context failed",
3568
+ url: resource
3569
+ };
3570
+ throw error;
3571
+ }
3572
+ const url = new URL(resource).href;
3573
+ if (url === "https://www.w3.org/ns/activitystreams" || url === "https://w3id.org/identity/v1") return await mockDocumentLoader(url);
3574
+ throw new Error(`Unexpected context: ${resource}`);
3575
+ }
3576
+ });
3577
+ federation.setInboxListeners("/users/{identifier}/inbox", "/inbox").on(Create, () => {
3578
+ throw new Error("listener should not run");
3579
+ }).onError(() => {
3580
+ errorCount++;
3581
+ });
3582
+ const signed = await signJsonLd({
3583
+ "@context": "https://www.w3.org/ns/activitystreams",
3584
+ id: "https://remote.example/activities/legacy-malformed-context",
3585
+ type: "Create",
3586
+ actor: "https://remote.example/users/alice",
3587
+ object: {
3588
+ id: "https://remote.example/notes/legacy-malformed-context",
3589
+ type: "Note",
3590
+ attributedTo: "https://remote.example/users/alice",
3591
+ content: "Hello from malformed legacy queue"
3592
+ }
3593
+ }, rsaPrivateKey3, rsaPublicKey3.id, { contextLoader: mockDocumentLoader });
3594
+ const inboxMessage = {
3595
+ type: "inbox",
3596
+ id: crypto.randomUUID(),
3597
+ baseUrl: "https://example.com",
3598
+ activity: {
3599
+ ...signed,
3600
+ "@context": ["app-context", "https://www.w3.org/ns/activitystreams"]
3601
+ },
3602
+ started: (/* @__PURE__ */ new Date()).toISOString(),
3603
+ attempt: 0,
3604
+ identifier: null,
3605
+ traceContext: {}
3606
+ };
3607
+ await federation.processQueuedTask(void 0, inboxMessage);
3608
+ assertEquals(errorCount, 1);
3609
+ assertEquals(queuedMessages, [{
3610
+ ...inboxMessage,
3611
+ attempt: 1
3612
+ }]);
3613
+ });
3614
+ await t.step("legacy raw LDS inbox messages with Invalid URL TypeErrors retry", async () => {
3615
+ const queue = {
3616
+ enqueue(message, _options) {
3617
+ queuedMessages.push(message);
3618
+ return Promise.resolve();
3619
+ },
3620
+ listen(_handler, _options) {
3621
+ return Promise.resolve();
3622
+ }
3623
+ };
3624
+ const kv = new MemoryKvStore();
3625
+ const queuedMessages = [];
3626
+ let errorCount = 0;
3627
+ const federation = new FederationImpl({
3628
+ kv,
3629
+ queue,
3630
+ contextLoaderFactory: () => async (resource) => {
3631
+ if (resource === "app:context") throw new TypeError(`Invalid URL: ${resource}`);
3632
+ const url = new URL(resource).href;
3633
+ if (url === "https://www.w3.org/ns/activitystreams" || url === "https://w3id.org/identity/v1") return await mockDocumentLoader(url);
3634
+ throw new Error(`Unexpected context: ${resource}`);
3635
+ }
3636
+ });
3637
+ federation.setInboxListeners("/users/{identifier}/inbox", "/inbox").on(Create, () => {
3638
+ throw new Error("listener should not run");
3639
+ }).onError(() => {
3640
+ errorCount++;
3641
+ });
3642
+ const signed = await signJsonLd({
3643
+ "@context": "https://www.w3.org/ns/activitystreams",
3644
+ id: "https://remote.example/activities/legacy-typeerror-invalid-url",
3645
+ type: "Create",
3646
+ actor: "https://remote.example/users/alice",
3647
+ object: {
3648
+ id: "https://remote.example/notes/legacy-typeerror-invalid-url",
3649
+ type: "Note",
3650
+ attributedTo: "https://remote.example/users/alice",
3651
+ content: "Hello from invalid-url typeerror queue"
3652
+ }
3653
+ }, rsaPrivateKey3, rsaPublicKey3.id, { contextLoader: mockDocumentLoader });
3654
+ const inboxMessage = {
3655
+ type: "inbox",
3656
+ id: crypto.randomUUID(),
3657
+ baseUrl: "https://example.com",
3658
+ activity: {
3659
+ ...signed,
3660
+ "@context": ["app:context", "https://www.w3.org/ns/activitystreams"]
3661
+ },
3662
+ started: (/* @__PURE__ */ new Date()).toISOString(),
3663
+ attempt: 0,
3664
+ identifier: null,
3665
+ traceContext: {}
3666
+ };
3667
+ await federation.processQueuedTask(void 0, inboxMessage);
3668
+ assertEquals(errorCount, 1);
3669
+ assertEquals(queuedMessages.length, 1);
3670
+ const retried = queuedMessages[0];
3671
+ assertEquals(retried.attempt, 1);
3672
+ assertEquals(retried.activity, inboxMessage.activity);
3673
+ });
3674
+ await t.step("legacy raw LDS inbox messages with malformed absolute context refs do not retry", async () => {
3675
+ const queue = {
3676
+ enqueue(message, _options) {
3677
+ queuedMessages.push(message);
3678
+ return Promise.resolve();
3679
+ },
3680
+ listen(_handler, _options) {
3681
+ return Promise.resolve();
3682
+ }
3683
+ };
3684
+ const kv = new MemoryKvStore();
3685
+ const queuedMessages = [];
3686
+ let errorCount = 0;
3687
+ const federation = new FederationImpl({
3688
+ kv,
3689
+ queue,
3690
+ contextLoaderFactory: () => async (resource) => {
3691
+ if (resource === "http:/[") throw new TypeError(`Invalid URL: ${resource}`);
3692
+ const url = new URL(resource).href;
3693
+ if (url === "https://www.w3.org/ns/activitystreams" || url === "https://w3id.org/identity/v1") return await mockDocumentLoader(url);
3694
+ throw new Error(`Unexpected context: ${resource}`);
3695
+ }
3696
+ });
3697
+ federation.setInboxListeners("/users/{identifier}/inbox", "/inbox").on(Create, () => {
3698
+ throw new Error("listener should not run");
3699
+ }).onError(() => {
3700
+ errorCount++;
3701
+ });
3702
+ const signed = await signJsonLd({
3703
+ "@context": "https://www.w3.org/ns/activitystreams",
3704
+ id: "https://remote.example/activities/legacy-malformed-absolute-context",
3705
+ type: "Create",
3706
+ actor: "https://remote.example/users/alice",
3707
+ object: {
3708
+ id: "https://remote.example/notes/legacy-malformed-absolute-context",
3709
+ type: "Note",
3710
+ attributedTo: "https://remote.example/users/alice",
3711
+ content: "Hello from malformed absolute context queue"
3712
+ }
3713
+ }, rsaPrivateKey3, rsaPublicKey3.id, { contextLoader: mockDocumentLoader });
3714
+ const inboxMessage = {
3715
+ type: "inbox",
3716
+ id: crypto.randomUUID(),
3717
+ baseUrl: "https://example.com",
3718
+ activity: {
3719
+ ...signed,
3720
+ "@context": ["http:/[", "https://www.w3.org/ns/activitystreams"]
3721
+ },
3722
+ started: (/* @__PURE__ */ new Date()).toISOString(),
3723
+ attempt: 0,
3724
+ identifier: null,
3725
+ traceContext: {}
3726
+ };
3727
+ await federation.processQueuedTask(void 0, inboxMessage);
3728
+ assertEquals(errorCount, 1);
3729
+ assertEquals(queuedMessages, []);
3730
+ });
3731
+ await t.step("malformed IRI fields are permanent queued inbox parse errors", async () => {
3732
+ const queuedMessages = [];
3733
+ const queue = {
3734
+ enqueue(message, _options) {
3735
+ queuedMessages.push(message);
3736
+ return Promise.resolve();
3737
+ },
3738
+ listen(_handler, _options) {
3739
+ return Promise.resolve();
3740
+ }
3741
+ };
3742
+ const kv = new MemoryKvStore();
3743
+ let errorCount = 0;
3744
+ const federation = new FederationImpl({
3745
+ kv,
3746
+ queue
3747
+ });
3748
+ federation.setInboxListeners("/users/{identifier}/inbox", "/inbox").on(Create, () => {
3749
+ throw new Error("listener should not run");
3750
+ }).onError(() => {
3751
+ errorCount++;
3752
+ });
3753
+ await federation.processQueuedTask(void 0, {
3754
+ type: "inbox",
3755
+ id: crypto.randomUUID(),
3756
+ baseUrl: "https://example.com",
3757
+ activity: {
3758
+ "@context": "https://www.w3.org/ns/activitystreams",
3759
+ id: "http://[",
3760
+ type: "Create",
3761
+ actor: "https://remote.example/users/alice",
3762
+ object: {
3763
+ id: "https://remote.example/notes/invalid-iri",
3764
+ type: "Note",
3765
+ attributedTo: "https://remote.example/users/alice",
3766
+ content: "Hello from invalid IRI queue"
3767
+ }
3768
+ },
3769
+ started: (/* @__PURE__ */ new Date()).toISOString(),
3770
+ attempt: 0,
3771
+ identifier: null,
3772
+ traceContext: {}
3773
+ });
3774
+ assertEquals(errorCount, 1);
3775
+ assertEquals(queuedMessages, []);
3776
+ });
3777
+ await t.step("legacy raw LDS inbox messages with network-path context ids retry", async () => {
3778
+ const queue = {
3779
+ enqueue(message, _options) {
3780
+ queuedMessages.push(message);
3781
+ return Promise.resolve();
3782
+ },
3783
+ listen(_handler, _options) {
3784
+ return Promise.resolve();
3785
+ }
3786
+ };
3787
+ const kv = new MemoryKvStore();
3788
+ const queuedMessages = [];
3789
+ let errorCount = 0;
3790
+ const federation = new FederationImpl({
3791
+ kv,
3792
+ queue,
3793
+ contextLoaderFactory: () => async (resource) => {
3794
+ if (resource === "//cdn.example/ctx") {
3795
+ const error = /* @__PURE__ */ new Error(`Network-path context backend is unavailable: ${resource}`);
3796
+ error.name = "jsonld.InvalidUrl";
3797
+ error.details = {
3798
+ code: "loading remote context failed",
3799
+ url: resource
3800
+ };
3801
+ throw error;
3802
+ }
3803
+ const url = new URL(resource).href;
3804
+ if (url === "https://www.w3.org/ns/activitystreams" || url === "https://w3id.org/identity/v1") return await mockDocumentLoader(url);
3805
+ throw new Error(`Unexpected context: ${resource}`);
3806
+ }
3807
+ });
3808
+ federation.setInboxListeners("/users/{identifier}/inbox", "/inbox").on(Create, () => {
3809
+ throw new Error("listener should not run");
3810
+ }).onError(() => {
3811
+ errorCount++;
3812
+ });
3813
+ const signed = await signJsonLd({
3814
+ "@context": "https://www.w3.org/ns/activitystreams",
3815
+ id: "https://remote.example/activities/legacy-network-path-context",
3816
+ type: "Create",
3817
+ actor: "https://remote.example/users/alice",
3818
+ object: {
3819
+ id: "https://remote.example/notes/legacy-network-path-context",
3820
+ type: "Note",
3821
+ attributedTo: "https://remote.example/users/alice",
3822
+ content: "Hello from network-path legacy queue"
3823
+ }
3824
+ }, rsaPrivateKey3, rsaPublicKey3.id, { contextLoader: mockDocumentLoader });
3825
+ const inboxMessage = {
3826
+ type: "inbox",
3827
+ id: crypto.randomUUID(),
3828
+ baseUrl: "https://example.com",
3829
+ activity: {
3830
+ ...signed,
3831
+ "@context": ["//cdn.example/ctx", "https://www.w3.org/ns/activitystreams"]
3832
+ },
3833
+ started: (/* @__PURE__ */ new Date()).toISOString(),
3834
+ attempt: 0,
3835
+ identifier: null,
3836
+ traceContext: {}
3837
+ };
3838
+ await federation.processQueuedTask(void 0, inboxMessage);
3839
+ assertEquals(errorCount, 1);
3840
+ assertEquals(queuedMessages, [{
3841
+ ...inboxMessage,
3842
+ attempt: 1
3843
+ }]);
3844
+ });
3845
+ await t.step("legacy raw LDS inbox messages with malformed network-path refs do not retry", async () => {
3846
+ const queue = {
3847
+ enqueue(message, _options) {
3848
+ queuedMessages.push(message);
3849
+ return Promise.resolve();
3850
+ },
3851
+ listen(_handler, _options) {
3852
+ return Promise.resolve();
3853
+ }
3854
+ };
3855
+ const kv = new MemoryKvStore();
3856
+ const queuedMessages = [];
3857
+ let errorCount = 0;
3858
+ const federation = new FederationImpl({
3859
+ kv,
3860
+ queue,
3861
+ contextLoaderFactory: () => async (resource) => {
3862
+ if (resource === "//[") {
3863
+ const error = /* @__PURE__ */ new Error(`Malformed network-path context: ${resource}`);
3864
+ error.name = "jsonld.InvalidUrl";
3865
+ error.details = {
3866
+ code: "loading remote context failed",
3867
+ url: resource
3868
+ };
3869
+ throw error;
3870
+ }
3871
+ const url = new URL(resource).href;
3872
+ if (url === "https://www.w3.org/ns/activitystreams" || url === "https://w3id.org/identity/v1") return await mockDocumentLoader(url);
3873
+ throw new Error(`Unexpected context: ${resource}`);
3874
+ }
3875
+ });
3876
+ federation.setInboxListeners("/users/{identifier}/inbox", "/inbox").on(Create, () => {
3877
+ throw new Error("listener should not run");
3878
+ }).onError(() => {
3879
+ errorCount++;
3880
+ });
3881
+ const signed = await signJsonLd({
3882
+ "@context": "https://www.w3.org/ns/activitystreams",
3883
+ id: "https://remote.example/activities/legacy-malformed-network-path-context",
3884
+ type: "Create",
3885
+ actor: "https://remote.example/users/alice",
3886
+ object: {
3887
+ id: "https://remote.example/notes/legacy-malformed-network-path-context",
3888
+ type: "Note",
3889
+ attributedTo: "https://remote.example/users/alice",
3890
+ content: "Hello from malformed network-path legacy queue"
3891
+ }
3892
+ }, rsaPrivateKey3, rsaPublicKey3.id, { contextLoader: mockDocumentLoader });
3893
+ const inboxMessage = {
3894
+ type: "inbox",
3895
+ id: crypto.randomUUID(),
3896
+ baseUrl: "https://example.com",
3897
+ activity: {
3898
+ ...signed,
3899
+ "@context": ["//[", "https://www.w3.org/ns/activitystreams"]
3900
+ },
3901
+ started: (/* @__PURE__ */ new Date()).toISOString(),
3902
+ attempt: 0,
3903
+ identifier: null,
3904
+ traceContext: {}
3905
+ };
3906
+ await federation.processQueuedTask(void 0, inboxMessage);
3907
+ assertEquals(errorCount, 1);
3908
+ assertEquals(queuedMessages, []);
3909
+ });
3910
+ await t.step("legacy raw LDS inbox messages with malformed context URLs do not retry", async () => {
3911
+ const queue = {
3912
+ enqueue(message, _options) {
3913
+ queuedMessages.push(message);
3914
+ return Promise.resolve();
3915
+ },
3916
+ listen(_handler, _options) {
3917
+ return Promise.resolve();
3918
+ }
3919
+ };
3920
+ const kv = new MemoryKvStore();
3921
+ const queuedMessages = [];
3922
+ let errorCount = 0;
3923
+ const federation = new FederationImpl({
3924
+ kv,
3925
+ queue,
3926
+ contextLoaderFactory: () => async (resource) => {
3927
+ if (resource === "not a url") {
3928
+ const error = /* @__PURE__ */ new Error(`Invalid remote context URL: ${resource}`);
3929
+ error.name = "jsonld.InvalidUrl";
3930
+ error.details = {
3931
+ code: "loading remote context failed",
3932
+ url: resource
3933
+ };
3934
+ throw error;
3935
+ }
3936
+ const url = new URL(resource).href;
3937
+ if (url === "https://www.w3.org/ns/activitystreams" || url === "https://w3id.org/identity/v1") return await mockDocumentLoader(url);
3938
+ throw new Error(`Unexpected context: ${resource}`);
3939
+ }
3940
+ });
3941
+ federation.setInboxListeners("/users/{identifier}/inbox", "/inbox").on(Create, () => {
3942
+ throw new Error("listener should not run");
3943
+ }).onError(() => {
3944
+ errorCount++;
3945
+ });
3946
+ const signed = await signJsonLd({
3947
+ "@context": "https://www.w3.org/ns/activitystreams",
3948
+ id: "https://remote.example/activities/legacy-malformed-context",
3949
+ type: "Create",
3950
+ actor: "https://remote.example/users/alice",
3951
+ object: {
3952
+ id: "https://remote.example/notes/legacy-malformed-context",
3953
+ type: "Note",
3954
+ attributedTo: "https://remote.example/users/alice",
3955
+ content: "Hello from malformed legacy queue"
3956
+ }
3957
+ }, rsaPrivateKey3, rsaPublicKey3.id, { contextLoader: mockDocumentLoader });
3958
+ const inboxMessage = {
3959
+ type: "inbox",
3960
+ id: crypto.randomUUID(),
3961
+ baseUrl: "https://example.com",
3962
+ activity: {
3963
+ ...signed,
3964
+ "@context": ["not a url", "https://www.w3.org/ns/activitystreams"]
3965
+ },
3966
+ started: (/* @__PURE__ */ new Date()).toISOString(),
3967
+ attempt: 0,
3968
+ identifier: null,
3969
+ traceContext: {}
3970
+ };
3971
+ await federation.processQueuedTask(void 0, inboxMessage);
3972
+ assertEquals(errorCount, 1);
3973
+ assertEquals(queuedMessages, []);
3974
+ });
3975
+ await t.step("legacy raw LDS inbox messages with invalid percent escapes do not retry", async () => {
3976
+ const queue = {
3977
+ enqueue(message, _options) {
3978
+ queuedMessages.push(message);
3979
+ return Promise.resolve();
3980
+ },
3981
+ listen(_handler, _options) {
3982
+ return Promise.resolve();
3983
+ }
3984
+ };
3985
+ const kv = new MemoryKvStore();
3986
+ const queuedMessages = [];
3987
+ let errorCount = 0;
3988
+ const federation = new FederationImpl({
3989
+ kv,
3990
+ queue,
3991
+ contextLoaderFactory: () => async (resource) => {
3992
+ if (resource === "foo%zz") {
3993
+ const error = /* @__PURE__ */ new Error(`Invalid remote context URL: ${resource}`);
3994
+ error.name = "jsonld.InvalidUrl";
3995
+ error.details = {
3996
+ code: "loading remote context failed",
3997
+ url: resource
3998
+ };
3999
+ throw error;
4000
+ }
4001
+ const url = new URL(resource).href;
4002
+ if (url === "https://www.w3.org/ns/activitystreams" || url === "https://w3id.org/identity/v1") return await mockDocumentLoader(url);
4003
+ throw new Error(`Unexpected context: ${resource}`);
4004
+ }
4005
+ });
4006
+ federation.setInboxListeners("/users/{identifier}/inbox", "/inbox").on(Create, () => {
4007
+ throw new Error("listener should not run");
4008
+ }).onError(() => {
4009
+ errorCount++;
4010
+ });
4011
+ const signed = await signJsonLd({
4012
+ "@context": "https://www.w3.org/ns/activitystreams",
4013
+ id: "https://remote.example/activities/legacy-malformed-percent-context",
4014
+ type: "Create",
4015
+ actor: "https://remote.example/users/alice",
4016
+ object: {
4017
+ id: "https://remote.example/notes/legacy-malformed-percent-context",
4018
+ type: "Note",
4019
+ attributedTo: "https://remote.example/users/alice",
4020
+ content: "Hello from malformed percent legacy queue"
4021
+ }
4022
+ }, rsaPrivateKey3, rsaPublicKey3.id, { contextLoader: mockDocumentLoader });
4023
+ const inboxMessage = {
4024
+ type: "inbox",
4025
+ id: crypto.randomUUID(),
4026
+ baseUrl: "https://example.com",
4027
+ activity: {
4028
+ ...signed,
4029
+ "@context": ["foo%zz", "https://www.w3.org/ns/activitystreams"]
4030
+ },
4031
+ started: (/* @__PURE__ */ new Date()).toISOString(),
4032
+ attempt: 0,
4033
+ identifier: null,
4034
+ traceContext: {}
4035
+ };
4036
+ await federation.processQueuedTask(void 0, inboxMessage);
4037
+ assertEquals(errorCount, 1);
4038
+ assertEquals(queuedMessages, []);
4039
+ });
4040
+ await t.step("legacy raw LDS inbox messages with invalid remote contexts do not retry", async () => {
4041
+ const remoteContextUrl = "https://remote.example/contexts/ext";
4042
+ const queue = {
4043
+ enqueue(message, _options) {
4044
+ queuedMessages.push(message);
4045
+ return Promise.resolve();
4046
+ },
4047
+ listen(_handler, _options) {
4048
+ return Promise.resolve();
4049
+ }
4050
+ };
4051
+ const kv = new MemoryKvStore();
4052
+ const queuedMessages = [];
4053
+ let errorCount = 0;
4054
+ const federation = new FederationImpl({
4055
+ kv,
4056
+ queue,
4057
+ contextLoaderFactory: () => async (resource) => {
4058
+ const url = new URL(resource).href;
4059
+ if (url === "https://www.w3.org/ns/activitystreams" || url === "https://w3id.org/identity/v1") return await mockDocumentLoader(url);
4060
+ if (url === remoteContextUrl) return {
4061
+ contextUrl: null,
4062
+ documentUrl: url,
4063
+ document: [
4064
+ "not",
4065
+ "an",
4066
+ "object"
4067
+ ]
4068
+ };
4069
+ throw new Error(`Unexpected context: ${url}`);
4070
+ }
4071
+ });
4072
+ federation.setInboxListeners("/users/{identifier}/inbox", "/inbox").on(Create, () => {
4073
+ throw new Error("listener should not run");
4074
+ }).onError(() => {
4075
+ errorCount++;
4076
+ });
4077
+ const signed = await signJsonLd({
4078
+ "@context": [remoteContextUrl, "https://www.w3.org/ns/activitystreams"],
4079
+ id: "https://remote.example/activities/legacy-invalid-remote-context",
4080
+ type: "Create",
4081
+ actor: "https://remote.example/users/alice",
4082
+ ext: "preserve-me",
4083
+ object: {
4084
+ id: "https://remote.example/notes/legacy-invalid-remote-context",
4085
+ type: "Note",
4086
+ attributedTo: "https://remote.example/users/alice",
4087
+ content: "Hello from invalid remote context queue"
4088
+ }
4089
+ }, rsaPrivateKey3, rsaPublicKey3.id, { contextLoader: async (resource) => {
4090
+ const url = new URL(resource).href;
4091
+ if (url === remoteContextUrl) return {
4092
+ contextUrl: null,
4093
+ documentUrl: url,
4094
+ document: { "@context": { ext: "https://example.com/ext" } }
4095
+ };
4096
+ return await mockDocumentLoader(url);
4097
+ } });
4098
+ const inboxMessage = {
4099
+ type: "inbox",
4100
+ id: crypto.randomUUID(),
4101
+ baseUrl: "https://example.com",
4102
+ activity: signed,
4103
+ started: (/* @__PURE__ */ new Date()).toISOString(),
4104
+ attempt: 0,
4105
+ identifier: null,
4106
+ traceContext: {}
4107
+ };
4108
+ await federation.processQueuedTask(void 0, inboxMessage);
4109
+ assertEquals(errorCount, 1);
4110
+ assertEquals(queuedMessages, []);
4111
+ });
4112
+ await t.step("legacy raw LDS inbox messages with string remote contexts retry", async () => {
4113
+ const remoteContextUrl = "https://remote.example/contexts/ext";
4114
+ const queue = {
4115
+ enqueue(message, _options) {
4116
+ queuedMessages.push(message);
4117
+ return Promise.resolve();
4118
+ },
4119
+ listen(_handler, _options) {
4120
+ return Promise.resolve();
4121
+ }
4122
+ };
4123
+ const kv = new MemoryKvStore();
4124
+ const queuedMessages = [];
4125
+ let errorCount = 0;
4126
+ const federation = new FederationImpl({
4127
+ kv,
4128
+ queue,
4129
+ contextLoaderFactory: () => async (resource) => {
4130
+ const url = new URL(resource).href;
4131
+ if (url === "https://www.w3.org/ns/activitystreams" || url === "https://w3id.org/identity/v1") return await mockDocumentLoader(url);
4132
+ if (url === remoteContextUrl) return {
4133
+ contextUrl: null,
4134
+ documentUrl: url,
4135
+ document: "{not valid json"
4136
+ };
4137
+ throw new Error(`Unexpected context: ${url}`);
4138
+ }
4139
+ });
4140
+ federation.setInboxListeners("/users/{identifier}/inbox", "/inbox").on(Create, () => {
4141
+ throw new Error("listener should not run");
4142
+ }).onError(() => {
4143
+ errorCount++;
4144
+ });
4145
+ const signed = await signJsonLd({
4146
+ "@context": [remoteContextUrl, "https://www.w3.org/ns/activitystreams"],
4147
+ id: "https://remote.example/activities/legacy-string-remote-context",
4148
+ type: "Create",
4149
+ actor: "https://remote.example/users/alice",
4150
+ ext: "preserve-me",
4151
+ object: {
4152
+ id: "https://remote.example/notes/legacy-string-remote-context",
4153
+ type: "Note",
4154
+ attributedTo: "https://remote.example/users/alice",
4155
+ content: "Hello from string remote context queue"
4156
+ }
4157
+ }, rsaPrivateKey3, rsaPublicKey3.id, { contextLoader: async (resource) => {
4158
+ const url = new URL(resource).href;
4159
+ if (url === remoteContextUrl) return {
4160
+ contextUrl: null,
4161
+ documentUrl: url,
4162
+ document: { "@context": { ext: "https://example.com/ext" } }
4163
+ };
4164
+ return await mockDocumentLoader(url);
4165
+ } });
4166
+ const inboxMessage = {
4167
+ type: "inbox",
4168
+ id: crypto.randomUUID(),
4169
+ baseUrl: "https://example.com",
4170
+ activity: signed,
4171
+ started: (/* @__PURE__ */ new Date()).toISOString(),
4172
+ attempt: 0,
4173
+ identifier: null,
4174
+ traceContext: {}
4175
+ };
4176
+ await federation.processQueuedTask(void 0, inboxMessage);
4177
+ assertEquals(errorCount, 1);
4178
+ assertEquals(queuedMessages, [{
4179
+ ...inboxMessage,
4180
+ attempt: 1
4181
+ }]);
4182
+ });
4183
+ await t.step("legacy raw LDS inbox messages with loader TypeErrors retry", async () => {
4184
+ const remoteContextUrl = "https://remote.example/contexts/ext";
4185
+ const queue = {
4186
+ enqueue(message, _options) {
4187
+ queuedMessages.push(message);
4188
+ return Promise.resolve();
4189
+ },
4190
+ listen(_handler, _options) {
4191
+ return Promise.resolve();
4192
+ }
4193
+ };
4194
+ const kv = new MemoryKvStore();
4195
+ const queuedMessages = [];
4196
+ let errorCount = 0;
4197
+ const federation = new FederationImpl({
4198
+ kv,
4199
+ queue,
4200
+ contextLoaderFactory: () => async (resource) => {
4201
+ const url = new URL(resource).href;
4202
+ if (url === "https://www.w3.org/ns/activitystreams" || url === "https://w3id.org/identity/v1") return await mockDocumentLoader(url);
4203
+ if (url === remoteContextUrl) throw new TypeError(`Cannot initialize remote context loader: ${url}`);
4204
+ throw new Error(`Unexpected context: ${url}`);
4205
+ }
4206
+ });
4207
+ federation.setInboxListeners("/users/{identifier}/inbox", "/inbox").on(Create, () => {
4208
+ throw new Error("listener should not run");
4209
+ }).onError(() => {
4210
+ errorCount++;
4211
+ });
4212
+ const signed = await signJsonLd({
4213
+ "@context": [remoteContextUrl, "https://www.w3.org/ns/activitystreams"],
4214
+ id: "https://remote.example/activities/legacy-typeerror-context",
4215
+ type: "Create",
4216
+ actor: "https://remote.example/users/alice",
4217
+ ext: "preserve-me",
4218
+ object: {
4219
+ id: "https://remote.example/notes/legacy-typeerror-context",
4220
+ type: "Note",
4221
+ attributedTo: "https://remote.example/users/alice",
4222
+ content: "Hello from typeerror legacy queue"
4223
+ }
4224
+ }, rsaPrivateKey3, rsaPublicKey3.id, { contextLoader: async (resource) => {
4225
+ const url = new URL(resource).href;
4226
+ if (url === remoteContextUrl) return {
4227
+ contextUrl: null,
4228
+ documentUrl: url,
4229
+ document: { "@context": { ext: "https://example.com/ext" } }
4230
+ };
4231
+ return await mockDocumentLoader(url);
4232
+ } });
4233
+ const inboxMessage = {
4234
+ type: "inbox",
4235
+ id: crypto.randomUUID(),
4236
+ baseUrl: "https://example.com",
4237
+ activity: signed,
4238
+ started: (/* @__PURE__ */ new Date()).toISOString(),
4239
+ attempt: 0,
4240
+ identifier: null,
4241
+ traceContext: {}
4242
+ };
4243
+ await federation.processQueuedTask(void 0, inboxMessage);
4244
+ assertEquals(errorCount, 1);
4245
+ assertEquals(queuedMessages, [{
4246
+ ...inboxMessage,
4247
+ attempt: 1
4248
+ }]);
4249
+ });
4250
+ await t.step("legacy raw LDS inbox messages with syntax errors in remote contexts retry", async () => {
4251
+ const remoteContextUrl = "https://remote.example/contexts/ext";
4252
+ const queue = {
4253
+ enqueue(message, _options) {
4254
+ queuedMessages.push(message);
4255
+ return Promise.resolve();
4256
+ },
4257
+ listen(_handler, _options) {
4258
+ return Promise.resolve();
4259
+ }
4260
+ };
4261
+ const kv = new MemoryKvStore();
4262
+ const queuedMessages = [];
4263
+ let errorCount = 0;
4264
+ const federation = new FederationImpl({
4265
+ kv,
4266
+ queue,
4267
+ contextLoaderFactory: () => async (resource) => {
4268
+ const url = new URL(resource).href;
4269
+ if (url === "https://www.w3.org/ns/activitystreams" || url === "https://w3id.org/identity/v1") return await mockDocumentLoader(url);
4270
+ if (url === remoteContextUrl) {
4271
+ const error = /* @__PURE__ */ new Error(`Transient syntax failure: ${url}`);
4272
+ error.name = "jsonld.SyntaxError";
4273
+ error.details = { code: "loading remote context failed" };
4274
+ throw error;
4275
+ }
4276
+ throw new Error(`Unexpected context: ${url}`);
4277
+ }
4278
+ });
4279
+ federation.setInboxListeners("/users/{identifier}/inbox", "/inbox").on(Create, () => {
4280
+ throw new Error("listener should not run");
4281
+ }).onError(() => {
4282
+ errorCount++;
4283
+ });
4284
+ const signed = await signJsonLd({
4285
+ "@context": [remoteContextUrl, "https://www.w3.org/ns/activitystreams"],
4286
+ id: "https://remote.example/activities/legacy-syntax-context",
4287
+ type: "Create",
4288
+ actor: "https://remote.example/users/alice",
4289
+ ext: "preserve-me",
4290
+ object: {
4291
+ id: "https://remote.example/notes/legacy-syntax-context",
4292
+ type: "Note",
4293
+ attributedTo: "https://remote.example/users/alice",
4294
+ content: "Hello from syntax legacy queue"
4295
+ }
4296
+ }, rsaPrivateKey3, rsaPublicKey3.id, { contextLoader: async (resource) => {
4297
+ const url = new URL(resource).href;
4298
+ if (url === remoteContextUrl) return {
4299
+ contextUrl: null,
4300
+ documentUrl: url,
4301
+ document: { "@context": { ext: "https://example.com/ext" } }
4302
+ };
4303
+ return await mockDocumentLoader(url);
4304
+ } });
4305
+ const inboxMessage = {
4306
+ type: "inbox",
4307
+ id: crypto.randomUUID(),
4308
+ baseUrl: "https://example.com",
4309
+ activity: signed,
4310
+ started: (/* @__PURE__ */ new Date()).toISOString(),
4311
+ attempt: 0,
4312
+ identifier: null,
4313
+ traceContext: {}
4314
+ };
4315
+ await federation.processQueuedTask(void 0, inboxMessage);
4316
+ assertEquals(errorCount, 1);
4317
+ assertEquals(queuedMessages, [{
4318
+ ...inboxMessage,
4319
+ attempt: 1
4320
+ }]);
4321
+ });
4322
+ await t.step("legacy raw LDS inbox messages with loader RangeErrors retry", async () => {
4323
+ const remoteContextUrl = "https://remote.example/contexts/ext";
4324
+ const queue = {
4325
+ enqueue(message, _options) {
4326
+ queuedMessages.push(message);
4327
+ return Promise.resolve();
4328
+ },
4329
+ listen(_handler, _options) {
4330
+ return Promise.resolve();
4331
+ }
4332
+ };
4333
+ const kv = new MemoryKvStore();
4334
+ const queuedMessages = [];
4335
+ let errorCount = 0;
4336
+ const federation = new FederationImpl({
4337
+ kv,
4338
+ queue,
4339
+ contextLoaderFactory: () => async (resource) => {
4340
+ const url = new URL(resource).href;
4341
+ if (url === "https://www.w3.org/ns/activitystreams" || url === "https://w3id.org/identity/v1") return await mockDocumentLoader(url);
4342
+ if (url === remoteContextUrl) throw new RangeError(`Temporary remote context cache window exceeded: ${url}`);
4343
+ throw new Error(`Unexpected context: ${url}`);
4344
+ }
4345
+ });
4346
+ federation.setInboxListeners("/users/{identifier}/inbox", "/inbox").on(Create, () => {
4347
+ throw new Error("listener should not run");
4348
+ }).onError(() => {
4349
+ errorCount++;
4350
+ });
4351
+ const signed = await signJsonLd({
4352
+ "@context": [remoteContextUrl, "https://www.w3.org/ns/activitystreams"],
4353
+ id: "https://remote.example/activities/legacy-rangeerror-context",
4354
+ type: "Create",
4355
+ actor: "https://remote.example/users/alice",
4356
+ ext: "preserve-me",
4357
+ object: {
4358
+ id: "https://remote.example/notes/legacy-rangeerror-context",
4359
+ type: "Note",
4360
+ attributedTo: "https://remote.example/users/alice",
4361
+ content: "Hello from rangeerror legacy queue"
4362
+ }
4363
+ }, rsaPrivateKey3, rsaPublicKey3.id, { contextLoader: async (resource) => {
4364
+ const url = new URL(resource).href;
4365
+ if (url === remoteContextUrl) return {
4366
+ contextUrl: null,
4367
+ documentUrl: url,
4368
+ document: { "@context": { ext: "https://example.com/ext" } }
4369
+ };
4370
+ return await mockDocumentLoader(url);
4371
+ } });
4372
+ const inboxMessage = {
4373
+ type: "inbox",
4374
+ id: crypto.randomUUID(),
4375
+ baseUrl: "https://example.com",
4376
+ activity: signed,
4377
+ started: (/* @__PURE__ */ new Date()).toISOString(),
4378
+ attempt: 0,
4379
+ identifier: null,
4380
+ traceContext: {}
4381
+ };
4382
+ await federation.processQueuedTask(void 0, inboxMessage);
4383
+ assertEquals(errorCount, 1);
4384
+ assertEquals(queuedMessages, [{
4385
+ ...inboxMessage,
4386
+ attempt: 1
4387
+ }]);
4388
+ });
4389
+ await t.step("permanent queued inbox parse errors do not re-enqueue poison messages", async () => {
4390
+ const queuedMessages = [];
4391
+ const queue = {
4392
+ enqueue(message, _options) {
4393
+ queuedMessages.push(message);
4394
+ return Promise.resolve();
4395
+ },
4396
+ listen(_handler, _options) {
4397
+ return Promise.resolve();
4398
+ }
4399
+ };
4400
+ const kv = new MemoryKvStore();
4401
+ let errorCount = 0;
4402
+ const federation = new FederationImpl({
4403
+ kv,
4404
+ queue
4405
+ });
4406
+ federation.setInboxListeners("/users/{identifier}/inbox", "/inbox").on(Create, () => {
4407
+ throw new Error("listener should not run");
4408
+ }).onError(() => {
4409
+ errorCount++;
4410
+ });
4411
+ await federation.processQueuedTask(void 0, {
4412
+ type: "inbox",
4413
+ id: crypto.randomUUID(),
4414
+ baseUrl: "https://example.com",
4415
+ activity: {
4416
+ "@context": "https://www.w3.org/ns/activitystreams",
4417
+ id: "https://remote.example/objects/not-an-activity",
4418
+ type: "Note",
4419
+ attributedTo: "https://remote.example/users/alice",
4420
+ content: "Not an activity"
4421
+ },
4422
+ started: (/* @__PURE__ */ new Date()).toISOString(),
4423
+ attempt: 0,
4424
+ identifier: null,
4425
+ traceContext: {}
4426
+ });
4427
+ assertEquals(errorCount, 1);
4428
+ assertEquals(queuedMessages, []);
4429
+ });
4430
+ await t.step("malformed Temporal fields are permanent queued inbox parse errors", async () => {
4431
+ const queuedMessages = [];
4432
+ const queue = {
4433
+ enqueue(message, _options) {
4434
+ queuedMessages.push(message);
4435
+ return Promise.resolve();
4436
+ },
4437
+ listen(_handler, _options) {
4438
+ return Promise.resolve();
4439
+ }
4440
+ };
4441
+ const kv = new MemoryKvStore();
4442
+ let errorCount = 0;
4443
+ const federation = new FederationImpl({
4444
+ kv,
4445
+ queue,
4446
+ documentLoaderFactory: () => mockDocumentLoader,
4447
+ contextLoaderFactory: () => mockDocumentLoader
4448
+ });
4449
+ federation.setInboxListeners("/users/{identifier}/inbox", "/inbox").on(Create, () => {
4450
+ throw new Error("listener should not run");
4451
+ }).onError(() => {
4452
+ errorCount++;
4453
+ });
4454
+ await federation.processQueuedTask(void 0, {
4455
+ type: "inbox",
4456
+ id: crypto.randomUUID(),
4457
+ baseUrl: "https://example.com",
4458
+ activity: {
4459
+ "@context": ["https://www.w3.org/ns/activitystreams", "https://w3id.org/security/data-integrity/v1"],
4460
+ id: "https://remote.example/activities/invalid-proof-created",
4461
+ type: "Create",
4462
+ actor: "https://remote.example/users/alice",
4463
+ object: {
4464
+ id: "https://remote.example/notes/invalid-proof-created",
4465
+ type: "Note",
4466
+ attributedTo: "https://remote.example/users/alice",
4467
+ content: "Hello, world!"
4468
+ },
4469
+ proof: {
4470
+ type: "DataIntegrityProof",
4471
+ cryptosuite: "eddsa-jcs-2022",
4472
+ verificationMethod: "https://remote.example/users/alice#ed25519-key",
4473
+ proofPurpose: "assertionMethod",
4474
+ created: { "@value": "not-a-date" },
4475
+ proofValue: "zLaewdp4H9kqtwyrLatK4cjY5oRHwVcw4gibPSUDYDMhi4M49v8pcYk3ZB6D69dNpAPbUmY8ocuJ3m9KhKJEEg7z"
4476
+ }
4477
+ },
4478
+ started: (/* @__PURE__ */ new Date()).toISOString(),
4479
+ attempt: 0,
4480
+ identifier: null,
4481
+ traceContext: {}
4482
+ });
4483
+ assertEquals(errorCount, 1);
4484
+ assertEquals(queuedMessages, []);
4485
+ });
4486
+ });
4487
+ test("FederationImpl.processQueuedTask() permanent failure", async (t) => {
4488
+ esm_default.spyGlobal();
2060
4489
  esm_default.post("https://gone.example/inbox", {
2061
4490
  status: 410,
2062
4491
  body: "Gone"
2063
4492
  });
2064
- esm_default.post("https://notfound.example/inbox", {
2065
- status: 404,
2066
- body: "Not Found"
4493
+ esm_default.post("https://notfound.example/inbox", {
4494
+ status: 404,
4495
+ body: "Not Found"
4496
+ });
4497
+ esm_default.post("https://error.example/inbox", {
4498
+ status: 500,
4499
+ body: "Internal Server Error"
4500
+ });
4501
+ esm_default.post("https://legal.example/inbox", {
4502
+ status: 451,
4503
+ body: "Unavailable For Legal Reasons"
4504
+ });
4505
+ function setup(options = {}) {
4506
+ const kv = new MemoryKvStore();
4507
+ const queuedMessages = [];
4508
+ const federation = new FederationImpl({
4509
+ kv,
4510
+ queue: {
4511
+ ...options.nativeRetrial ? { nativeRetrial: true } : {},
4512
+ enqueue(message, _options) {
4513
+ queuedMessages.push(message);
4514
+ return Promise.resolve();
4515
+ },
4516
+ listen(_handler, _options) {
4517
+ return Promise.resolve();
4518
+ }
4519
+ },
4520
+ ...options.permanentFailureStatusCodes ? { permanentFailureStatusCodes: options.permanentFailureStatusCodes } : {},
4521
+ ...options.meterProvider ? { meterProvider: options.meterProvider } : {},
4522
+ ...options.tracerProvider ? { tracerProvider: options.tracerProvider } : {}
4523
+ });
4524
+ federation.setInboxListeners("/users/{identifier}/inbox", "/inbox");
4525
+ return {
4526
+ federation,
4527
+ queuedMessages
4528
+ };
4529
+ }
4530
+ function createOutboxMessage(inbox, activityId, actorIds) {
4531
+ return {
4532
+ type: "outbox",
4533
+ id: crypto.randomUUID(),
4534
+ baseUrl: "https://example.com",
4535
+ keys: [],
4536
+ activity: {
4537
+ "@context": "https://www.w3.org/ns/activitystreams",
4538
+ type: "Create",
4539
+ id: activityId,
4540
+ actor: "https://example.com/users/alice",
4541
+ object: {
4542
+ type: "Note",
4543
+ content: "test"
4544
+ }
4545
+ },
4546
+ activityType: "https://www.w3.org/ns/activitystreams#Create",
4547
+ inbox,
4548
+ sharedInbox: false,
4549
+ ...actorIds != null ? { actorIds } : {},
4550
+ started: (/* @__PURE__ */ new Date()).toISOString(),
4551
+ attempt: 0,
4552
+ headers: {},
4553
+ traceContext: {}
4554
+ };
4555
+ }
4556
+ await t.step("410 Gone triggers permanent failure handler", async () => {
4557
+ const [meterProvider, recorder] = createTestMeterProvider();
4558
+ const [tracerProvider, exporter] = createTestTracerProvider();
4559
+ const { federation, queuedMessages } = setup({
4560
+ meterProvider,
4561
+ tracerProvider
4562
+ });
4563
+ let handlerCalled = false;
4564
+ let handlerValues = {};
4565
+ federation.setOutboxPermanentFailureHandler((_ctx, values) => {
4566
+ handlerCalled = true;
4567
+ handlerValues = { ...values };
4568
+ });
4569
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://gone.example/inbox", "https://example.com/activity/1", ["https://gone.example/users/bob", "https://gone.example/users/charlie"]));
4570
+ assert(handlerCalled, "Permanent failure handler should be called");
4571
+ assertEquals(handlerValues.inbox, new URL("https://gone.example/inbox"));
4572
+ assertEquals(handlerValues.statusCode, 410);
4573
+ assertInstanceOf(handlerValues.activity, vocab.Create);
4574
+ assertEquals(handlerValues.actorIds, [new URL("https://gone.example/users/bob"), new URL("https://gone.example/users/charlie")]);
4575
+ assertEquals(queuedMessages, []);
4576
+ const failures = recorder.getMeasurements("activitypub.delivery.permanent_failure");
4577
+ assertEquals(failures.length, 1);
4578
+ assertEquals(failures[0].value, 1);
4579
+ assertEquals(failures[0].attributes["activitypub.remote.host"], "gone.example");
4580
+ assertEquals(failures[0].attributes["http.response.status_code"], 410);
4581
+ const abandoned = recorder.getMeasurements("activitypub.outbox.activity");
4582
+ assertEquals(abandoned.length, 1);
4583
+ assertEquals(abandoned[0].attributes["activitypub.processing.result"], "abandoned");
4584
+ assertEquals(abandoned[0].attributes["activitypub.activity.type"], "https://www.w3.org/ns/activitystreams#Create");
4585
+ const events = exporter.getEvents("activitypub.outbox", "activitypub.delivery.failed");
4586
+ assertEquals(events.length, 1);
4587
+ assertEquals(events[0].attributes?.["activitypub.remote.host"], "gone.example");
4588
+ assertEquals(events[0].attributes?.["activitypub.delivery.attempt"], 0);
4589
+ assertEquals(events[0].attributes?.["activitypub.delivery.permanent_failure"], true);
4590
+ assertEquals(events[0].attributes?.["http.response.status_code"], 410);
4591
+ });
4592
+ await t.step("404 Not Found triggers permanent failure handler", async () => {
4593
+ const { federation, queuedMessages } = setup();
4594
+ let handlerCalled = false;
4595
+ let handlerStatusCode = 0;
4596
+ federation.setOutboxPermanentFailureHandler((_ctx, values) => {
4597
+ handlerCalled = true;
4598
+ handlerStatusCode = values.statusCode;
4599
+ });
4600
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://notfound.example/inbox", "https://example.com/activity/2", ["https://notfound.example/users/bob"]));
4601
+ assert(handlerCalled, "Permanent failure handler should be called");
4602
+ assertEquals(handlerStatusCode, 404);
4603
+ assertEquals(queuedMessages, []);
4604
+ });
4605
+ await t.step("500 error does NOT trigger permanent failure handler", async () => {
4606
+ const { federation, queuedMessages } = setup();
4607
+ let handlerCalled = false;
4608
+ federation.setOutboxPermanentFailureHandler(() => {
4609
+ handlerCalled = true;
4610
+ });
4611
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://error.example/inbox", "https://example.com/activity/3", ["https://error.example/users/bob"]));
4612
+ assertFalse(handlerCalled, "Permanent failure handler should NOT be called");
4613
+ assertEquals(queuedMessages.length, 1);
4614
+ assertEquals(queuedMessages[0].attempt, 1);
4615
+ });
4616
+ await t.step("custom permanentFailureStatusCodes", async () => {
4617
+ const { federation, queuedMessages } = setup({ permanentFailureStatusCodes: [
4618
+ 404,
4619
+ 410,
4620
+ 451
4621
+ ] });
4622
+ let handlerCalled = false;
4623
+ let handlerStatusCode = 0;
4624
+ federation.setOutboxPermanentFailureHandler((_ctx, values) => {
4625
+ handlerCalled = true;
4626
+ handlerStatusCode = values.statusCode;
4627
+ });
4628
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://legal.example/inbox", "https://example.com/activity/4", ["https://legal.example/users/bob"]));
4629
+ assert(handlerCalled, "Permanent failure handler should be called for 451");
4630
+ assertEquals(handlerStatusCode, 451);
4631
+ assertEquals(queuedMessages, []);
4632
+ });
4633
+ await t.step("handler exception is caught and logged", async () => {
4634
+ const { federation, queuedMessages } = setup();
4635
+ federation.setOutboxPermanentFailureHandler(() => {
4636
+ throw new Error("Handler error that should be ignored");
4637
+ });
4638
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://gone.example/inbox", "https://example.com/activity/5", ["https://gone.example/users/bob"]));
4639
+ assertEquals(queuedMessages, []);
4640
+ });
4641
+ await t.step("permanent failure skips retry without handler registered", async () => {
4642
+ const { federation, queuedMessages } = setup();
4643
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://gone.example/inbox", "https://example.com/activity/6", []));
4644
+ assertEquals(queuedMessages, []);
4645
+ });
4646
+ await t.step("nativeRetrial: permanent failure does not re-throw", async () => {
4647
+ const { federation, queuedMessages } = setup({ nativeRetrial: true });
4648
+ let handlerCalled = false;
4649
+ federation.setOutboxPermanentFailureHandler(() => {
4650
+ handlerCalled = true;
4651
+ });
4652
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://gone.example/inbox", "https://example.com/activity/7", ["https://gone.example/users/bob"]));
4653
+ assert(handlerCalled, "Permanent failure handler should be called");
4654
+ assertEquals(queuedMessages, []);
4655
+ });
4656
+ await t.step("actorIds missing from message defaults to empty array", async () => {
4657
+ const { federation, queuedMessages } = setup();
4658
+ let handlerActorIds = [];
4659
+ federation.setOutboxPermanentFailureHandler((_ctx, values) => {
4660
+ handlerActorIds = values.actorIds;
4661
+ });
4662
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://gone.example/inbox", "https://example.com/activity/8"));
4663
+ assertEquals(handlerActorIds, []);
4664
+ assertEquals(queuedMessages, []);
4665
+ });
4666
+ await t.step("malformed inbox does not break failure handling", async () => {
4667
+ await withLogtapeLock(async () => {
4668
+ const [tracerProvider, exporter] = createTestTracerProvider();
4669
+ const { federation, queuedMessages } = setup({ tracerProvider });
4670
+ const records = [];
4671
+ await reset();
4672
+ try {
4673
+ await configure({
4674
+ sinks: { buffer(record) {
4675
+ records.push(record);
4676
+ } },
4677
+ filters: {},
4678
+ loggers: [{
4679
+ category: [],
4680
+ sinks: ["buffer"]
4681
+ }]
4682
+ });
4683
+ await federation.processQueuedTask(void 0, createOutboxMessage("not a url", "https://example.com/activity/9", ["https://gone.example/users/bob"]));
4684
+ assertEquals(queuedMessages.length, 1);
4685
+ assertEquals(queuedMessages[0].attempt, 1);
4686
+ const events = exporter.getEvents("activitypub.outbox", "activitypub.delivery.failed");
4687
+ assertEquals(events.length, 1);
4688
+ assertEquals(events[0].attributes?.["activitypub.remote.host"], void 0);
4689
+ assertEquals(events[0].attributes?.["activitypub.delivery.attempt"], 0);
4690
+ assertEquals(records.some((record) => record.rawMessage === "Invalid inbox URL in queued outbox message: {inbox}" && record.properties.inbox === "not a url"), true);
4691
+ } finally {
4692
+ await reset();
4693
+ }
4694
+ });
4695
+ });
4696
+ esm_default.hardReset();
4697
+ });
4698
+ test("FederationImpl.processQueuedTask() circuit breaker", async (t) => {
4699
+ esm_default.spyGlobal();
4700
+ function setup(options, federationOptions = {}, queueOptions = {}) {
4701
+ const kv = new MemoryKvStore();
4702
+ const queued = [];
4703
+ const federation = new FederationImpl({
4704
+ kv,
4705
+ queue: {
4706
+ nativeRetrial: queueOptions.nativeRetrial,
4707
+ enqueue(message, options) {
4708
+ queued.push({
4709
+ message,
4710
+ options
4711
+ });
4712
+ return Promise.resolve();
4713
+ },
4714
+ listen(_handler, _options) {
4715
+ return Promise.resolve();
4716
+ }
4717
+ },
4718
+ circuitBreaker: options,
4719
+ ...federationOptions
4720
+ });
4721
+ federation.setInboxListeners("/users/{identifier}/inbox", "/inbox");
4722
+ return {
4723
+ federation,
4724
+ kv,
4725
+ queued
4726
+ };
4727
+ }
4728
+ function createOutboxMessage(inbox, overrides = {}) {
4729
+ return {
4730
+ type: "outbox",
4731
+ id: crypto.randomUUID(),
4732
+ baseUrl: "https://example.com",
4733
+ keys: [],
4734
+ activity: {
4735
+ "@context": "https://www.w3.org/ns/activitystreams",
4736
+ type: "Create",
4737
+ id: "https://example.com/activity/circuit",
4738
+ actor: "https://example.com/users/alice",
4739
+ object: {
4740
+ type: "Note",
4741
+ content: "test"
4742
+ }
4743
+ },
4744
+ activityId: "https://example.com/activity/circuit",
4745
+ activityType: "https://www.w3.org/ns/activitystreams#Create",
4746
+ inbox,
4747
+ sharedInbox: false,
4748
+ actorIds: ["https://breaker.example/users/bob"],
4749
+ started: (/* @__PURE__ */ new Date()).toISOString(),
4750
+ attempt: 0,
4751
+ headers: {},
4752
+ traceContext: {},
4753
+ ...overrides
4754
+ };
4755
+ }
4756
+ await t.step("is not created without an outbox queue", () => {
4757
+ assertEquals(new FederationImpl({ kv: new MemoryKvStore() }).circuitBreaker, void 0);
4758
+ });
4759
+ await t.step("5xx opens circuit and holds the failed message", async () => {
4760
+ esm_default.hardReset();
4761
+ esm_default.spyGlobal();
4762
+ esm_default.post("https://breaker.example/inbox", {
4763
+ status: 500,
4764
+ body: "server error"
4765
+ });
4766
+ const { federation, queued, kv } = setup({
4767
+ failureThreshold: 1,
4768
+ failureWindow: { minutes: 10 },
4769
+ recoveryDelay: { minutes: 30 }
4770
+ });
4771
+ const orderingKey = "https://example.com/object/breaker";
4772
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://breaker.example/inbox", { orderingKey }));
4773
+ assertEquals(queued.length, 1);
4774
+ const held = queued[0].message;
4775
+ assertEquals(held.attempt, 0);
4776
+ assertEquals(held.orderingKey, orderingKey);
4777
+ assertEquals(held.circuitHeld, true);
4778
+ assertExists(held.circuitHeldSince);
4779
+ assertEquals(queued[0].options?.orderingKey, orderingKey);
4780
+ assertEquals(queued[0].options?.delay, Temporal.Duration.from({ minutes: 30 }));
4781
+ const state = await kv.get([
4782
+ "_fedify",
4783
+ "circuit",
4784
+ "breaker.example"
4785
+ ]);
4786
+ assertEquals(state?.state, "open");
4787
+ assertEquals(Array.isArray(state?.failures), true);
4788
+ assertEquals((state?.failures).length, 1);
4789
+ assertExists(state?.opened);
4790
+ });
4791
+ await t.step("open circuit requeues without sending", async () => {
4792
+ esm_default.hardReset();
4793
+ esm_default.spyGlobal();
4794
+ let requests = 0;
4795
+ esm_default.post("https://open.example/inbox", () => {
4796
+ requests++;
4797
+ return {
4798
+ status: 500,
4799
+ body: "server error"
4800
+ };
4801
+ });
4802
+ const { federation, queued } = setup({
4803
+ failureThreshold: 1,
4804
+ recoveryDelay: { hours: 1 }
4805
+ });
4806
+ const orderingKey = "https://example.com/object/open";
4807
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://open.example/inbox", { orderingKey }));
4808
+ const held = queued[0].message;
4809
+ queued.length = 0;
4810
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://open.example/inbox", {
4811
+ circuitHeld: true,
4812
+ circuitHeldSince: held.circuitHeldSince,
4813
+ orderingKey
4814
+ }));
4815
+ assertEquals(requests, 1);
4816
+ assertEquals(queued.length, 1);
4817
+ const requeued = queued[0].message;
4818
+ assertEquals(requeued.attempt, 0);
4819
+ assertEquals(requeued.orderingKey, orderingKey);
4820
+ assertEquals(requeued.circuitHeld, true);
4821
+ assertEquals(requeued.circuitHeldSince, held.circuitHeldSince);
4822
+ assertEquals(queued[0].options?.orderingKey, orderingKey);
4823
+ assertEquals(queued[0].options?.delay, Temporal.Duration.from({ hours: 1 }));
4824
+ });
4825
+ await t.step("circuit keys include non-default ports", async () => {
4826
+ esm_default.hardReset();
4827
+ esm_default.spyGlobal();
4828
+ let defaultPortRequests = 0;
4829
+ esm_default.post("https://ports.example:8443/inbox", {
4830
+ status: 500,
4831
+ body: "server error"
4832
+ });
4833
+ esm_default.post("https://ports.example/inbox", () => {
4834
+ defaultPortRequests++;
4835
+ return {
4836
+ status: 202,
4837
+ body: ""
4838
+ };
4839
+ });
4840
+ const { federation, queued, kv } = setup({
4841
+ failureThreshold: 1,
4842
+ recoveryDelay: { hours: 1 }
4843
+ });
4844
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://ports.example:8443/inbox"));
4845
+ assertEquals((await kv.get([
4846
+ "_fedify",
4847
+ "circuit",
4848
+ "ports.example:8443"
4849
+ ]))?.state, "open");
4850
+ assertEquals(await kv.get([
4851
+ "_fedify",
4852
+ "circuit",
4853
+ "ports.example"
4854
+ ]), void 0);
4855
+ queued.length = 0;
4856
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://ports.example/inbox"));
4857
+ assertEquals(defaultPortRequests, 1);
4858
+ assertEquals(queued, []);
4859
+ });
4860
+ await t.step("post-send circuit errors do not retry delivery", async () => {
4861
+ esm_default.hardReset();
4862
+ esm_default.spyGlobal();
4863
+ esm_default.post("https://success-bookkeeping.example/inbox", {
4864
+ status: 202,
4865
+ body: ""
4866
+ });
4867
+ const { federation, queued, kv } = setup({ failureThreshold: 1 });
4868
+ await kv.set([
4869
+ "_fedify",
4870
+ "circuit",
4871
+ "success-bookkeeping.example"
4872
+ ], {
4873
+ state: "closed",
4874
+ failures: []
4875
+ });
4876
+ kv.cas = () => Promise.resolve(false);
4877
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://success-bookkeeping.example/inbox"));
4878
+ assertEquals(queued, []);
4879
+ });
4880
+ await t.step("pre-send circuit errors do not block delivery", async () => {
4881
+ esm_default.hardReset();
4882
+ esm_default.spyGlobal();
4883
+ let requests = 0;
4884
+ esm_default.post("https://presend-bookkeeping.example/inbox", () => {
4885
+ requests++;
4886
+ return {
4887
+ status: 202,
4888
+ body: ""
4889
+ };
4890
+ });
4891
+ const { federation, queued, kv } = setup({ failureThreshold: 1 });
4892
+ kv.get = () => Promise.reject(/* @__PURE__ */ new Error("kv get failed"));
4893
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://presend-bookkeeping.example/inbox"));
4894
+ assertEquals(requests, 1);
4895
+ assertEquals(queued, []);
4896
+ });
4897
+ await t.step("circuit failure errors fall back to retry", async () => {
4898
+ esm_default.hardReset();
4899
+ esm_default.spyGlobal();
4900
+ esm_default.post("https://failure-bookkeeping.example/inbox", {
4901
+ status: 500,
4902
+ body: "server error"
4903
+ });
4904
+ const { federation, queued, kv } = setup({ failureThreshold: 1 }, { outboxRetryPolicy: () => Temporal.Duration.from({ seconds: 3 }) });
4905
+ kv.cas = () => Promise.resolve(false);
4906
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://failure-bookkeeping.example/inbox"));
4907
+ assertEquals(queued.length, 1);
4908
+ const retry = queued[0].message;
4909
+ assertEquals(retry.attempt, 1);
4910
+ assertEquals(retry.circuitHeld, void 0);
4911
+ assertEquals(queued[0].options?.delay, Temporal.Duration.from({ seconds: 3 }));
4912
+ });
4913
+ await t.step("local delivery errors do not open circuit", async () => {
4914
+ esm_default.hardReset();
4915
+ esm_default.spyGlobal();
4916
+ const { federation, queued, kv } = setup({ failureThreshold: 1 }, { outboxRetryPolicy: () => Temporal.Duration.from({ seconds: 3 }) });
4917
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://local-error.example/inbox", { headers: { "Invalid Header": "x" } }));
4918
+ assertEquals(queued.length, 1);
4919
+ const retry = queued[0].message;
4920
+ assertEquals(retry.attempt, 1);
4921
+ assertEquals(retry.circuitHeld, void 0);
4922
+ assertEquals(queued[0].options?.delay, Temporal.Duration.from({ seconds: 3 }));
4923
+ assertEquals(await kv.get([
4924
+ "_fedify",
4925
+ "circuit",
4926
+ "local-error.example"
4927
+ ]), void 0);
4928
+ });
4929
+ await t.step("calendar retry delays are enqueued", async () => {
4930
+ esm_default.hardReset();
4931
+ esm_default.spyGlobal();
4932
+ esm_default.post("https://calendar-delay.example/inbox", {
4933
+ status: 500,
4934
+ body: "server error"
4935
+ });
4936
+ const { federation, queued } = setup({ failureThreshold: 5 }, { outboxRetryPolicy: () => Temporal.Duration.from({ days: 1 }) });
4937
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://calendar-delay.example/inbox"));
4938
+ assertEquals(queued.length, 1);
4939
+ assertEquals(queued[0].options?.delay, Temporal.Duration.from({ days: 1 }));
4940
+ });
4941
+ await t.step("negative calendar retry delays are clamped", async () => {
4942
+ esm_default.hardReset();
4943
+ esm_default.spyGlobal();
4944
+ esm_default.post("https://negative-calendar-delay.example/inbox", {
4945
+ status: 500,
4946
+ body: "server error"
4947
+ });
4948
+ const { federation, queued } = setup({ failureThreshold: 5 }, { outboxRetryPolicy: () => Temporal.Duration.from({ days: -1 }) });
4949
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://negative-calendar-delay.example/inbox"));
4950
+ assertEquals(queued.length, 1);
4951
+ assertEquals(queued[0].options?.delay, Temporal.Duration.from({ seconds: 0 }));
4952
+ });
4953
+ await t.step("circuit hold respects retry give-up", async () => {
4954
+ esm_default.hardReset();
4955
+ esm_default.spyGlobal();
4956
+ esm_default.post("https://hold-give-up.example/inbox", {
4957
+ status: 500,
4958
+ body: "server error"
4959
+ });
4960
+ const { federation, queued, kv } = setup({
4961
+ failureThreshold: 1,
4962
+ recoveryDelay: { minutes: 30 }
4963
+ }, { outboxRetryPolicy: () => null });
4964
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://hold-give-up.example/inbox"));
4965
+ assertEquals(queued, []);
4966
+ assertEquals((await kv.get([
4967
+ "_fedify",
4968
+ "circuit",
4969
+ "hold-give-up.example"
4970
+ ]))?.state, "open");
4971
+ });
4972
+ await t.step("circuit decision errors fall back to retry", async () => {
4973
+ esm_default.hardReset();
4974
+ esm_default.spyGlobal();
4975
+ esm_default.post("https://decision-bookkeeping.example/inbox", {
4976
+ status: 500,
4977
+ body: "server error"
4978
+ });
4979
+ const { federation, queued, kv } = setup({ failureThreshold: 1 }, { outboxRetryPolicy: () => Temporal.Duration.from({ seconds: 4 }) });
4980
+ const originalGet = kv.get.bind(kv);
4981
+ let getCalls = 0;
4982
+ kv.get = (...args) => {
4983
+ getCalls++;
4984
+ return getCalls === 1 ? originalGet(...args) : Promise.reject(/* @__PURE__ */ new Error("kv get failed"));
4985
+ };
4986
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://decision-bookkeeping.example/inbox"));
4987
+ assertEquals(queued.length, 1);
4988
+ const retry = queued[0].message;
4989
+ assertEquals(retry.attempt, 1);
4990
+ assertEquals(retry.circuitHeld, void 0);
4991
+ assertEquals(queued[0].options?.delay, Temporal.Duration.from({ seconds: 4 }));
4992
+ });
4993
+ await t.step("circuit reachable errors keep permanent failure", async () => {
4994
+ esm_default.hardReset();
4995
+ esm_default.spyGlobal();
4996
+ esm_default.post("https://permanent-bookkeeping.example/inbox", {
4997
+ status: 500,
4998
+ body: "server error"
4999
+ });
5000
+ const { federation, queued, kv } = setup({ failureThreshold: 1 }, { permanentFailureStatusCodes: [500] });
5001
+ await kv.set([
5002
+ "_fedify",
5003
+ "circuit",
5004
+ "permanent-bookkeeping.example"
5005
+ ], {
5006
+ state: "half-open",
5007
+ failures: ["2026-05-25T00:00:00Z"],
5008
+ opened: "2026-05-25T00:00:00Z",
5009
+ halfOpened: "2026-05-25T00:00:00Z"
5010
+ });
5011
+ const originalCas = kv.cas.bind(kv);
5012
+ let casCalls = 0;
5013
+ kv.cas = (...args) => {
5014
+ casCalls++;
5015
+ return casCalls === 1 ? originalCas(...args) : Promise.resolve(false);
5016
+ };
5017
+ let permanentFailureStatusCode;
5018
+ federation.setOutboxPermanentFailureHandler((_ctx, values) => {
5019
+ permanentFailureStatusCode = values.statusCode;
5020
+ });
5021
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://permanent-bookkeeping.example/inbox"));
5022
+ assertEquals(queued, []);
5023
+ assertEquals(permanentFailureStatusCode, 500);
5024
+ });
5025
+ await t.step("429 respects Retry-After without opening circuit", async () => {
5026
+ esm_default.hardReset();
5027
+ esm_default.spyGlobal();
5028
+ esm_default.post("https://rate.example/inbox", {
5029
+ status: 429,
5030
+ headers: { "Retry-After": "120" },
5031
+ body: "rate limited"
5032
+ });
5033
+ const { federation, queued, kv } = setup({
5034
+ failureThreshold: 1,
5035
+ recoveryDelay: { minutes: 30 }
5036
+ });
5037
+ const orderingKey = "https://example.com/object/rate";
5038
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://rate.example/inbox", { orderingKey }));
5039
+ assertEquals(queued.length, 1);
5040
+ const retry = queued[0].message;
5041
+ assertEquals(retry.attempt, 1);
5042
+ assertEquals(retry.orderingKey, orderingKey);
5043
+ assertEquals(retry.circuitHeld, void 0);
5044
+ assertEquals(queued[0].options?.orderingKey, orderingKey);
5045
+ assertEquals(queued[0].options?.delay, Temporal.Duration.from({ seconds: 120 }));
5046
+ assertEquals(await kv.get([
5047
+ "_fedify",
5048
+ "circuit",
5049
+ "rate.example"
5050
+ ]), void 0);
5051
+ });
5052
+ await t.step("429 respects Retry-After with circuit breaker disabled", async () => {
5053
+ esm_default.hardReset();
5054
+ esm_default.spyGlobal();
5055
+ esm_default.post("https://rate-disabled.example/inbox", {
5056
+ status: 429,
5057
+ headers: { "Retry-After": "120" },
5058
+ body: "rate limited"
5059
+ });
5060
+ const { federation, queued, kv } = setup(false, {}, { nativeRetrial: true });
5061
+ assertEquals(federation.circuitBreaker, void 0);
5062
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://rate-disabled.example/inbox", { orderingKey: "https://example.com/object/rate-limited" }));
5063
+ assertEquals(queued.length, 1);
5064
+ const retry = queued[0].message;
5065
+ assertEquals(retry.attempt, 1);
5066
+ assertEquals(retry.circuitHeld, void 0);
5067
+ assertEquals(queued[0].options?.delay, Temporal.Duration.from({ seconds: 120 }));
5068
+ assertEquals(queued[0].options?.orderingKey, "https://example.com/object/rate-limited");
5069
+ assertEquals(await kv.get([
5070
+ "_fedify",
5071
+ "circuit",
5072
+ "rate-disabled.example"
5073
+ ]), void 0);
5074
+ });
5075
+ await t.step("429 Retry-After still respects retry give-up", async () => {
5076
+ esm_default.hardReset();
5077
+ esm_default.spyGlobal();
5078
+ esm_default.post("https://give-up.example/inbox", {
5079
+ status: 429,
5080
+ headers: { "Retry-After": "120" },
5081
+ body: "rate limited"
5082
+ });
5083
+ const { federation, queued, kv } = setup({ failureThreshold: 1 }, { outboxRetryPolicy: () => null });
5084
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://give-up.example/inbox"));
5085
+ assertEquals(queued, []);
5086
+ assertEquals(await kv.get([
5087
+ "_fedify",
5088
+ "circuit",
5089
+ "give-up.example"
5090
+ ]), void 0);
5091
+ });
5092
+ await t.step("503 respects Retry-After while counting failure", async () => {
5093
+ esm_default.hardReset();
5094
+ esm_default.spyGlobal();
5095
+ esm_default.post("https://unavailable.example/inbox", {
5096
+ status: 503,
5097
+ headers: { "Retry-After": "120" },
5098
+ body: "temporarily unavailable"
5099
+ });
5100
+ const { federation, queued, kv } = setup({
5101
+ failureThreshold: 5,
5102
+ failureWindow: { minutes: 10 }
5103
+ }, { outboxRetryPolicy: () => Temporal.Duration.from({ seconds: 3 }) });
5104
+ const orderingKey = "https://example.com/object/unavailable";
5105
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://unavailable.example/inbox", { orderingKey }));
5106
+ assertEquals(queued.length, 1);
5107
+ const retry = queued[0].message;
5108
+ assertEquals(retry.attempt, 1);
5109
+ assertEquals(retry.circuitHeld, void 0);
5110
+ assertEquals(retry.orderingKey, orderingKey);
5111
+ assertEquals(queued[0].options?.delay, Temporal.Duration.from({ seconds: 120 }));
5112
+ assertEquals(queued[0].options?.orderingKey, orderingKey);
5113
+ const state = await kv.get([
5114
+ "_fedify",
5115
+ "circuit",
5116
+ "unavailable.example"
5117
+ ]);
5118
+ assertEquals(state?.state, "closed");
5119
+ assertEquals((state?.failures).length, 1);
5120
+ });
5121
+ await t.step("503 Retry-After delays newly opened circuit hold", async () => {
5122
+ esm_default.hardReset();
5123
+ esm_default.spyGlobal();
5124
+ esm_default.post("https://open-retry-after.example/inbox", {
5125
+ status: 503,
5126
+ headers: { "Retry-After": "3600" },
5127
+ body: "temporarily unavailable"
5128
+ });
5129
+ const { federation, queued, kv } = setup({
5130
+ failureThreshold: 1,
5131
+ recoveryDelay: { seconds: 30 }
5132
+ }, { outboxRetryPolicy: () => Temporal.Duration.from({ seconds: 3 }) });
5133
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://open-retry-after.example/inbox"));
5134
+ assertEquals(queued.length, 1);
5135
+ const held = queued[0].message;
5136
+ assertEquals(held.attempt, 0);
5137
+ assertEquals(held.circuitHeld, true);
5138
+ assertEquals(queued[0].options?.delay?.toString(), "PT3600S");
5139
+ assertEquals((await kv.get([
5140
+ "_fedify",
5141
+ "circuit",
5142
+ "open-retry-after.example"
5143
+ ]))?.state, "open");
2067
5144
  });
2068
- esm_default.post("https://error.example/inbox", {
2069
- status: 500,
2070
- body: "Internal Server Error"
5145
+ await t.step("malformed Retry-After falls back to retry policy", async () => {
5146
+ esm_default.hardReset();
5147
+ esm_default.spyGlobal();
5148
+ esm_default.post("https://huge-retry-after.example/inbox", {
5149
+ status: 429,
5150
+ headers: { "Retry-After": "999999999999999999999999999999" },
5151
+ body: "rate limited"
5152
+ });
5153
+ const { federation, queued, kv } = setup({ failureThreshold: 1 }, { outboxRetryPolicy: () => Temporal.Duration.from({ seconds: 3 }) });
5154
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://huge-retry-after.example/inbox"));
5155
+ assertEquals(queued.length, 1);
5156
+ assertEquals(queued[0].options?.delay?.total({ unit: "second" }), 3);
5157
+ assertEquals(await kv.get([
5158
+ "_fedify",
5159
+ "circuit",
5160
+ "huge-retry-after.example"
5161
+ ]), void 0);
2071
5162
  });
2072
- esm_default.post("https://legal.example/inbox", {
2073
- status: 451,
2074
- body: "Unavailable For Legal Reasons"
5163
+ await t.step("invalid Retry-After date falls back to retry policy", async () => {
5164
+ esm_default.hardReset();
5165
+ esm_default.spyGlobal();
5166
+ esm_default.post("https://invalid-retry-after.example/inbox", {
5167
+ status: 429,
5168
+ headers: { "Retry-After": "1.5" },
5169
+ body: "rate limited"
5170
+ });
5171
+ const { federation, queued, kv } = setup({ failureThreshold: 1 }, { outboxRetryPolicy: () => Temporal.Duration.from({ seconds: 3 }) });
5172
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://invalid-retry-after.example/inbox"));
5173
+ assertEquals(queued.length, 1);
5174
+ assertEquals(queued[0].options?.delay?.total({ unit: "second" }), 3);
5175
+ assertEquals(await kv.get([
5176
+ "_fedify",
5177
+ "circuit",
5178
+ "invalid-retry-after.example"
5179
+ ]), void 0);
2075
5180
  });
2076
- function setup(options = {}) {
5181
+ await t.step("asctime Retry-After date is interpreted as UTC", async () => {
5182
+ esm_default.hardReset();
5183
+ esm_default.spyGlobal();
5184
+ const retryAfter = "Wed Dec 31 23:59:59 2036";
5185
+ esm_default.post("https://asctime-retry-after.example/inbox", {
5186
+ status: 429,
5187
+ headers: { "Retry-After": retryAfter },
5188
+ body: "rate limited"
5189
+ });
5190
+ const { federation, queued } = setup({ failureThreshold: 1 }, { outboxRetryPolicy: () => Temporal.Duration.from({ seconds: 3 }) });
5191
+ const before = Temporal.Now.instant();
5192
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://asctime-retry-after.example/inbox"));
5193
+ const after = Temporal.Now.instant();
5194
+ const retryAtMs = Date.parse(`${retryAfter} GMT`);
5195
+ assertEquals(queued.length, 1);
5196
+ const delayMs = queued[0].options?.delay?.total({ unit: "millisecond" });
5197
+ assertExists(delayMs);
5198
+ assertEquals(delayMs <= retryAtMs - before.epochMilliseconds, true);
5199
+ assertEquals(delayMs >= retryAtMs - after.epochMilliseconds, true);
5200
+ });
5201
+ await t.step("permanent 5xx does not open circuit", async () => {
5202
+ esm_default.hardReset();
5203
+ esm_default.spyGlobal();
5204
+ esm_default.post("https://permanent-500.example/inbox", {
5205
+ status: 500,
5206
+ body: "server error"
5207
+ });
5208
+ const { federation, queued, kv } = setup({ failureThreshold: 1 }, { permanentFailureStatusCodes: [500] });
5209
+ let permanentFailureStatusCode;
5210
+ federation.setOutboxPermanentFailureHandler((_ctx, values) => {
5211
+ permanentFailureStatusCode = values.statusCode;
5212
+ });
5213
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://permanent-500.example/inbox"));
5214
+ assertEquals(queued, []);
5215
+ assertEquals(permanentFailureStatusCode, 500);
5216
+ assertEquals(await kv.get([
5217
+ "_fedify",
5218
+ "circuit",
5219
+ "permanent-500.example"
5220
+ ]), void 0);
5221
+ });
5222
+ await t.step("permanent 5xx closes half-open circuit", async () => {
5223
+ esm_default.hardReset();
5224
+ esm_default.spyGlobal();
5225
+ esm_default.post("https://permanent-probe.example/inbox", {
5226
+ status: 500,
5227
+ body: "server error"
5228
+ });
5229
+ const { federation, queued, kv } = setup({
5230
+ failureThreshold: 1,
5231
+ releaseInterval: { seconds: 1 }
5232
+ }, { permanentFailureStatusCodes: [500] });
5233
+ await kv.set([
5234
+ "_fedify",
5235
+ "circuit",
5236
+ "permanent-probe.example"
5237
+ ], {
5238
+ state: "half-open",
5239
+ failures: ["2026-05-25T00:00:00Z"],
5240
+ opened: "2026-05-25T00:00:00Z",
5241
+ halfOpened: "2026-05-25T00:00:00Z"
5242
+ });
5243
+ let permanentFailureStatusCode;
5244
+ federation.setOutboxPermanentFailureHandler((_ctx, values) => {
5245
+ permanentFailureStatusCode = values.statusCode;
5246
+ });
5247
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://permanent-probe.example/inbox"));
5248
+ assertEquals(queued, []);
5249
+ assertEquals(permanentFailureStatusCode, 500);
5250
+ assertEquals(await kv.get([
5251
+ "_fedify",
5252
+ "circuit",
5253
+ "permanent-probe.example"
5254
+ ]), void 0);
5255
+ });
5256
+ await t.step("permanent 4xx closes half-open circuit", async () => {
5257
+ esm_default.hardReset();
5258
+ esm_default.spyGlobal();
5259
+ esm_default.post("https://gone.example/inbox", {
5260
+ status: 410,
5261
+ body: "gone"
5262
+ });
5263
+ const { federation, queued, kv } = setup({
5264
+ failureThreshold: 1,
5265
+ releaseInterval: { seconds: 1 }
5266
+ });
5267
+ await kv.set([
5268
+ "_fedify",
5269
+ "circuit",
5270
+ "gone.example"
5271
+ ], {
5272
+ state: "half-open",
5273
+ failures: ["2026-05-25T00:00:00Z"],
5274
+ opened: "2026-05-25T00:00:00Z",
5275
+ halfOpened: "2026-05-25T00:00:00Z"
5276
+ });
5277
+ let permanentFailureStatusCode;
5278
+ federation.setOutboxPermanentFailureHandler((_ctx, values) => {
5279
+ permanentFailureStatusCode = values.statusCode;
5280
+ });
5281
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://gone.example/inbox"));
5282
+ assertEquals(queued, []);
5283
+ assertEquals(permanentFailureStatusCode, 410);
5284
+ assertEquals(await kv.get([
5285
+ "_fedify",
5286
+ "circuit",
5287
+ "gone.example"
5288
+ ]), void 0);
5289
+ });
5290
+ await t.step("false disables circuit handling", async () => {
5291
+ esm_default.hardReset();
5292
+ esm_default.spyGlobal();
5293
+ esm_default.post("https://disabled.example/inbox", {
5294
+ status: 500,
5295
+ body: "server error"
5296
+ });
5297
+ const { federation, queued, kv } = setup(false);
5298
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://disabled.example/inbox"));
5299
+ assertEquals(queued.length, 1);
5300
+ const retry = queued[0].message;
5301
+ assertEquals(retry.attempt, 1);
5302
+ assertEquals(retry.circuitHeld, void 0);
5303
+ assertEquals(await kv.get([
5304
+ "_fedify",
5305
+ "circuit",
5306
+ "disabled.example"
5307
+ ]), void 0);
5308
+ });
5309
+ await t.step("state changes are recorded in metrics and spans", async () => {
5310
+ esm_default.hardReset();
5311
+ esm_default.spyGlobal();
5312
+ esm_default.post("https://telemetry.example/inbox", {
5313
+ status: 500,
5314
+ body: "server error"
5315
+ });
5316
+ const [meterProvider, recorder] = createTestMeterProvider();
5317
+ const [tracerProvider, exporter] = createTestTracerProvider();
5318
+ const { federation, queued } = setup({ failureThreshold: 1 }, {
5319
+ meterProvider,
5320
+ tracerProvider
5321
+ });
5322
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://telemetry.example/inbox"));
5323
+ assertEquals(queued.length, 1);
5324
+ const measurements = recorder.getMeasurements("activitypub.circuit_breaker.state_change");
5325
+ assertEquals(measurements.length, 1);
5326
+ assertEquals(measurements[0].attributes["activitypub.remote.host"], "telemetry.example");
5327
+ assertEquals(measurements[0].attributes["activitypub.circuit_breaker.state"], "open");
5328
+ const events = exporter.getEvents("activitypub.outbox", "activitypub.circuit_breaker.state_change");
5329
+ assertEquals(events.length, 1);
5330
+ assertEquals(events[0].attributes?.["activitypub.remote.host"], "telemetry.example");
5331
+ assertEquals(events[0].attributes?.["activitypub.circuit_breaker.previous_state"], "closed");
5332
+ assertEquals(events[0].attributes?.["activitypub.circuit_breaker.state"], "open");
5333
+ const heldEvents = exporter.getEvents("activitypub.outbox", "activitypub.circuit_breaker.held");
5334
+ assertEquals(heldEvents.length, 1);
5335
+ assertEquals(heldEvents[0].attributes?.["activitypub.remote.host"], "telemetry.example");
5336
+ assertEquals(heldEvents[0].attributes?.["activitypub.circuit_breaker.state"], "open");
5337
+ });
5338
+ await t.step("held half-open circuit is recorded in spans", async () => {
5339
+ esm_default.hardReset();
5340
+ esm_default.spyGlobal();
5341
+ const now = Temporal.Instant.from("2026-05-25T00:00:30Z");
5342
+ const [tracerProvider, exporter] = createTestTracerProvider();
5343
+ const { federation, queued, kv } = setup({
5344
+ failureThreshold: 1,
5345
+ recoveryDelay: { minutes: 5 },
5346
+ releaseInterval: { minutes: 1 }
5347
+ }, { tracerProvider });
5348
+ federation.circuitBreaker = new CircuitBreaker({
5349
+ kv,
5350
+ prefix: ["_fedify", "circuit"],
5351
+ now: () => now,
5352
+ options: {
5353
+ failureThreshold: 1,
5354
+ recoveryDelay: { minutes: 5 },
5355
+ releaseInterval: { minutes: 1 }
5356
+ }
5357
+ });
5358
+ await kv.set([
5359
+ "_fedify",
5360
+ "circuit",
5361
+ "half-open-telemetry.example"
5362
+ ], {
5363
+ state: "half-open",
5364
+ failures: ["2026-05-25T00:00:00Z"],
5365
+ opened: "2026-05-25T00:00:00Z",
5366
+ halfOpened: "2026-05-25T00:00:00Z"
5367
+ });
5368
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://half-open-telemetry.example/inbox", {
5369
+ circuitHeld: true,
5370
+ circuitHeldSince: "2026-05-25T00:00:00Z"
5371
+ }));
5372
+ assertEquals(queued.length, 1);
5373
+ const events = exporter.getEvents("activitypub.outbox", "activitypub.circuit_breaker.held");
5374
+ assertEquals(events.length, 1);
5375
+ assertEquals(events[0].attributes?.["activitypub.remote.host"], "half-open-telemetry.example");
5376
+ assertEquals(events[0].attributes?.["activitypub.circuit_breaker.state"], "half_open");
5377
+ });
5378
+ await t.step("stale half-open probe does not record open transition", async () => {
5379
+ esm_default.hardReset();
5380
+ esm_default.spyGlobal();
5381
+ esm_default.post("https://stale-probe-telemetry.example/inbox", {
5382
+ status: 202,
5383
+ body: ""
5384
+ });
5385
+ const now = Temporal.Instant.from("2026-05-25T00:00:02Z");
5386
+ const [tracerProvider, exporter] = createTestTracerProvider();
5387
+ const { federation, kv } = setup({
5388
+ failureThreshold: 1,
5389
+ recoveryDelay: { seconds: 1 }
5390
+ }, { tracerProvider });
5391
+ federation.circuitBreaker = new CircuitBreaker({
5392
+ kv,
5393
+ prefix: ["_fedify", "circuit"],
5394
+ now: () => now,
5395
+ options: {
5396
+ failureThreshold: 1,
5397
+ recoveryDelay: { seconds: 1 }
5398
+ }
5399
+ });
5400
+ await kv.set([
5401
+ "_fedify",
5402
+ "circuit",
5403
+ "stale-probe-telemetry.example"
5404
+ ], {
5405
+ state: "half-open",
5406
+ failures: ["2026-05-25T00:00:00Z"],
5407
+ opened: "2026-05-25T00:00:00Z",
5408
+ halfOpened: "2026-05-25T00:00:00Z"
5409
+ });
5410
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://stale-probe-telemetry.example/inbox"));
5411
+ const events = exporter.getEvents("activitypub.outbox", "activitypub.circuit_breaker.state_change");
5412
+ assertEquals(events.length, 1);
5413
+ assertEquals(events[0].attributes?.["activitypub.circuit_breaker.previous_state"], "half_open");
5414
+ assertEquals(events[0].attributes?.["activitypub.circuit_breaker.state"], "closed");
5415
+ });
5416
+ await t.step("expired held activity is dropped", async () => {
5417
+ esm_default.hardReset();
5418
+ esm_default.spyGlobal();
5419
+ const now = Temporal.Instant.from("2026-05-25T00:00:02Z");
5420
+ let dropped = null;
5421
+ const { federation, queued, kv } = setup(false);
5422
+ federation.circuitBreaker = new CircuitBreaker({
5423
+ kv,
5424
+ prefix: ["_fedify", "circuit"],
5425
+ now: () => now,
5426
+ options: {
5427
+ failureThreshold: 1,
5428
+ heldActivityTtl: { seconds: 1 },
5429
+ onActivityDrop(remoteHost, details) {
5430
+ dropped = {
5431
+ remoteHost,
5432
+ heldSince: details.heldSince
5433
+ };
5434
+ }
5435
+ }
5436
+ });
5437
+ let permanentFailureReason;
5438
+ federation.setOutboxPermanentFailureHandler((_ctx, values) => {
5439
+ permanentFailureReason = values.reason;
5440
+ });
5441
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://ttl.example/inbox", {
5442
+ circuitHeld: true,
5443
+ circuitHeldSince: "2026-05-25T00:00:00Z"
5444
+ }));
5445
+ assertEquals(queued, []);
5446
+ assertEquals(dropped, {
5447
+ remoteHost: "ttl.example",
5448
+ heldSince: Temporal.Instant.from("2026-05-25T00:00:00Z")
5449
+ });
5450
+ assertEquals(permanentFailureReason, "circuit-breaker-ttl");
5451
+ });
5452
+ await t.step("expired held probe is dropped after failed send", async () => {
5453
+ esm_default.hardReset();
5454
+ esm_default.spyGlobal();
5455
+ let now = Temporal.Instant.from("2026-05-25T00:00:01Z");
5456
+ const heldSince = Temporal.Instant.from("2026-05-25T00:00:00Z");
5457
+ esm_default.post("https://expired-probe.example/inbox", () => {
5458
+ now = Temporal.Instant.from("2026-05-25T00:00:03Z");
5459
+ return {
5460
+ status: 500,
5461
+ body: "server error"
5462
+ };
5463
+ });
5464
+ let dropped = null;
5465
+ const { federation, queued, kv } = setup({
5466
+ failureThreshold: 1,
5467
+ recoveryDelay: { seconds: 1 },
5468
+ heldActivityTtl: { seconds: 2 },
5469
+ releaseInterval: { seconds: 1 }
5470
+ });
5471
+ federation.circuitBreaker = new CircuitBreaker({
5472
+ kv,
5473
+ prefix: ["_fedify", "circuit"],
5474
+ now: () => now,
5475
+ options: {
5476
+ failureThreshold: 1,
5477
+ recoveryDelay: { seconds: 1 },
5478
+ heldActivityTtl: { seconds: 2 },
5479
+ releaseInterval: { seconds: 1 },
5480
+ onActivityDrop(remoteHost, details) {
5481
+ dropped = {
5482
+ remoteHost,
5483
+ heldSince: details.heldSince
5484
+ };
5485
+ }
5486
+ }
5487
+ });
5488
+ await kv.set([
5489
+ "_fedify",
5490
+ "circuit",
5491
+ "expired-probe.example"
5492
+ ], {
5493
+ state: "half-open",
5494
+ failures: ["2026-05-25T00:00:00Z"],
5495
+ opened: "2026-05-25T00:00:00Z",
5496
+ halfOpened: "2026-05-25T00:00:00Z"
5497
+ });
5498
+ let permanentFailureReason;
5499
+ federation.setOutboxPermanentFailureHandler((_ctx, values) => {
5500
+ permanentFailureReason = values.reason;
5501
+ });
5502
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://expired-probe.example/inbox", {
5503
+ circuitHeld: true,
5504
+ circuitHeldSince: heldSince.toString()
5505
+ }));
5506
+ assertEquals(queued, []);
5507
+ assertEquals(dropped, {
5508
+ remoteHost: "expired-probe.example",
5509
+ heldSince
5510
+ });
5511
+ assertEquals(permanentFailureReason, "circuit-breaker-ttl");
5512
+ });
5513
+ esm_default.hardReset();
5514
+ });
5515
+ test("FederationImpl.processQueuedTask() queue task metrics", async (t) => {
5516
+ await t.step("records failed result when worker re-throws (nativeRetrial)", async () => {
5517
+ const kv = new MemoryKvStore();
5518
+ const [meterProvider, recorder] = createTestMeterProvider();
5519
+ const federation = new FederationImpl({
5520
+ kv,
5521
+ meterProvider,
5522
+ queue: {
5523
+ nativeRetrial: true,
5524
+ enqueue(_message, _options) {
5525
+ return Promise.resolve();
5526
+ },
5527
+ listen(_handler, _options) {
5528
+ return Promise.resolve();
5529
+ }
5530
+ }
5531
+ });
5532
+ federation.setInboxListeners("/users/{identifier}/inbox", "/inbox").on(vocab.Create, () => {
5533
+ throw new Error("Intended error for testing");
5534
+ });
5535
+ await assertRejects(() => federation.processQueuedTask(void 0, {
5536
+ type: "inbox",
5537
+ id: crypto.randomUUID(),
5538
+ baseUrl: "https://example.com",
5539
+ activity: {
5540
+ "@context": "https://www.w3.org/ns/activitystreams",
5541
+ type: "Create",
5542
+ id: "https://remote.example/activities/2",
5543
+ actor: "https://remote.example/users/alice",
5544
+ object: {
5545
+ type: "Note",
5546
+ content: "Hello world"
5547
+ }
5548
+ },
5549
+ started: (/* @__PURE__ */ new Date()).toISOString(),
5550
+ attempt: 0,
5551
+ identifier: null,
5552
+ traceContext: {}
5553
+ }), Error);
5554
+ assertEquals(recorder.getMeasurements("fedify.queue.task.completed").length, 0);
5555
+ const failed = recorder.getMeasurements("fedify.queue.task.failed");
5556
+ assertEquals(failed.length, 1);
5557
+ assertEquals(failed[0].attributes["fedify.queue.role"], "inbox");
5558
+ assertEquals(failed[0].attributes["fedify.queue.task.result"], "failed");
5559
+ assertEquals(failed[0].attributes["fedify.queue.native_retrial"], true);
5560
+ assertEquals(failed[0].attributes["activitypub.activity.type"], "https://www.w3.org/ns/activitystreams#Create");
5561
+ const taskDurations = recorder.getMeasurements("fedify.queue.task.duration");
5562
+ assertEquals(taskDurations.length, 1);
5563
+ assertEquals(taskDurations[0].attributes["fedify.queue.task.result"], "failed");
5564
+ const inFlight = recorder.getMeasurements("fedify.queue.task.in_flight");
5565
+ assertEquals(inFlight.length, 2);
5566
+ assertEquals(inFlight[0].value, 1);
5567
+ assertEquals(inFlight[1].value, -1);
5568
+ assertEquals(inFlight[0].attributes, inFlight[1].attributes);
5569
+ });
5570
+ await t.step("records completed when retry handler swallows listener error", async () => {
2077
5571
  const kv = new MemoryKvStore();
5572
+ const [meterProvider, recorder] = createTestMeterProvider();
2078
5573
  const queuedMessages = [];
2079
5574
  const federation = new FederationImpl({
2080
5575
  kv,
5576
+ meterProvider,
2081
5577
  queue: {
2082
- ...options.nativeRetrial ? { nativeRetrial: true } : {},
2083
5578
  enqueue(message, _options) {
2084
5579
  queuedMessages.push(message);
2085
5580
  return Promise.resolve();
@@ -2087,132 +5582,233 @@ test("FederationImpl.processQueuedTask() permanent failure", async (t) => {
2087
5582
  listen(_handler, _options) {
2088
5583
  return Promise.resolve();
2089
5584
  }
2090
- },
2091
- ...options.permanentFailureStatusCodes ? { permanentFailureStatusCodes: options.permanentFailureStatusCodes } : {}
5585
+ }
2092
5586
  });
2093
- federation.setInboxListeners("/users/{identifier}/inbox", "/inbox");
2094
- return {
2095
- federation,
2096
- queuedMessages
2097
- };
2098
- }
2099
- function createOutboxMessage(inbox, activityId, actorIds) {
2100
- return {
2101
- type: "outbox",
5587
+ federation.setInboxListeners("/users/{identifier}/inbox", "/inbox").on(vocab.Create, () => {
5588
+ throw new Error("Intended error for testing");
5589
+ });
5590
+ await federation.processQueuedTask(void 0, {
5591
+ type: "inbox",
2102
5592
  id: crypto.randomUUID(),
2103
5593
  baseUrl: "https://example.com",
2104
- keys: [],
2105
5594
  activity: {
2106
5595
  "@context": "https://www.w3.org/ns/activitystreams",
2107
5596
  type: "Create",
2108
- id: activityId,
2109
- actor: "https://example.com/users/alice",
5597
+ id: "https://remote.example/activities/retry",
5598
+ actor: "https://remote.example/users/alice",
2110
5599
  object: {
2111
5600
  type: "Note",
2112
- content: "test"
5601
+ content: "Hello world"
2113
5602
  }
2114
5603
  },
2115
- activityType: "https://www.w3.org/ns/activitystreams#Create",
2116
- inbox,
2117
- sharedInbox: false,
2118
- ...actorIds != null ? { actorIds } : {},
2119
5604
  started: (/* @__PURE__ */ new Date()).toISOString(),
2120
5605
  attempt: 0,
2121
- headers: {},
5606
+ identifier: null,
2122
5607
  traceContext: {}
2123
- };
2124
- }
2125
- await t.step("410 Gone triggers permanent failure handler", async () => {
2126
- const { federation, queuedMessages } = setup();
2127
- let handlerCalled = false;
2128
- let handlerValues = {};
2129
- federation.setOutboxPermanentFailureHandler((_ctx, values) => {
2130
- handlerCalled = true;
2131
- handlerValues = { ...values };
2132
5608
  });
2133
- await federation.processQueuedTask(void 0, createOutboxMessage("https://gone.example/inbox", "https://example.com/activity/1", ["https://gone.example/users/bob", "https://gone.example/users/charlie"]));
2134
- assert(handlerCalled, "Permanent failure handler should be called");
2135
- assertEquals(handlerValues.inbox, new URL("https://gone.example/inbox"));
2136
- assertEquals(handlerValues.statusCode, 410);
2137
- assertInstanceOf(handlerValues.activity, vocab.Create);
2138
- assertEquals(handlerValues.actorIds, [new URL("https://gone.example/users/bob"), new URL("https://gone.example/users/charlie")]);
2139
- assertEquals(queuedMessages, []);
5609
+ assertEquals(queuedMessages.length, 1);
5610
+ const completed = recorder.getMeasurements("fedify.queue.task.completed");
5611
+ assertEquals(completed.length, 1);
5612
+ assertEquals(completed[0].attributes["fedify.queue.task.result"], "completed");
5613
+ const enqueued = recorder.getMeasurements("fedify.queue.task.enqueued");
5614
+ assertEquals(enqueued.length, 1);
5615
+ assertEquals(enqueued[0].attributes["fedify.queue.role"], "inbox");
5616
+ assertEquals(enqueued[0].attributes["fedify.queue.task.attempt"], 1);
5617
+ assertEquals(enqueued[0].attributes["activitypub.activity.type"], "https://www.w3.org/ns/activitystreams#Create");
2140
5618
  });
2141
- await t.step("404 Not Found triggers permanent failure handler", async () => {
2142
- const { federation, queuedMessages } = setup();
2143
- let handlerCalled = false;
2144
- let handlerStatusCode = 0;
2145
- federation.setOutboxPermanentFailureHandler((_ctx, values) => {
2146
- handlerCalled = true;
2147
- handlerStatusCode = values.statusCode;
5619
+ await t.step("records aborted result when worker re-throws AbortError", async () => {
5620
+ const kv = new MemoryKvStore();
5621
+ const [meterProvider, recorder] = createTestMeterProvider();
5622
+ const [tracerProvider, exporter] = createTestTracerProvider();
5623
+ const federation = new FederationImpl({
5624
+ kv,
5625
+ meterProvider,
5626
+ tracerProvider,
5627
+ queue: {
5628
+ nativeRetrial: true,
5629
+ enqueue(_message, _options) {
5630
+ return Promise.resolve();
5631
+ },
5632
+ listen(_handler, _options) {
5633
+ return Promise.resolve();
5634
+ }
5635
+ }
2148
5636
  });
2149
- await federation.processQueuedTask(void 0, createOutboxMessage("https://notfound.example/inbox", "https://example.com/activity/2", ["https://notfound.example/users/bob"]));
2150
- assert(handlerCalled, "Permanent failure handler should be called");
2151
- assertEquals(handlerStatusCode, 404);
2152
- assertEquals(queuedMessages, []);
2153
- });
2154
- await t.step("500 error does NOT trigger permanent failure handler", async () => {
2155
- const { federation, queuedMessages } = setup();
2156
- let handlerCalled = false;
2157
- federation.setOutboxPermanentFailureHandler(() => {
2158
- handlerCalled = true;
5637
+ federation.setInboxListeners("/users/{identifier}/inbox", "/inbox").on(vocab.Create, () => {
5638
+ throw new DOMException("aborted", "AbortError");
2159
5639
  });
2160
- await federation.processQueuedTask(void 0, createOutboxMessage("https://error.example/inbox", "https://example.com/activity/3", ["https://error.example/users/bob"]));
2161
- assertFalse(handlerCalled, "Permanent failure handler should NOT be called");
2162
- assertEquals(queuedMessages.length, 1);
2163
- assertEquals(queuedMessages[0].attempt, 1);
5640
+ await assertRejects(() => federation.processQueuedTask(void 0, {
5641
+ type: "inbox",
5642
+ id: crypto.randomUUID(),
5643
+ baseUrl: "https://example.com",
5644
+ activity: {
5645
+ "@context": "https://www.w3.org/ns/activitystreams",
5646
+ type: "Create",
5647
+ id: "https://remote.example/activities/3",
5648
+ actor: "https://remote.example/users/alice",
5649
+ object: {
5650
+ type: "Note",
5651
+ content: "Hello world"
5652
+ }
5653
+ },
5654
+ started: (/* @__PURE__ */ new Date()).toISOString(),
5655
+ attempt: 0,
5656
+ identifier: null,
5657
+ traceContext: {}
5658
+ }), DOMException);
5659
+ assertEquals(recorder.getMeasurements("fedify.queue.task.failed").length, 0);
5660
+ assertEquals(recorder.getMeasurements("fedify.queue.task.completed").length, 0);
5661
+ const taskDurations = recorder.getMeasurements("fedify.queue.task.duration");
5662
+ assertEquals(taskDurations.length, 1);
5663
+ assertEquals(taskDurations[0].attributes["fedify.queue.task.result"], "aborted");
5664
+ const inboxSpans = exporter.getSpans("activitypub.inbox");
5665
+ assertEquals(inboxSpans.length, 1);
5666
+ assertEquals(inboxSpans[0].status.code, SpanStatusCode.UNSET);
2164
5667
  });
2165
- await t.step("custom permanentFailureStatusCodes", async () => {
2166
- const { federation, queuedMessages } = setup({ permanentFailureStatusCodes: [
2167
- 404,
2168
- 410,
2169
- 451
2170
- ] });
2171
- let handlerCalled = false;
2172
- let handlerStatusCode = 0;
2173
- federation.setOutboxPermanentFailureHandler((_ctx, values) => {
2174
- handlerCalled = true;
2175
- handlerStatusCode = values.statusCode;
5668
+ await t.step("records native_retrial and backend attributes", async () => {
5669
+ const kv = new MemoryKvStore();
5670
+ const [meterProvider, recorder] = createTestMeterProvider();
5671
+ class TestMessageQueue {
5672
+ nativeRetrial = true;
5673
+ enqueue(_message, _options) {
5674
+ return Promise.resolve();
5675
+ }
5676
+ listen(_handler, _options) {
5677
+ return Promise.resolve();
5678
+ }
5679
+ }
5680
+ const federation = new FederationImpl({
5681
+ kv,
5682
+ meterProvider,
5683
+ queue: new TestMessageQueue()
5684
+ });
5685
+ federation.setInboxListeners("/users/{identifier}/inbox", "/inbox").on(vocab.Create, () => {});
5686
+ await federation.processQueuedTask(void 0, {
5687
+ type: "inbox",
5688
+ id: crypto.randomUUID(),
5689
+ baseUrl: "https://example.com",
5690
+ activity: {
5691
+ "@context": "https://www.w3.org/ns/activitystreams",
5692
+ type: "Create",
5693
+ id: "https://remote.example/activities/4",
5694
+ actor: "https://remote.example/users/alice",
5695
+ object: {
5696
+ type: "Note",
5697
+ content: "Hello world"
5698
+ }
5699
+ },
5700
+ started: (/* @__PURE__ */ new Date()).toISOString(),
5701
+ attempt: 0,
5702
+ identifier: null,
5703
+ traceContext: {}
2176
5704
  });
2177
- await federation.processQueuedTask(void 0, createOutboxMessage("https://legal.example/inbox", "https://example.com/activity/4", ["https://legal.example/users/bob"]));
2178
- assert(handlerCalled, "Permanent failure handler should be called for 451");
2179
- assertEquals(handlerStatusCode, 451);
2180
- assertEquals(queuedMessages, []);
5705
+ const completed = recorder.getMeasurements("fedify.queue.task.completed");
5706
+ assertEquals(completed.length, 1);
5707
+ assertEquals(completed[0].attributes["fedify.queue.backend"], "TestMessageQueue");
5708
+ assertEquals(completed[0].attributes["fedify.queue.native_retrial"], true);
2181
5709
  });
2182
- await t.step("handler exception is caught and logged", async () => {
2183
- const { federation, queuedMessages } = setup();
2184
- federation.setOutboxPermanentFailureHandler(() => {
2185
- throw new Error("Handler error that should be ignored");
5710
+ await t.step("records outbox worker metrics on successful delivery", async () => {
5711
+ const kv = new MemoryKvStore();
5712
+ const [meterProvider, recorder] = createTestMeterProvider();
5713
+ const federation = new FederationImpl({
5714
+ kv,
5715
+ meterProvider,
5716
+ queue: {
5717
+ enqueue(_message, _options) {
5718
+ return Promise.resolve();
5719
+ },
5720
+ listen(_handler, _options) {
5721
+ return Promise.resolve();
5722
+ }
5723
+ }
2186
5724
  });
2187
- await federation.processQueuedTask(void 0, createOutboxMessage("https://gone.example/inbox", "https://example.com/activity/5", ["https://gone.example/users/bob"]));
2188
- assertEquals(queuedMessages, []);
2189
- });
2190
- await t.step("permanent failure skips retry without handler registered", async () => {
2191
- const { federation, queuedMessages } = setup();
2192
- await federation.processQueuedTask(void 0, createOutboxMessage("https://gone.example/inbox", "https://example.com/activity/6", []));
2193
- assertEquals(queuedMessages, []);
5725
+ federation.setInboxListeners("/users/{identifier}/inbox", "/inbox");
5726
+ esm_default.spyGlobal();
5727
+ esm_default.post("https://remote.example/inbox", { status: 202 });
5728
+ try {
5729
+ await federation.processQueuedTask(void 0, {
5730
+ type: "outbox",
5731
+ id: crypto.randomUUID(),
5732
+ baseUrl: "https://example.com",
5733
+ keys: [],
5734
+ activity: {
5735
+ "@context": "https://www.w3.org/ns/activitystreams",
5736
+ type: "Create",
5737
+ id: "https://example.com/activities/1",
5738
+ actor: "https://example.com/users/alice",
5739
+ object: {
5740
+ type: "Note",
5741
+ content: "test"
5742
+ }
5743
+ },
5744
+ activityType: "https://www.w3.org/ns/activitystreams#Create",
5745
+ inbox: "https://remote.example/inbox",
5746
+ sharedInbox: false,
5747
+ started: (/* @__PURE__ */ new Date()).toISOString(),
5748
+ attempt: 0,
5749
+ headers: {},
5750
+ traceContext: {}
5751
+ });
5752
+ } finally {
5753
+ esm_default.hardReset();
5754
+ }
5755
+ const started = recorder.getMeasurements("fedify.queue.task.started");
5756
+ assertEquals(started.length, 1);
5757
+ assertEquals(started[0].attributes["fedify.queue.role"], "outbox");
5758
+ assertEquals(started[0].attributes["activitypub.activity.type"], "https://www.w3.org/ns/activitystreams#Create");
5759
+ const completed = recorder.getMeasurements("fedify.queue.task.completed");
5760
+ assertEquals(completed.length, 1);
5761
+ assertEquals(completed[0].attributes["fedify.queue.task.result"], "completed");
5762
+ assertEquals(recorder.getMeasurements("fedify.queue.task.enqueued").length, 0);
2194
5763
  });
2195
- await t.step("nativeRetrial: permanent failure does not re-throw", async () => {
2196
- const { federation, queuedMessages } = setup({ nativeRetrial: true });
2197
- let handlerCalled = false;
2198
- federation.setOutboxPermanentFailureHandler(() => {
2199
- handlerCalled = true;
5764
+ await t.step("records started/completed for a fanout task with no recipients", async () => {
5765
+ const kv = new MemoryKvStore();
5766
+ const [meterProvider, recorder] = createTestMeterProvider();
5767
+ const exportedKey = await crypto.subtle.exportKey("jwk", rsaPrivateKey3);
5768
+ const federation = new FederationImpl({
5769
+ kv,
5770
+ meterProvider,
5771
+ queue: {
5772
+ enqueue(_message, _options) {
5773
+ return Promise.resolve();
5774
+ },
5775
+ listen(_handler, _options) {
5776
+ return Promise.resolve();
5777
+ }
5778
+ }
2200
5779
  });
2201
- await federation.processQueuedTask(void 0, createOutboxMessage("https://gone.example/inbox", "https://example.com/activity/7", ["https://gone.example/users/bob"]));
2202
- assert(handlerCalled, "Permanent failure handler should be called");
2203
- assertEquals(queuedMessages, []);
2204
- });
2205
- await t.step("actorIds missing from message defaults to empty array", async () => {
2206
- const { federation, queuedMessages } = setup();
2207
- let handlerActorIds = [];
2208
- federation.setOutboxPermanentFailureHandler((_ctx, values) => {
2209
- handlerActorIds = values.actorIds;
5780
+ federation.setInboxListeners("/users/{identifier}/inbox", "/inbox");
5781
+ await federation.processQueuedTask(void 0, {
5782
+ type: "fanout",
5783
+ id: crypto.randomUUID(),
5784
+ baseUrl: "https://example.com",
5785
+ keys: [{
5786
+ keyId: "https://example.com/users/alice#main-key",
5787
+ privateKey: exportedKey
5788
+ }],
5789
+ inboxes: {},
5790
+ activity: {
5791
+ "@context": "https://www.w3.org/ns/activitystreams",
5792
+ type: "Create",
5793
+ id: "https://example.com/activities/1",
5794
+ actor: "https://example.com/users/alice",
5795
+ object: {
5796
+ type: "Note",
5797
+ content: "test"
5798
+ }
5799
+ },
5800
+ activityType: "https://www.w3.org/ns/activitystreams#Create",
5801
+ traceContext: {}
2210
5802
  });
2211
- await federation.processQueuedTask(void 0, createOutboxMessage("https://gone.example/inbox", "https://example.com/activity/8"));
2212
- assertEquals(handlerActorIds, []);
2213
- assertEquals(queuedMessages, []);
5803
+ const started = recorder.getMeasurements("fedify.queue.task.started");
5804
+ assertEquals(started.length, 1);
5805
+ assertEquals(started[0].attributes["fedify.queue.role"], "fanout");
5806
+ assertEquals(started[0].attributes["activitypub.activity.type"], "https://www.w3.org/ns/activitystreams#Create");
5807
+ const completed = recorder.getMeasurements("fedify.queue.task.completed");
5808
+ assertEquals(completed.length, 1);
5809
+ assertEquals(completed[0].attributes["fedify.queue.role"], "fanout");
5810
+ assertEquals(completed[0].attributes["fedify.queue.task.result"], "completed");
2214
5811
  });
2215
- esm_default.hardReset();
2216
5812
  });
2217
5813
  test("ContextImpl.lookupObject()", async (t) => {
2218
5814
  esm_default.spyGlobal();
@@ -2770,6 +6366,66 @@ test("ContextImpl.sendActivity()", async (t) => {
2770
6366
  });
2771
6367
  esm_default.hardReset();
2772
6368
  });
6369
+ test("ContextImpl.sendActivity() records fanout recipient metrics", async () => {
6370
+ const kv = new MemoryKvStore();
6371
+ const [meterProvider, recorder] = createTestMeterProvider();
6372
+ const queue = {
6373
+ messages: [],
6374
+ enqueue(message) {
6375
+ this.messages.push(message);
6376
+ return Promise.resolve();
6377
+ },
6378
+ async listen() {}
6379
+ };
6380
+ const federation = new FederationImpl({
6381
+ kv,
6382
+ contextLoaderFactory: () => mockDocumentLoader,
6383
+ queue,
6384
+ meterProvider
6385
+ });
6386
+ federation.setActorDispatcher("/{identifier}", async (ctx, identifier) => {
6387
+ if (identifier !== "john") return null;
6388
+ const keys = await ctx.getActorKeyPairs(identifier);
6389
+ return new vocab.Person({
6390
+ id: ctx.getActorUri(identifier),
6391
+ preferredUsername: "john",
6392
+ publicKey: keys[0].cryptographicKey,
6393
+ assertionMethods: keys.map((k) => k.multikey)
6394
+ });
6395
+ }).setKeyPairsDispatcher((_ctx, identifier) => {
6396
+ if (identifier !== "john") return [];
6397
+ return [{
6398
+ privateKey: rsaPrivateKey2,
6399
+ publicKey: rsaPublicKey2.publicKey
6400
+ }, {
6401
+ privateKey: ed25519PrivateKey,
6402
+ publicKey: ed25519PublicKey.publicKey
6403
+ }];
6404
+ });
6405
+ const ctx = new ContextImpl({
6406
+ data: void 0,
6407
+ federation,
6408
+ url: new URL("https://example.com/"),
6409
+ documentLoader: mockDocumentLoader,
6410
+ contextLoader: mockDocumentLoader
6411
+ });
6412
+ const activity = new vocab.Create({
6413
+ id: new URL("https://example.com/activity/1"),
6414
+ actor: new URL("https://example.com/person")
6415
+ });
6416
+ const recipients = Array.from({ length: 7 }, (_, i) => ({
6417
+ id: new URL(`https://example${i + 1}.com/recipient`),
6418
+ inboxId: new URL(`https://example${i + 1}.com/inbox`)
6419
+ }));
6420
+ await ctx.sendActivity({ username: "john" }, recipients, activity, { fanout: "force" });
6421
+ assertEquals(queue.messages.length, 1);
6422
+ assertEquals(queue.messages[0].type, "fanout");
6423
+ const measurements = recorder.getMeasurements("activitypub.fanout.recipients");
6424
+ assertEquals(measurements.length, 1);
6425
+ assertEquals(measurements[0].type, "histogram");
6426
+ assertEquals(measurements[0].value, recipients.length);
6427
+ assertEquals(measurements[0].attributes["activitypub.activity.type"], "https://www.w3.org/ns/activitystreams#Create");
6428
+ });
2773
6429
  test({
2774
6430
  name: "ContextImpl.routeActivity()",
2775
6431
  permissions: {
@@ -2842,6 +6498,182 @@ test({
2842
6498
  ]);
2843
6499
  }
2844
6500
  });
6501
+ test({
6502
+ name: "ContextImpl.routeActivity() forwards meterProvider to inbox enqueue",
6503
+ permissions: {
6504
+ env: true,
6505
+ read: true
6506
+ },
6507
+ async fn() {
6508
+ const [meterProvider, recorder] = createTestMeterProvider();
6509
+ const enqueued = [];
6510
+ const federation = new FederationImpl({
6511
+ kv: new MemoryKvStore(),
6512
+ meterProvider,
6513
+ queue: {
6514
+ enqueue(message) {
6515
+ enqueued.push(message);
6516
+ return Promise.resolve();
6517
+ },
6518
+ listen() {
6519
+ return Promise.resolve();
6520
+ }
6521
+ }
6522
+ });
6523
+ federation.setInboxListeners("/u/{identifier}/i", "/i");
6524
+ const ctx = new ContextImpl({
6525
+ url: new URL("https://example.com/"),
6526
+ federation,
6527
+ data: void 0,
6528
+ documentLoader: mockDocumentLoader,
6529
+ contextLoader: documentLoader
6530
+ });
6531
+ const signedOffer = await signObject(new vocab.Offer({ actor: new URL("https://example.com/person2") }), ed25519PrivateKey, ed25519Multikey.id);
6532
+ assert(await ctx.routeActivity(null, signedOffer));
6533
+ assertEquals(enqueued.length, 1);
6534
+ const enqueuedMetrics = recorder.getMeasurements("fedify.queue.task.enqueued");
6535
+ assertEquals(enqueuedMetrics.length, 1);
6536
+ assertEquals(enqueuedMetrics[0].attributes["fedify.queue.role"], "inbox");
6537
+ assertEquals(enqueuedMetrics[0].attributes["fedify.queue.task.attempt"], 0);
6538
+ }
6539
+ });
6540
+ test({
6541
+ name: "ContextImpl.routeActivity() records inbox.activity lifecycle metrics",
6542
+ permissions: {
6543
+ env: true,
6544
+ read: true
6545
+ },
6546
+ async fn() {
6547
+ const [meterProvider, recorder] = createTestMeterProvider();
6548
+ const federation = new FederationImpl({
6549
+ kv: new MemoryKvStore(),
6550
+ meterProvider,
6551
+ queue: {
6552
+ enqueue() {
6553
+ return Promise.resolve();
6554
+ },
6555
+ listen() {
6556
+ return Promise.resolve();
6557
+ }
6558
+ }
6559
+ });
6560
+ federation.setInboxListeners("/u/{identifier}/i", "/i");
6561
+ const ctx = new ContextImpl({
6562
+ url: new URL("https://example.com/"),
6563
+ federation,
6564
+ data: void 0,
6565
+ documentLoader: mockDocumentLoader,
6566
+ contextLoader: documentLoader
6567
+ });
6568
+ const signedOffer = await signObject(new vocab.Offer({
6569
+ id: new URL("https://example.com/offer-queued"),
6570
+ actor: new URL("https://example.com/person2")
6571
+ }), ed25519PrivateKey, ed25519Multikey.id);
6572
+ assert(await ctx.routeActivity(null, signedOffer));
6573
+ const queued = recorder.getMeasurements("activitypub.inbox.activity");
6574
+ assertEquals(queued.length, 1);
6575
+ assertEquals(queued[0].type, "counter");
6576
+ assertEquals(queued[0].attributes["activitypub.processing.result"], "queued");
6577
+ assertEquals(queued[0].attributes["activitypub.activity.type"], "https://www.w3.org/ns/activitystreams#Offer");
6578
+ }
6579
+ });
6580
+ test({
6581
+ name: "ContextImpl.routeActivity() records inbox.activity processed without queue",
6582
+ permissions: {
6583
+ env: true,
6584
+ read: true
6585
+ },
6586
+ async fn() {
6587
+ const [meterProvider, recorder] = createTestMeterProvider();
6588
+ const federation = new FederationImpl({
6589
+ kv: new MemoryKvStore(),
6590
+ meterProvider
6591
+ });
6592
+ federation.setInboxListeners("/u/{identifier}/i", "/i").on(vocab.Offer, () => {});
6593
+ const ctx = new ContextImpl({
6594
+ url: new URL("https://example.com/"),
6595
+ federation,
6596
+ data: void 0,
6597
+ documentLoader: mockDocumentLoader,
6598
+ contextLoader: documentLoader
6599
+ });
6600
+ const signedOffer = await signObject(new vocab.Offer({
6601
+ id: new URL("https://example.com/offer-processed"),
6602
+ actor: new URL("https://example.com/person2")
6603
+ }), ed25519PrivateKey, ed25519Multikey.id);
6604
+ assert(await ctx.routeActivity(null, signedOffer));
6605
+ const processed = recorder.getMeasurements("activitypub.inbox.activity");
6606
+ assertEquals(processed.length, 1);
6607
+ assertEquals(processed[0].attributes["activitypub.processing.result"], "processed");
6608
+ assertEquals(processed[0].attributes["activitypub.activity.type"], "https://www.w3.org/ns/activitystreams#Offer");
6609
+ }
6610
+ });
6611
+ test({
6612
+ name: "ContextImpl.routeActivity() records inbox.activity rejected for unsupported type and duplicates",
6613
+ permissions: {
6614
+ env: true,
6615
+ read: true
6616
+ },
6617
+ async fn() {
6618
+ const [meterProvider, recorder] = createTestMeterProvider();
6619
+ const federation = new FederationImpl({
6620
+ kv: new MemoryKvStore(),
6621
+ meterProvider
6622
+ });
6623
+ federation.setInboxListeners("/u/{identifier}/i", "/i").on(vocab.Offer, () => {});
6624
+ const ctx = new ContextImpl({
6625
+ url: new URL("https://example.com/"),
6626
+ federation,
6627
+ data: void 0,
6628
+ documentLoader: mockDocumentLoader,
6629
+ contextLoader: documentLoader
6630
+ });
6631
+ const signedCreate = await signObject(new vocab.Create({
6632
+ id: new URL("https://example.com/create-unsupported"),
6633
+ actor: new URL("https://example.com/person2")
6634
+ }), ed25519PrivateKey, ed25519Multikey.id);
6635
+ assert(await ctx.routeActivity(null, signedCreate));
6636
+ const dupOffer = await signObject(new vocab.Offer({
6637
+ id: new URL("https://example.com/offer-duplicate"),
6638
+ actor: new URL("https://example.com/person2")
6639
+ }), ed25519PrivateKey, ed25519Multikey.id);
6640
+ assert(await ctx.routeActivity(null, dupOffer));
6641
+ assert(await ctx.routeActivity(null, dupOffer));
6642
+ const rejected = recorder.getMeasurements("activitypub.inbox.activity").filter((m) => m.attributes["activitypub.processing.result"] === "rejected");
6643
+ assertEquals(rejected.length, 2);
6644
+ assertEquals(rejected[0].attributes["activitypub.activity.type"], "https://www.w3.org/ns/activitystreams#Create");
6645
+ assertEquals(rejected[1].attributes["activitypub.activity.type"], "https://www.w3.org/ns/activitystreams#Offer");
6646
+ }
6647
+ });
6648
+ test("ContextImpl.routeActivity() marks queued signed activities as non-LDS", async () => {
6649
+ let queuedMessage = null;
6650
+ const federation = new FederationImpl({
6651
+ kv: new MemoryKvStore(),
6652
+ queue: {
6653
+ enqueue(message) {
6654
+ queuedMessage = message;
6655
+ return Promise.resolve();
6656
+ },
6657
+ async listen() {}
6658
+ }
6659
+ });
6660
+ federation.setInboxListeners("/u/{identifier}/i", "/i").on(Offer, () => {
6661
+ throw new Error("listener should not run for queued routeActivity");
6662
+ });
6663
+ const ctx = new ContextImpl({
6664
+ url: new URL("https://example.com/"),
6665
+ federation,
6666
+ data: void 0,
6667
+ documentLoader: mockDocumentLoader,
6668
+ contextLoader: documentLoader
6669
+ });
6670
+ const signedOffer = await signObject(new Offer({ actor: new URL("https://example.com/person2") }), ed25519PrivateKey, ed25519Multikey.id);
6671
+ assert(await ctx.routeActivity(null, signedOffer));
6672
+ if (queuedMessage == null) throw new Error("Inbox message not queued.");
6673
+ const inboxMessage = queuedMessage;
6674
+ assertEquals(inboxMessage.ldSignatureVerified, false);
6675
+ assertEquals(inboxMessage.normalizedActivity, void 0);
6676
+ });
2845
6677
  test("ContextImpl.getCollectionUri()", () => {
2846
6678
  const federation = new FederationImpl({ kv: new MemoryKvStore() });
2847
6679
  const base = "https://example.com";
@@ -3066,5 +6898,63 @@ test("KvSpecDeterminer", async (t) => {
3066
6898
  assertEquals(spec, "rfc9421");
3067
6899
  });
3068
6900
  });
6901
+ test("createFederation() instruments documentLoader with activitypub.document.fetch", async () => {
6902
+ const [meterProvider, recorder] = createTestMeterProvider();
6903
+ await createFederation({
6904
+ kv: new MemoryKvStore(),
6905
+ meterProvider,
6906
+ documentLoaderFactory: () => mockDocumentLoader,
6907
+ contextLoaderFactory: () => mockDocumentLoader
6908
+ }).createContext(new URL("https://example.com/"), void 0).documentLoader("https://example.com/object");
6909
+ const counters = recorder.getMeasurements("activitypub.document.fetch");
6910
+ assertEquals(counters.length, 1);
6911
+ assertEquals(counters[0].attributes["activitypub.lookup.kind"], "object");
6912
+ assertEquals(counters[0].attributes["activitypub.lookup.result"], "fetched");
6913
+ assertEquals(counters[0].attributes["activitypub.remote.host"], "example.com");
6914
+ assertFalse("activitypub.cache.enabled" in counters[0].attributes);
6915
+ });
6916
+ test("createFederation() records kind=context on contextLoader fetches", async () => {
6917
+ const [meterProvider, recorder] = createTestMeterProvider();
6918
+ await createFederation({
6919
+ kv: new MemoryKvStore(),
6920
+ meterProvider,
6921
+ documentLoaderFactory: () => mockDocumentLoader,
6922
+ contextLoaderFactory: () => mockDocumentLoader
6923
+ }).createContext(new URL("https://example.com/"), void 0).contextLoader("https://example.com/object");
6924
+ const counters = recorder.getMeasurements("activitypub.document.fetch");
6925
+ assertEquals(counters.length, 1);
6926
+ assertEquals(counters[0].attributes["activitypub.lookup.kind"], "context");
6927
+ });
6928
+ test("createFederation() forwards DocumentLoaderFactoryOptions to a user-supplied authenticatedDocumentLoaderFactory", () => {
6929
+ const kv = new MemoryKvStore();
6930
+ const seen = [];
6931
+ createFederation({
6932
+ kv,
6933
+ authenticatedDocumentLoaderFactory: (_identity, opts) => {
6934
+ seen.push(opts);
6935
+ return mockDocumentLoader;
6936
+ }
6937
+ }).authenticatedDocumentLoaderFactory({
6938
+ keyId: new URL("https://example.com/users/alice#main-key"),
6939
+ privateKey: {}
6940
+ }, {
6941
+ allowPrivateAddress: true,
6942
+ userAgent: "test-ua"
6943
+ });
6944
+ assertEquals(seen.length, 1);
6945
+ assertEquals(seen[0], {
6946
+ allowPrivateAddress: true,
6947
+ userAgent: "test-ua"
6948
+ });
6949
+ });
6950
+ test("createFederation() omits instrumentation when no meterProvider is set", () => {
6951
+ const ctx = createFederation({
6952
+ kv: new MemoryKvStore(),
6953
+ documentLoaderFactory: () => mockDocumentLoader,
6954
+ contextLoaderFactory: () => mockDocumentLoader
6955
+ }).createContext(new URL("https://example.com/"), void 0);
6956
+ assertStrictEquals(ctx.documentLoader, mockDocumentLoader);
6957
+ assertStrictEquals(ctx.contextLoader, mockDocumentLoader);
6958
+ });
3069
6959
  //#endregion
3070
6960
  export {};