@fedify/fedify 2.4.5-dev.2453 → 2.4.5

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 (119) hide show
  1. package/README.md +3 -0
  2. package/dist/{builder-CS2LjGi-.mjs → builder-CUAVOHg6.mjs} +2 -2
  3. package/dist/{client-D-XP_Ccb.d.cts → client-3InDZbba.d.cts} +0 -1
  4. package/dist/{client-D-XP_Ccb.d.ts → client-3InDZbba.d.ts} +0 -1
  5. package/dist/{client-CEnNz8O7.mjs → client-5SIFzW3q.mjs} +113 -64
  6. package/dist/compat/mod.d.cts +1 -1
  7. package/dist/compat/mod.d.ts +1 -1
  8. package/dist/compat/transformers.test.mjs +1 -1
  9. package/dist/{compound-proof-C6_4hMjm.mjs → compound-proof-BNjXclzO.mjs} +2 -2
  10. package/dist/{context-7NyiqIcg.d.ts → context-CVOOd9pD.d.ts} +28 -2
  11. package/dist/{context-tsYPJ2Ew.d.cts → context-D0gFIgev.d.cts} +28 -2
  12. package/dist/{deno-CTWhU1oT.mjs → deno-DcJISdwu.mjs} +1 -1
  13. package/dist/docloader-CP1UxMLL.mjs +136 -0
  14. package/dist/federation/builder.test.mjs +1 -1
  15. package/dist/federation/compound-fanout.test.mjs +6 -3
  16. package/dist/federation/handler-compound-proof.test.mjs +3 -3
  17. package/dist/federation/handler.test.mjs +4 -4
  18. package/dist/federation/hashlink-media.test.mjs +2 -2
  19. package/dist/federation/idempotency.test.mjs +2 -2
  20. package/dist/federation/inbox-report.test.mjs +5 -5
  21. package/dist/federation/inbox.test.mjs +1 -1
  22. package/dist/federation/metrics.test.mjs +1 -1
  23. package/dist/federation/middleware.test.mjs +465 -32
  24. package/dist/federation/mod.cjs +1 -1
  25. package/dist/federation/mod.d.cts +3 -3
  26. package/dist/federation/mod.d.ts +3 -3
  27. package/dist/federation/mod.js +1 -1
  28. package/dist/federation/outgoing-proof.test.mjs +9 -6
  29. package/dist/federation/portable-actor.test.mjs +3 -3
  30. package/dist/federation/portable-chain.test.mjs +3 -3
  31. package/dist/federation/portable-collection.test.mjs +4 -4
  32. package/dist/federation/portable-delivery.test.mjs +2 -2
  33. package/dist/federation/portable-gateway-keys.test.mjs +11 -5
  34. package/dist/federation/portable-inbox.test.mjs +4 -4
  35. package/dist/federation/portable-parse-uri.test.mjs +1 -1
  36. package/dist/federation/portable-route-activity.test.mjs +2 -2
  37. package/dist/federation/portable.test.mjs +3 -3
  38. package/dist/federation/send.test.mjs +498 -88
  39. package/dist/federation/tasks/enqueue.test.mjs +1 -1
  40. package/dist/federation/tasks/tasks.test.mjs +2 -2
  41. package/dist/federation/temporal.test.mjs +1 -1
  42. package/dist/federation/webfinger.test.mjs +1 -1
  43. package/dist/{federation-DWVfQYX_.cjs → federation-Bkfq2YpS.cjs} +240 -102
  44. package/dist/{http-Dx5s5-pP.cjs → http-DHVH0DqC.cjs} +179 -110
  45. package/dist/{http-rgt1uxns.mjs → http-DuGmr8J8.mjs} +204 -106
  46. package/dist/{http-y9FnJFIe.js → http-R0ty0Udi.js} +178 -109
  47. package/dist/{inbox-4AZHfaO8.mjs → inbox-Bl1Y0s_A.mjs} +2 -2
  48. package/dist/{key-DYxuyd-s.d.cts → key-CyCcut3h.d.ts} +1 -2
  49. package/dist/{key-C1nv7_jD.d.ts → key-VEL1QqHF.d.cts} +1 -2
  50. package/dist/{kv-cache-DmxFPJaR.cjs → kv-cache-BtBSBZi9.cjs} +72 -27
  51. package/dist/{kv-cache-C3QkJF-x.mjs → kv-cache-DAV2_U44.mjs} +1 -1
  52. package/dist/{kv-cache-w4yWxqS1.js → kv-cache-FnLtmATH.js} +73 -28
  53. package/dist/{ld-CT-bAqSC.mjs → ld-Cy5GqbvS.mjs} +2 -2
  54. package/dist/{metrics-DsFjgE5X.mjs → metrics-CW8LoN7n.mjs} +1 -1
  55. package/dist/{middleware-DC8Omn-e.mjs → middleware-CdP-lz8Q.mjs} +1 -1
  56. package/dist/{middleware-Ch9WIaoZ.mjs → middleware-D2fk45uN.mjs} +82 -42
  57. package/dist/{middleware-BCHbutlW.js → middleware-D4rQARvq.js} +241 -103
  58. package/dist/{mod-DEZXwV6J.d.ts → mod-B1Qt5cQQ.d.ts} +2 -9
  59. package/dist/{mod-Xj5P4s4d.d.ts → mod-B5COx5Wd.d.ts} +1 -1
  60. package/dist/{mod-CavhAgLw.d.cts → mod-BTxJG7ZH.d.cts} +1 -1
  61. package/dist/{mod-CtpF2BU5.d.ts → mod-CpyjFA9s.d.ts} +1 -1
  62. package/dist/{mod-BojP2Bw3.d.cts → mod-makPMVMg.d.cts} +1 -1
  63. package/dist/{mod-Dw-nPxQh.d.cts → mod-ss0wBUH1.d.cts} +2 -9
  64. package/dist/mod.cjs +5 -5
  65. package/dist/mod.d.cts +6 -6
  66. package/dist/mod.d.ts +6 -6
  67. package/dist/mod.js +5 -5
  68. package/dist/nodeinfo/client.test.mjs +337 -19
  69. package/dist/nodeinfo/handler.test.mjs +1 -1
  70. package/dist/nodeinfo/mod.cjs +1 -1
  71. package/dist/nodeinfo/mod.d.cts +1 -1
  72. package/dist/nodeinfo/mod.d.ts +1 -1
  73. package/dist/nodeinfo/mod.js +1 -1
  74. package/dist/otel/exporter.test.mjs +292 -30
  75. package/dist/otel/mod.cjs +36 -30
  76. package/dist/otel/mod.d.cts +13 -0
  77. package/dist/otel/mod.d.ts +13 -0
  78. package/dist/otel/mod.js +36 -30
  79. package/dist/{owner-BGYYk-xi.mjs → owner-CbQZqFim.mjs} +3 -3
  80. package/dist/{portable-collection-DzEc4cB2.js → portable-collection-B5Aryi8I.js} +1 -1
  81. package/dist/{portable-collection-Cwn7DM0G.cjs → portable-collection-C_Kmvl3q.cjs} +1 -1
  82. package/dist/{portable-collection-DJSwe8QM.mjs → portable-collection-D2JYDadi.mjs} +2 -2
  83. package/dist/{portable-key-Dc6N1SEB.mjs → portable-key-Cc-ctNU2.mjs} +1 -1
  84. package/dist/{proof-DP93iZiH.mjs → proof-UN4CW55z.mjs} +3 -3
  85. package/dist/{send-c6buRbhI.mjs → send-BzbX2e6v.mjs} +174 -76
  86. package/dist/sig/compound-proof-discovery.test.mjs +1 -1
  87. package/dist/sig/compound-proof-producer.test.mjs +1 -1
  88. package/dist/sig/compound-proof-verification.test.mjs +2 -2
  89. package/dist/sig/compound-proof.test.mjs +2 -2
  90. package/dist/sig/http.test.mjs +553 -2
  91. package/dist/sig/key.test.mjs +1 -1
  92. package/dist/sig/ld.test.mjs +1 -1
  93. package/dist/sig/mitra-receive-interop.test.mjs +4 -4
  94. package/dist/sig/mitra-send-interop.test.mjs +3 -3
  95. package/dist/sig/mod.cjs +2 -2
  96. package/dist/sig/mod.d.cts +2 -2
  97. package/dist/sig/mod.d.ts +2 -2
  98. package/dist/sig/mod.js +2 -2
  99. package/dist/sig/owner.test.mjs +1 -1
  100. package/dist/sig/portable-collection.test.mjs +1 -1
  101. package/dist/sig/portable-interop.test.mjs +3 -3
  102. package/dist/sig/portable-key.test.mjs +5 -5
  103. package/dist/sig/proof.test.mjs +4 -4
  104. package/dist/sig/tootik-request-interop.test.mjs +4 -4
  105. package/dist/sig/tootik-send-interop.test.mjs +3 -3
  106. package/dist/sig/verification.test.mjs +3 -3
  107. package/dist/{temporal-DfC1NkLg.mjs → temporal-B7Wvxrjs.mjs} +1 -1
  108. package/dist/testing/kv.test.mjs +1 -1
  109. package/dist/testing/mod.d.mts +26 -0
  110. package/dist/{types-_S2_MeLF.js → types-DUjZb4i6.js} +112 -63
  111. package/dist/{types-poIao40f.cjs → types-hKCCjii0.cjs} +111 -62
  112. package/dist/utils/docloader.test.mjs +344 -36
  113. package/dist/utils/kv-cache.test.mjs +1 -1
  114. package/dist/utils/mod.cjs +1 -1
  115. package/dist/utils/mod.d.cts +1 -1
  116. package/dist/utils/mod.d.ts +1 -1
  117. package/dist/utils/mod.js +1 -1
  118. package/package.json +7 -7
  119. package/dist/docloader-CXFE-n2O.mjs +0 -91
@@ -10,16 +10,20 @@ import { t as assertNotEquals } from "../assert_not_equals-CpPK2t89.mjs";
10
10
  import "../std__assert-DvMDRPes.mjs";
11
11
  import { t as assertRejects } from "../assert_rejects-CTptdYck.mjs";
12
12
  import { t as assert } from "../assert-D1Eafmd_.mjs";
13
- import { f as verifyRequest } from "../http-rgt1uxns.mjs";
13
+ import { p as verifyRequest } from "../http-DuGmr8J8.mjs";
14
14
  import { i as rsaPrivateKey2, n as ed25519PrivateKey, s as rsaPublicKey2, t as ed25519Multikey } from "../keys-DIJNHW-Y.mjs";
15
- import { t as doesActorOwnKey } from "../owner-BGYYk-xi.mjs";
16
- import { n as extractInboxes, r as sendActivity, t as SendActivityError } from "../send-c6buRbhI.mjs";
15
+ import { t as doesActorOwnKey } from "../owner-CbQZqFim.mjs";
16
+ import { i as sendActivity, n as extractInboxes, r as fetchWithValidatedRedirects, t as SendActivityError } from "../send-BzbX2e6v.mjs";
17
+ import { configure, reset } from "@logtape/logtape";
17
18
  import { Activity, Application, Endpoints, Group, Person, Service } from "@fedify/vocab";
18
19
  import { FetchError, UrlError } from "@fedify/vocab-runtime";
19
20
  import { createTestMeterProvider, createTestTracerProvider, mockDocumentLoader, test } from "@fedify/fixture";
20
- import dns from "node:dns/promises";
21
+ import { openDirectFetch, openPublicFetch } from "@fedify/vocab-runtime/internal";
21
22
  import { AggregationTemporality, InMemoryMetricExporter, MeterProvider, PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
22
23
  //#region src/federation/send.test.ts
24
+ function sendDirect(options) {
25
+ return sendActivity(options, { openHop: openDirectFetch });
26
+ }
23
27
  test("extractInboxes()", () => {
24
28
  const recipients = [
25
29
  new Person({
@@ -155,7 +159,7 @@ test("sendActivity()", async (t) => {
155
159
  "id": "https://example.com/activity",
156
160
  "actor": "https://example.com/person"
157
161
  };
158
- await sendActivity({
162
+ await sendDirect({
159
163
  activity,
160
164
  keys: [{
161
165
  privateKey: rsaPrivateKey2,
@@ -171,7 +175,7 @@ test("sendActivity()", async (t) => {
171
175
  assertEquals(request?.headers.get("Content-Type"), "application/activity+json");
172
176
  assertEquals(request?.headers.get("X-Test"), "test");
173
177
  httpSigVerified = null;
174
- await assertRejects(() => sendActivity({
178
+ await assertRejects(() => sendDirect({
175
179
  activity: {
176
180
  ...activity,
177
181
  actor: "https://example.com/person2"
@@ -200,7 +204,7 @@ test("sendActivity()", async (t) => {
200
204
  "id": "https://example.com/activity",
201
205
  "actor": "https://example.com/person"
202
206
  };
203
- await assertRejects(() => sendActivity({
207
+ await assertRejects(() => sendDirect({
204
208
  activity,
205
209
  activityId: "https://example.com/activity",
206
210
  keys: [{
@@ -218,7 +222,7 @@ test("sendActivity()", async (t) => {
218
222
  "actor": "https://example.com/person"
219
223
  };
220
224
  try {
221
- await sendActivity({
225
+ await sendDirect({
222
226
  activity,
223
227
  activityId: "https://example.com/activity",
224
228
  keys: [{
@@ -253,7 +257,7 @@ test("sendActivity()", async (t) => {
253
257
  });
254
258
  throw failure;
255
259
  });
256
- const error = await assertRejects(() => sendActivity({
260
+ const error = await assertRejects(() => sendDirect({
257
261
  activity,
258
262
  activityId: "https://example.com/activity",
259
263
  keys: [{
@@ -278,7 +282,7 @@ test("sendActivity()", async (t) => {
278
282
  "actor": "https://example.com/person"
279
283
  };
280
284
  try {
281
- await sendActivity({
285
+ await sendDirect({
282
286
  activity,
283
287
  activityId: "https://example.com/activity",
284
288
  keys: [{
@@ -307,7 +311,7 @@ test("sendActivity()", async (t) => {
307
311
  "actor": "https://example.com/person"
308
312
  };
309
313
  try {
310
- await sendActivity({
314
+ await sendDirect({
311
315
  activity,
312
316
  activityId: "https://example.com/activity",
313
317
  keys: [{
@@ -337,7 +341,7 @@ test("sendActivity()", async (t) => {
337
341
  "actor": "https://example.com/person"
338
342
  };
339
343
  try {
340
- await sendActivity({
344
+ await sendDirect({
341
345
  activity,
342
346
  activityId: "https://example.com/activity",
343
347
  keys: [{
@@ -364,7 +368,7 @@ test("sendActivity()", async (t) => {
364
368
  "actor": "https://example.com/person"
365
369
  };
366
370
  try {
367
- await sendActivity({
371
+ await sendDirect({
368
372
  activity,
369
373
  activityId: "https://example.com/activity",
370
374
  keys: [{
@@ -393,7 +397,7 @@ test("sendActivity()", async (t) => {
393
397
  "actor": "https://example.com/person"
394
398
  };
395
399
  try {
396
- await sendActivity({
400
+ await sendDirect({
397
401
  activity,
398
402
  activityId: "https://example.com/activity",
399
403
  keys: [{
@@ -417,7 +421,7 @@ test("sendActivity() records OpenTelemetry span events", async (t) => {
417
421
  await t.step("successful send", async () => {
418
422
  esm_default.get("https://example.com/", { status: 404 });
419
423
  esm_default.post("https://example.com/inbox", { status: 202 });
420
- await sendActivity({
424
+ await sendDirect({
421
425
  activity: {
422
426
  "@context": "https://www.w3.org/ns/activitystreams",
423
427
  type: "Create",
@@ -451,6 +455,41 @@ test("sendActivity() records OpenTelemetry span events", async (t) => {
451
455
  esm_default.hardReset();
452
456
  });
453
457
  });
458
+ test("sendActivity() validates the initial policy before serializing", async () => {
459
+ const [meterProvider, recorder] = createTestMeterProvider();
460
+ let serialized = false;
461
+ await assertRejects(() => sendActivity({
462
+ activity: { toJSON() {
463
+ serialized = true;
464
+ throw new Error("must not serialize");
465
+ } },
466
+ keys: [],
467
+ inbox: new URL("http://127.0.0.1/inbox"),
468
+ meterProvider
469
+ }), UrlError);
470
+ assertFalse(serialized);
471
+ assertEquals(recorder.getMeasurements("activitypub.delivery.sent"), []);
472
+ });
473
+ test("sendActivity() closes the initial session when serialization fails", async () => {
474
+ const [meterProvider, recorder] = createTestMeterProvider();
475
+ const url = "https://example.com/lifecycle/serialization-failure";
476
+ const hops = recordHops(() => ({
477
+ status: 202,
478
+ body: "Accepted"
479
+ }));
480
+ const failure = /* @__PURE__ */ new Error("serialization failed");
481
+ assertEquals(await assertRejects(() => sendActivity({
482
+ activity: { toJSON() {
483
+ throw failure;
484
+ } },
485
+ keys: [],
486
+ inbox: new URL(url),
487
+ meterProvider
488
+ }, { openHop: hops.openHop }), Error, "serialization failed"), failure);
489
+ assertEquals(hops.events, [`open ${url}`, `close ${url}`]);
490
+ assertEquals(recorder.getMeasurements("activitypub.delivery.sent"), []);
491
+ hops.assertInvariants();
492
+ });
454
493
  test("sendActivity() records OpenTelemetry delivery metrics", async (t) => {
455
494
  const [meterProvider, recorder] = createTestMeterProvider();
456
495
  esm_default.spyGlobal();
@@ -691,85 +730,456 @@ for (const signed of [false, true]) {
691
730
  });
692
731
  });
693
732
  }
694
- for (const signed of [false, true]) test({
695
- name: `sendActivity() classifies DNS failures (signed: ${signed})`,
696
- ignore: "Deno" in globalThis && (await Deno.permissions.query({ name: "net" })).state !== "granted",
697
- async fn(t) {
698
- const activity = {
699
- type: "Create",
700
- id: "https://example.com/activity"
733
+ for (const signed of [false, true]) test(`sendActivity() classifies DNS failures (signed: ${signed})`, async (t) => {
734
+ const activity = {
735
+ type: "Create",
736
+ id: "https://example.com/activity"
737
+ };
738
+ const destination = "https://delivery.invalid/inbox";
739
+ const publicInbox = "https://8.8.8.8/inbox";
740
+ const keys = signed ? [{
741
+ privateKey: rsaPrivateKey2,
742
+ keyId: rsaPublicKey2.id
743
+ }] : [];
744
+ for (const result of [
745
+ "throws",
746
+ "empty",
747
+ "cname",
748
+ "private"
749
+ ]) for (const redirected of [false, true]) await t.step(`${result}, redirected: ${redirected}`, async () => {
750
+ const [meterProvider, recorder] = createTestMeterProvider();
751
+ const resolverError = /* @__PURE__ */ new Error("Resolver unavailable");
752
+ const lookups = [];
753
+ const lookup = (hostname) => {
754
+ lookups.push(hostname);
755
+ assertEquals(hostname, "delivery.invalid");
756
+ if (result === "throws") return Promise.reject(resolverError);
757
+ return Promise.resolve(result === "empty" ? [] : [{
758
+ address: result === "private" ? "127.0.0.1" : "alias.invalid.",
759
+ family: 4
760
+ }]);
701
761
  };
702
- const destination = "https://delivery.invalid/inbox";
703
- const publicInbox = "https://8.8.8.8/inbox";
704
- const keys = signed ? [{
705
- privateKey: rsaPrivateKey2,
706
- keyId: rsaPublicKey2.id
707
- }] : [];
708
- for (const result of [
709
- "throws",
710
- "empty",
711
- "cname",
712
- "private"
713
- ]) for (const redirected of [false, true]) await t.step(`${result}, redirected: ${redirected}`, async () => {
714
- const [meterProvider, recorder] = createTestMeterProvider();
715
- const originalLookup = dns.lookup;
716
- const resolverError = /* @__PURE__ */ new Error("Resolver unavailable");
717
- const lookups = [];
718
- dns.lookup = ((hostname, options) => {
719
- lookups.push(hostname);
720
- assertEquals(hostname, "delivery.invalid");
721
- assertEquals(options, { all: true });
722
- if (result === "throws") return Promise.reject(resolverError);
723
- return Promise.resolve(result === "empty" ? [] : [{
724
- address: result === "private" ? "127.0.0.1" : "alias.invalid.",
725
- family: 4
726
- }]);
762
+ try {
763
+ esm_default.mockGlobal().catch(202);
764
+ if (redirected) esm_default.route(publicInbox, {
765
+ status: 307,
766
+ headers: { Location: destination }
727
767
  });
728
- try {
729
- esm_default.mockGlobal().catch(202);
730
- if (redirected) esm_default.route(publicInbox, {
731
- status: 307,
732
- headers: { Location: destination }
733
- });
734
- const send = () => sendActivity({
735
- activity,
736
- activityType: "https://www.w3.org/ns/activitystreams#Create",
737
- meterProvider,
738
- keys,
739
- inbox: new URL(redirected ? publicInbox : destination)
768
+ const send = () => sendActivity({
769
+ activity,
770
+ activityType: "https://www.w3.org/ns/activitystreams#Create",
771
+ meterProvider,
772
+ keys,
773
+ inbox: new URL(redirected ? publicInbox : destination)
774
+ }, { openHop: (url) => openPublicFetch(url, { lookup }) });
775
+ if (result === "private") assertEquals((await assertRejects(send, UrlError)).reason, "disallowed");
776
+ else {
777
+ const error = await assertRejects(send, FetchError);
778
+ assertEquals(error.url.href, destination);
779
+ assertInstanceOf(error.cause, UrlError);
780
+ assertEquals(error.cause.reason, "dns");
781
+ assertEquals(error.cause.cause, result === "throws" ? resolverError : void 0);
782
+ }
783
+ const expectedCount = result === "private" && !redirected ? 0 : 1;
784
+ const sent = recorder.getMeasurements("activitypub.delivery.sent");
785
+ const durations = recorder.getMeasurements("activitypub.delivery.duration");
786
+ assertEquals(sent.length, expectedCount);
787
+ assertEquals(durations.length, expectedCount);
788
+ if (expectedCount === 1) {
789
+ assertEquals(sent[0].value, 1);
790
+ assertEquals(sent[0].attributes, {
791
+ "activitypub.remote.host": redirected ? "8.8.8.8" : "delivery.invalid",
792
+ "activitypub.activity.type": "https://www.w3.org/ns/activitystreams#Create",
793
+ "activitypub.delivery.success": false
740
794
  });
741
- if (result === "private") assertEquals((await assertRejects(send, UrlError)).reason, "disallowed");
742
- else {
743
- const error = await assertRejects(send, FetchError);
744
- assertEquals(error.url.href, destination);
745
- assertInstanceOf(error.cause, UrlError);
746
- assertEquals(error.cause.reason, "dns");
747
- assertEquals(error.cause.cause, result === "throws" ? resolverError : void 0);
748
- }
749
- const expectedCount = result === "private" && !redirected ? 0 : 1;
750
- const sent = recorder.getMeasurements("activitypub.delivery.sent");
751
- const durations = recorder.getMeasurements("activitypub.delivery.duration");
752
- assertEquals(sent.length, expectedCount);
753
- assertEquals(durations.length, expectedCount);
754
- if (expectedCount === 1) {
755
- assertEquals(sent[0].value, 1);
756
- assertEquals(sent[0].attributes, {
757
- "activitypub.remote.host": redirected ? "8.8.8.8" : "delivery.invalid",
758
- "activitypub.activity.type": "https://www.w3.org/ns/activitystreams#Create",
759
- "activitypub.delivery.success": false
760
- });
761
- assertEquals(durations[0].attributes, sent[0].attributes);
762
- assertGreaterOrEqual(durations[0].value, 0);
763
- }
764
- assertEquals(lookups, ["delivery.invalid"]);
765
- assertEquals(esm_default.callHistory.calls(destination).length, 0);
766
- assertEquals(esm_default.callHistory.calls().length, redirected ? 1 : 0);
767
- } finally {
768
- dns.lookup = originalLookup;
769
- esm_default.hardReset();
795
+ assertEquals(durations[0].attributes, sent[0].attributes);
796
+ assertGreaterOrEqual(durations[0].value, 0);
770
797
  }
798
+ assertEquals(lookups, ["delivery.invalid"]);
799
+ assertEquals(esm_default.callHistory.calls(destination).length, 0);
800
+ assertEquals(esm_default.callHistory.calls().length, redirected ? 1 : 0);
801
+ } finally {
802
+ esm_default.hardReset();
803
+ }
804
+ });
805
+ });
806
+ const lifecycleActivity = {
807
+ "@context": "https://www.w3.org/ns/activitystreams",
808
+ type: "Create",
809
+ id: "https://example.com/activity",
810
+ actor: "https://example.com/person"
811
+ };
812
+ const signedKeys = [{
813
+ privateKey: rsaPrivateKey2,
814
+ keyId: rsaPublicKey2.id
815
+ }];
816
+ const unsignedKeys = [{
817
+ privateKey: ed25519PrivateKey,
818
+ keyId: ed25519Multikey.id
819
+ }];
820
+ test("sendActivity() cancels a successful response body", async (t) => {
821
+ for (const signed of [true, false]) await t.step(`signed: ${signed}`, async () => {
822
+ const inbox = "https://example.com/lifecycle/inbox";
823
+ const hops = recordHops(() => ({
824
+ status: 202,
825
+ body: "Accepted"
826
+ }));
827
+ await sendActivity({
828
+ activity: lifecycleActivity,
829
+ keys: signed ? signedKeys : unsignedKeys,
830
+ inbox: new URL(inbox)
831
+ }, { openHop: hops.openHop });
832
+ assertEquals(hops.events, [
833
+ `open ${inbox}`,
834
+ `fetch #1 ${inbox}`,
835
+ "cancel #1",
836
+ `close ${inbox}`
837
+ ]);
838
+ hops.assertInvariants();
839
+ });
840
+ });
841
+ test("sendActivity() reads at most 1 KiB of an error response body", async (t) => {
842
+ for (const signed of [true, false]) await t.step(`signed: ${signed}`, async () => {
843
+ const inbox = "https://example.com/lifecycle/error";
844
+ const hops = recordHops(() => ({
845
+ status: 500,
846
+ body: "x".repeat(4096)
847
+ }));
848
+ const error = await assertRejects(() => sendActivity({
849
+ activity: lifecycleActivity,
850
+ activityId: lifecycleActivity.id,
851
+ keys: signed ? signedKeys : unsignedKeys,
852
+ inbox: new URL(inbox)
853
+ }, { openHop: hops.openHop }), SendActivityError);
854
+ assertEquals(error.responseBody, "x".repeat(1024) + "… (truncated)");
855
+ assert(error.message.includes("… (truncated)"));
856
+ assertEquals(hops.events, [
857
+ `open ${inbox}`,
858
+ `fetch #1 ${inbox}`,
859
+ "cancel #1",
860
+ ...signed ? [`fetch #2 ${inbox}`, "cancel #2"] : [],
861
+ `close ${inbox}`
862
+ ]);
863
+ hops.assertInvariants();
864
+ });
865
+ });
866
+ test("sendActivity() closes each redirect hop before opening the next", async (t) => {
867
+ for (const [signed, status] of [[true, 307], [false, 308]]) await t.step(`signed: ${signed}, ${status}`, async () => {
868
+ const inbox = "https://example.com/lifecycle/redirect";
869
+ const next = "https://example.com/lifecycle/redirected";
870
+ const hops = recordHops((request) => request.url === inbox ? {
871
+ status,
872
+ headers: { Location: next },
873
+ body: "Moved"
874
+ } : {
875
+ status: 202,
876
+ body: "Accepted"
771
877
  });
878
+ await sendActivity({
879
+ activity: lifecycleActivity,
880
+ keys: signed ? signedKeys : unsignedKeys,
881
+ inbox: new URL(inbox)
882
+ }, { openHop: hops.openHop });
883
+ assertEquals(hops.events, [
884
+ `open ${inbox}`,
885
+ `fetch #1 ${inbox}`,
886
+ "cancel #1",
887
+ `close ${inbox}`,
888
+ `open ${next}`,
889
+ `fetch #2 ${next}`,
890
+ "cancel #2",
891
+ `close ${next}`
892
+ ]);
893
+ assertEquals(hops.requests[1].method, "POST");
894
+ assertEquals(await hops.requests[1].text(), JSON.stringify(lifecycleActivity));
895
+ hops.assertInvariants();
896
+ });
897
+ });
898
+ async function captureLogs(fn) {
899
+ const records = [];
900
+ await configure({
901
+ sinks: { buffer: (record) => records.push(record) },
902
+ loggers: [{
903
+ category: "fedify",
904
+ sinks: ["buffer"],
905
+ lowestLevel: "debug"
906
+ }, {
907
+ category: ["logtape", "meta"],
908
+ sinks: []
909
+ }],
910
+ reset: true
911
+ });
912
+ try {
913
+ await fn();
914
+ await new Promise((resolve) => setTimeout(resolve, 0));
915
+ } finally {
916
+ await reset();
772
917
  }
918
+ return records;
919
+ }
920
+ function getCancelFailureUrls(records) {
921
+ return records.filter((r) => r.rawMessage === "Failed to cancel the response body from {url}: {error}").map((r) => r.properties.url);
922
+ }
923
+ test("fetchWithValidatedRedirects() logs a redirect body failing to cancel and follows the redirect", async (t) => {
924
+ const url = "https://example.com/lifecycle/cancel-failure";
925
+ const next = "https://example.com/lifecycle/cancel-failure-final";
926
+ const respond = (_, index) => index === 1 ? {
927
+ status: 302,
928
+ headers: { Location: next },
929
+ body: "Found",
930
+ failCancel: true
931
+ } : {
932
+ status: 202,
933
+ body: "Accepted"
934
+ };
935
+ await t.step("fetchWithValidatedRedirects()", async () => {
936
+ const hops = recordHops(respond);
937
+ let result;
938
+ const records = await captureLogs(async () => {
939
+ result = await fetchWithValidatedRedirects(new Request(url, {
940
+ method: "POST",
941
+ body: "activity"
942
+ }), hops.openHop, (response) => response.text());
943
+ });
944
+ assertEquals(result, "Accepted");
945
+ assertEquals(hops.events, [
946
+ `open ${url}`,
947
+ `fetch #1 ${url}`,
948
+ "cancel #1",
949
+ `close ${url}`,
950
+ `open ${next}`,
951
+ `fetch #2 ${next}`,
952
+ "consumed #2",
953
+ `close ${next}`
954
+ ]);
955
+ assertEquals(getCancelFailureUrls(records), [url]);
956
+ hops.assertInvariants();
957
+ });
958
+ for (const signed of [true, false]) await t.step(`signed sendActivity(): ${signed}`, async () => {
959
+ const hops = recordHops(respond);
960
+ const records = await captureLogs(() => sendActivity({
961
+ activity: lifecycleActivity,
962
+ keys: signed ? signedKeys : unsignedKeys,
963
+ inbox: new URL(url)
964
+ }, { openHop: hops.openHop }));
965
+ assertEquals(hops.events, [
966
+ `open ${url}`,
967
+ `fetch #1 ${url}`,
968
+ "cancel #1",
969
+ `close ${url}`,
970
+ `open ${next}`,
971
+ `fetch #2 ${next}`,
972
+ "cancel #2",
973
+ `close ${next}`
974
+ ]);
975
+ assertEquals(getCancelFailureUrls(records), [url]);
976
+ hops.assertInvariants();
977
+ });
978
+ });
979
+ test("fetchWithValidatedRedirects() closes the session when consuming fails", async () => {
980
+ const url = "https://example.com/lifecycle/consume-failure";
981
+ const hops = recordHops(() => ({
982
+ status: 200,
983
+ body: "x".repeat(1024)
984
+ }));
985
+ await assertRejects(() => fetchWithValidatedRedirects(new Request(url, {
986
+ method: "POST",
987
+ body: "activity"
988
+ }), hops.openHop, () => Promise.reject(/* @__PURE__ */ new Error("consume failed"))), Error, "consume failed");
989
+ assertEquals(hops.events, [
990
+ `open ${url}`,
991
+ `fetch #1 ${url}`,
992
+ "cancel #1 by close",
993
+ `close ${url}`
994
+ ]);
995
+ hops.assertInvariants();
996
+ });
997
+ test("sendActivity() logs a successful response body failing to cancel", async (t) => {
998
+ for (const signed of [true, false]) await t.step(`signed: ${signed}`, async () => {
999
+ const inbox = "https://example.com/lifecycle/cancel-failure-inbox";
1000
+ const hops = recordHops(() => ({
1001
+ status: 202,
1002
+ body: "Accepted",
1003
+ failCancel: true
1004
+ }));
1005
+ const records = await captureLogs(() => sendActivity({
1006
+ activity: lifecycleActivity,
1007
+ keys: signed ? signedKeys : unsignedKeys,
1008
+ inbox: new URL(inbox)
1009
+ }, { openHop: hops.openHop }));
1010
+ assertEquals(hops.events, [
1011
+ `open ${inbox}`,
1012
+ `fetch #1 ${inbox}`,
1013
+ "cancel #1",
1014
+ `close ${inbox}`
1015
+ ]);
1016
+ assertEquals(getCancelFailureUrls(records), [inbox]);
1017
+ hops.assertInvariants();
1018
+ });
773
1019
  });
1020
+ test("sendActivity() is not blocked by a body cancellation that never settles", async (t) => {
1021
+ for (const signed of [true, false]) await t.step(`signed: ${signed}`, async () => {
1022
+ const inbox = "https://example.com/lifecycle/hanging-cancel";
1023
+ const next = "https://example.com/lifecycle/hanging-cancel-final";
1024
+ const hops = recordHops((request) => request.url === inbox ? {
1025
+ status: 302,
1026
+ headers: { Location: next },
1027
+ body: "Found",
1028
+ hangCancel: true
1029
+ } : {
1030
+ status: 202,
1031
+ body: "Accepted",
1032
+ hangCancel: true
1033
+ });
1034
+ await sendActivity({
1035
+ activity: lifecycleActivity,
1036
+ keys: signed ? signedKeys : unsignedKeys,
1037
+ inbox: new URL(inbox)
1038
+ }, { openHop: hops.openHop });
1039
+ assertEquals(hops.events, [
1040
+ `open ${inbox}`,
1041
+ `fetch #1 ${inbox}`,
1042
+ "cancel #1",
1043
+ `close ${inbox}`,
1044
+ `open ${next}`,
1045
+ `fetch #2 ${next}`,
1046
+ "cancel #2",
1047
+ `close ${next}`
1048
+ ]);
1049
+ hops.assertInvariants();
1050
+ });
1051
+ await t.step("truncated error body", async () => {
1052
+ const inbox = "https://example.com/lifecycle/hanging-error";
1053
+ const hops = recordHops(() => ({
1054
+ status: 500,
1055
+ body: "x".repeat(4096),
1056
+ hangCancel: true
1057
+ }));
1058
+ assertEquals((await assertRejects(() => sendActivity({
1059
+ activity: lifecycleActivity,
1060
+ keys: unsignedKeys,
1061
+ inbox: new URL(inbox)
1062
+ }, { openHop: hops.openHop }), SendActivityError)).responseBody, "x".repeat(1024) + "… (truncated)");
1063
+ assertEquals(hops.events, [
1064
+ `open ${inbox}`,
1065
+ `fetch #1 ${inbox}`,
1066
+ "cancel #1",
1067
+ `close ${inbox}`
1068
+ ]);
1069
+ hops.assertInvariants();
1070
+ });
1071
+ });
1072
+ test("sendActivity() completes with responses cloned by fetch-mock", async (t) => {
1073
+ for (const signed of [true, false]) await t.step(`signed: ${signed}`, async () => {
1074
+ esm_default.spyGlobal();
1075
+ try {
1076
+ esm_default.post("https://example.com/tee/inbox", () => new Response("Moved", {
1077
+ status: 307,
1078
+ headers: { Location: "https://example.com/tee/final" }
1079
+ }));
1080
+ esm_default.post("https://example.com/tee/final", () => new Response("Accepted", { status: 202 }));
1081
+ esm_default.post("https://example.com/tee/error", () => new Response("x".repeat(4096), { status: 404 }));
1082
+ const keys = signed ? signedKeys : unsignedKeys;
1083
+ await sendDirect({
1084
+ activity: lifecycleActivity,
1085
+ keys,
1086
+ inbox: new URL("https://example.com/tee/inbox")
1087
+ });
1088
+ assertEquals(esm_default.callHistory.calls("https://example.com/tee/final").length, 1);
1089
+ const error = await assertRejects(() => sendDirect({
1090
+ activity: lifecycleActivity,
1091
+ keys,
1092
+ inbox: new URL("https://example.com/tee/error")
1093
+ }), SendActivityError);
1094
+ assertEquals(error.statusCode, 404);
1095
+ assertEquals(error.responseBody, "x".repeat(1024) + "… (truncated)");
1096
+ } finally {
1097
+ esm_default.hardReset();
1098
+ }
1099
+ });
1100
+ });
1101
+ function createRecordedResponse(events, index, init, cancelSuffix = () => "") {
1102
+ const bytes = new TextEncoder().encode(init.body ?? "");
1103
+ const chunkSize = init.chunkSize ?? 256;
1104
+ let offset = 0;
1105
+ let settled = false;
1106
+ const body = new ReadableStream({
1107
+ pull(controller) {
1108
+ if (offset >= bytes.length) {
1109
+ settled = true;
1110
+ events.push(`consumed #${index}`);
1111
+ controller.close();
1112
+ return;
1113
+ }
1114
+ controller.enqueue(bytes.slice(offset, offset + chunkSize));
1115
+ offset += chunkSize;
1116
+ },
1117
+ cancel() {
1118
+ settled = true;
1119
+ events.push(`cancel #${index}${cancelSuffix()}`);
1120
+ if (init.failCancel) throw new Error(`cancel #${index} failed`);
1121
+ if (init.hangCancel) return new Promise(() => {});
1122
+ }
1123
+ }, { highWaterMark: 0 });
1124
+ return {
1125
+ response: new Response(body, {
1126
+ status: init.status ?? 200,
1127
+ headers: init.headers
1128
+ }),
1129
+ settled: () => settled
1130
+ };
1131
+ }
1132
+ function recordHops(respond) {
1133
+ const events = [];
1134
+ const requests = [];
1135
+ const unsettledAtClose = [];
1136
+ let opens = 0;
1137
+ let closes = 0;
1138
+ let fetches = 0;
1139
+ return {
1140
+ events,
1141
+ requests,
1142
+ openHop(url) {
1143
+ opens++;
1144
+ events.push(`open ${url}`);
1145
+ const tracked = [];
1146
+ let closed = false;
1147
+ let closing = false;
1148
+ return Promise.resolve({
1149
+ url: new URL(url),
1150
+ async fetch(input, init) {
1151
+ if (closed) throw new TypeError("The session is closed.");
1152
+ const request = new Request(input instanceof Request ? input.clone() : input, init);
1153
+ const index = ++fetches;
1154
+ requests.push(request);
1155
+ events.push(`fetch #${index} ${request.url}`);
1156
+ const recorded = createRecordedResponse(events, index, await respond(request, index), () => closing ? " by close" : "");
1157
+ tracked.push({
1158
+ ...recorded,
1159
+ index
1160
+ });
1161
+ return recorded.response;
1162
+ },
1163
+ close() {
1164
+ if (closed) return Promise.resolve();
1165
+ closed = true;
1166
+ closes++;
1167
+ closing = true;
1168
+ for (const { response, settled, index } of tracked) {
1169
+ if (settled()) continue;
1170
+ if (response.body?.locked) unsettledAtClose.push(index);
1171
+ else response.body?.cancel().catch(() => {});
1172
+ }
1173
+ events.push(`close ${url}`);
1174
+ return Promise.resolve();
1175
+ }
1176
+ });
1177
+ },
1178
+ assertInvariants() {
1179
+ assertEquals(opens, closes);
1180
+ assertEquals(unsettledAtClose, []);
1181
+ }
1182
+ };
1183
+ }
774
1184
  //#endregion
775
1185
  export {};