@fedify/fedify 2.4.0-pr.936.41 → 2.5.0-dev.2271
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -39
- package/dist/{assert-CJ1KXcvv.mjs → assert-D1Eafmd_.mjs} +1 -1
- package/dist/{assert_equals-BV7oLMxm.mjs → assert_equals-Z6IcEbWU.mjs} +2 -28
- package/dist/assert_exists-cffSpJ7X.mjs +28 -0
- package/dist/assert_false-CPdRnc3E.mjs +23 -0
- package/dist/assert_greater_or_equal-BlafOF4-.mjs +31 -0
- package/dist/{assert_instance_of-Q9KTBnrf.mjs → assert_instance_of-mrXWWNY-.mjs} +1 -1
- package/dist/{assert_not_equals-EPJPoxuf.mjs → assert_not_equals-CpPK2t89.mjs} +2 -1
- package/dist/assert_rejects-CTptdYck.mjs +39 -0
- package/dist/{assert_strict_equals-Ec20AUwa.mjs → assert_strict_equals-CYvBmkpC.mjs} +2 -1
- package/dist/{assert_throws-NiFfqbnH.mjs → assert_throws-BYITOvPO.mjs} +2 -1
- package/dist/assertion_error-B-cx0fFA.mjs +31 -0
- package/dist/audience-CHQ-FlLD.mjs +95 -0
- package/dist/{builder-DonuxRl_.mjs → builder-Bda6LK7A.mjs} +169 -14
- package/dist/{circuit-breaker-CBGyz5F7.mjs → circuit-breaker-XJ53md4S.mjs} +224 -17
- package/dist/circuit-breaker-test-utils-DW6YC_Pe.mjs +14 -0
- package/dist/{client-CjF5IYQS.mjs → client-CEnNz8O7.mjs} +144 -8
- package/dist/{codec-BBga-FWI.mjs → codec-C6rTrpIp.mjs} +26 -3
- package/dist/{collection-BIhlldqK.mjs → collection-CnnP1UAv.mjs} +1 -1
- package/dist/compat/mod.cjs +1 -1
- package/dist/compat/mod.d.cts +1 -1
- package/dist/compat/mod.d.ts +1 -1
- package/dist/compat/mod.js +1 -1
- package/dist/compat/outgoing-jsonld.test.mjs +71 -3
- package/dist/compat/public-audience.test.mjs +14 -5
- package/dist/compat/secured-document.test.d.mts +2 -0
- package/dist/compat/secured-document.test.mjs +58 -0
- package/dist/compat/transformers.test.mjs +5 -5
- package/dist/compound-proof-B-9mXxFW.mjs +989 -0
- package/dist/{context-Zii1f5xS.d.ts → context-7NyiqIcg.d.ts} +1600 -25
- package/dist/{context-BJXxPElm.mjs → context-8Tu9kea0.mjs} +21 -63
- package/dist/{context-lxX6I7as.d.cts → context-tsYPJ2Ew.d.cts} +1600 -25
- package/dist/{deno-BHzKn9Ig.mjs → deno-oR5aJKpU.mjs} +1 -1
- package/dist/dist-DJQM4u7i.mjs +189 -0
- package/dist/docloader-DBoYbvYW.mjs +87 -0
- package/dist/{esm-yYQfPhIY.mjs → esm-BHXXx4Wx.mjs} +13 -6
- package/dist/federation/audience.test.d.mts +2 -0
- package/dist/federation/audience.test.mjs +130 -0
- package/dist/federation/builder.test.mjs +54 -5
- package/dist/federation/circuit-breaker.test.mjs +838 -7
- package/dist/federation/collection.test.mjs +3 -3
- package/dist/federation/compound-fanout.test.d.mts +2 -0
- package/dist/federation/compound-fanout.test.mjs +228 -0
- package/dist/federation/handler-compound-proof.test.d.mts +2 -0
- package/dist/federation/handler-compound-proof.test.mjs +384 -0
- package/dist/federation/handler.test.mjs +385 -15
- package/dist/federation/hashlink-media.test.d.mts +2 -0
- package/dist/federation/hashlink-media.test.mjs +422 -0
- package/dist/federation/idempotency.test.mjs +6 -6
- package/dist/federation/inbox-report.test.d.mts +2 -0
- package/dist/federation/inbox-report.test.mjs +475 -0
- package/dist/federation/inbox.test.mjs +3 -3
- package/dist/federation/keycache.test.mjs +215 -14
- package/dist/federation/kv.test.mjs +3 -3
- package/dist/federation/metrics.test.mjs +5 -5
- package/dist/federation/middleware.test.mjs +967 -35
- package/dist/federation/mod.cjs +18 -261
- package/dist/federation/mod.d.cts +5 -5
- package/dist/federation/mod.d.ts +5 -5
- package/dist/federation/mod.js +279 -1
- package/dist/federation/mq.test.mjs +123 -47
- package/dist/federation/negotiation.test.mjs +4 -4
- package/dist/federation/outgoing-proof.test.d.mts +2 -0
- package/dist/federation/outgoing-proof.test.mjs +931 -0
- package/dist/federation/portable-actor.test.d.mts +2 -0
- package/dist/federation/portable-actor.test.mjs +496 -0
- package/dist/federation/portable-chain.test.d.mts +2 -0
- package/dist/federation/portable-chain.test.mjs +370 -0
- package/dist/federation/portable-collection.test.d.mts +2 -0
- package/dist/federation/portable-collection.test.mjs +832 -0
- package/dist/federation/portable-delivery.test.d.mts +2 -0
- package/dist/federation/portable-delivery.test.mjs +746 -0
- package/dist/federation/portable-gateway-keys.test.d.mts +2 -0
- package/dist/federation/portable-gateway-keys.test.mjs +686 -0
- package/dist/federation/portable-inbox.test.d.mts +2 -0
- package/dist/federation/portable-inbox.test.mjs +1535 -0
- package/dist/federation/portable-parse-uri.test.d.mts +2 -0
- package/dist/federation/portable-parse-uri.test.mjs +348 -0
- package/dist/federation/portable-route-activity.test.d.mts +2 -0
- package/dist/federation/portable-route-activity.test.mjs +259 -0
- package/dist/federation/portable.test.d.mts +2 -0
- package/dist/federation/portable.test.mjs +1072 -0
- package/dist/federation/retry.test.mjs +4 -3
- package/dist/federation/router.test.mjs +9 -159
- package/dist/federation/send.test.mjs +245 -27
- package/dist/federation/tasks/codec.test.mjs +4 -4
- package/dist/federation/tasks/enqueue.test.mjs +42 -4
- package/dist/federation/tasks/tasks.test.mjs +7 -6
- package/dist/federation/temporal.test.mjs +4 -4
- package/dist/federation/webfinger.test.mjs +275 -12
- package/dist/{middleware-FrSMAYEx.cjs → federation-CkZiAcWY.cjs} +8640 -3722
- package/dist/http-B5JT115n.cjs +10630 -0
- package/dist/http-DydWCB7R.js +6527 -0
- package/dist/{http-CPVMUgsW.mjs → http-siciWkmx.mjs} +397 -168
- package/dist/key-BspPiXqn.mjs +951 -0
- package/dist/key-C1nv7_jD.d.ts +1125 -0
- package/dist/key-DYxuyd-s.d.cts +1125 -0
- package/dist/key-owner-evidence-APB6NpSk.mjs +108 -0
- package/dist/keycache-Da8A8mZa.mjs +200 -0
- package/dist/{kv-D6hNiMTK.d.ts → kv-B2mzK5tl.d.cts} +3 -2
- package/dist/{kv-gJ8LYbxX.d.cts → kv-B2mzK5tl.d.ts} +3 -2
- package/dist/kv-DBRWVx3Q.mjs +104 -0
- package/dist/{kv-cache-KyGqJqSp.mjs → kv-cache-BF1hnW65.mjs} +1 -1
- package/dist/{kv-cache-5bnX3-Jq.js → kv-cache-CTQr1tbn.js} +54 -16
- package/dist/{kv-cache-DQ29C4XL.cjs → kv-cache-DCac1nEU.cjs} +55 -19
- package/dist/{ld-Cj9F_5AP.mjs → ld-CzaP-hJl.mjs} +44 -12
- package/dist/map-local-context-conflict-DOYEEJ3u.mjs +204 -0
- package/dist/map-local-create-note-B0SeXJms.mjs +214 -0
- package/dist/{metrics-DMk-nTxI.mjs → metrics-CYg9yxBL.mjs} +3 -2
- package/dist/metrics-DVKYY6K1.d.cts +36 -0
- package/dist/metrics-DoRtI0qU.d.ts +36 -0
- package/dist/{middleware-Y-kpsp6S.js → middleware-CsDDXKuL.js} +5493 -1103
- package/dist/{middleware-CvwTUgnP.mjs → middleware-D7_LO-Vs.mjs} +3157 -272
- package/dist/{middleware-CEctxBN6.mjs → middleware-DWjrfpcz.mjs} +1 -1
- package/dist/{mod-gOlPDuT8.d.cts → mod-BojP2Bw3.d.cts} +1 -1
- package/dist/mod-CavhAgLw.d.cts +320 -0
- package/dist/mod-CtpF2BU5.d.ts +320 -0
- package/dist/{mod-BtEaIEcD.d.cts → mod-DEZXwV6J.d.ts} +10 -4
- package/dist/{mod-UMmveiO0.d.ts → mod-Dw-nPxQh.d.cts} +10 -4
- package/dist/{mod-DLc2Ot6f.d.ts → mod-Xj5P4s4d.d.ts} +1 -1
- package/dist/mod.cjs +40 -39
- package/dist/mod.d.cts +10 -11
- package/dist/mod.d.ts +10 -11
- package/dist/mod.js +9 -9
- package/dist/{mq-llHKivST.mjs → mq-Dm1Wmtm-.mjs} +6 -0
- package/dist/nodeinfo/client.test.mjs +139 -4
- package/dist/nodeinfo/handler.test.mjs +5 -5
- 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/nodeinfo/types.test.mjs +4 -4
- package/dist/otel/exporter.test.mjs +3 -3
- package/dist/otel/mod.cjs +0 -1
- package/dist/otel/mod.d.cts +1 -1
- package/dist/otel/mod.d.ts +1 -1
- package/dist/{outgoing-jsonld-URuqsICz.mjs → outgoing-jsonld-4IJqPYos.mjs} +12 -10
- package/dist/{owner-DEvZuyOE.d.cts → owner-BF0L9cMt.d.cts} +11 -0
- package/dist/{owner-CnngXDNJ.d.ts → owner-BSzr5-I8.d.ts} +11 -0
- package/dist/owner-DYgfTFHy.mjs +267 -0
- package/dist/portable-collection-BpsVsRov.js +670 -0
- package/dist/portable-collection-C74P1vtC.mjs +418 -0
- package/dist/portable-collection-DZd2bm7M.cjs +686 -0
- package/dist/portable-key-BIMLrAP4.mjs +549 -0
- package/dist/portable-key-id-Ca30-hOa.mjs +248 -0
- package/dist/proof-Dxy6ByqI.mjs +1299 -0
- package/dist/{public-audience-Cr5MsnMq.mjs → public-audience--klBIOk1.mjs} +15 -10
- package/dist/router-u5O5KCQr.mjs +156 -0
- package/dist/runtime/mod.cjs +1 -1
- package/dist/runtime/mod.js +1 -1
- package/dist/secured-document-CxzZZ9nL.mjs +35 -0
- package/dist/send-C4dDpAxm.mjs +495 -0
- package/dist/sig/accept.test.mjs +1 -1
- package/dist/sig/compound-proof-discovery.test.d.mts +2 -0
- package/dist/sig/compound-proof-discovery.test.mjs +331 -0
- package/dist/sig/compound-proof-producer.test.d.mts +2 -0
- package/dist/sig/compound-proof-producer.test.mjs +107 -0
- package/dist/sig/compound-proof-verification.test.d.mts +2 -0
- package/dist/sig/compound-proof-verification.test.mjs +833 -0
- package/dist/sig/compound-proof.test.d.mts +2 -0
- package/dist/sig/compound-proof.test.mjs +411 -0
- package/dist/sig/http.test.mjs +702 -31
- package/dist/sig/key-owner-evidence.test.d.mts +2 -0
- package/dist/sig/key-owner-evidence.test.mjs +100 -0
- package/dist/sig/key.test.mjs +220 -8
- package/dist/sig/ld.test.mjs +65 -7
- package/dist/sig/mitra-receive-interop.test.d.mts +2 -0
- package/dist/sig/mitra-receive-interop.test.mjs +260 -0
- package/dist/sig/mitra-send-interop.test.d.mts +2 -0
- package/dist/sig/mitra-send-interop.test.mjs +461 -0
- package/dist/sig/mod.cjs +18 -16
- package/dist/sig/mod.d.cts +4 -4
- package/dist/sig/mod.d.ts +4 -4
- package/dist/sig/mod.js +3 -3
- package/dist/sig/owner.test.mjs +108 -9
- package/dist/sig/portable-collection.test.d.mts +2 -0
- package/dist/sig/portable-collection.test.mjs +697 -0
- package/dist/sig/portable-interop.test.d.mts +2 -0
- package/dist/sig/portable-interop.test.mjs +156 -0
- package/dist/sig/portable-key-id.test.d.mts +2 -0
- package/dist/sig/portable-key-id.test.mjs +67 -0
- package/dist/sig/portable-key.test.d.mts +2 -0
- package/dist/sig/portable-key.test.mjs +1439 -0
- package/dist/sig/proof.test.mjs +1753 -16
- package/dist/sig/tootik-request-interop.test.d.mts +2 -0
- package/dist/sig/tootik-request-interop.test.mjs +190 -0
- package/dist/sig/tootik-send-interop.test.d.mts +2 -0
- package/dist/sig/tootik-send-interop.test.mjs +331 -0
- package/dist/sig/verification.test.d.mts +2 -0
- package/dist/sig/verification.test.mjs +310 -0
- package/dist/std__assert-DvMDRPes.mjs +8 -0
- package/dist/{testing-CO7ftO8A.mjs → tasks-IG3pHfvZ.mjs} +2 -2
- package/dist/{temporal-fsXO5IvD.mjs → temporal-D0KJI4NF.mjs} +3 -3
- package/dist/testing/context.test.d.mts +2 -0
- package/dist/testing/context.test.mjs +19 -0
- package/dist/testing/kv.test.d.mts +2 -0
- package/dist/testing/kv.test.mjs +101 -0
- package/dist/testing/mod.d.mts +1617 -48
- package/dist/testing/mod.mjs +6 -3
- package/dist/testing-t8xDeJZ5.mjs +8 -0
- package/dist/{transformers-DGwl-hD1.cjs → transformers-BalCrSsr.cjs} +0 -1
- package/dist/{types-DC1pNgJA.js → types-_S2_MeLF.js} +144 -8
- package/dist/{types-CPym83zc.cjs → types-poIao40f.cjs} +160 -7
- package/dist/utils/docloader.test.mjs +530 -8
- package/dist/utils/kv-cache.test.mjs +2 -2
- 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/dist/verification-C3954hQh.mjs +5 -0
- package/dist/verification-Dpfcw8ml.mjs +105 -0
- package/dist/vocab/cjs.test.mjs +1 -1
- package/package.json +8 -8
- package/dist/assert_rejects-EgstcQy5.mjs +0 -82
- package/dist/docloader-e1khNUKy.mjs +0 -49
- package/dist/http-CsmQ-BQ4.js +0 -2483
- package/dist/http-VyDTd4G3.d.cts +0 -553
- package/dist/http-hlIjmewS.cjs +0 -2694
- package/dist/http-lf8Hsd91.d.ts +0 -553
- package/dist/key-DfPsX8hv.mjs +0 -453
- package/dist/keycache-_DHqz5z_.mjs +0 -97
- package/dist/metrics-CoUJLQhZ.d.cts +0 -276
- package/dist/metrics-Dc4jR-IC.d.ts +0 -276
- package/dist/mod-B0hW12_O.d.cts +0 -307
- package/dist/mod-COIAjwRS.d.ts +0 -307
- package/dist/owner-Br9YYzn_.mjs +0 -120
- package/dist/proof-B4ngoaNQ.mjs +0 -316
- package/dist/proof-B6E0XUXJ.js +0 -1415
- package/dist/proof-NN4xCQGL.cjs +0 -1562
- package/dist/send-CxHKUNzv.mjs +0 -239
- package/dist/std__assert-B2NnY9bq.mjs +0 -79
- package/dist/temporal-polyfill-BCdTHexd.cjs +0 -3565
- /package/dist/{accept-8PiolDP7.mjs → accept-CPVqki78.mjs} +0 -0
- /package/dist/{activity-listener-BtTSxZWA.mjs → activity-listener-C-BSbKI7.mjs} +0 -0
- /package/dist/{client-CAM_bQXx.d.cts → client-D-XP_Ccb.d.cts} +0 -0
- /package/dist/{client-CSddvgWN.d.ts → client-D-XP_Ccb.d.ts} +0 -0
- /package/dist/{keys-Dbyd7bhl.mjs → keys-DIJNHW-Y.mjs} +0 -0
- /package/dist/{kv-CXBAok3k.mjs → kv-DCHkaJ9-.mjs} +0 -0
- /package/dist/{negotiation-RuwrPFqi.mjs → negotiation-CCocWT_N.mjs} +0 -0
- /package/dist/{retry-DgFqeS5k.mjs → retry-zX5xw5e0.mjs} +0 -0
- /package/dist/{chunk-D5IdezsZ.js → rolldown-runtime-BC-Eo5Mj.js} +0 -0
- /package/dist/{chunk-Czpeue4M.cjs → rolldown-runtime-Dmm3t92W.cjs} +0 -0
- /package/dist/{transformers-DXze6FUB.js → transformers-DAtXl_Kc.js} +0 -0
- /package/dist/{types-CrtdVuDP.mjs → types-DE_LV1JB.mjs} +0 -0
|
@@ -0,0 +1,1072 @@
|
|
|
1
|
+
import { Temporal } from "temporal-polyfill";
|
|
2
|
+
import "urlpattern-polyfill";
|
|
3
|
+
globalThis.addEventListener = () => {};
|
|
4
|
+
import { t as MemoryKvStore } from "../kv-DCHkaJ9-.mjs";
|
|
5
|
+
import { t as assertEquals } from "../assert_equals-Z6IcEbWU.mjs";
|
|
6
|
+
import { t as assertInstanceOf } from "../assert_instance_of-mrXWWNY-.mjs";
|
|
7
|
+
import { t as assertThrows } from "../assert_throws-BYITOvPO.mjs";
|
|
8
|
+
import "../std__assert-DvMDRPes.mjs";
|
|
9
|
+
import { t as assertStrictEquals } from "../assert_strict_equals-CYvBmkpC.mjs";
|
|
10
|
+
import { t as assert } from "../assert-D1Eafmd_.mjs";
|
|
11
|
+
import { a as signObject, c as verifyPortableObjectProof } from "../proof-Dxy6ByqI.mjs";
|
|
12
|
+
import { l as signRequest } from "../http-siciWkmx.mjs";
|
|
13
|
+
import { a as rsaPrivateKey3, c as rsaPublicKey3, i as rsaPrivateKey2, n as ed25519PrivateKey, r as ed25519PublicKey, s as rsaPublicKey2 } from "../keys-DIJNHW-Y.mjs";
|
|
14
|
+
import { b as PORTABLE_OBJECT_CONTENT_TYPE, o as createFederation } from "../middleware-D7_LO-Vs.mjs";
|
|
15
|
+
import { configure, reset } from "@logtape/logtape";
|
|
16
|
+
import { Collection, Create, Note, Object as Object$1, PUBLIC_COLLECTION, Person, Tombstone, lookupObject } from "@fedify/vocab";
|
|
17
|
+
import { exportDidKey, formatIri, parseIri, toCompatibleEf61Id } from "@fedify/vocab-runtime";
|
|
18
|
+
import { mockDocumentLoader, test } from "@fedify/fixture";
|
|
19
|
+
//#region src/federation/portable.test.ts
|
|
20
|
+
const did = await exportDidKey(ed25519PublicKey.publicKey);
|
|
21
|
+
const keyId = new URL(`${did}#${did.substring(8)}`);
|
|
22
|
+
const otherKeyPair = await crypto.subtle.generateKey("Ed25519", true, ["sign", "verify"]);
|
|
23
|
+
const otherDid = await exportDidKey(otherKeyPair.publicKey);
|
|
24
|
+
const otherKeyId = new URL(`${otherDid}#${otherDid.substring(8)}`);
|
|
25
|
+
const ACCEPT = "application/activity+json";
|
|
26
|
+
function gatewayUrl(path, authority = did) {
|
|
27
|
+
return `https://example.com/.well-known/apgateway/${authority}${path}`;
|
|
28
|
+
}
|
|
29
|
+
function gatewayRequest(path, init = {}) {
|
|
30
|
+
const { authority, ...rest } = init;
|
|
31
|
+
return new Request(gatewayUrl(path, authority), {
|
|
32
|
+
...rest,
|
|
33
|
+
headers: {
|
|
34
|
+
Accept: ACCEPT,
|
|
35
|
+
...rest.headers
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
function createTestFederation() {
|
|
40
|
+
return createFederation({
|
|
41
|
+
kv: new MemoryKvStore(),
|
|
42
|
+
documentLoaderFactory: () => mockDocumentLoader,
|
|
43
|
+
contextLoaderFactory: () => mockDocumentLoader
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
async function sign(object, privateKey = ed25519PrivateKey, key = keyId) {
|
|
47
|
+
return await signObject(object, privateKey, key, { contextLoader: mockDocumentLoader });
|
|
48
|
+
}
|
|
49
|
+
async function signedNote(path, authority = did, content = "Hello") {
|
|
50
|
+
return await sign(new Note({
|
|
51
|
+
id: parseIri(`ap+ef61://${authority}${path}`),
|
|
52
|
+
attribution: parseIri(`ap+ef61://${authority}/actor`),
|
|
53
|
+
to: PUBLIC_COLLECTION,
|
|
54
|
+
content
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
57
|
+
test("Federation.fetch() serves portable objects through object dispatchers", async (t) => {
|
|
58
|
+
const federation = createTestFederation();
|
|
59
|
+
const calls = [];
|
|
60
|
+
federation.setObjectDispatcher(Note, "/users/{userId}/notes/{noteId}", async (ctx, values) => {
|
|
61
|
+
calls.push({
|
|
62
|
+
values,
|
|
63
|
+
portableRequest: ctx.portableRequest
|
|
64
|
+
});
|
|
65
|
+
if (values.noteId === "missing") return null;
|
|
66
|
+
if (values.noteId === "https") return new Note({
|
|
67
|
+
id: ctx.getObjectUri(Note, values),
|
|
68
|
+
content: "Not portable"
|
|
69
|
+
});
|
|
70
|
+
if (values.noteId === "ap") return await sign(new Note({
|
|
71
|
+
id: parseIri(`ap://${did}/users/${values.userId}/notes/ap`),
|
|
72
|
+
to: PUBLIC_COLLECTION,
|
|
73
|
+
content: "Hello"
|
|
74
|
+
}));
|
|
75
|
+
if (values.noteId === "ignores-authority") return await signedNote(`/users/${values.userId}/notes/${values.noteId}`);
|
|
76
|
+
if (ctx.portableRequest?.authority !== did) return null;
|
|
77
|
+
return await sign(new Note({
|
|
78
|
+
id: ctx.getPortableObjectUri(Note, values),
|
|
79
|
+
attribution: parseIri(`ap+ef61://${did}/actor`),
|
|
80
|
+
to: PUBLIC_COLLECTION,
|
|
81
|
+
content: `Note ${values.noteId} by ${values.userId}`
|
|
82
|
+
}));
|
|
83
|
+
});
|
|
84
|
+
federation.setActorDispatcher("/actors/{identifier}", (ctx, identifier) => new Person({ id: ctx.getActorUri(identifier) }));
|
|
85
|
+
await t.step("serves a portable object", async () => {
|
|
86
|
+
calls.length = 0;
|
|
87
|
+
const response = await federation.fetch(gatewayRequest("/users/alice/notes/123"), { contextData: void 0 });
|
|
88
|
+
assertEquals(response.status, 200);
|
|
89
|
+
assertEquals(response.headers.get("Content-Type"), PORTABLE_OBJECT_CONTENT_TYPE);
|
|
90
|
+
assertEquals(response.headers.get("Vary"), "Accept");
|
|
91
|
+
const json = await response.json();
|
|
92
|
+
assertEquals(json.id, `ap+ef61://${did}/users/alice/notes/123`);
|
|
93
|
+
assertEquals(json.content, "Note 123 by alice");
|
|
94
|
+
assertEquals((await verifyPortableObjectProof(json, { contextLoader: mockDocumentLoader })).verified, true);
|
|
95
|
+
assertEquals(calls.length, 1);
|
|
96
|
+
assertEquals(calls[0].values, {
|
|
97
|
+
userId: "alice",
|
|
98
|
+
noteId: "123"
|
|
99
|
+
});
|
|
100
|
+
assertEquals(calls[0].portableRequest?.authority, did);
|
|
101
|
+
assertEquals(formatIri(calls[0].portableRequest.id), `ap+ef61://${did}/users/alice/notes/123`);
|
|
102
|
+
});
|
|
103
|
+
await t.step("accepts a percent-encoded DID authority", async () => {
|
|
104
|
+
calls.length = 0;
|
|
105
|
+
assertEquals((await federation.fetch(gatewayRequest("/users/alice/notes/123", { authority: did.replaceAll(":", "%3A") }), { contextData: void 0 })).status, 200);
|
|
106
|
+
assertEquals(calls[0].portableRequest?.authority, did);
|
|
107
|
+
});
|
|
108
|
+
await t.step("ignores the query, including location hints", async () => {
|
|
109
|
+
assertEquals((await federation.fetch(gatewayRequest("/users/alice/notes/123?@gateway=https%3A%2F%2Fexample.com&x=%ZZ"), { contextData: void 0 })).status, 200);
|
|
110
|
+
});
|
|
111
|
+
await t.step("accepts an ap: ID returned by the dispatcher", async () => {
|
|
112
|
+
assertEquals((await federation.fetch(gatewayRequest("/users/alice/notes/ap"), { contextData: void 0 })).status, 200);
|
|
113
|
+
});
|
|
114
|
+
await t.step("responds to HEAD without a body", async () => {
|
|
115
|
+
const response = await federation.fetch(gatewayRequest("/users/alice/notes/123", { method: "HEAD" }), { contextData: void 0 });
|
|
116
|
+
assertEquals(response.status, 200);
|
|
117
|
+
assertEquals(response.headers.get("Content-Type"), PORTABLE_OBJECT_CONTENT_TYPE);
|
|
118
|
+
assertEquals(response.body, null);
|
|
119
|
+
});
|
|
120
|
+
await t.step("responds with 404 for objects it does not serve", async () => {
|
|
121
|
+
for (const request of [
|
|
122
|
+
gatewayRequest("/users/alice/notes/missing"),
|
|
123
|
+
gatewayRequest("/users/alice/notes/https"),
|
|
124
|
+
gatewayRequest("/users/alice/notes/123", { authority: otherDid }),
|
|
125
|
+
gatewayRequest("/users/alice/notes/ignores-authority", { authority: otherDid }),
|
|
126
|
+
gatewayRequest("/users/alice/posts/123"),
|
|
127
|
+
gatewayRequest("/actors/alice")
|
|
128
|
+
]) assertEquals((await federation.fetch(request, { contextData: void 0 })).status, 404, request.url);
|
|
129
|
+
});
|
|
130
|
+
await t.step("responds with 400 for malformed portable IDs", async () => {
|
|
131
|
+
for (const path of [
|
|
132
|
+
`/.well-known/apgateway/${did}`,
|
|
133
|
+
"/.well-known/apgateway/did:key:/users/alice/notes/123",
|
|
134
|
+
`/.well-known/apgateway/${did}/users/alice/notes/%ZZ`
|
|
135
|
+
]) assertEquals((await federation.fetch(new Request(`https://example.com${path}`, { headers: { Accept: ACCEPT } }), { contextData: void 0 })).status, 400, path);
|
|
136
|
+
});
|
|
137
|
+
await t.step("leaves other gateway requests to onNotFound()", async () => {
|
|
138
|
+
for (const request of [
|
|
139
|
+
new Request("https://example.com/.well-known/apgateway", { headers: { Accept: ACCEPT } }),
|
|
140
|
+
new Request("https://example.com/.well-known/apgateway/hl:zQmdfTbBqBPQ7VNxZEYEj14VmRuZBkqFbiwReogJgS1zR1n"),
|
|
141
|
+
gatewayRequest("/users/alice/notes/123", {
|
|
142
|
+
method: "POST",
|
|
143
|
+
body: "{}"
|
|
144
|
+
})
|
|
145
|
+
]) {
|
|
146
|
+
let notFound = false;
|
|
147
|
+
assertEquals((await federation.fetch(request, {
|
|
148
|
+
contextData: void 0,
|
|
149
|
+
onNotFound() {
|
|
150
|
+
notFound = true;
|
|
151
|
+
return new Response("Not found", { status: 404 });
|
|
152
|
+
}
|
|
153
|
+
})).status, 404);
|
|
154
|
+
assertEquals(notFound, true, request.url);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
await t.step("responds to HEAD without a body on errors", async () => {
|
|
158
|
+
for (const [request, status] of [[gatewayRequest("/users/alice/notes/missing", { method: "HEAD" }), 404], [gatewayRequest("/users/alice/notes/123", {
|
|
159
|
+
method: "HEAD",
|
|
160
|
+
headers: { Accept: "text/html" }
|
|
161
|
+
}), 406]]) {
|
|
162
|
+
const response = await federation.fetch(request, { contextData: void 0 });
|
|
163
|
+
assertEquals(response.status, status);
|
|
164
|
+
assertEquals(response.body, null);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
await t.step("responds with 406 without a JSON-LD Accept header", async () => {
|
|
168
|
+
assertEquals((await federation.fetch(gatewayRequest("/users/alice/notes/123", { headers: { Accept: "text/html" } }), { contextData: void 0 })).status, 406);
|
|
169
|
+
});
|
|
170
|
+
await t.step("keeps ordinary object requests unchanged", async () => {
|
|
171
|
+
const response = await federation.fetch(new Request("https://example.com/users/alice/notes/https", { headers: { Accept: ACCEPT } }), { contextData: void 0 });
|
|
172
|
+
assertEquals(response.status, 200);
|
|
173
|
+
assertEquals(response.headers.get("Content-Type"), "application/activity+json");
|
|
174
|
+
assertEquals((await response.json()).id, "https://example.com/users/alice/notes/https");
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
test("Federation.fetch() applies the FEP-ef61 proof policy to portable objects", async (t) => {
|
|
178
|
+
const federation = createTestFederation();
|
|
179
|
+
const objects = {
|
|
180
|
+
"valid": () => signedNote("/objects/valid"),
|
|
181
|
+
"unsigned": () => Promise.resolve(new Note({ id: parseIri(`ap+ef61://${did}/objects/unsigned`) })),
|
|
182
|
+
"wrong-did": () => sign(new Note({ id: parseIri(`ap+ef61://${did}/objects/wrong-did`) }), otherKeyPair.privateKey, otherKeyId),
|
|
183
|
+
"tampered": async () => (await signedNote("/objects/tampered")).clone({ content: "Tampered" }),
|
|
184
|
+
"unsigned-collection": () => Promise.resolve(new Collection({
|
|
185
|
+
id: parseIri(`ap+ef61://${did}/objects/unsigned-collection`),
|
|
186
|
+
totalItems: 0
|
|
187
|
+
})),
|
|
188
|
+
"aliased-collection": () => Collection.fromJsonLd({
|
|
189
|
+
"@context": ["https://www.w3.org/ns/activitystreams", { "identifier": "@id" }],
|
|
190
|
+
type: "Collection",
|
|
191
|
+
identifier: `ap+ef61://${did}/objects/aliased-collection`,
|
|
192
|
+
totalItems: 0
|
|
193
|
+
}, {
|
|
194
|
+
contextLoader: mockDocumentLoader,
|
|
195
|
+
documentLoader: mockDocumentLoader
|
|
196
|
+
}),
|
|
197
|
+
"tampered-collection": async () => (await sign(new Collection({
|
|
198
|
+
id: parseIri(`ap+ef61://${did}/objects/tampered-collection`),
|
|
199
|
+
totalItems: 0
|
|
200
|
+
}))).clone({ totalItems: 1 }),
|
|
201
|
+
"activity": () => sign(new Create({
|
|
202
|
+
id: parseIri(`ap+ef61://${did}/objects/activity`),
|
|
203
|
+
actor: parseIri(`ap+ef61://${did}/actor`),
|
|
204
|
+
object: parseIri(`ap+ef61://${did}/objects/valid`)
|
|
205
|
+
})),
|
|
206
|
+
"actor": () => sign(new Person({
|
|
207
|
+
id: parseIri(`ap+ef61://${did}/objects/actor`),
|
|
208
|
+
inbox: parseIri(`ap+ef61://${did}/objects/actor/inbox`),
|
|
209
|
+
outbox: parseIri(`ap+ef61://${did}/objects/actor/outbox`),
|
|
210
|
+
gateways: [new URL("https://example.com/")]
|
|
211
|
+
})),
|
|
212
|
+
"mutated-id": async () => {
|
|
213
|
+
const signed = await signedNote("/objects/original");
|
|
214
|
+
const object = await Note.fromJsonLd(await signed.toJsonLd({ contextLoader: mockDocumentLoader }), {
|
|
215
|
+
contextLoader: mockDocumentLoader,
|
|
216
|
+
documentLoader: mockDocumentLoader
|
|
217
|
+
});
|
|
218
|
+
object.id.pathname = "/objects/mutated-id";
|
|
219
|
+
return object;
|
|
220
|
+
},
|
|
221
|
+
"actor-without-gateways": () => sign(new Person({
|
|
222
|
+
id: parseIri(`ap+ef61://${did}/objects/actor-without-gateways`),
|
|
223
|
+
inbox: parseIri(`ap+ef61://${did}/objects/actor-without-gateways/inbox`),
|
|
224
|
+
outbox: parseIri(`ap+ef61://${did}/objects/actor-without-gateways/outbox`)
|
|
225
|
+
})),
|
|
226
|
+
"unsigned-actor": () => Promise.resolve(new Person({
|
|
227
|
+
id: parseIri(`ap+ef61://${did}/objects/unsigned-actor`),
|
|
228
|
+
inbox: parseIri(`ap+ef61://${did}/objects/unsigned-actor/inbox`),
|
|
229
|
+
outbox: parseIri(`ap+ef61://${did}/objects/unsigned-actor/outbox`),
|
|
230
|
+
gateways: [new URL("https://example.com/")]
|
|
231
|
+
}))
|
|
232
|
+
};
|
|
233
|
+
federation.setObjectDispatcher(Object$1, "/objects/{id}", (_ctx, { id }) => objects[id]?.() ?? null).authorize(() => true);
|
|
234
|
+
for (const [id, status] of globalThis.Object.entries({
|
|
235
|
+
"valid": 200,
|
|
236
|
+
"unsigned": 500,
|
|
237
|
+
"wrong-did": 500,
|
|
238
|
+
"tampered": 500,
|
|
239
|
+
"unsigned-collection": 200,
|
|
240
|
+
"aliased-collection": 200,
|
|
241
|
+
"tampered-collection": 500,
|
|
242
|
+
"activity": 200,
|
|
243
|
+
"actor": 200,
|
|
244
|
+
"mutated-id": 404,
|
|
245
|
+
"actor-without-gateways": 500,
|
|
246
|
+
"unsigned-actor": 500
|
|
247
|
+
})) await t.step(`${id} → ${status}`, async () => {
|
|
248
|
+
const response = await federation.fetch(gatewayRequest(`/objects/${id}`), { contextData: void 0 });
|
|
249
|
+
assertEquals(response.status, status);
|
|
250
|
+
if (status === 500) assertEquals(await response.text(), "Internal server error.");
|
|
251
|
+
});
|
|
252
|
+
await t.step("responds to HEAD without a body on failure", async () => {
|
|
253
|
+
const response = await federation.fetch(gatewayRequest("/objects/unsigned", { method: "HEAD" }), { contextData: void 0 });
|
|
254
|
+
assertEquals(response.status, 500);
|
|
255
|
+
assertEquals(response.body, null);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
test("Federation.fetch() serves tombstones of portable objects", async (t) => {
|
|
259
|
+
const federation = createTestFederation();
|
|
260
|
+
const compatible = (path) => new URL(`https://example.com/.well-known/apgateway/${did}${path}`);
|
|
261
|
+
const tombstoneFromJsonLd = (path, properties) => Tombstone.fromJsonLd({
|
|
262
|
+
"@context": "https://www.w3.org/ns/activitystreams",
|
|
263
|
+
type: "Tombstone",
|
|
264
|
+
id: `ap+ef61://${did}${path}`,
|
|
265
|
+
...properties
|
|
266
|
+
}, {
|
|
267
|
+
contextLoader: mockDocumentLoader,
|
|
268
|
+
documentLoader: mockDocumentLoader
|
|
269
|
+
});
|
|
270
|
+
const tombstones = {
|
|
271
|
+
"signed": () => sign(new Tombstone({
|
|
272
|
+
id: parseIri(`ap+ef61://${did}/notes/signed`),
|
|
273
|
+
formerType: Note,
|
|
274
|
+
deleted: Temporal.Instant.from("2026-01-01T00:00:00Z")
|
|
275
|
+
})),
|
|
276
|
+
"compatible": () => sign(new Tombstone({
|
|
277
|
+
id: compatible("/notes/compatible"),
|
|
278
|
+
formerType: Note
|
|
279
|
+
})),
|
|
280
|
+
"signed-collection": async () => sign(await tombstoneFromJsonLd("/notes/signed-collection", { totalItems: 0 })),
|
|
281
|
+
"unsigned": () => Promise.resolve(new Tombstone({
|
|
282
|
+
id: parseIri(`ap+ef61://${did}/notes/unsigned`),
|
|
283
|
+
formerType: Note
|
|
284
|
+
})),
|
|
285
|
+
"wrong-did": () => sign(new Tombstone({ id: parseIri(`ap+ef61://${did}/notes/wrong-did`) }), otherKeyPair.privateKey, otherKeyId),
|
|
286
|
+
"tampered": async () => (await sign(new Tombstone({ id: parseIri(`ap+ef61://${did}/notes/tampered`) }))).clone({ formerType: Note }),
|
|
287
|
+
"unsigned-collection": () => tombstoneFromJsonLd("/notes/unsigned-collection", { totalItems: 0 }),
|
|
288
|
+
"link": () => tombstoneFromJsonLd("/notes/link", { href: "https://example.com/" }),
|
|
289
|
+
"mismatched": () => sign(new Tombstone({ id: parseIri(`ap+ef61://${did}/notes/other`) })),
|
|
290
|
+
"mutated-id": async () => {
|
|
291
|
+
const signed = await sign(new Tombstone({ id: parseIri(`ap+ef61://${did}/notes/original`) }));
|
|
292
|
+
const tombstone = await Tombstone.fromJsonLd(await signed.toJsonLd({ contextLoader: mockDocumentLoader }), {
|
|
293
|
+
contextLoader: mockDocumentLoader,
|
|
294
|
+
documentLoader: mockDocumentLoader
|
|
295
|
+
});
|
|
296
|
+
tombstone.id.pathname = tombstone.id.pathname.replace(/original$/, "mutated-id");
|
|
297
|
+
return tombstone;
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
federation.setObjectDispatcher(Note, "/notes/{id}", (_ctx, { id }) => tombstones[id]?.() ?? null).authorize((ctx) => ctx.request.headers.get("X-Deny") == null);
|
|
301
|
+
for (const [id, reason] of globalThis.Object.entries({
|
|
302
|
+
"unsigned": "missingProof",
|
|
303
|
+
"wrong-did": "verificationMethodMismatch",
|
|
304
|
+
"tampered": "invalidProof",
|
|
305
|
+
"unsigned-collection": "unsecuredCollection",
|
|
306
|
+
"link": "unsupportedObjectType"
|
|
307
|
+
})) {
|
|
308
|
+
const result = await verifyPortableObjectProof(await (await tombstones[id]()).toJsonLd({ contextLoader: mockDocumentLoader }), { contextLoader: mockDocumentLoader });
|
|
309
|
+
assert(!result.verified, id);
|
|
310
|
+
assertEquals(result.reason.type, reason, id);
|
|
311
|
+
}
|
|
312
|
+
for (const [id, status] of globalThis.Object.entries({
|
|
313
|
+
"signed": 410,
|
|
314
|
+
"compatible": 410,
|
|
315
|
+
"signed-collection": 410,
|
|
316
|
+
"unsigned": 404,
|
|
317
|
+
"wrong-did": 500,
|
|
318
|
+
"tampered": 500,
|
|
319
|
+
"unsigned-collection": 500,
|
|
320
|
+
"link": 500,
|
|
321
|
+
"mismatched": 404,
|
|
322
|
+
"mutated-id": 404
|
|
323
|
+
})) await t.step(`${id} → ${status}`, async () => {
|
|
324
|
+
let notFound = false;
|
|
325
|
+
const response = await federation.fetch(gatewayRequest(`/notes/${id}`), {
|
|
326
|
+
contextData: void 0,
|
|
327
|
+
onNotFound() {
|
|
328
|
+
notFound = true;
|
|
329
|
+
return new Response("Not found", { status: 404 });
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
assertEquals(response.status, status);
|
|
333
|
+
assertEquals(notFound, status === 404);
|
|
334
|
+
if (status === 410) {
|
|
335
|
+
assertEquals(response.headers.get("Content-Type"), PORTABLE_OBJECT_CONTENT_TYPE);
|
|
336
|
+
assertEquals(response.headers.get("Vary"), "Accept");
|
|
337
|
+
const body = await response.json();
|
|
338
|
+
assertEquals(body.type, "Tombstone");
|
|
339
|
+
assert((await verifyPortableObjectProof(body, { contextLoader: mockDocumentLoader })).verified);
|
|
340
|
+
} else if (status === 500) assertEquals(await response.text(), "Internal server error.");
|
|
341
|
+
});
|
|
342
|
+
await t.step("serves the tombstone as the dispatcher returns it", async () => {
|
|
343
|
+
let response = await federation.fetch(gatewayRequest("/notes/signed"), { contextData: void 0 });
|
|
344
|
+
let body = await response.json();
|
|
345
|
+
assertEquals(body.id, `ap+ef61://${did}/notes/signed`);
|
|
346
|
+
assertEquals(body.formerType, "as:Note");
|
|
347
|
+
assertEquals(body.deleted, "2026-01-01T00:00:00Z");
|
|
348
|
+
response = await federation.fetch(gatewayRequest("/notes/compatible"), { contextData: void 0 });
|
|
349
|
+
body = await response.json();
|
|
350
|
+
assertEquals(body.id, compatible("/notes/compatible").href);
|
|
351
|
+
});
|
|
352
|
+
await t.step("responds to HEAD without a body", async () => {
|
|
353
|
+
for (const [id, status] of [["signed", 410], ["unsigned", 404]]) {
|
|
354
|
+
const response = await federation.fetch(gatewayRequest(`/notes/${id}`, { method: "HEAD" }), { contextData: void 0 });
|
|
355
|
+
assertEquals(response.status, status, id);
|
|
356
|
+
assertEquals(response.body, null, id);
|
|
357
|
+
if (status === 410) {
|
|
358
|
+
assertEquals(response.headers.get("Content-Type"), PORTABLE_OBJECT_CONTENT_TYPE);
|
|
359
|
+
assertEquals(response.headers.get("Vary"), "Accept");
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
await t.step("authorizes before serving a tombstone", async () => {
|
|
364
|
+
for (const id of ["signed", "unsigned"]) {
|
|
365
|
+
let unauthorized = false;
|
|
366
|
+
assertEquals((await federation.fetch(gatewayRequest(`/notes/${id}`, { headers: { "X-Deny": "1" } }), {
|
|
367
|
+
contextData: void 0,
|
|
368
|
+
onUnauthorized() {
|
|
369
|
+
unauthorized = true;
|
|
370
|
+
return new Response("Unauthorized", { status: 401 });
|
|
371
|
+
}
|
|
372
|
+
})).status, 401, id);
|
|
373
|
+
assertEquals(unauthorized, true, id);
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
await t.step("does not warn about tombstones with compatible IDs", async () => {
|
|
377
|
+
assertEquals((await captureWarnings(async () => {
|
|
378
|
+
assertEquals((await federation.fetch(gatewayRequest("/notes/compatible"), { contextData: void 0 })).status, 410);
|
|
379
|
+
})).filter((record) => record.category[2] === "object"), []);
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
test("Federation.fetch() authorizes portable object requests", async (t) => {
|
|
383
|
+
const federation = createTestFederation();
|
|
384
|
+
let dispatched = 0;
|
|
385
|
+
federation.setObjectDispatcher(Note, "/notes/{id}", (ctx, values) => {
|
|
386
|
+
dispatched++;
|
|
387
|
+
if (values.id === "unsigned") return new Note({ id: ctx.getPortableObjectUri(Note, values) });
|
|
388
|
+
return signedNote(`/notes/${values.id}`);
|
|
389
|
+
}).authorize(async (ctx) => {
|
|
390
|
+
assertEquals(ctx.portableRequest?.authority, did);
|
|
391
|
+
return (await ctx.getSignedKeyOwner())?.id?.href === "https://example.com/person2";
|
|
392
|
+
});
|
|
393
|
+
await t.step("allows a signed request by the audience", async () => {
|
|
394
|
+
const request = await signRequest(gatewayRequest("/notes/1"), rsaPrivateKey3, rsaPublicKey3.id);
|
|
395
|
+
assertEquals((await federation.fetch(request, { contextData: void 0 })).status, 200);
|
|
396
|
+
});
|
|
397
|
+
await t.step("denies an unsigned request", async () => {
|
|
398
|
+
assertEquals((await federation.fetch(gatewayRequest("/notes/1"), { contextData: void 0 })).status, 401);
|
|
399
|
+
});
|
|
400
|
+
await t.step("denies a request signed by someone else", async () => {
|
|
401
|
+
const request = await signRequest(gatewayRequest("/notes/1"), rsaPrivateKey2, rsaPublicKey2.id);
|
|
402
|
+
assertEquals((await federation.fetch(request, { contextData: void 0 })).status, 401);
|
|
403
|
+
});
|
|
404
|
+
await t.step("denies a request whose target was tampered with", async () => {
|
|
405
|
+
const signed = await signRequest(gatewayRequest("/notes/1"), rsaPrivateKey3, rsaPublicKey3.id);
|
|
406
|
+
assertEquals((await federation.fetch(new Request(gatewayUrl("/notes/2"), { headers: signed.headers }), { contextData: void 0 })).status, 401);
|
|
407
|
+
});
|
|
408
|
+
await t.step("authorizes before checking proofs", async () => {
|
|
409
|
+
dispatched = 0;
|
|
410
|
+
assertEquals((await federation.fetch(gatewayRequest("/notes/unsigned"), { contextData: void 0 })).status, 401);
|
|
411
|
+
assertEquals(dispatched, 1);
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
test("Federation.fetch() serves only public portable objects by default", async (t) => {
|
|
415
|
+
const federation = createTestFederation();
|
|
416
|
+
const person2 = new URL("https://example.com/person2");
|
|
417
|
+
const note = (id, values = {}) => sign(new Note({
|
|
418
|
+
id: parseIri(`ap+ef61://${did}/objects/${id}`),
|
|
419
|
+
...values
|
|
420
|
+
}));
|
|
421
|
+
const objects = {
|
|
422
|
+
"to": () => note("to", { to: PUBLIC_COLLECTION }),
|
|
423
|
+
"cc": () => note("cc", {
|
|
424
|
+
to: person2,
|
|
425
|
+
cc: PUBLIC_COLLECTION
|
|
426
|
+
}),
|
|
427
|
+
"bto": () => note("bto", { bto: PUBLIC_COLLECTION }),
|
|
428
|
+
"bcc": () => note("bcc", { bcc: PUBLIC_COLLECTION }),
|
|
429
|
+
"audience": () => note("audience", { audience: PUBLIC_COLLECTION }),
|
|
430
|
+
"unaddressed": () => note("unaddressed"),
|
|
431
|
+
"direct": () => note("direct", { to: person2 }),
|
|
432
|
+
"followers": () => note("followers", { cc: new URL("https://example.com/followers") }),
|
|
433
|
+
"wrong-did": () => sign(new Note({
|
|
434
|
+
id: parseIri(`ap+ef61://${did}/objects/wrong-did`),
|
|
435
|
+
to: person2
|
|
436
|
+
}), otherKeyPair.privateKey, otherKeyId),
|
|
437
|
+
"public-create-of-private-note": async () => await sign(new Create({
|
|
438
|
+
id: parseIri(`ap+ef61://${did}/objects/public-create-of-private-note`),
|
|
439
|
+
actor: parseIri(`ap+ef61://${did}/actor`),
|
|
440
|
+
to: PUBLIC_COLLECTION,
|
|
441
|
+
object: await note("private-note", { to: person2 })
|
|
442
|
+
})),
|
|
443
|
+
"private-create-of-public-note": async () => await sign(new Create({
|
|
444
|
+
id: parseIri(`ap+ef61://${did}/objects/private-create-of-public-note`),
|
|
445
|
+
actor: parseIri(`ap+ef61://${did}/actor`),
|
|
446
|
+
object: await note("public-note", { to: PUBLIC_COLLECTION })
|
|
447
|
+
})),
|
|
448
|
+
"collection": () => Promise.resolve(new Collection({
|
|
449
|
+
id: parseIri(`ap+ef61://${did}/objects/collection`),
|
|
450
|
+
totalItems: 0
|
|
451
|
+
})),
|
|
452
|
+
"public-collection": () => Promise.resolve(new Collection({
|
|
453
|
+
id: parseIri(`ap+ef61://${did}/objects/public-collection`),
|
|
454
|
+
to: PUBLIC_COLLECTION,
|
|
455
|
+
totalItems: 0
|
|
456
|
+
})),
|
|
457
|
+
"tombstone": () => sign(new Tombstone({ id: parseIri(`ap+ef61://${did}/objects/tombstone`) })),
|
|
458
|
+
"public-tombstone": () => sign(new Tombstone({
|
|
459
|
+
id: parseIri(`ap+ef61://${did}/objects/public-tombstone`),
|
|
460
|
+
to: PUBLIC_COLLECTION
|
|
461
|
+
})),
|
|
462
|
+
"actor": () => sign(new Person({
|
|
463
|
+
id: parseIri(`ap+ef61://${did}/objects/actor`),
|
|
464
|
+
inbox: parseIri(`ap+ef61://${did}/objects/actor/inbox`),
|
|
465
|
+
outbox: parseIri(`ap+ef61://${did}/objects/actor/outbox`),
|
|
466
|
+
gateways: [new URL("https://example.com/")]
|
|
467
|
+
})),
|
|
468
|
+
"mutated-audience": async () => {
|
|
469
|
+
const signed = await note("mutated-audience", { to: person2 });
|
|
470
|
+
const object = await Note.fromJsonLd(await signed.toJsonLd({ contextLoader: mockDocumentLoader }), {
|
|
471
|
+
contextLoader: mockDocumentLoader,
|
|
472
|
+
documentLoader: mockDocumentLoader
|
|
473
|
+
});
|
|
474
|
+
object.toIds[0].href = PUBLIC_COLLECTION.href;
|
|
475
|
+
return object;
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
let dispatched = 0;
|
|
479
|
+
federation.setObjectDispatcher(Object$1, "/objects/{id}", (ctx, { id }) => {
|
|
480
|
+
dispatched++;
|
|
481
|
+
if (ctx.portableRequest == null) return new Note({
|
|
482
|
+
id: ctx.getObjectUri(Object$1, { id }),
|
|
483
|
+
to: person2
|
|
484
|
+
});
|
|
485
|
+
return objects[id]?.() ?? null;
|
|
486
|
+
});
|
|
487
|
+
for (const [id, status] of globalThis.Object.entries({
|
|
488
|
+
"to": 200,
|
|
489
|
+
"cc": 200,
|
|
490
|
+
"bto": 200,
|
|
491
|
+
"bcc": 200,
|
|
492
|
+
"audience": 200,
|
|
493
|
+
"unaddressed": 404,
|
|
494
|
+
"direct": 404,
|
|
495
|
+
"followers": 404,
|
|
496
|
+
"wrong-did": 404,
|
|
497
|
+
"public-create-of-private-note": 200,
|
|
498
|
+
"private-create-of-public-note": 404,
|
|
499
|
+
"collection": 404,
|
|
500
|
+
"public-collection": 200,
|
|
501
|
+
"tombstone": 404,
|
|
502
|
+
"public-tombstone": 410,
|
|
503
|
+
"actor": 200,
|
|
504
|
+
"mutated-audience": 404
|
|
505
|
+
})) await t.step(`${id} → ${status}`, async () => {
|
|
506
|
+
for (const method of ["GET", "HEAD"]) {
|
|
507
|
+
dispatched = 0;
|
|
508
|
+
let response;
|
|
509
|
+
const records = await captureWarnings(async () => {
|
|
510
|
+
response = await federation.fetch(gatewayRequest(`/objects/${id}`, { method }), { contextData: void 0 });
|
|
511
|
+
});
|
|
512
|
+
assertEquals(response?.status, status, method);
|
|
513
|
+
assertEquals(dispatched, 1);
|
|
514
|
+
assertEquals(response?.headers.get("Cache-Control"), null);
|
|
515
|
+
assertEquals(records.filter((r) => r.level === "error").map((r) => r.rawMessage), []);
|
|
516
|
+
if (method === "HEAD") assertEquals(response?.body, null);
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
await t.step("responds to a denied request with onNotFound()", async () => {
|
|
520
|
+
let notFound = false;
|
|
521
|
+
assertEquals((await federation.fetch(gatewayRequest("/objects/direct"), {
|
|
522
|
+
contextData: void 0,
|
|
523
|
+
onNotFound() {
|
|
524
|
+
notFound = true;
|
|
525
|
+
return new Response("Not found", { status: 404 });
|
|
526
|
+
}
|
|
527
|
+
})).status, 404);
|
|
528
|
+
assertEquals(notFound, true);
|
|
529
|
+
});
|
|
530
|
+
await t.step("keeps ordinary object requests unchanged", async () => {
|
|
531
|
+
assertEquals((await federation.fetch(new Request("https://example.com/objects/direct", { headers: { Accept: ACCEPT } }), { contextData: void 0 })).status, 200);
|
|
532
|
+
});
|
|
533
|
+
});
|
|
534
|
+
test("Federation.fetch() serves non-public portable objects to their audience", async (t) => {
|
|
535
|
+
const federation = createTestFederation();
|
|
536
|
+
const person2 = new URL("https://example.com/person2");
|
|
537
|
+
const followers = new URL("https://example.com/users/alice/followers");
|
|
538
|
+
const notes = {
|
|
539
|
+
direct: sign(new Note({
|
|
540
|
+
id: parseIri(`ap+ef61://${did}/notes/direct`),
|
|
541
|
+
to: person2
|
|
542
|
+
})),
|
|
543
|
+
followers: sign(new Note({
|
|
544
|
+
id: parseIri(`ap+ef61://${did}/notes/followers`),
|
|
545
|
+
cc: followers
|
|
546
|
+
})),
|
|
547
|
+
public: signedNote("/notes/public"),
|
|
548
|
+
other: sign(new Note({
|
|
549
|
+
id: parseIri(`ap+ef61://${did}/notes/other`),
|
|
550
|
+
to: new URL("https://example.com/other")
|
|
551
|
+
}))
|
|
552
|
+
};
|
|
553
|
+
federation.setObjectDispatcher(Note, "/notes/{id}", (_ctx, values) => notes[values.id] ?? null).authorize(async (ctx, values) => {
|
|
554
|
+
const note = await notes[values.id];
|
|
555
|
+
return note != null && await ctx.isSignedByAudience(note, { isMember: (addressee, actor) => addressee.href === followers.href && actor.id?.href === person2.href });
|
|
556
|
+
});
|
|
557
|
+
const signed = async (id, key = "key3") => await signRequest(gatewayRequest(`/notes/${id}`), key === "key3" ? rsaPrivateKey3 : rsaPrivateKey2, key === "key3" ? rsaPublicKey3.id : rsaPublicKey2.id);
|
|
558
|
+
await t.step("serves a request signed by an addressee", async () => {
|
|
559
|
+
for (const id of ["direct", "followers"]) {
|
|
560
|
+
const response = await federation.fetch(await signed(id), { contextData: void 0 });
|
|
561
|
+
assertEquals(response.status, 200, id);
|
|
562
|
+
assertEquals(response.headers.get("Cache-Control"), "private", id);
|
|
563
|
+
assertEquals((await response.json()).id, `ap+ef61://${did}/notes/${id}`);
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
await t.step("denies a request by anyone else", async () => {
|
|
567
|
+
for (const request of [
|
|
568
|
+
gatewayRequest("/notes/direct"),
|
|
569
|
+
await signed("direct", "key2"),
|
|
570
|
+
await signed("followers", "key2"),
|
|
571
|
+
await signed("other")
|
|
572
|
+
]) assertEquals((await federation.fetch(request, { contextData: void 0 })).status, 401, request.url);
|
|
573
|
+
});
|
|
574
|
+
await t.step("serves a public object to anyone", async () => {
|
|
575
|
+
const response = await federation.fetch(gatewayRequest("/notes/public"), { contextData: void 0 });
|
|
576
|
+
assertEquals(response.status, 200);
|
|
577
|
+
assertEquals(response.headers.get("Cache-Control"), "private");
|
|
578
|
+
});
|
|
579
|
+
await t.step("serves anything if the predicate allows it", async () => {
|
|
580
|
+
const permissive = createTestFederation();
|
|
581
|
+
permissive.setObjectDispatcher(Note, "/notes/{id}", (_ctx, values) => notes[values.id] ?? null).authorize(() => true);
|
|
582
|
+
const response = await permissive.fetch(gatewayRequest("/notes/direct"), { contextData: void 0 });
|
|
583
|
+
assertEquals(response.status, 200);
|
|
584
|
+
assertEquals(response.headers.get("Cache-Control"), "private");
|
|
585
|
+
});
|
|
586
|
+
});
|
|
587
|
+
test("Federation.fetch() serves portable objects with compatible IDs", async (t) => {
|
|
588
|
+
const federation = createTestFederation();
|
|
589
|
+
const compatible = (path, authority = did, origin = "example.com") => new URL(`https://${origin}/.well-known/apgateway/${authority}${path}`);
|
|
590
|
+
const objects = {
|
|
591
|
+
"compatible": () => sign(new Note({
|
|
592
|
+
to: PUBLIC_COLLECTION,
|
|
593
|
+
id: compatible("/objects/compatible")
|
|
594
|
+
})),
|
|
595
|
+
"other-gateway": () => sign(new Note({
|
|
596
|
+
to: PUBLIC_COLLECTION,
|
|
597
|
+
id: compatible("/objects/other-gateway", did, "other.example")
|
|
598
|
+
})),
|
|
599
|
+
"percent-encoded": () => sign(new Note({
|
|
600
|
+
to: PUBLIC_COLLECTION,
|
|
601
|
+
id: compatible("/objects/percent-encoded", did.replaceAll(":", "%3A"))
|
|
602
|
+
})),
|
|
603
|
+
"other-did": () => sign(new Note({
|
|
604
|
+
to: PUBLIC_COLLECTION,
|
|
605
|
+
id: compatible("/objects/other-did", otherDid)
|
|
606
|
+
}), otherKeyPair.privateKey, otherKeyId),
|
|
607
|
+
"other-path": () => sign(new Note({
|
|
608
|
+
to: PUBLIC_COLLECTION,
|
|
609
|
+
id: compatible("/objects/else")
|
|
610
|
+
})),
|
|
611
|
+
"fragment": () => sign(new Note({
|
|
612
|
+
to: PUBLIC_COLLECTION,
|
|
613
|
+
id: compatible("/objects/fragment#note")
|
|
614
|
+
})),
|
|
615
|
+
"malformed": () => sign(new Note({
|
|
616
|
+
to: PUBLIC_COLLECTION,
|
|
617
|
+
id: new URL(compatible("/objects/malformed").href + "?@gateway=https%3A%2F%2Fexample.com")
|
|
618
|
+
})),
|
|
619
|
+
"ordinary": () => sign(new Note({
|
|
620
|
+
to: PUBLIC_COLLECTION,
|
|
621
|
+
id: new URL("https://example.com/objects/ordinary")
|
|
622
|
+
})),
|
|
623
|
+
"unsigned": () => Promise.resolve(new Note({
|
|
624
|
+
to: PUBLIC_COLLECTION,
|
|
625
|
+
id: compatible("/objects/unsigned")
|
|
626
|
+
})),
|
|
627
|
+
"wrong-did": () => sign(new Note({
|
|
628
|
+
to: PUBLIC_COLLECTION,
|
|
629
|
+
id: compatible("/objects/wrong-did")
|
|
630
|
+
}), otherKeyPair.privateKey, otherKeyId),
|
|
631
|
+
"mutated-id": async () => {
|
|
632
|
+
const signed = await sign(new Note({
|
|
633
|
+
to: PUBLIC_COLLECTION,
|
|
634
|
+
id: compatible("/objects/original")
|
|
635
|
+
}));
|
|
636
|
+
const object = await Note.fromJsonLd(await signed.toJsonLd({ contextLoader: mockDocumentLoader }), {
|
|
637
|
+
contextLoader: mockDocumentLoader,
|
|
638
|
+
documentLoader: mockDocumentLoader
|
|
639
|
+
});
|
|
640
|
+
object.id.pathname = object.id.pathname.replace(/original$/, "mutated-id");
|
|
641
|
+
return object;
|
|
642
|
+
},
|
|
643
|
+
"actor": () => sign(new Person({
|
|
644
|
+
id: compatible("/objects/actor"),
|
|
645
|
+
inbox: compatible("/objects/actor/inbox"),
|
|
646
|
+
outbox: compatible("/objects/actor/outbox"),
|
|
647
|
+
gateways: [new URL("https://example.com/")]
|
|
648
|
+
}))
|
|
649
|
+
};
|
|
650
|
+
federation.setObjectDispatcher(Object$1, "/objects/{id}", (_ctx, { id }) => objects[id]?.() ?? null);
|
|
651
|
+
const expected = {
|
|
652
|
+
"compatible": 200,
|
|
653
|
+
"other-gateway": 200,
|
|
654
|
+
"percent-encoded": 200,
|
|
655
|
+
"other-did": 404,
|
|
656
|
+
"other-path": 404,
|
|
657
|
+
"fragment": 404,
|
|
658
|
+
"malformed": 404,
|
|
659
|
+
"ordinary": 404,
|
|
660
|
+
"unsigned": 500,
|
|
661
|
+
"wrong-did": 500,
|
|
662
|
+
"mutated-id": 404,
|
|
663
|
+
"actor": 200
|
|
664
|
+
};
|
|
665
|
+
for (const host of ["example.com", "other.example"]) for (const [id, status] of globalThis.Object.entries(expected)) await t.step(`${id} on ${host} → ${status}`, async () => {
|
|
666
|
+
const response = await federation.fetch(new Request(`https://${host}/.well-known/apgateway/${did}/objects/${id}`, { headers: { Accept: ACCEPT } }), { contextData: void 0 });
|
|
667
|
+
assertEquals(response.status, status);
|
|
668
|
+
if (status !== 200) return;
|
|
669
|
+
assertEquals(response.headers.get("Content-Type"), PORTABLE_OBJECT_CONTENT_TYPE);
|
|
670
|
+
assertEquals((await response.json()).id, (await objects[id]()).id?.href);
|
|
671
|
+
});
|
|
672
|
+
await t.step("authorizes compatible-ID objects on any gateway", async () => {
|
|
673
|
+
const authorized = createTestFederation();
|
|
674
|
+
authorized.setObjectDispatcher(Note, "/notes/{id}", (_ctx, values) => sign(new Note({ id: compatible(`/notes/${values.id}`) }))).authorize(async (ctx) => {
|
|
675
|
+
return (await ctx.getSignedKeyOwner())?.id?.href === "https://example.com/person2";
|
|
676
|
+
});
|
|
677
|
+
for (const host of ["example.com", "other.example"]) {
|
|
678
|
+
const url = `https://${host}/.well-known/apgateway/${did}/notes/1`;
|
|
679
|
+
const request = new Request(url, { headers: { Accept: ACCEPT } });
|
|
680
|
+
let response = await authorized.fetch(request, { contextData: void 0 });
|
|
681
|
+
assertEquals(response.status, 401, host);
|
|
682
|
+
response = await authorized.fetch(await signRequest(new Request(url, { headers: { Accept: ACCEPT } }), rsaPrivateKey3, rsaPublicKey3.id), { contextData: void 0 });
|
|
683
|
+
assertEquals(response.status, 200, host);
|
|
684
|
+
}
|
|
685
|
+
});
|
|
686
|
+
});
|
|
687
|
+
test("Federation.fetch() lets application routes shadow the gateway", async () => {
|
|
688
|
+
const federation = createTestFederation();
|
|
689
|
+
const paths = [];
|
|
690
|
+
federation.setObjectDispatcher(Note, "/{+path}", (_ctx, { path }) => {
|
|
691
|
+
paths.push(path);
|
|
692
|
+
return null;
|
|
693
|
+
});
|
|
694
|
+
assertEquals((await federation.fetch(gatewayRequest("/notes/1"), { contextData: void 0 })).status, 404);
|
|
695
|
+
assertEquals(paths, [`.well-known/apgateway/${did}/notes/1`]);
|
|
696
|
+
});
|
|
697
|
+
test("Federation.fetch() compares portable IDs with the requested path", async () => {
|
|
698
|
+
const federation = createTestFederation();
|
|
699
|
+
federation.setObjectDispatcher(Note, "/notes/{id}", async (ctx, values) => {
|
|
700
|
+
if (values.id === "a/b") return await sign(new Note({
|
|
701
|
+
id: ctx.getPortableObjectUri(Note, values),
|
|
702
|
+
to: PUBLIC_COLLECTION
|
|
703
|
+
}));
|
|
704
|
+
return await signedNote(`/notes/${values.id}`);
|
|
705
|
+
});
|
|
706
|
+
let response = await federation.fetch(gatewayRequest("/notes/a%2Fb"), { contextData: void 0 });
|
|
707
|
+
assertEquals(response.status, 200);
|
|
708
|
+
assertEquals((await response.json()).id, `ap+ef61://${did}/notes/a%2Fb`);
|
|
709
|
+
const tolerant = createFederation({
|
|
710
|
+
kv: new MemoryKvStore(),
|
|
711
|
+
documentLoaderFactory: () => mockDocumentLoader,
|
|
712
|
+
contextLoaderFactory: () => mockDocumentLoader,
|
|
713
|
+
trailingSlashInsensitive: true
|
|
714
|
+
});
|
|
715
|
+
tolerant.setObjectDispatcher(Note, "/notes/{id}", (_ctx, values) => signedNote(`/notes/${values.id}`));
|
|
716
|
+
response = await tolerant.fetch(gatewayRequest("/notes/1/"), { contextData: void 0 });
|
|
717
|
+
assertEquals(response.status, 404);
|
|
718
|
+
});
|
|
719
|
+
test("RequestContext.portableRequest", async (t) => {
|
|
720
|
+
const federation = createTestFederation();
|
|
721
|
+
const contexts = [];
|
|
722
|
+
federation.setObjectDispatcher(Tombstone, "/tombstones/{id}", (ctx) => {
|
|
723
|
+
contexts.push(ctx);
|
|
724
|
+
return null;
|
|
725
|
+
});
|
|
726
|
+
federation.setObjectDispatcher(Note, "/notes/{id}", async (ctx, values) => {
|
|
727
|
+
contexts.push(ctx);
|
|
728
|
+
ctx.portableRequest.id.pathname = "/notes/other";
|
|
729
|
+
await ctx.getObject(Tombstone, { id: values.id });
|
|
730
|
+
contexts.push(ctx.clone(void 0));
|
|
731
|
+
return await sign(new Note({
|
|
732
|
+
id: ctx.getPortableObjectUri(Note, values),
|
|
733
|
+
to: PUBLIC_COLLECTION
|
|
734
|
+
}));
|
|
735
|
+
});
|
|
736
|
+
assertEquals((await federation.fetch(gatewayRequest("/notes/1"), { contextData: void 0 })).status, 200);
|
|
737
|
+
await t.step("is frozen and returns a new URL each time", () => {
|
|
738
|
+
const [ctx] = contexts;
|
|
739
|
+
const portableRequest = ctx.portableRequest;
|
|
740
|
+
assertEquals(globalThis.Object.isFrozen(portableRequest), true);
|
|
741
|
+
assertEquals(formatIri(portableRequest.id), `ap+ef61://${did}/notes/1`);
|
|
742
|
+
assertInstanceOf(portableRequest.id, URL);
|
|
743
|
+
const first = portableRequest.id;
|
|
744
|
+
const second = portableRequest.id;
|
|
745
|
+
assertEquals(first === second, false);
|
|
746
|
+
});
|
|
747
|
+
await t.step("is kept by derived contexts", () => {
|
|
748
|
+
assertEquals(contexts.length, 3);
|
|
749
|
+
assertStrictEquals(contexts[1].portableRequest, contexts[0].portableRequest);
|
|
750
|
+
assertStrictEquals(contexts[2].portableRequest, contexts[0].portableRequest);
|
|
751
|
+
});
|
|
752
|
+
await t.step("is undefined for ordinary requests", () => {
|
|
753
|
+
assertEquals(federation.createContext(new Request("https://example.com/notes/1"), void 0).portableRequest, void 0);
|
|
754
|
+
});
|
|
755
|
+
});
|
|
756
|
+
test("Context.getPortableObjectUri()", async (t) => {
|
|
757
|
+
const federation = createTestFederation();
|
|
758
|
+
federation.setObjectDispatcher(Note, "/users/{userId}/notes/{noteId}", () => null);
|
|
759
|
+
const ctx = federation.createContext(new URL("https://example.com/"), void 0);
|
|
760
|
+
const values = {
|
|
761
|
+
userId: "alice",
|
|
762
|
+
noteId: "123"
|
|
763
|
+
};
|
|
764
|
+
await t.step("builds a portable ID from the object path", () => {
|
|
765
|
+
const uri = ctx.getPortableObjectUri(Note, values, did);
|
|
766
|
+
assertInstanceOf(uri, URL);
|
|
767
|
+
assertEquals(uri.protocol, "ap+ef61:");
|
|
768
|
+
assertEquals(formatIri(uri), `ap+ef61://${did}/users/alice/notes/123`);
|
|
769
|
+
assertEquals(ctx.getObjectUri(Note, values), new URL("https://example.com/users/alice/notes/123"));
|
|
770
|
+
});
|
|
771
|
+
await t.step("requires a bare DID authority", () => {
|
|
772
|
+
for (const authority of [
|
|
773
|
+
`${did}/extra`,
|
|
774
|
+
`${did}?query`,
|
|
775
|
+
`${did}#fragment`,
|
|
776
|
+
"did:key:",
|
|
777
|
+
"https://example.com",
|
|
778
|
+
""
|
|
779
|
+
]) assertThrows(() => ctx.getPortableObjectUri(Note, values, authority), TypeError, void 0, authority);
|
|
780
|
+
});
|
|
781
|
+
await t.step("requires an authority outside gateway requests", () => {
|
|
782
|
+
const requestCtx = federation.createContext(new Request("https://example.com/"), void 0);
|
|
783
|
+
assertThrows(() => requestCtx.getPortableObjectUri(Note, values), TypeError);
|
|
784
|
+
assertEquals(formatIri(requestCtx.getPortableObjectUri(Note, values, did)), `ap+ef61://${did}/users/alice/notes/123`);
|
|
785
|
+
});
|
|
786
|
+
await t.step("validates values like getObjectUri()", () => {
|
|
787
|
+
assertThrows(() => ctx.getPortableObjectUri(Note, { userId: "alice" }, did), TypeError);
|
|
788
|
+
assertThrows(() => ctx.getPortableObjectUri(Person, values, did));
|
|
789
|
+
});
|
|
790
|
+
});
|
|
791
|
+
test("Federation.fetch() serves portable actors through WebFinger", async () => {
|
|
792
|
+
const federation = createTestFederation();
|
|
793
|
+
const getActor = async (ctx, name) => {
|
|
794
|
+
if (name !== "alice") return null;
|
|
795
|
+
return await sign(new Person({
|
|
796
|
+
id: ctx.getPortableObjectUri(Person, { name }, did),
|
|
797
|
+
preferredUsername: name,
|
|
798
|
+
gateways: [new URL("https://example.com")]
|
|
799
|
+
}));
|
|
800
|
+
};
|
|
801
|
+
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => getActor(ctx, identifier)).mapHandle((_ctx, username) => username);
|
|
802
|
+
federation.setObjectDispatcher(Person, "/actors/{name}", (ctx, values) => ctx.portableRequest?.authority === did ? getActor(ctx, values.name) : null);
|
|
803
|
+
const webFinger = await federation.fetch(new Request("https://example.com/.well-known/webfinger?resource=acct:alice@example.com"), { contextData: void 0 });
|
|
804
|
+
assertEquals(webFinger.status, 200);
|
|
805
|
+
const jrd = await webFinger.json();
|
|
806
|
+
const compatibleId = gatewayUrl("/actors/alice");
|
|
807
|
+
assertEquals(jrd.subject, "acct:alice@example.com");
|
|
808
|
+
assertEquals(jrd.links?.[0], {
|
|
809
|
+
rel: "self",
|
|
810
|
+
href: compatibleId,
|
|
811
|
+
type: "application/activity+json"
|
|
812
|
+
});
|
|
813
|
+
const documentLoader = async (url) => {
|
|
814
|
+
const response = await federation.fetch(new Request(url, { headers: { Accept: ACCEPT } }), { contextData: void 0 });
|
|
815
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}: ${url}`);
|
|
816
|
+
return {
|
|
817
|
+
contextUrl: null,
|
|
818
|
+
documentUrl: url,
|
|
819
|
+
document: await response.json()
|
|
820
|
+
};
|
|
821
|
+
};
|
|
822
|
+
const actor = await lookupObject(compatibleId, {
|
|
823
|
+
documentLoader,
|
|
824
|
+
contextLoader: mockDocumentLoader,
|
|
825
|
+
verifyPortableObject: verifyPortableObjectProof
|
|
826
|
+
});
|
|
827
|
+
assertInstanceOf(actor, Person);
|
|
828
|
+
assertEquals(formatIri(actor.id), `ap+ef61://${did}/actors/alice`);
|
|
829
|
+
assertEquals(actor.preferredUsername, "alice");
|
|
830
|
+
const ctx = federation.createContext(new URL("https://example.com/"), void 0);
|
|
831
|
+
assertInstanceOf(await ctx.lookupObject(compatibleId, { documentLoader }), Person);
|
|
832
|
+
assertInstanceOf(await ctx.lookupObject(`ap://${did}/actors/alice`, {
|
|
833
|
+
documentLoader,
|
|
834
|
+
gateways: ["https://example.com"]
|
|
835
|
+
}), Person);
|
|
836
|
+
const unsigned = {
|
|
837
|
+
"@context": "https://www.w3.org/ns/activitystreams",
|
|
838
|
+
id: `ap://${did}/actors/alice`,
|
|
839
|
+
type: "Person"
|
|
840
|
+
};
|
|
841
|
+
assertEquals(await ctx.lookupObject(compatibleId, { documentLoader: (url) => Promise.resolve({
|
|
842
|
+
contextUrl: null,
|
|
843
|
+
documentUrl: url,
|
|
844
|
+
document: unsigned
|
|
845
|
+
}) }), null);
|
|
846
|
+
});
|
|
847
|
+
/**
|
|
848
|
+
* Captures Fedify's warnings logged while `run` executes.
|
|
849
|
+
*/
|
|
850
|
+
async function captureWarnings(run) {
|
|
851
|
+
const records = [];
|
|
852
|
+
await reset();
|
|
853
|
+
try {
|
|
854
|
+
await configure({
|
|
855
|
+
sinks: { buffer: (record) => records.push(record) },
|
|
856
|
+
filters: {},
|
|
857
|
+
loggers: [{
|
|
858
|
+
category: ["logtape", "meta"],
|
|
859
|
+
sinks: []
|
|
860
|
+
}, {
|
|
861
|
+
category: [],
|
|
862
|
+
sinks: ["buffer"],
|
|
863
|
+
lowestLevel: "warning"
|
|
864
|
+
}]
|
|
865
|
+
});
|
|
866
|
+
await run();
|
|
867
|
+
} finally {
|
|
868
|
+
await reset();
|
|
869
|
+
}
|
|
870
|
+
return records.filter((record) => record.category[0] === "fedify");
|
|
871
|
+
}
|
|
872
|
+
test("object responses warn about compatible IDs off the owner's first gateway", async () => {
|
|
873
|
+
const federation = createTestFederation();
|
|
874
|
+
const ownerId = parseIri(`ap://${did}/users/alice`);
|
|
875
|
+
const gateways = [new URL("https://primary.example"), new URL("https://secondary.example")];
|
|
876
|
+
const secondary = await sign(new Note({
|
|
877
|
+
id: toCompatibleEf61Id(parseIri(`ap://${did}/notes/secondary`), gateways[1]),
|
|
878
|
+
attribution: ownerId,
|
|
879
|
+
to: PUBLIC_COLLECTION,
|
|
880
|
+
content: "Hello"
|
|
881
|
+
}));
|
|
882
|
+
let actorCalls = 0;
|
|
883
|
+
let actorLookupFails = false;
|
|
884
|
+
federation.setActorDispatcher("/users/{identifier}", (_ctx, identifier) => {
|
|
885
|
+
actorCalls++;
|
|
886
|
+
if (actorLookupFails) throw new Error("Actor storage unavailable");
|
|
887
|
+
return identifier === "alice" ? new Person({
|
|
888
|
+
id: ownerId,
|
|
889
|
+
gateways
|
|
890
|
+
}) : null;
|
|
891
|
+
});
|
|
892
|
+
federation.setObjectDispatcher(Note, "/notes/{id}", async (_ctx, values) => {
|
|
893
|
+
if (values.id === "secondary") return secondary;
|
|
894
|
+
const note = new Note({
|
|
895
|
+
id: values.id === "portable" ? parseIri(`ap://${did}/notes/portable`) : values.id === "ordinary" ? new URL("https://example.com/notes/ordinary") : toCompatibleEf61Id(parseIri(`ap://${did}/notes/${values.id}`), values.id === "secondary" || values.id === "retained" ? gateways[1] : gateways[0]),
|
|
896
|
+
attribution: ownerId,
|
|
897
|
+
to: PUBLIC_COLLECTION,
|
|
898
|
+
content: "Hello"
|
|
899
|
+
});
|
|
900
|
+
if (values.id === "retained") {
|
|
901
|
+
const raw = await (await sign(note)).toJsonLd({
|
|
902
|
+
format: "compact",
|
|
903
|
+
contextLoader: mockDocumentLoader
|
|
904
|
+
});
|
|
905
|
+
const parsed = await Note.fromJsonLd(raw, {
|
|
906
|
+
contextLoader: mockDocumentLoader,
|
|
907
|
+
documentLoader: mockDocumentLoader
|
|
908
|
+
});
|
|
909
|
+
parsed.id.hostname = "primary.example";
|
|
910
|
+
return parsed;
|
|
911
|
+
}
|
|
912
|
+
return note;
|
|
913
|
+
});
|
|
914
|
+
federation.setObjectDispatcher(Create, "/activities/{id}", (_ctx, values) => new Create({
|
|
915
|
+
id: toCompatibleEf61Id(parseIri(`ap://${did}/activities/${values.id}`), gateways[1]),
|
|
916
|
+
actor: ownerId
|
|
917
|
+
}));
|
|
918
|
+
for (const [id, expected] of [
|
|
919
|
+
["secondary", 1],
|
|
920
|
+
["retained", 1],
|
|
921
|
+
["primary", 0],
|
|
922
|
+
["portable", 0],
|
|
923
|
+
["ordinary", 0]
|
|
924
|
+
]) assertEquals((await captureWarnings(async () => {
|
|
925
|
+
const response = await federation.fetch(new Request(`https://example.com/notes/${id}`, { headers: { Accept: ACCEPT } }), { contextData: void 0 });
|
|
926
|
+
assertEquals(response.status, 200);
|
|
927
|
+
const body = await response.json();
|
|
928
|
+
assertEquals(body.type, "Note");
|
|
929
|
+
if (id === "retained") assertEquals(body.id, `https://secondary.example/.well-known/apgateway/${did}/notes/retained`);
|
|
930
|
+
})).filter((r) => r.properties.kind === "object").length, expected, id);
|
|
931
|
+
assertEquals((await captureWarnings(async () => {
|
|
932
|
+
assertEquals((await federation.fetch(new Request("https://example.com/activities/1", { headers: { Accept: ACCEPT } }), { contextData: void 0 })).status, 200);
|
|
933
|
+
})).filter((r) => r.properties.kind === "activity").length, 1);
|
|
934
|
+
for (const id of ["secondary", "retained"]) assertEquals((await captureWarnings(async () => {
|
|
935
|
+
assertEquals((await federation.fetch(gatewayRequest(`/notes/${id}`), { contextData: void 0 })).status, 200);
|
|
936
|
+
})).filter((r) => r.properties.kind === "object").length, 1);
|
|
937
|
+
actorLookupFails = true;
|
|
938
|
+
const expectedBody = await secondary.toJsonLd({
|
|
939
|
+
format: "compact",
|
|
940
|
+
contextLoader: mockDocumentLoader
|
|
941
|
+
});
|
|
942
|
+
for (const request of [new Request("https://example.com/notes/secondary", { headers: { Accept: ACCEPT } }), gatewayRequest("/notes/secondary")]) assertEquals((await captureWarnings(async () => {
|
|
943
|
+
const response = await federation.fetch(request, { contextData: void 0 });
|
|
944
|
+
assertEquals(response.status, 200);
|
|
945
|
+
assertEquals(await response.json(), expectedBody);
|
|
946
|
+
})).filter((r) => r.properties.kind != null), []);
|
|
947
|
+
assertEquals(actorCalls, 8);
|
|
948
|
+
});
|
|
949
|
+
test("Federation.fetch() serves compatible-ID actors", async (t) => {
|
|
950
|
+
const federation = createTestFederation();
|
|
951
|
+
const actorGateways = {
|
|
952
|
+
alice: ["https://example.com"],
|
|
953
|
+
secondary: ["https://primary.example", "https://example.com"],
|
|
954
|
+
nogateway: [],
|
|
955
|
+
malformed: ["https://example.com"]
|
|
956
|
+
};
|
|
957
|
+
const getActor = async (ctx, name) => {
|
|
958
|
+
const gateways = actorGateways[name];
|
|
959
|
+
if (gateways == null) return null;
|
|
960
|
+
const id = toCompatibleEf61Id(ctx.getPortableObjectUri(Person, { name }, did), "https://example.com");
|
|
961
|
+
if (name === "malformed") id.search = "?@gateway=https%3A%2F%2Fexample.com";
|
|
962
|
+
return await sign(new Person({
|
|
963
|
+
id,
|
|
964
|
+
preferredUsername: name,
|
|
965
|
+
inbox: new URL(`${id.href}/inbox`),
|
|
966
|
+
followers: toCompatibleEf61Id(ctx.getPortableFollowersUri(name, did), "https://example.com"),
|
|
967
|
+
gateways: gateways.map((g) => new URL(g))
|
|
968
|
+
}));
|
|
969
|
+
};
|
|
970
|
+
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => getActor(ctx, identifier)).mapHandle((_ctx, username) => username).mapAlias((_ctx, resource) => {
|
|
971
|
+
const match = resource.href.match(/\/actors\/([^/?#]+)$/);
|
|
972
|
+
return match == null || !resource.href.startsWith(gatewayUrl("/")) ? null : { identifier: match[1] };
|
|
973
|
+
});
|
|
974
|
+
federation.setFollowersDispatcher("/users/{identifier}/followers", () => ({ items: [] }));
|
|
975
|
+
federation.setObjectDispatcher(Person, "/actors/{name}", (ctx, values) => ctx.portableRequest?.authority === did ? getActor(ctx, values.name) : null);
|
|
976
|
+
const compatibleId = gatewayUrl("/actors/alice");
|
|
977
|
+
await t.step("WebFinger", async () => {
|
|
978
|
+
let records = [];
|
|
979
|
+
records = await captureWarnings(async () => {
|
|
980
|
+
for (const resource of ["acct:alice@example.com", compatibleId]) {
|
|
981
|
+
const response = await federation.fetch(new Request(`https://example.com/.well-known/webfinger?resource=${encodeURIComponent(resource)}`), { contextData: void 0 });
|
|
982
|
+
assertEquals(response.status, 200, resource);
|
|
983
|
+
const jrd = await response.json();
|
|
984
|
+
assertEquals(jrd.subject, "acct:alice@example.com", resource);
|
|
985
|
+
assertEquals(jrd.links?.[0], {
|
|
986
|
+
rel: "self",
|
|
987
|
+
href: compatibleId,
|
|
988
|
+
type: "application/activity+json"
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
});
|
|
992
|
+
assertEquals(records.map((r) => r.rawMessage), []);
|
|
993
|
+
});
|
|
994
|
+
await t.step("gateway", async () => {
|
|
995
|
+
const response = await federation.fetch(new Request(compatibleId, { headers: { Accept: ACCEPT } }), { contextData: void 0 });
|
|
996
|
+
assertEquals(response.status, 200);
|
|
997
|
+
const json = await response.json();
|
|
998
|
+
assertEquals(json.id, compatibleId);
|
|
999
|
+
const actor = await lookupObject(compatibleId, {
|
|
1000
|
+
documentLoader: (url) => Promise.resolve({
|
|
1001
|
+
contextUrl: null,
|
|
1002
|
+
documentUrl: url,
|
|
1003
|
+
document: json
|
|
1004
|
+
}),
|
|
1005
|
+
contextLoader: mockDocumentLoader,
|
|
1006
|
+
verifyPortableObject: verifyPortableObjectProof
|
|
1007
|
+
});
|
|
1008
|
+
assertInstanceOf(actor, Person);
|
|
1009
|
+
assertEquals(actor.id?.href, compatibleId);
|
|
1010
|
+
});
|
|
1011
|
+
await t.step("warns if the ID is not on the first gateway", async () => {
|
|
1012
|
+
assertEquals((await captureWarnings(async () => {
|
|
1013
|
+
const response = await federation.fetch(new Request("https://example.com/.well-known/webfinger?resource=acct:secondary@example.com"), { contextData: void 0 });
|
|
1014
|
+
assertEquals(response.status, 200);
|
|
1015
|
+
const jrd = await response.json();
|
|
1016
|
+
assertEquals(jrd.subject, "acct:secondary@primary.example");
|
|
1017
|
+
assertEquals(jrd.links?.[0].href, gatewayUrl("/actors/secondary"));
|
|
1018
|
+
})).map((r) => r.properties.gateway), ["https://primary.example"]);
|
|
1019
|
+
});
|
|
1020
|
+
await t.step("warns if the actor has no gateways", async () => {
|
|
1021
|
+
const records = await captureWarnings(async () => {
|
|
1022
|
+
assertEquals((await federation.fetch(new Request("https://example.com/users/nogateway", { headers: { Accept: ACCEPT } }), { contextData: void 0 })).status, 200);
|
|
1023
|
+
});
|
|
1024
|
+
assertEquals(records.map((r) => r.properties.actorId), [gatewayUrl("/actors/nogateway")]);
|
|
1025
|
+
assert(String(records[0].rawMessage).includes("gateways property"));
|
|
1026
|
+
});
|
|
1027
|
+
await t.step("warns if the ID is a malformed compatible identifier", async () => {
|
|
1028
|
+
const records = await captureWarnings(async () => {
|
|
1029
|
+
assertEquals((await federation.fetch(new Request("https://example.com/users/malformed", { headers: { Accept: ACCEPT } }), { contextData: void 0 })).status, 200);
|
|
1030
|
+
});
|
|
1031
|
+
assertEquals(records.length, 1);
|
|
1032
|
+
assert(String(records[0].rawMessage).includes("malformed"));
|
|
1033
|
+
});
|
|
1034
|
+
});
|
|
1035
|
+
test("Federation.fetch() warns about portable actors without valid gateways", async (t) => {
|
|
1036
|
+
const federation = createTestFederation();
|
|
1037
|
+
const actors = {
|
|
1038
|
+
valid: (ctx) => new Person({
|
|
1039
|
+
id: ctx.getPortableObjectUri(Person, { name: "valid" }, did),
|
|
1040
|
+
gateways: [new URL("https://example.com"), new URL("https://other.example/")]
|
|
1041
|
+
}),
|
|
1042
|
+
empty: (ctx) => new Person({ id: ctx.getPortableObjectUri(Person, { name: "empty" }, did) }),
|
|
1043
|
+
mutated: (ctx) => {
|
|
1044
|
+
const actor = new Person({
|
|
1045
|
+
id: ctx.getPortableObjectUri(Person, { name: "mutated" }, did),
|
|
1046
|
+
gateways: [new URL("https://example.com"), new URL("https://other.example/")]
|
|
1047
|
+
});
|
|
1048
|
+
actor.gateways[1].pathname = "/gateway";
|
|
1049
|
+
return actor;
|
|
1050
|
+
},
|
|
1051
|
+
https: (ctx) => new Person({ id: ctx.getActorUri("https") }),
|
|
1052
|
+
tombstone: (ctx) => new Tombstone({ id: ctx.getPortableObjectUri(Person, { name: "tombstone" }, did) })
|
|
1053
|
+
};
|
|
1054
|
+
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => actors[identifier]?.(ctx) ?? null);
|
|
1055
|
+
federation.setObjectDispatcher(Person, "/actors/{name}", () => null);
|
|
1056
|
+
const expected = {
|
|
1057
|
+
valid: [],
|
|
1058
|
+
empty: [`ap+ef61://${did}/actors/empty`],
|
|
1059
|
+
mutated: [`ap+ef61://${did}/actors/mutated`],
|
|
1060
|
+
https: [],
|
|
1061
|
+
tombstone: []
|
|
1062
|
+
};
|
|
1063
|
+
for (const [identifier, actorIds] of globalThis.Object.entries(expected)) await t.step(identifier, async () => {
|
|
1064
|
+
const actorRecords = (await captureWarnings(async () => {
|
|
1065
|
+
await federation.fetch(new Request(`https://example.com/users/${identifier}`, { headers: { Accept: ACCEPT } }), { contextData: void 0 });
|
|
1066
|
+
})).filter((r) => r.category.join(".") === "fedify.federation.actor");
|
|
1067
|
+
assertEquals(actorRecords.map((r) => r.properties.actorId), actorIds.map((id) => parseIri(id).href));
|
|
1068
|
+
for (const record of actorRecords) assert(String(record.rawMessage).includes("gateways property"));
|
|
1069
|
+
});
|
|
1070
|
+
});
|
|
1071
|
+
//#endregion
|
|
1072
|
+
export {};
|