@fedify/fedify 2.4.5-dev.2462 → 2.4.6-dev.2628
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.
- package/README.md +3 -0
- package/dist/{builder-BBUGd9wr.mjs → builder-kj5boG2l.mjs} +2 -2
- package/dist/{client-D-XP_Ccb.d.cts → client-3InDZbba.d.cts} +0 -1
- package/dist/{client-D-XP_Ccb.d.ts → client-3InDZbba.d.ts} +0 -1
- package/dist/{client-CEnNz8O7.mjs → client-5SIFzW3q.mjs} +113 -64
- package/dist/compat/mod.d.cts +1 -1
- package/dist/compat/mod.d.ts +1 -1
- package/dist/compat/transformers.test.mjs +1 -1
- package/dist/{compound-proof-CariICma.mjs → compound-proof-BoNAYSHt.mjs} +2 -2
- package/dist/{context-7NyiqIcg.d.ts → context-CVOOd9pD.d.ts} +28 -2
- package/dist/{context-tsYPJ2Ew.d.cts → context-D0gFIgev.d.cts} +28 -2
- package/dist/{deno-BO7-a3hz.mjs → deno-ksnVeCA7.mjs} +1 -1
- package/dist/docloader-BhVNi06k.mjs +136 -0
- package/dist/federation/builder.test.mjs +1 -1
- package/dist/federation/compound-fanout.test.mjs +6 -3
- package/dist/federation/handler-compound-proof.test.mjs +3 -3
- package/dist/federation/handler.test.mjs +4 -4
- package/dist/federation/hashlink-media.test.mjs +2 -2
- package/dist/federation/idempotency.test.mjs +2 -2
- package/dist/federation/inbox-report.test.mjs +5 -5
- package/dist/federation/inbox.test.mjs +1 -1
- package/dist/federation/metrics.test.mjs +1 -1
- package/dist/federation/middleware.test.mjs +465 -32
- package/dist/federation/mod.cjs +1 -1
- package/dist/federation/mod.d.cts +3 -3
- package/dist/federation/mod.d.ts +3 -3
- package/dist/federation/mod.js +1 -1
- package/dist/federation/outgoing-proof.test.mjs +9 -6
- package/dist/federation/portable-actor.test.mjs +3 -3
- package/dist/federation/portable-chain.test.mjs +3 -3
- package/dist/federation/portable-collection.test.mjs +4 -4
- package/dist/federation/portable-delivery.test.mjs +2 -2
- package/dist/federation/portable-gateway-keys.test.mjs +11 -5
- package/dist/federation/portable-inbox.test.mjs +4 -4
- package/dist/federation/portable-parse-uri.test.mjs +1 -1
- package/dist/federation/portable-route-activity.test.mjs +2 -2
- package/dist/federation/portable.test.mjs +3 -3
- package/dist/federation/send.test.mjs +498 -88
- package/dist/federation/tasks/enqueue.test.mjs +1 -1
- package/dist/federation/tasks/tasks.test.mjs +2 -2
- package/dist/federation/temporal.test.mjs +1 -1
- package/dist/federation/webfinger.test.mjs +1 -1
- package/dist/{federation-Dd9IwOEi.cjs → federation-BtPz9sr3.cjs} +240 -102
- package/dist/{http-D7L2pqLT.mjs → http-BSARoDPh.mjs} +204 -106
- package/dist/{http-DLf8QYD6.cjs → http-CeVcKQMZ.cjs} +179 -110
- package/dist/{http-BVrOR6MG.js → http-rH230xCD.js} +178 -109
- package/dist/{inbox-DtEKOr3W.mjs → inbox-BdZenpZQ.mjs} +2 -2
- package/dist/{key-DYxuyd-s.d.cts → key-CyCcut3h.d.ts} +1 -2
- package/dist/{key-C1nv7_jD.d.ts → key-VEL1QqHF.d.cts} +1 -2
- package/dist/{kv-cache-Dkzuqs8B.cjs → kv-cache-BpGIONmd.cjs} +72 -27
- package/dist/{kv-cache-DoZoRS9p.js → kv-cache-CNfNI_95.js} +73 -28
- package/dist/{kv-cache-m3RIr811.mjs → kv-cache-Cxn7pNMx.mjs} +1 -1
- package/dist/{ld-PJLGA2MJ.mjs → ld-DJZrjNuN.mjs} +2 -2
- package/dist/{metrics-CGR9cI-Y.mjs → metrics-DtxsYIkN.mjs} +1 -1
- package/dist/{middleware-BxhBd7pw.js → middleware-DDvck2q9.js} +241 -103
- package/dist/{middleware-ByanhTvb.mjs → middleware-DblGj642.mjs} +82 -42
- package/dist/{middleware-DPI-Jp9w.mjs → middleware-DyEjt33n.mjs} +1 -1
- package/dist/{mod-DEZXwV6J.d.ts → mod-B1Qt5cQQ.d.ts} +2 -9
- package/dist/{mod-Xj5P4s4d.d.ts → mod-B5COx5Wd.d.ts} +1 -1
- package/dist/{mod-CavhAgLw.d.cts → mod-BTxJG7ZH.d.cts} +1 -1
- package/dist/{mod-CtpF2BU5.d.ts → mod-CpyjFA9s.d.ts} +1 -1
- package/dist/{mod-BojP2Bw3.d.cts → mod-makPMVMg.d.cts} +1 -1
- package/dist/{mod-Dw-nPxQh.d.cts → mod-ss0wBUH1.d.cts} +2 -9
- package/dist/mod.cjs +5 -5
- package/dist/mod.d.cts +6 -6
- package/dist/mod.d.ts +6 -6
- package/dist/mod.js +5 -5
- package/dist/nodeinfo/client.test.mjs +337 -19
- package/dist/nodeinfo/handler.test.mjs +1 -1
- package/dist/nodeinfo/mod.cjs +1 -1
- package/dist/nodeinfo/mod.d.cts +1 -1
- package/dist/nodeinfo/mod.d.ts +1 -1
- package/dist/nodeinfo/mod.js +1 -1
- package/dist/otel/exporter.test.mjs +292 -30
- package/dist/otel/mod.cjs +36 -30
- package/dist/otel/mod.d.cts +13 -0
- package/dist/otel/mod.d.ts +13 -0
- package/dist/otel/mod.js +36 -30
- package/dist/{owner-B5c276hH.mjs → owner-2vfqhUJw.mjs} +3 -3
- package/dist/{portable-collection-BMZY9qT-.js → portable-collection-B7AQ_dW7.js} +1 -1
- package/dist/{portable-collection-CR5uwjws.cjs → portable-collection-Cs--KD5A.cjs} +1 -1
- package/dist/{portable-collection-C88tBiy0.mjs → portable-collection-DKJwGO8S.mjs} +2 -2
- package/dist/{portable-key-DxTXUpCK.mjs → portable-key-DXGepvtD.mjs} +1 -1
- package/dist/{proof-DNu-qP0i.mjs → proof-DW0FK6Cp.mjs} +3 -3
- package/dist/{send-CdCifQs4.mjs → send-CqsocpvF.mjs} +174 -76
- package/dist/sig/compound-proof-discovery.test.mjs +1 -1
- package/dist/sig/compound-proof-producer.test.mjs +1 -1
- package/dist/sig/compound-proof-verification.test.mjs +2 -2
- package/dist/sig/compound-proof.test.mjs +2 -2
- package/dist/sig/http.test.mjs +553 -2
- package/dist/sig/key.test.mjs +1 -1
- package/dist/sig/ld.test.mjs +1 -1
- package/dist/sig/mitra-receive-interop.test.mjs +4 -4
- package/dist/sig/mitra-send-interop.test.mjs +3 -3
- package/dist/sig/mod.cjs +2 -2
- package/dist/sig/mod.d.cts +2 -2
- package/dist/sig/mod.d.ts +2 -2
- package/dist/sig/mod.js +2 -2
- package/dist/sig/owner.test.mjs +1 -1
- package/dist/sig/portable-collection.test.mjs +1 -1
- package/dist/sig/portable-interop.test.mjs +3 -3
- package/dist/sig/portable-key.test.mjs +5 -5
- package/dist/sig/proof.test.mjs +4 -4
- package/dist/sig/tootik-request-interop.test.mjs +4 -4
- package/dist/sig/tootik-send-interop.test.mjs +3 -3
- package/dist/sig/verification.test.mjs +3 -3
- package/dist/{temporal-BuMu2bgP.mjs → temporal-Dm2E1-vs.mjs} +1 -1
- package/dist/testing/kv.test.mjs +1 -1
- package/dist/testing/mod.d.mts +26 -0
- package/dist/{types-_S2_MeLF.js → types-DUjZb4i6.js} +112 -63
- package/dist/{types-poIao40f.cjs → types-hKCCjii0.cjs} +111 -62
- package/dist/utils/docloader.test.mjs +344 -36
- package/dist/utils/kv-cache.test.mjs +1 -1
- package/dist/utils/mod.cjs +1 -1
- package/dist/utils/mod.d.cts +1 -1
- package/dist/utils/mod.d.ts +1 -1
- package/dist/utils/mod.js +1 -1
- package/package.json +7 -7
- package/dist/docloader-DQNRDYDb.mjs +0 -91
|
@@ -5,15 +5,30 @@ import { t as esm_default } from "../esm-BHXXx4Wx.mjs";
|
|
|
5
5
|
import { t as assertEquals } from "../assert_equals-Z6IcEbWU.mjs";
|
|
6
6
|
import "../std__assert-DvMDRPes.mjs";
|
|
7
7
|
import { t as assertRejects } from "../assert_rejects-CTptdYck.mjs";
|
|
8
|
-
import {
|
|
8
|
+
import { p as verifyRequest } from "../http-BSARoDPh.mjs";
|
|
9
9
|
import { i as rsaPrivateKey2 } from "../keys-DIJNHW-Y.mjs";
|
|
10
|
-
import {
|
|
10
|
+
import { n as getAuthenticatedDocumentLoader, t as createAuthenticatedDocumentLoader } from "../docloader-BhVNi06k.mjs";
|
|
11
11
|
import { configure, reset } from "@logtape/logtape";
|
|
12
12
|
import { FetchError, UrlError } from "@fedify/vocab-runtime";
|
|
13
13
|
import { mockDocumentLoader, test } from "@fedify/fixture";
|
|
14
|
-
import
|
|
14
|
+
import { openDirectFetch, openPublicFetch } from "@fedify/vocab-runtime/internal";
|
|
15
15
|
import { deepStrictEqual, ok, rejects } from "node:assert/strict";
|
|
16
16
|
//#region src/utils/docloader.test.ts
|
|
17
|
+
const identity = {
|
|
18
|
+
keyId: new URL("https://example.com/key2"),
|
|
19
|
+
privateKey: rsaPrivateKey2
|
|
20
|
+
};
|
|
21
|
+
const directDeps = { openHop: openDirectFetch };
|
|
22
|
+
function publicPolicyHop(lookup) {
|
|
23
|
+
return async (url) => {
|
|
24
|
+
await (await openPublicFetch(url, { lookup })).close();
|
|
25
|
+
return openDirectFetch(url);
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
const publicLookup = () => Promise.resolve([{
|
|
29
|
+
address: "8.8.8.8",
|
|
30
|
+
family: 4
|
|
31
|
+
}]);
|
|
17
32
|
test("getAuthenticatedDocumentLoader()", async (t) => {
|
|
18
33
|
esm_default.spyGlobal();
|
|
19
34
|
esm_default.get("begin:https://example.com/object", async (cl) => {
|
|
@@ -25,10 +40,7 @@ test("getAuthenticatedDocumentLoader()", async (t) => {
|
|
|
25
40
|
return new Response(JSON.stringify(v != null), { headers: { "Content-Type": "application/json" } });
|
|
26
41
|
});
|
|
27
42
|
await t.step("test", async () => {
|
|
28
|
-
assertEquals(await (
|
|
29
|
-
keyId: new URL("https://example.com/key2"),
|
|
30
|
-
privateKey: rsaPrivateKey2
|
|
31
|
-
}))("https://example.com/object"), {
|
|
43
|
+
assertEquals(await createAuthenticatedDocumentLoader(identity, {}, directDeps)("https://example.com/object"), {
|
|
32
44
|
contextUrl: null,
|
|
33
45
|
documentUrl: "https://example.com/object",
|
|
34
46
|
document: true
|
|
@@ -57,10 +69,10 @@ test("getAuthenticatedDocumentLoader()", async (t) => {
|
|
|
57
69
|
const index = Number(cl.url.split("/").at(-1));
|
|
58
70
|
return Response.redirect(`https://example.com/custom-too-many-redirects/${index + 1}`, 302);
|
|
59
71
|
});
|
|
60
|
-
const loader =
|
|
72
|
+
const loader = createAuthenticatedDocumentLoader({
|
|
61
73
|
keyId: new URL("https://example.com/key2"),
|
|
62
74
|
privateKey: rsaPrivateKey2
|
|
63
|
-
}, { maxRedirection: 1 });
|
|
75
|
+
}, { maxRedirection: 1 }, directDeps);
|
|
64
76
|
await assertRejects(() => loader("https://example.com/custom-too-many-redirects/0"), Error, "Too many redirections");
|
|
65
77
|
assertEquals(requestCount, 2);
|
|
66
78
|
esm_default.hardReset();
|
|
@@ -74,21 +86,16 @@ test("getAuthenticatedDocumentLoader() validates redirects", async (t) => {
|
|
|
74
86
|
privateRequestCount++;
|
|
75
87
|
return Response.json({ private: true });
|
|
76
88
|
});
|
|
89
|
+
const policyDeps = { openHop: publicPolicyHop(publicLookup) };
|
|
77
90
|
await t.step("deny public-to-private redirects", async () => {
|
|
78
|
-
const loader =
|
|
79
|
-
keyId: new URL("https://example.com/key2"),
|
|
80
|
-
privateKey: rsaPrivateKey2
|
|
81
|
-
});
|
|
91
|
+
const loader = createAuthenticatedDocumentLoader(identity, {}, policyDeps);
|
|
82
92
|
await assertRejects(() => loader("https://example.com/redirect-to-private"), UrlError);
|
|
83
93
|
assertEquals(privateRequestCount, 0);
|
|
84
94
|
});
|
|
85
95
|
esm_default.get("https://example.com/redirect-to-public", () => Response.redirect("https://www.example.com/document", 302));
|
|
86
96
|
esm_default.get("https://www.example.com/document", () => Response.json({ public: true }));
|
|
87
97
|
await t.step("allow public-to-public redirects", async () => {
|
|
88
|
-
assertEquals((await
|
|
89
|
-
keyId: new URL("https://example.com/key2"),
|
|
90
|
-
privateKey: rsaPrivateKey2
|
|
91
|
-
})("https://example.com/redirect-to-public")).document, { public: true });
|
|
98
|
+
assertEquals((await createAuthenticatedDocumentLoader(identity, {}, policyDeps)("https://example.com/redirect-to-public")).document, { public: true });
|
|
92
99
|
});
|
|
93
100
|
await t.step("allow private redirects when explicitly enabled", async () => {
|
|
94
101
|
assertEquals((await getAuthenticatedDocumentLoader({
|
|
@@ -118,10 +125,7 @@ test("getAuthenticatedDocumentLoader() cancellation", async (t) => {
|
|
|
118
125
|
});
|
|
119
126
|
}, 1e3);
|
|
120
127
|
}));
|
|
121
|
-
const loader =
|
|
122
|
-
keyId: new URL("https://example.com/key2"),
|
|
123
|
-
privateKey: rsaPrivateKey2
|
|
124
|
-
});
|
|
128
|
+
const loader = createAuthenticatedDocumentLoader(identity, {}, directDeps);
|
|
125
129
|
const controller = new AbortController();
|
|
126
130
|
const promise = loader("https://example.com/slow-object", { signal: controller.signal });
|
|
127
131
|
controller.abort();
|
|
@@ -132,10 +136,7 @@ test("getAuthenticatedDocumentLoader() cancellation", async (t) => {
|
|
|
132
136
|
}
|
|
133
137
|
});
|
|
134
138
|
await t.step("immediate cancellation", async () => {
|
|
135
|
-
const loader =
|
|
136
|
-
keyId: new URL("https://example.com/key2"),
|
|
137
|
-
privateKey: rsaPrivateKey2
|
|
138
|
-
});
|
|
139
|
+
const loader = createAuthenticatedDocumentLoader(identity, {}, directDeps);
|
|
139
140
|
const controller = new AbortController();
|
|
140
141
|
controller.abort();
|
|
141
142
|
await assertRejects(() => loader("https://example.com/object", { signal: controller.signal }), Error);
|
|
@@ -206,10 +207,10 @@ test("authenticated document loader suppresses HTTP failure logs", async () => {
|
|
|
206
207
|
});
|
|
207
208
|
test("authenticated document loader suppresses private URL logs", async () => {
|
|
208
209
|
const records = [];
|
|
209
|
-
const loader =
|
|
210
|
+
const loader = createAuthenticatedDocumentLoader({
|
|
210
211
|
keyId: new URL("https://example.com/key2"),
|
|
211
212
|
privateKey: rsaPrivateKey2
|
|
212
|
-
});
|
|
213
|
+
}, {}, { openHop: publicPolicyHop(publicLookup) });
|
|
213
214
|
await reset();
|
|
214
215
|
await configure({
|
|
215
216
|
sinks: { capture: (record) => {
|
|
@@ -246,21 +247,17 @@ test("authenticated document loader suppresses private URL logs", async () => {
|
|
|
246
247
|
await reset();
|
|
247
248
|
}
|
|
248
249
|
});
|
|
249
|
-
test("getAuthenticatedDocumentLoader() logs DNS failures as such",
|
|
250
|
-
const loader = getAuthenticatedDocumentLoader({
|
|
251
|
-
keyId: new URL("https://example.com/key2"),
|
|
252
|
-
privateKey: rsaPrivateKey2
|
|
253
|
-
});
|
|
250
|
+
test("getAuthenticatedDocumentLoader() logs DNS failures as such", async (t) => {
|
|
254
251
|
for (const result of [
|
|
255
252
|
"throws",
|
|
256
253
|
"empty",
|
|
257
254
|
"private"
|
|
258
255
|
]) await t.step(result, async () => {
|
|
259
|
-
const
|
|
260
|
-
dns.lookup = (() => result === "throws" ? Promise.reject(/* @__PURE__ */ new Error("Resolver unavailable")) : Promise.resolve(result === "empty" ? [] : [{
|
|
256
|
+
const lookup = () => result === "throws" ? Promise.reject(/* @__PURE__ */ new Error("Resolver unavailable")) : Promise.resolve(result === "empty" ? [] : [{
|
|
261
257
|
address: "127.0.0.1",
|
|
262
258
|
family: 4
|
|
263
|
-
}])
|
|
259
|
+
}]);
|
|
260
|
+
const loader = createAuthenticatedDocumentLoader(identity, {}, { openHop: (url) => openPublicFetch(url, { lookup }) });
|
|
264
261
|
const records = [];
|
|
265
262
|
await configure({
|
|
266
263
|
sinks: { buffer: (record) => records.push(record) },
|
|
@@ -294,7 +291,6 @@ test("getAuthenticatedDocumentLoader() logs DNS failures as such", { ignore: "De
|
|
|
294
291
|
assertEquals(records[0].properties.error, error);
|
|
295
292
|
} finally {
|
|
296
293
|
await reset();
|
|
297
|
-
dns.lookup = originalLookup;
|
|
298
294
|
}
|
|
299
295
|
});
|
|
300
296
|
});
|
|
@@ -630,6 +626,211 @@ test("getAuthenticatedDocumentLoader() times out", async (t) => {
|
|
|
630
626
|
}
|
|
631
627
|
});
|
|
632
628
|
});
|
|
629
|
+
test("getAuthenticatedDocumentLoader() closes each hop before opening the next", async (t) => {
|
|
630
|
+
await t.step("redirect", async () => {
|
|
631
|
+
const start = "https://example.com/lifecycle/start";
|
|
632
|
+
const next = "https://example.com/lifecycle/document";
|
|
633
|
+
const hops = recordHops((request) => request.url === start ? {
|
|
634
|
+
status: 302,
|
|
635
|
+
headers: { Location: next },
|
|
636
|
+
body: "Found"
|
|
637
|
+
} : {
|
|
638
|
+
headers: { "Content-Type": "application/activity+json" },
|
|
639
|
+
body: "{\"done\":true}"
|
|
640
|
+
});
|
|
641
|
+
deepStrictEqual(await createAuthenticatedDocumentLoader(identity, {}, hops)(start), {
|
|
642
|
+
contextUrl: null,
|
|
643
|
+
document: { done: true },
|
|
644
|
+
documentUrl: next
|
|
645
|
+
});
|
|
646
|
+
deepStrictEqual(hops.events, [
|
|
647
|
+
`open ${start}`,
|
|
648
|
+
`fetch #1 ${start}`,
|
|
649
|
+
"cancel #1",
|
|
650
|
+
`close ${start}`,
|
|
651
|
+
`open ${next}`,
|
|
652
|
+
`fetch #2 ${next}`,
|
|
653
|
+
"consumed #2",
|
|
654
|
+
`close ${next}`
|
|
655
|
+
]);
|
|
656
|
+
hops.assertInvariants();
|
|
657
|
+
});
|
|
658
|
+
await t.step("Link header alternate", async () => {
|
|
659
|
+
const start = "https://example.com/lifecycle/page";
|
|
660
|
+
const next = "https://example.com/lifecycle/alternate";
|
|
661
|
+
const hops = recordHops((request) => request.url === start ? {
|
|
662
|
+
headers: {
|
|
663
|
+
"Content-Type": "text/plain",
|
|
664
|
+
Link: `<${next}>; rel="alternate"; type="application/activity+json"`
|
|
665
|
+
},
|
|
666
|
+
body: "not JSON"
|
|
667
|
+
} : {
|
|
668
|
+
headers: { "Content-Type": "application/activity+json" },
|
|
669
|
+
body: "{\"done\":true}"
|
|
670
|
+
});
|
|
671
|
+
deepStrictEqual((await createAuthenticatedDocumentLoader(identity, {}, hops)(start)).document, { done: true });
|
|
672
|
+
deepStrictEqual(hops.events, [
|
|
673
|
+
`open ${start}`,
|
|
674
|
+
`fetch #1 ${start}`,
|
|
675
|
+
"cancel #1",
|
|
676
|
+
`close ${start}`,
|
|
677
|
+
`open ${next}`,
|
|
678
|
+
`fetch #2 ${next}`,
|
|
679
|
+
"consumed #2",
|
|
680
|
+
`close ${next}`
|
|
681
|
+
]);
|
|
682
|
+
hops.assertInvariants();
|
|
683
|
+
});
|
|
684
|
+
await t.step("one lookup per hop", async () => {
|
|
685
|
+
const start = "https://first.example/start";
|
|
686
|
+
const next = "https://second.example/document";
|
|
687
|
+
const lookups = [];
|
|
688
|
+
const lookup = (hostname) => {
|
|
689
|
+
lookups.push(hostname);
|
|
690
|
+
return Promise.resolve([{
|
|
691
|
+
address: "8.8.8.8",
|
|
692
|
+
family: 4
|
|
693
|
+
}]);
|
|
694
|
+
};
|
|
695
|
+
await withFetchStub((request) => new URL(request.url).pathname === "/start" ? Response.redirect(next, 302) : Response.json({ done: true }), async (fetched) => {
|
|
696
|
+
deepStrictEqual((await createAuthenticatedDocumentLoader(identity, {}, { openHop: (url) => openPublicFetch(url, { lookup }) })(start)).document, { done: true });
|
|
697
|
+
deepStrictEqual(lookups, ["first.example", "second.example"]);
|
|
698
|
+
deepStrictEqual(fetched.length, 2);
|
|
699
|
+
});
|
|
700
|
+
});
|
|
701
|
+
});
|
|
702
|
+
async function captureLogs(fn) {
|
|
703
|
+
const records = [];
|
|
704
|
+
await configure({
|
|
705
|
+
sinks: { buffer: (record) => records.push(record) },
|
|
706
|
+
loggers: [{
|
|
707
|
+
category: "fedify",
|
|
708
|
+
sinks: ["buffer"],
|
|
709
|
+
lowestLevel: "debug"
|
|
710
|
+
}, {
|
|
711
|
+
category: ["logtape", "meta"],
|
|
712
|
+
sinks: []
|
|
713
|
+
}],
|
|
714
|
+
reset: true
|
|
715
|
+
});
|
|
716
|
+
try {
|
|
717
|
+
await fn();
|
|
718
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
719
|
+
} finally {
|
|
720
|
+
await reset();
|
|
721
|
+
}
|
|
722
|
+
return records;
|
|
723
|
+
}
|
|
724
|
+
function getCancelFailures(records) {
|
|
725
|
+
return records.filter((r) => r.rawMessage === "Failed to cancel the response body from {url}: {error}").map((r) => r.properties.error.message);
|
|
726
|
+
}
|
|
727
|
+
const linkAlternate = (next) => ({
|
|
728
|
+
headers: {
|
|
729
|
+
"Content-Type": "text/plain",
|
|
730
|
+
Link: `<${next}>; rel="alternate"; type="application/activity+json"`
|
|
731
|
+
},
|
|
732
|
+
body: "not JSON"
|
|
733
|
+
});
|
|
734
|
+
const jsonDocument = {
|
|
735
|
+
headers: { "Content-Type": "application/activity+json" },
|
|
736
|
+
body: "{\"done\":true}"
|
|
737
|
+
};
|
|
738
|
+
test("getAuthenticatedDocumentLoader() logs bodies failing to cancel and loads the next hop", async (t) => {
|
|
739
|
+
for (const kind of ["redirect", "Link header alternate"]) await t.step(kind, async () => {
|
|
740
|
+
const start = "https://example.com/lifecycle/cancel-failure";
|
|
741
|
+
const next = "https://example.com/lifecycle/cancel-failure-next";
|
|
742
|
+
const hops = recordHops((request) => request.url === start ? {
|
|
743
|
+
...kind === "redirect" ? {
|
|
744
|
+
status: 302,
|
|
745
|
+
headers: { Location: next },
|
|
746
|
+
body: "Found"
|
|
747
|
+
} : linkAlternate(next),
|
|
748
|
+
failCancel: true
|
|
749
|
+
} : jsonDocument);
|
|
750
|
+
const loader = createAuthenticatedDocumentLoader(identity, {}, hops);
|
|
751
|
+
let document;
|
|
752
|
+
const records = await captureLogs(async () => {
|
|
753
|
+
document = (await loader(start)).document;
|
|
754
|
+
});
|
|
755
|
+
deepStrictEqual(document, { done: true });
|
|
756
|
+
deepStrictEqual(hops.events, [
|
|
757
|
+
`open ${start}`,
|
|
758
|
+
`fetch #1 ${start}`,
|
|
759
|
+
"cancel #1",
|
|
760
|
+
`close ${start}`,
|
|
761
|
+
`open ${next}`,
|
|
762
|
+
`fetch #2 ${next}`,
|
|
763
|
+
"consumed #2",
|
|
764
|
+
`close ${next}`
|
|
765
|
+
]);
|
|
766
|
+
deepStrictEqual(getCancelFailures(records), ["cancel #1 failed"]);
|
|
767
|
+
hops.assertInvariants();
|
|
768
|
+
});
|
|
769
|
+
});
|
|
770
|
+
test("getAuthenticatedDocumentLoader() is not blocked by a body cancellation that never settles", async (t) => {
|
|
771
|
+
await t.step("redirect and Link header alternate", async () => {
|
|
772
|
+
const start = "https://example.com/lifecycle/hanging-start";
|
|
773
|
+
const page = "https://example.com/lifecycle/hanging-page";
|
|
774
|
+
const next = "https://example.com/lifecycle/hanging-document";
|
|
775
|
+
const hops = recordHops((request) => ({
|
|
776
|
+
...request.url === start ? {
|
|
777
|
+
status: 302,
|
|
778
|
+
headers: { Location: page },
|
|
779
|
+
body: "Found"
|
|
780
|
+
} : request.url === page ? linkAlternate(next) : jsonDocument,
|
|
781
|
+
hangCancel: true
|
|
782
|
+
}));
|
|
783
|
+
deepStrictEqual((await createAuthenticatedDocumentLoader(identity, {}, hops)(start)).document, { done: true });
|
|
784
|
+
deepStrictEqual(hops.events, [
|
|
785
|
+
`open ${start}`,
|
|
786
|
+
`fetch #1 ${start}`,
|
|
787
|
+
"cancel #1",
|
|
788
|
+
`close ${start}`,
|
|
789
|
+
`open ${page}`,
|
|
790
|
+
`fetch #2 ${page}`,
|
|
791
|
+
"cancel #2",
|
|
792
|
+
`close ${page}`,
|
|
793
|
+
`open ${next}`,
|
|
794
|
+
`fetch #3 ${next}`,
|
|
795
|
+
"consumed #3",
|
|
796
|
+
`close ${next}`
|
|
797
|
+
]);
|
|
798
|
+
hops.assertInvariants();
|
|
799
|
+
});
|
|
800
|
+
await t.step("not found", async () => {
|
|
801
|
+
const url = "https://example.com/lifecycle/hanging-missing";
|
|
802
|
+
const hops = recordHops(() => ({
|
|
803
|
+
status: 404,
|
|
804
|
+
body: "Not Found",
|
|
805
|
+
hangCancel: true
|
|
806
|
+
}));
|
|
807
|
+
await rejects(createAuthenticatedDocumentLoader(identity, {}, hops)(url), FetchError);
|
|
808
|
+
deepStrictEqual(hops.events.slice(0, 2), [`open ${url}`, `fetch #1 ${url}`]);
|
|
809
|
+
deepStrictEqual(hops.events.at(-1), `close ${url}`);
|
|
810
|
+
hops.assertInvariants();
|
|
811
|
+
});
|
|
812
|
+
});
|
|
813
|
+
test("getAuthenticatedDocumentLoader() completes with responses cloned by fetch-mock", async () => {
|
|
814
|
+
esm_default.mockGlobal();
|
|
815
|
+
try {
|
|
816
|
+
const base = "https://example.com/tee/";
|
|
817
|
+
esm_default.get(`${base}start`, () => new Response("Found", {
|
|
818
|
+
status: 302,
|
|
819
|
+
headers: { Location: `${base}page` }
|
|
820
|
+
}));
|
|
821
|
+
esm_default.get(`${base}page`, () => new Response("not JSON", { headers: {
|
|
822
|
+
"Content-Type": "text/plain",
|
|
823
|
+
Link: `<${base}document>; rel="alternate"; type="application/activity+json"`
|
|
824
|
+
} }));
|
|
825
|
+
esm_default.get(`${base}document`, () => new Response("{\"done\":true}", { headers: { "Content-Type": "application/activity+json" } }));
|
|
826
|
+
esm_default.get(`${base}missing`, () => new Response("Not Found", { status: 404 }));
|
|
827
|
+
const loader = createAuthenticatedDocumentLoader(identity, {}, directDeps);
|
|
828
|
+
deepStrictEqual((await loader(`${base}start`)).document, { done: true });
|
|
829
|
+
await rejects(loader(`${base}missing`), FetchError);
|
|
830
|
+
} finally {
|
|
831
|
+
esm_default.hardReset();
|
|
832
|
+
}
|
|
833
|
+
});
|
|
633
834
|
const unsupportedUrls = [
|
|
634
835
|
"ftp://example.com/collection",
|
|
635
836
|
"file:///collection",
|
|
@@ -764,5 +965,112 @@ for (const url of unsupportedUrls) {
|
|
|
764
965
|
});
|
|
765
966
|
});
|
|
766
967
|
}
|
|
968
|
+
function createRecordedResponse(events, index, init, cancelSuffix = () => "") {
|
|
969
|
+
const bytes = new TextEncoder().encode(init.body ?? "");
|
|
970
|
+
const chunkSize = init.chunkSize ?? 256;
|
|
971
|
+
let offset = 0;
|
|
972
|
+
let settled = false;
|
|
973
|
+
const body = new ReadableStream({
|
|
974
|
+
pull(controller) {
|
|
975
|
+
if (offset >= bytes.length) {
|
|
976
|
+
settled = true;
|
|
977
|
+
events.push(`consumed #${index}`);
|
|
978
|
+
controller.close();
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
controller.enqueue(bytes.slice(offset, offset + chunkSize));
|
|
982
|
+
offset += chunkSize;
|
|
983
|
+
},
|
|
984
|
+
cancel() {
|
|
985
|
+
settled = true;
|
|
986
|
+
events.push(`cancel #${index}${cancelSuffix()}`);
|
|
987
|
+
if (init.failCancel) throw new Error(`cancel #${index} failed`);
|
|
988
|
+
if (init.hangCancel) return new Promise(() => {});
|
|
989
|
+
}
|
|
990
|
+
}, { highWaterMark: 0 });
|
|
991
|
+
return {
|
|
992
|
+
response: new Response(body, {
|
|
993
|
+
status: init.status ?? 200,
|
|
994
|
+
headers: init.headers
|
|
995
|
+
}),
|
|
996
|
+
settled: () => settled
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
function recordHops(respond) {
|
|
1000
|
+
const events = [];
|
|
1001
|
+
const requests = [];
|
|
1002
|
+
const unsettledAtClose = [];
|
|
1003
|
+
let opens = 0;
|
|
1004
|
+
let closes = 0;
|
|
1005
|
+
let fetches = 0;
|
|
1006
|
+
return {
|
|
1007
|
+
events,
|
|
1008
|
+
requests,
|
|
1009
|
+
openHop(url) {
|
|
1010
|
+
opens++;
|
|
1011
|
+
events.push(`open ${url}`);
|
|
1012
|
+
const tracked = [];
|
|
1013
|
+
let closed = false;
|
|
1014
|
+
let closing = false;
|
|
1015
|
+
return Promise.resolve({
|
|
1016
|
+
url: new URL(url),
|
|
1017
|
+
async fetch(input, init) {
|
|
1018
|
+
if (closed) throw new TypeError("The session is closed.");
|
|
1019
|
+
const request = new Request(input instanceof Request ? input.clone() : input, init);
|
|
1020
|
+
const index = ++fetches;
|
|
1021
|
+
requests.push(request);
|
|
1022
|
+
events.push(`fetch #${index} ${request.url}`);
|
|
1023
|
+
const recorded = createRecordedResponse(events, index, await respond(request, index), () => closing ? " by close" : "");
|
|
1024
|
+
tracked.push({
|
|
1025
|
+
...recorded,
|
|
1026
|
+
index
|
|
1027
|
+
});
|
|
1028
|
+
return recorded.response;
|
|
1029
|
+
},
|
|
1030
|
+
close() {
|
|
1031
|
+
if (closed) return Promise.resolve();
|
|
1032
|
+
closed = true;
|
|
1033
|
+
closes++;
|
|
1034
|
+
closing = true;
|
|
1035
|
+
for (const { response, settled, index } of tracked) {
|
|
1036
|
+
if (settled()) continue;
|
|
1037
|
+
if (response.body?.locked) unsettledAtClose.push(index);
|
|
1038
|
+
else response.body?.cancel().catch(() => {});
|
|
1039
|
+
}
|
|
1040
|
+
events.push(`close ${url}`);
|
|
1041
|
+
return Promise.resolve();
|
|
1042
|
+
}
|
|
1043
|
+
});
|
|
1044
|
+
},
|
|
1045
|
+
assertInvariants() {
|
|
1046
|
+
deepStrictEqual(opens, closes);
|
|
1047
|
+
deepStrictEqual(unsettledAtClose, []);
|
|
1048
|
+
}
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
test("authenticated loader closes a hop that opens after its timeout", async () => {
|
|
1052
|
+
const url = "https://example.com/late-open";
|
|
1053
|
+
let release = () => {};
|
|
1054
|
+
const opened = new Promise((resolve) => release = resolve);
|
|
1055
|
+
let closed = () => {};
|
|
1056
|
+
const closure = new Promise((resolve) => closed = resolve);
|
|
1057
|
+
const hops = recordHops(() => jsonDocument);
|
|
1058
|
+
await rejects(createAuthenticatedDocumentLoader(identity, { timeout: 20 }, { async openHop(url) {
|
|
1059
|
+
await opened;
|
|
1060
|
+
const session = await hops.openHop(url);
|
|
1061
|
+
return {
|
|
1062
|
+
...session,
|
|
1063
|
+
async close() {
|
|
1064
|
+
await session.close();
|
|
1065
|
+
closed();
|
|
1066
|
+
}
|
|
1067
|
+
};
|
|
1068
|
+
} })(url), (error) => assertTimeoutFetchError(error, url));
|
|
1069
|
+
release();
|
|
1070
|
+
await closure;
|
|
1071
|
+
deepStrictEqual(hops.requests, []);
|
|
1072
|
+
deepStrictEqual(hops.events, [`open ${url}`, `close ${url}`]);
|
|
1073
|
+
hops.assertInvariants();
|
|
1074
|
+
});
|
|
767
1075
|
//#endregion
|
|
768
1076
|
export {};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Temporal } from "temporal-polyfill";
|
|
2
2
|
import { URLPattern } from "urlpattern-polyfill";
|
|
3
3
|
globalThis.addEventListener = () => {};
|
|
4
|
-
import { n as kvCache, t as MockKvStore } from "../kv-cache-
|
|
4
|
+
import { n as kvCache, t as MockKvStore } from "../kv-cache-Cxn7pNMx.mjs";
|
|
5
5
|
import { preloadedContexts } from "@fedify/vocab-runtime";
|
|
6
6
|
import { deepStrictEqual, throws } from "node:assert";
|
|
7
7
|
import { createTestMeterProvider, mockDocumentLoader, test } from "@fedify/fixture";
|
package/dist/utils/mod.cjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
const { URLPattern } = require("urlpattern-polyfill");
|
|
2
2
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
3
|
-
const require_kv_cache = require("../kv-cache-
|
|
3
|
+
const require_kv_cache = require("../kv-cache-BpGIONmd.cjs");
|
|
4
4
|
exports.getAuthenticatedDocumentLoader = require_kv_cache.getAuthenticatedDocumentLoader;
|
|
5
5
|
exports.kvCache = require_kv_cache.kvCache;
|
package/dist/utils/mod.d.cts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
/// <reference lib="esnext.temporal" />
|
|
2
|
-
import { n as getAuthenticatedDocumentLoader, t as kvCache } from "../mod-
|
|
2
|
+
import { n as getAuthenticatedDocumentLoader, t as kvCache } from "../mod-ss0wBUH1.cjs";
|
|
3
3
|
export { getAuthenticatedDocumentLoader, kvCache };
|
package/dist/utils/mod.d.ts
CHANGED
package/dist/utils/mod.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import { Temporal } from "temporal-polyfill";
|
|
2
2
|
import { URLPattern } from "urlpattern-polyfill";
|
|
3
|
-
import { n as getAuthenticatedDocumentLoader, t as kvCache } from "../kv-cache-
|
|
3
|
+
import { n as getAuthenticatedDocumentLoader, t as kvCache } from "../kv-cache-CNfNI_95.js";
|
|
4
4
|
export { getAuthenticatedDocumentLoader, kvCache };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fedify/fedify",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.6-dev.2628+2a78382f",
|
|
4
4
|
"description": "An ActivityPub server framework",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ActivityPub",
|
|
@@ -137,7 +137,7 @@
|
|
|
137
137
|
},
|
|
138
138
|
"dependencies": {
|
|
139
139
|
"@logtape/logtape": "^2.2.0",
|
|
140
|
-
"@opentelemetry/api": "
|
|
140
|
+
"@opentelemetry/api": "~1.9.1",
|
|
141
141
|
"@opentelemetry/core": "^2.7.1",
|
|
142
142
|
"@opentelemetry/sdk-metrics": "2.7.1",
|
|
143
143
|
"@opentelemetry/sdk-trace-base": "^2.7.1",
|
|
@@ -151,10 +151,10 @@
|
|
|
151
151
|
"structured-field-values": "^2.0.4",
|
|
152
152
|
"temporal-polyfill": "^1.0.1",
|
|
153
153
|
"urlpattern-polyfill": "^10.1.0",
|
|
154
|
-
"@fedify/uri-template": "2.4.
|
|
155
|
-
"@fedify/vocab": "2.4.
|
|
156
|
-
"@fedify/
|
|
157
|
-
"@fedify/
|
|
154
|
+
"@fedify/uri-template": "2.4.6-dev.2628+2a78382f",
|
|
155
|
+
"@fedify/vocab": "2.4.6-dev.2628+2a78382f",
|
|
156
|
+
"@fedify/webfinger": "2.4.6-dev.2628+2a78382f",
|
|
157
|
+
"@fedify/vocab-runtime": "2.4.6-dev.2628+2a78382f"
|
|
158
158
|
},
|
|
159
159
|
"devDependencies": {
|
|
160
160
|
"@std/assert": "npm:@jsr/std__assert@^0.226.0",
|
|
@@ -167,7 +167,7 @@
|
|
|
167
167
|
"typescript": "^6.0.0",
|
|
168
168
|
"wrangler": "^4.17.0",
|
|
169
169
|
"@fedify/fixture": "2.0.0",
|
|
170
|
-
"@fedify/vocab-tools": "^2.4.
|
|
170
|
+
"@fedify/vocab-tools": "^2.4.6-dev.2628+2a78382f"
|
|
171
171
|
},
|
|
172
172
|
"scripts": {
|
|
173
173
|
"build:self": "tsdown",
|
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
import "temporal-polyfill";
|
|
2
|
-
import "urlpattern-polyfill";
|
|
3
|
-
globalThis.addEventListener = () => {};
|
|
4
|
-
import { T as validateCryptoKey } from "./ld-PJLGA2MJ.mjs";
|
|
5
|
-
import { n as doubleKnock } from "./http-D7L2pqLT.mjs";
|
|
6
|
-
import { getLogger } from "@logtape/logtape";
|
|
7
|
-
import { FetchError, UrlError, createActivityPubRequest, getRemoteDocument, logRequest, resolveDocumentLoaderTimeout, validatePublicUrl, withDocumentLoaderTimeout } from "@fedify/vocab-runtime";
|
|
8
|
-
import { curry } from "es-toolkit";
|
|
9
|
-
//#region src/utils/docloader.ts
|
|
10
|
-
const logger = getLogger([
|
|
11
|
-
"fedify",
|
|
12
|
-
"utils",
|
|
13
|
-
"docloader"
|
|
14
|
-
]);
|
|
15
|
-
const DEFAULT_MAX_REDIRECTION = 20;
|
|
16
|
-
/**
|
|
17
|
-
* Gets an authenticated {@link DocumentLoader} for the given identity.
|
|
18
|
-
* Note that an authenticated document loader intentionally does not cache
|
|
19
|
-
* the fetched documents.
|
|
20
|
-
* At most 20 HTTP redirects and alternate document links are followed in total
|
|
21
|
-
* per call. Revisiting a URL within that chain throws a {@link FetchError}.
|
|
22
|
-
* Each call, including its double-knocking retries, times out after
|
|
23
|
-
* 10 seconds by default; see {@link DocumentLoaderFactoryOptions.timeout}.
|
|
24
|
-
* @param identity The identity to get the document loader for.
|
|
25
|
-
* The actor's key pair.
|
|
26
|
-
* @param options The options for the document loader.
|
|
27
|
-
* @returns The authenticated document loader.
|
|
28
|
-
* @throws {TypeError} If the key is invalid or unsupported.
|
|
29
|
-
* @throws {RangeError} If the `timeout` option is invalid.
|
|
30
|
-
* @since 0.4.0
|
|
31
|
-
*/
|
|
32
|
-
function getAuthenticatedDocumentLoader(identity, { allowPrivateAddress, maxRedirection, userAgent, specDeterminer, timeout, tracerProvider } = {}) {
|
|
33
|
-
validateCryptoKey(identity.privateKey);
|
|
34
|
-
const resolvedTimeout = resolveDocumentLoaderTimeout(timeout);
|
|
35
|
-
async function load(url, options, redirected = 0, visited = /* @__PURE__ */ new Set()) {
|
|
36
|
-
options?.signal?.throwIfAborted();
|
|
37
|
-
let currentUrl = new URL(url).href;
|
|
38
|
-
await validateUrl(currentUrl, options);
|
|
39
|
-
visited.add(currentUrl);
|
|
40
|
-
const originalRequest = createActivityPubRequest(currentUrl, { userAgent });
|
|
41
|
-
function follow(nextUrl) {
|
|
42
|
-
options?.signal?.throwIfAborted();
|
|
43
|
-
if (redirected >= DEFAULT_MAX_REDIRECTION) throw new FetchError(currentUrl, `Too many redirections (${redirected + 1})`);
|
|
44
|
-
if (visited.has(nextUrl)) throw new FetchError(currentUrl, `Redirect loop detected: ${nextUrl}`);
|
|
45
|
-
redirected++;
|
|
46
|
-
}
|
|
47
|
-
const response = await doubleKnock(originalRequest, identity, {
|
|
48
|
-
maxRedirection,
|
|
49
|
-
specDeterminer,
|
|
50
|
-
log: curry(logRequest)(logger),
|
|
51
|
-
tracerProvider,
|
|
52
|
-
signal: options?.signal,
|
|
53
|
-
validateRedirect: async (nextUrl) => {
|
|
54
|
-
follow(nextUrl);
|
|
55
|
-
await validateUrl(nextUrl, options);
|
|
56
|
-
visited.add(nextUrl);
|
|
57
|
-
currentUrl = nextUrl;
|
|
58
|
-
}
|
|
59
|
-
});
|
|
60
|
-
options?.signal?.throwIfAborted();
|
|
61
|
-
return getRemoteDocument(currentUrl, response, (alternateUrl) => {
|
|
62
|
-
follow(alternateUrl);
|
|
63
|
-
return load(alternateUrl, options, redirected, visited);
|
|
64
|
-
}, options);
|
|
65
|
-
}
|
|
66
|
-
async function validateUrl(url, options) {
|
|
67
|
-
const parsedUrl = new URL(url);
|
|
68
|
-
const protocol = parsedUrl.protocol;
|
|
69
|
-
if (protocol !== "http:" && protocol !== "https:") throw new UrlError(`Unsupported protocol: ${protocol}`);
|
|
70
|
-
if (parsedUrl.username !== "" || parsedUrl.password !== "") throw new UrlError("URLs with credentials are not allowed");
|
|
71
|
-
if (!allowPrivateAddress) {
|
|
72
|
-
try {
|
|
73
|
-
await validatePublicUrl(url);
|
|
74
|
-
} catch (error) {
|
|
75
|
-
if (error instanceof UrlError) if (error.reason === "dns") logger.debug("DNS lookup failed for {url}", {
|
|
76
|
-
url,
|
|
77
|
-
error
|
|
78
|
-
});
|
|
79
|
-
else logger[options?.suppressError ? "warn" : "error"]("Disallowed private URL: {url}", {
|
|
80
|
-
url,
|
|
81
|
-
error
|
|
82
|
-
});
|
|
83
|
-
throw error;
|
|
84
|
-
}
|
|
85
|
-
options?.signal?.throwIfAborted();
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
return withDocumentLoaderTimeout((url, options) => load(url, options), resolvedTimeout);
|
|
89
|
-
}
|
|
90
|
-
//#endregion
|
|
91
|
-
export { getAuthenticatedDocumentLoader as t };
|