@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,10 +1,12 @@
1
1
  import "@js-temporal/polyfill";
2
2
  import "urlpattern-polyfill";
3
3
  globalThis.addEventListener = () => {};
4
- import { n as version, t as name } from "./deno-XBVlKnOX.mjs";
5
- import { n as doubleKnock } from "./http-CSlLA54v.mjs";
6
- import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
4
+ import { n as version, t as name } from "./deno-DCdM7d93.mjs";
5
+ import { n as getDurationMs, r as getFederationMetrics } from "./metrics-BHeWd24f.mjs";
6
+ import { n as doubleKnock } from "./http-BufbkLuW.mjs";
7
7
  import { getLogger } from "@logtape/logtape";
8
+ import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
9
+ import { FetchError } from "@fedify/vocab-runtime";
8
10
  //#region src/federation/send.ts
9
11
  /**
10
12
  * Extracts the inbox URLs from recipients.
@@ -64,6 +66,25 @@ function sendActivity(options) {
64
66
  });
65
67
  }
66
68
  const MAX_ERROR_RESPONSE_BODY_BYTES = 1024;
69
+ function getActivityActorId(activity) {
70
+ if (!isRecord(activity)) return void 0;
71
+ return getIdValue(activity.actor);
72
+ }
73
+ function getIdValue(value) {
74
+ if (typeof value === "string" && value !== "") return value;
75
+ if (value instanceof URL) return value.href;
76
+ if (Array.isArray(value)) {
77
+ for (const item of value) {
78
+ const id = getIdValue(item);
79
+ if (id != null) return id;
80
+ }
81
+ return;
82
+ }
83
+ if (isRecord(value)) return getIdValue(value.id);
84
+ }
85
+ function isRecord(value) {
86
+ return typeof value === "object" && value != null;
87
+ }
67
88
  async function readLimitedResponseBody(response, maxBytes) {
68
89
  if (response.body == null) return "";
69
90
  const reader = response.body.getReader();
@@ -91,12 +112,15 @@ async function readLimitedResponseBody(response, maxBytes) {
91
112
  if (truncated) result += "… (truncated)";
92
113
  return result;
93
114
  }
94
- async function sendActivityInternal({ activity, activityId, keys, inbox, headers, specDeterminer, tracerProvider }, span) {
115
+ async function sendActivityInternal({ activity, activityId, activityType, keys, inbox, headers, specDeterminer, meterProvider, tracerProvider }, span) {
95
116
  const logger = getLogger([
96
117
  "fedify",
97
118
  "federation",
98
119
  "outbox"
99
120
  ]);
121
+ const federationMetrics = getFederationMetrics(meterProvider);
122
+ const started = performance.now();
123
+ let deliverySuccess = false;
100
124
  headers = new Headers(headers);
101
125
  headers.set("Content-Type", "application/activity+json");
102
126
  const request = new Request(inbox, {
@@ -123,34 +147,49 @@ async function sendActivityInternal({ activity, activityId, keys, inbox, headers
123
147
  specDeterminer
124
148
  });
125
149
  } catch (error) {
150
+ const transportError = error instanceof FetchError ? error : createFetchError(inbox.href, error);
126
151
  logger.error("Failed to send activity {activityId} to {inbox}:\n{error}", {
127
152
  activityId,
128
153
  inbox: inbox.href,
129
- error
154
+ error: transportError
130
155
  });
131
- throw error;
156
+ federationMetrics.recordDelivery(inbox, getDurationMs(started), false, activityType);
157
+ throw transportError;
132
158
  }
133
- if (!response.ok) {
134
- let error;
135
- try {
136
- error = await readLimitedResponseBody(response, MAX_ERROR_RESPONSE_BODY_BYTES);
137
- } catch (_) {
138
- error = "";
159
+ try {
160
+ if (!response.ok) {
161
+ let error;
162
+ try {
163
+ error = await readLimitedResponseBody(response, MAX_ERROR_RESPONSE_BODY_BYTES);
164
+ } catch (_) {
165
+ error = "";
166
+ }
167
+ logger.error("Failed to send activity {activityId} to {inbox} ({status} {statusText}):\n{error}", {
168
+ activityId,
169
+ inbox: inbox.href,
170
+ status: response.status,
171
+ statusText: response.statusText,
172
+ error
173
+ });
174
+ throw new SendActivityError(inbox, response.status, `Failed to send activity ${activityId} to ${inbox.href} (${response.status} ${response.statusText}):\n${error}`, error, response.headers);
139
175
  }
140
- logger.error("Failed to send activity {activityId} to {inbox} ({status} {statusText}):\n{error}", {
141
- activityId,
142
- inbox: inbox.href,
143
- status: response.status,
144
- statusText: response.statusText,
145
- error
146
- });
147
- throw new SendActivityError(inbox, response.status, `Failed to send activity ${activityId} to ${inbox.href} (${response.status} ${response.statusText}):\n${error}`, error);
176
+ deliverySuccess = true;
177
+ const eventAttributes = {
178
+ "activitypub.inbox.url": inbox.href,
179
+ "activitypub.activity.id": activityId ?? ""
180
+ };
181
+ if (activityType != null) eventAttributes["activitypub.activity.type"] = activityType;
182
+ const actorId = getActivityActorId(activity);
183
+ if (actorId != null) eventAttributes["activitypub.actor.id"] = actorId;
184
+ span.addEvent("activitypub.activity.sent", eventAttributes);
185
+ } finally {
186
+ federationMetrics.recordDelivery(inbox, getDurationMs(started), deliverySuccess, activityType);
148
187
  }
149
- span.addEvent("activitypub.activity.sent", {
150
- "activitypub.activity.json": JSON.stringify(activity),
151
- "activitypub.inbox.url": inbox.href,
152
- "activitypub.activity.id": activityId ?? ""
153
- });
188
+ }
189
+ function createFetchError(url, cause) {
190
+ const error = new FetchError(url, cause instanceof Error ? cause.message : String(cause));
191
+ error.cause = cause;
192
+ return error;
154
193
  }
155
194
  /**
156
195
  * An error that is thrown when an activity fails to send to a remote inbox.
@@ -175,18 +214,25 @@ var SendActivityError = class extends Error {
175
214
  */
176
215
  responseBody;
177
216
  /**
217
+ * The response headers from the inbox.
218
+ * @since 2.3.0
219
+ */
220
+ responseHeaders;
221
+ /**
178
222
  * Creates a new {@link SendActivityError}.
179
223
  * @param inbox The inbox URL.
180
224
  * @param statusCode The HTTP status code.
181
225
  * @param message The error message.
182
226
  * @param responseBody The response body.
227
+ * @param responseHeaders The response headers.
183
228
  */
184
- constructor(inbox, statusCode, message, responseBody) {
229
+ constructor(inbox, statusCode, message, responseBody, responseHeaders) {
185
230
  super(message);
186
231
  this.name = "SendActivityError";
187
232
  this.inbox = inbox;
188
233
  this.statusCode = statusCode;
189
234
  this.responseBody = responseBody;
235
+ this.responseHeaders = new Headers(responseHeaders);
190
236
  }
191
237
  };
192
238
  //#endregion
@@ -1,16 +1,16 @@
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 { t as assertEquals } from "../assert_equals-Ew3jOFa3.mjs";
6
- import { i as assertExists, t as assertStringIncludes } from "../std__assert-CRDpx_HF.mjs";
7
- import { n as assertFalse, t as assertRejects } from "../assert_rejects-B-qJtC9Z.mjs";
8
- import { t as assertThrows } from "../assert_throws-4NwKEy2q.mjs";
9
- import { t as assert } from "../assert-DikXweDx.mjs";
10
- import { t as exportJwk } from "../key-Bzx--sHD.mjs";
11
- import { a as parseRfc9421Signature, c as timingSafeEqual, i as formatRfc9421SignatureParameters, l as verifyRequest, n as doubleKnock, o as parseRfc9421SignatureInput, r as formatRfc9421Signature, s as signRequest, t as createRfc9421SignatureBase, u as verifyRequestDetailed } 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, t as assertStringIncludes } from "../std__assert-BBjXFNOb.mjs";
7
+ import { n as assertGreaterOrEqual, r as assertFalse, t as assertRejects } from "../assert_rejects-DN60FHPX.mjs";
8
+ import { t as assertThrows } from "../assert_throws-BOkhLGYc.mjs";
9
+ import { t as assert } from "../assert-OguE97r2.mjs";
10
+ import { t as exportJwk } from "../key-DZdKLJXT.mjs";
11
+ import { a as parseRfc9421Signature, c as timingSafeEqual, i as formatRfc9421SignatureParameters, l as verifyRequest, n as doubleKnock, o as parseRfc9421SignatureInput, r as formatRfc9421Signature, s as signRequest, t as createRfc9421SignatureBase, u as verifyRequestDetailed } from "../http-BufbkLuW.mjs";
12
12
  import { i as rsaPrivateKey2, l as rsaPublicKey5, o as rsaPublicKey1, s as rsaPublicKey2 } from "../keys-DGu1NFwu.mjs";
13
- import { createTestTracerProvider, mockDocumentLoader, test } from "@fedify/fixture";
13
+ import { createTestMeterProvider, createTestTracerProvider, mockDocumentLoader, test } from "@fedify/fixture";
14
14
  import { FetchError, exportSpki } from "@fedify/vocab-runtime";
15
15
  import { encodeBase64 } from "byte-encodings/base64";
16
16
  //#region src/sig/http.test.ts
@@ -230,6 +230,216 @@ test("verifyRequestDetailed() records failure details on span", async () => {
230
230
  assertEquals(span.attributes["http_signatures.key_id"], keyId.href);
231
231
  assertEquals(span.attributes["http_signatures.key_fetch_status"], 410);
232
232
  });
233
+ test("verifyRequestDetailed() records verification duration metric", async (t) => {
234
+ const buildSignedRequest = () => signRequest(new Request("https://example.com/inbox", {
235
+ method: "POST",
236
+ headers: {
237
+ "Content-Type": "application/activity+json",
238
+ accept: "application/ld+json"
239
+ },
240
+ body: JSON.stringify({
241
+ "@context": "https://www.w3.org/ns/activitystreams",
242
+ type: "Create",
243
+ actor: "https://example.com/key2"
244
+ })
245
+ }), rsaPrivateKey2, new URL("https://example.com/key2"));
246
+ await t.step("verified path emits one measurement", async () => {
247
+ const [meterProvider, recorder] = createTestMeterProvider();
248
+ assert((await verifyRequestDetailed(await buildSignedRequest(), {
249
+ contextLoader: mockDocumentLoader,
250
+ documentLoader: mockDocumentLoader,
251
+ meterProvider
252
+ })).verified);
253
+ const measurements = recorder.getMeasurements("activitypub.signature.verification.duration");
254
+ assertEquals(measurements.length, 1);
255
+ const measurement = measurements[0];
256
+ assertEquals(measurement.type, "histogram");
257
+ assertGreaterOrEqual(measurement.value, 0);
258
+ assertEquals(measurement.attributes["activitypub.signature.kind"], "http");
259
+ assertEquals(measurement.attributes["activitypub.signature.result"], "verified");
260
+ assertEquals(measurement.attributes["http_signatures.algorithm"], "rsa-sha256");
261
+ assertFalse("http_signatures.failure_reason" in measurement.attributes);
262
+ const keyLookups = recorder.getMeasurements("activitypub.key.lookup");
263
+ assertEquals(keyLookups.length, 1);
264
+ assertEquals(keyLookups[0].attributes["activitypub.lookup.kind"], "public_key");
265
+ assertEquals(keyLookups[0].attributes["activitypub.lookup.result"], "fetched");
266
+ });
267
+ await t.step("missing signature is recorded as result=missing", async () => {
268
+ const [meterProvider, recorder] = createTestMeterProvider();
269
+ const result = await verifyRequestDetailed(new Request("https://example.com/inbox", {
270
+ method: "POST",
271
+ headers: { "Content-Type": "application/activity+json" },
272
+ body: "{}"
273
+ }), {
274
+ contextLoader: mockDocumentLoader,
275
+ documentLoader: mockDocumentLoader,
276
+ meterProvider
277
+ });
278
+ assertFalse(result.verified);
279
+ assertEquals(result.reason.type, "noSignature");
280
+ const measurements = recorder.getMeasurements("activitypub.signature.verification.duration");
281
+ assertEquals(measurements.length, 1);
282
+ assertEquals(measurements[0].attributes["activitypub.signature.kind"], "http");
283
+ assertEquals(measurements[0].attributes["activitypub.signature.result"], "missing");
284
+ assertFalse("http_signatures.failure_reason" in measurements[0].attributes);
285
+ });
286
+ await t.step("invalid signature is recorded as result=rejected with failure_reason", async () => {
287
+ const [meterProvider, recorder] = createTestMeterProvider();
288
+ const result = await verifyRequestDetailed(new Request("https://example.com/", {
289
+ method: "POST",
290
+ headers: {
291
+ Date: "Tue, 05 Mar 2024 07:49:44 GMT",
292
+ Digest: "sha-256=47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=",
293
+ Signature: "keyId=\"https://example.com/key2\",headers=\"(request-target) date digest\",signature=\"AAAA\""
294
+ },
295
+ body: ""
296
+ }), {
297
+ documentLoader: mockDocumentLoader,
298
+ contextLoader: mockDocumentLoader,
299
+ meterProvider
300
+ });
301
+ assertFalse(result.verified);
302
+ assertEquals(result.reason.type, "invalidSignature");
303
+ const measurements = recorder.getMeasurements("activitypub.signature.verification.duration");
304
+ assertEquals(measurements.length, 1);
305
+ assertEquals(measurements[0].attributes["activitypub.signature.kind"], "http");
306
+ assertEquals(measurements[0].attributes["activitypub.signature.result"], "rejected");
307
+ assertEquals(measurements[0].attributes["http_signatures.failure_reason"], "invalidSignature");
308
+ });
309
+ await t.step("key fetch failure is recorded as result=rejected with failure_reason=keyFetchError", async () => {
310
+ const [meterProvider, recorder] = createTestMeterProvider();
311
+ const keyId = new URL("https://gone.example/actors/alice#main-key");
312
+ const result = await verifyRequestDetailed(await signRequest(new Request("https://example.com/inbox", {
313
+ method: "POST",
314
+ headers: {
315
+ "Content-Type": "application/activity+json",
316
+ accept: "application/ld+json"
317
+ },
318
+ body: JSON.stringify({
319
+ "@context": "https://www.w3.org/ns/activitystreams",
320
+ type: "Create",
321
+ actor: "https://gone.example/actors/alice"
322
+ })
323
+ }), rsaPrivateKey2, keyId), {
324
+ contextLoader: mockDocumentLoader,
325
+ documentLoader(url) {
326
+ if (url === keyId.href) throw new FetchError(keyId, `HTTP 410: ${keyId.href}`, new Response(null, { status: 410 }));
327
+ return mockDocumentLoader(url);
328
+ },
329
+ meterProvider
330
+ });
331
+ assertFalse(result.verified);
332
+ assertEquals(result.reason.type, "keyFetchError");
333
+ const measurements = recorder.getMeasurements("activitypub.signature.verification.duration");
334
+ assertEquals(measurements.length, 1);
335
+ assertEquals(measurements[0].attributes["activitypub.signature.result"], "rejected");
336
+ assertEquals(measurements[0].attributes["http_signatures.failure_reason"], "keyFetchError");
337
+ });
338
+ await t.step("verifyRequest() wrapper emits exactly one measurement, not two", async () => {
339
+ const [meterProvider, recorder] = createTestMeterProvider();
340
+ assertExists(await verifyRequest(await buildSignedRequest(), {
341
+ contextLoader: mockDocumentLoader,
342
+ documentLoader: mockDocumentLoader,
343
+ meterProvider
344
+ }));
345
+ assertEquals(recorder.getMeasurements("activitypub.signature.verification.duration").length, 1);
346
+ });
347
+ await t.step("cached-key retry emits one measurement, not two", async () => {
348
+ const [meterProvider, recorder] = createTestMeterProvider();
349
+ const cache = { "https://example.com/key2": rsaPublicKey1 };
350
+ assertExists(await verifyRequest(await buildSignedRequest(), {
351
+ contextLoader: mockDocumentLoader,
352
+ documentLoader: mockDocumentLoader,
353
+ meterProvider,
354
+ keyCache: {
355
+ get(keyId) {
356
+ return Promise.resolve(cache[keyId.href]);
357
+ },
358
+ set(keyId, k) {
359
+ cache[keyId.href] = k;
360
+ return Promise.resolve();
361
+ }
362
+ }
363
+ }));
364
+ assertEquals(recorder.getMeasurements("activitypub.signature.verification.duration").length, 1);
365
+ });
366
+ await t.step("key fetch records result=fetched on a cold cache", async () => {
367
+ const [meterProvider, recorder] = createTestMeterProvider();
368
+ assertExists(await verifyRequest(await buildSignedRequest(), {
369
+ contextLoader: mockDocumentLoader,
370
+ documentLoader: mockDocumentLoader,
371
+ meterProvider
372
+ }));
373
+ const measurements = recorder.getMeasurements("activitypub.signature.key_fetch.duration");
374
+ assertEquals(measurements.length, 1);
375
+ assertEquals(measurements[0].type, "histogram");
376
+ assertGreaterOrEqual(measurements[0].value, 0);
377
+ assertEquals(measurements[0].attributes["activitypub.signature.kind"], "http");
378
+ assertEquals(measurements[0].attributes["activitypub.signature.key_fetch.result"], "fetched");
379
+ });
380
+ await t.step("key fetch records result=hit when served from the key cache", async () => {
381
+ const [meterProvider, recorder] = createTestMeterProvider();
382
+ const cache = { "https://example.com/key2": rsaPublicKey2 };
383
+ assertExists(await verifyRequest(await buildSignedRequest(), {
384
+ contextLoader: mockDocumentLoader,
385
+ documentLoader: mockDocumentLoader,
386
+ meterProvider,
387
+ keyCache: {
388
+ get(keyId) {
389
+ return Promise.resolve(cache[keyId.href]);
390
+ },
391
+ set(keyId, k) {
392
+ cache[keyId.href] = k;
393
+ return Promise.resolve();
394
+ }
395
+ }
396
+ }));
397
+ const measurements = recorder.getMeasurements("activitypub.signature.key_fetch.duration");
398
+ assertEquals(measurements.length, 1);
399
+ assertEquals(measurements[0].attributes["activitypub.signature.key_fetch.result"], "hit");
400
+ });
401
+ await t.step("key fetch records result=error when the remote key returns HTTP 410", async () => {
402
+ const [meterProvider, recorder] = createTestMeterProvider();
403
+ const keyId = new URL("https://gone.example/actors/alice#main-key");
404
+ assertFalse((await verifyRequestDetailed(await signRequest(new Request("https://example.com/inbox", {
405
+ method: "POST",
406
+ headers: {
407
+ "Content-Type": "application/activity+json",
408
+ accept: "application/ld+json"
409
+ },
410
+ body: "{}"
411
+ }), rsaPrivateKey2, keyId), {
412
+ contextLoader: mockDocumentLoader,
413
+ documentLoader(url) {
414
+ if (url === keyId.href) throw new FetchError(keyId, `HTTP 410: ${keyId.href}`, new Response(null, { status: 410 }));
415
+ return mockDocumentLoader(url);
416
+ },
417
+ meterProvider
418
+ })).verified);
419
+ const measurements = recorder.getMeasurements("activitypub.signature.key_fetch.duration");
420
+ assertEquals(measurements.length, 1);
421
+ assertEquals(measurements[0].attributes["activitypub.signature.key_fetch.result"], "error");
422
+ });
423
+ await t.step("draft-cavage with unknown algorithm omits the algorithm metric attribute", async () => {
424
+ const [meterProvider, recorder] = createTestMeterProvider();
425
+ assertFalse((await verifyRequestDetailed(new Request("https://example.com/", {
426
+ method: "POST",
427
+ headers: {
428
+ Date: "Tue, 05 Mar 2024 07:49:44 GMT",
429
+ Digest: "sha-256=47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=",
430
+ Signature: "keyId=\"https://example.com/key2\",algorithm=\"x-attacker-supplied\",headers=\"(request-target) date digest\",signature=\"AAAA\""
431
+ },
432
+ body: ""
433
+ }), {
434
+ documentLoader: mockDocumentLoader,
435
+ contextLoader: mockDocumentLoader,
436
+ meterProvider
437
+ })).verified);
438
+ const measurements = recorder.getMeasurements("activitypub.signature.verification.duration");
439
+ assertEquals(measurements.length, 1);
440
+ assertFalse("http_signatures.algorithm" in measurements[0].attributes);
441
+ });
442
+ });
233
443
  test("signRequest() and verifyRequest() [rfc9421] implementation", async () => {
234
444
  const currentTimestamp = 1709626184;
235
445
  const currentTime = Temporal.Instant.from("2024-03-05T08:09:44Z");
@@ -335,15 +545,12 @@ test("createRfc9421SignatureBase()", () => {
335
545
  ].join("\n"));
336
546
  });
337
547
  test("formatRfc9421Signature()", () => {
338
- const signature = new Uint8Array([
548
+ const [signatureInput, signatureHeader] = formatRfc9421Signature(new Uint8Array([
339
549
  1,
340
550
  2,
341
551
  3,
342
552
  4
343
- ]);
344
- const keyId = new URL("https://example.com/key");
345
- const algorithm = "rsa-v1_5-sha256";
346
- const [signatureInput, signatureHeader] = formatRfc9421Signature(signature, [
553
+ ]), [
347
554
  {
348
555
  "value": "@method",
349
556
  params: {}
@@ -357,8 +564,8 @@ test("formatRfc9421Signature()", () => {
357
564
  params: {}
358
565
  }
359
566
  ], formatRfc9421SignatureParameters({
360
- algorithm,
361
- keyId,
567
+ algorithm: "rsa-v1_5-sha256",
568
+ keyId: new URL("https://example.com/key"),
362
569
  created: 1709626184
363
570
  }));
364
571
  assertEquals(signatureInput, `sig1=("@method" "@target-uri" "host");alg="rsa-v1_5-sha256";keyid="https://example.com/key";created=1709626184`);
@@ -855,12 +1062,6 @@ test("doubleKnock() function with specDeterminer choosing draft-cavage first", a
855
1062
  else if (req.headers.has("Signature")) firstSpec = "draft-cavage";
856
1063
  return new Response("", { status: 202 });
857
1064
  });
858
- const specDeterminer = {
859
- determineSpec(_origin) {
860
- return "draft-cavage-http-signatures-12";
861
- },
862
- rememberSpec(_origin, _spec) {}
863
- };
864
1065
  assertEquals((await doubleKnock(new Request("https://example.com/inbox-accepts-any", {
865
1066
  method: "POST",
866
1067
  body: "Test message with draft-cavage preference",
@@ -868,7 +1069,12 @@ test("doubleKnock() function with specDeterminer choosing draft-cavage first", a
868
1069
  }), {
869
1070
  keyId: rsaPublicKey2.id,
870
1071
  privateKey: rsaPrivateKey2
871
- }, { specDeterminer })).status, 202, "Response status should be 202 Accepted");
1072
+ }, { specDeterminer: {
1073
+ determineSpec(_origin) {
1074
+ return "draft-cavage-http-signatures-12";
1075
+ },
1076
+ rememberSpec(_origin, _spec) {}
1077
+ } })).status, 202, "Response status should be 202 Accepted");
872
1078
  assertEquals(requestCount, 1, "Only one request should have been made");
873
1079
  assertEquals(firstSpec, "draft-cavage", "First attempt should use draft-cavage");
874
1080
  esm_default.hardReset();
@@ -979,6 +1185,124 @@ test("doubleKnock() detects redirect loops", async () => {
979
1185
  assertEquals(requestCount, 2);
980
1186
  esm_default.hardReset();
981
1187
  });
1188
+ test("doubleKnock() retries idempotent request transport errors", async () => {
1189
+ esm_default.spyGlobal();
1190
+ try {
1191
+ let requestCount = 0;
1192
+ esm_default.get("https://example.com/flaky-document", () => {
1193
+ requestCount++;
1194
+ if (requestCount === 1) throw new TypeError("temporary DNS failure");
1195
+ return new Response("Success", { status: 200 });
1196
+ });
1197
+ const response = await doubleKnock(new Request("https://example.com/flaky-document"), {
1198
+ keyId: rsaPublicKey2.id,
1199
+ privateKey: rsaPrivateKey2
1200
+ });
1201
+ assertEquals(response.status, 200);
1202
+ assertEquals(await response.text(), "Success");
1203
+ assertEquals(requestCount, 2);
1204
+ } finally {
1205
+ esm_default.hardReset();
1206
+ }
1207
+ });
1208
+ test("doubleKnock() wraps repeated transport errors", async () => {
1209
+ esm_default.spyGlobal();
1210
+ try {
1211
+ let requestCount = 0;
1212
+ const failure = /* @__PURE__ */ new TypeError("DNS lookup failed");
1213
+ esm_default.get("https://example.com/unreachable-document", () => {
1214
+ requestCount++;
1215
+ throw failure;
1216
+ });
1217
+ const request = new Request("https://example.com/unreachable-document");
1218
+ const error = await assertRejects(() => doubleKnock(request, {
1219
+ keyId: rsaPublicKey2.id,
1220
+ privateKey: rsaPrivateKey2
1221
+ }), FetchError, "DNS lookup failed");
1222
+ assertEquals(error.url.href, "https://example.com/unreachable-document");
1223
+ assertEquals(error.cause, failure);
1224
+ assertEquals(requestCount, 2);
1225
+ } finally {
1226
+ esm_default.hardReset();
1227
+ }
1228
+ });
1229
+ test("doubleKnock() does not retry non-idempotent transport errors", async () => {
1230
+ esm_default.spyGlobal();
1231
+ try {
1232
+ let requestCount = 0;
1233
+ const failure = /* @__PURE__ */ new TypeError("connection reset");
1234
+ esm_default.post("https://example.com/flaky-inbox", () => {
1235
+ requestCount++;
1236
+ throw failure;
1237
+ });
1238
+ const request = new Request("https://example.com/flaky-inbox", {
1239
+ method: "POST",
1240
+ body: "Test activity content",
1241
+ headers: { "Content-Type": "application/activity+json" }
1242
+ });
1243
+ const error = await assertRejects(() => doubleKnock(request, {
1244
+ keyId: rsaPublicKey2.id,
1245
+ privateKey: rsaPrivateKey2
1246
+ }), FetchError, "connection reset");
1247
+ assertEquals(error.url.href, "https://example.com/flaky-inbox");
1248
+ assertEquals(error.cause, failure);
1249
+ assertEquals(requestCount, 1);
1250
+ } finally {
1251
+ esm_default.hardReset();
1252
+ }
1253
+ });
1254
+ test("doubleKnock() preserves Request signal abort reasons", async () => {
1255
+ const controller = new AbortController();
1256
+ const abortReason = "request aborted";
1257
+ controller.abort(abortReason);
1258
+ const request = new Request("https://example.com/request-abort", { signal: controller.signal });
1259
+ assertEquals(await assertRejects(() => doubleKnock(request, {
1260
+ keyId: rsaPublicKey2.id,
1261
+ privateKey: rsaPrivateKey2
1262
+ })), abortReason);
1263
+ });
1264
+ test("doubleKnock() preserves Request signal aborts during retry delay", async () => {
1265
+ esm_default.spyGlobal();
1266
+ try {
1267
+ let requestCount = 0;
1268
+ const controller = new AbortController();
1269
+ const abortReason = "retry aborted";
1270
+ esm_default.get("https://example.com/aborted-retry", () => {
1271
+ requestCount++;
1272
+ setTimeout(() => controller.abort(abortReason));
1273
+ throw new TypeError("temporary DNS failure");
1274
+ });
1275
+ const request = new Request("https://example.com/aborted-retry", { signal: controller.signal });
1276
+ assertEquals(await assertRejects(() => doubleKnock(request, {
1277
+ keyId: rsaPublicKey2.id,
1278
+ privateKey: rsaPrivateKey2
1279
+ })), abortReason);
1280
+ assertEquals(requestCount, 1);
1281
+ } finally {
1282
+ esm_default.hardReset();
1283
+ }
1284
+ });
1285
+ test("doubleKnock() prefers Request aborts over transport errors", async () => {
1286
+ esm_default.spyGlobal();
1287
+ try {
1288
+ let requestCount = 0;
1289
+ const controller = new AbortController();
1290
+ const abortReason = "transport aborted";
1291
+ esm_default.get("https://example.com/abort-with-transport-error", () => {
1292
+ requestCount++;
1293
+ controller.abort(abortReason);
1294
+ throw new TypeError("temporary DNS failure");
1295
+ });
1296
+ const request = new Request("https://example.com/abort-with-transport-error", { signal: controller.signal });
1297
+ assertEquals(await assertRejects(() => doubleKnock(request, {
1298
+ keyId: rsaPublicKey2.id,
1299
+ privateKey: rsaPrivateKey2
1300
+ })), abortReason);
1301
+ assertEquals(requestCount, 1);
1302
+ } finally {
1303
+ esm_default.hardReset();
1304
+ }
1305
+ });
982
1306
  test("doubleKnock() async specDeterminer test", async () => {
983
1307
  esm_default.spyGlobal();
984
1308
  let requestCount = 0;
@@ -990,15 +1314,6 @@ test("doubleKnock() async specDeterminer test", async () => {
990
1314
  else if (req.headers.has("Signature")) specUsed = "draft-cavage-http-signatures-12";
991
1315
  return new Response("", { status: 202 });
992
1316
  });
993
- const specDeterminer = {
994
- async determineSpec(_origin) {
995
- await new Promise((resolve) => setTimeout(resolve, 10));
996
- return "draft-cavage-http-signatures-12";
997
- },
998
- async rememberSpec(_origin, _spec) {
999
- await new Promise((resolve) => setTimeout(resolve, 10));
1000
- }
1001
- };
1002
1317
  assertEquals((await doubleKnock(new Request("https://example.com/inbox-async-determiner", {
1003
1318
  method: "POST",
1004
1319
  body: "Test message with async spec determiner",
@@ -1006,7 +1321,15 @@ test("doubleKnock() async specDeterminer test", async () => {
1006
1321
  }), {
1007
1322
  keyId: rsaPublicKey2.id,
1008
1323
  privateKey: rsaPrivateKey2
1009
- }, { specDeterminer })).status, 202, "Response status should be 202 Accepted");
1324
+ }, { specDeterminer: {
1325
+ async determineSpec(_origin) {
1326
+ await new Promise((resolve) => setTimeout(resolve, 10));
1327
+ return "draft-cavage-http-signatures-12";
1328
+ },
1329
+ async rememberSpec(_origin, _spec) {
1330
+ await new Promise((resolve) => setTimeout(resolve, 10));
1331
+ }
1332
+ } })).status, 202, "Response status should be 202 Accepted");
1010
1333
  assertEquals(requestCount, 1, "Only one request should have been made");
1011
1334
  assertEquals(specUsed, "draft-cavage-http-signatures-12", "Should use spec from async determiner");
1012
1335
  esm_default.hardReset();