This release is 23 versions behind 1.4.9 — the latest version of @fedify/fedify. Jump to latest
Built and signed on GitHub ActionsBuilt and signed on GitHub Actions
Built and signed on GitHub Actions
An ActivityPub/fediverse server framework
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534import { Invite } from "../mod.ts"; import { assert, assertEquals, assertFalse, assertInstanceOf, assertRejects, assertStrictEquals, assertThrows, } from "jsr:@std/assert@^0.226.0"; import { dirname, join } from "jsr:@std/path@^1.0.6"; import * as mf from "jsr:@hongminhee/deno-mock-fetch@^0.3.2"; import { fetchDocumentLoader, FetchError, getAuthenticatedDocumentLoader, } from "../runtime/docloader.ts"; import { signRequest, verifyRequest } from "../sig/http.ts"; import type { KeyCache } from "../sig/key.ts"; import { detachSignature, signJsonLd, verifyJsonLd } from "../sig/ld.ts"; import { doesActorOwnKey } from "../sig/owner.ts"; import { signObject, verifyObject } from "../sig/proof.ts"; import { mockDocumentLoader } from "../testing/docloader.ts"; import { ed25519Multikey, ed25519PrivateKey, ed25519PublicKey, rsaPrivateKey2, rsaPrivateKey3, rsaPublicKey2, rsaPublicKey3, } from "../testing/keys.ts"; import { test } from "../testing/mod.ts"; import { lookupObject } from "../vocab/lookup.ts"; import { getTypeId } from "../vocab/type.ts"; import { Activity, Announce, Create, type CryptographicKey, Multikey, Note, Object, Offer, Person, } from "../vocab/vocab.ts"; import type { Context } from "./context.ts"; import { MemoryKvStore } from "./kv.ts"; import { ContextImpl, createFederation, FederationImpl, InboxContextImpl, } from "./middleware.ts"; import { RouterError } from "./router.ts"; test("createFederation()", () => { const kv = new MemoryKvStore(); assertThrows(() => createFederation<number>({ kv, documentLoader: mockDocumentLoader, allowPrivateAddress: true, }), TypeError); assertThrows(() => createFederation<number>({ kv, contextLoader: mockDocumentLoader, allowPrivateAddress: true, }), TypeError); assertThrows(() => createFederation<number>({ kv, authenticatedDocumentLoaderFactory: () => mockDocumentLoader, allowPrivateAddress: true, }), TypeError); }); test("Federation.createContext()", async (t) => { const kv = new MemoryKvStore(); const documentLoader = (url: string) => { throw new FetchError(new URL(url), "Not found"); }; mf.install(); mf.mock("GET@/object", async (req) => { const v = await verifyRequest( req, { contextLoader: mockDocumentLoader, documentLoader: mockDocumentLoader, currentTime: Temporal.Now.instant(), }, ); return new Response(JSON.stringify(v != null), { headers: { "Content-Type": "application/json" }, }); }); await t.step("Context", async () => { const federation = createFederation<number>({ kv, documentLoader, contextLoader: mockDocumentLoader, }); let ctx = federation.createContext( new URL("https://example.com:1234/"), 123, ); assertEquals(ctx.data, 123); assertEquals(ctx.origin, "https://example.com:1234"); assertEquals(ctx.host, "example.com:1234"); assertEquals(ctx.hostname, "example.com"); assertStrictEquals(ctx.documentLoader, documentLoader); assertStrictEquals(ctx.contextLoader, mockDocumentLoader); assertThrows(() => ctx.getNodeInfoUri(), RouterError); assertThrows(() => ctx.getActorUri("handle"), RouterError); assertThrows( () => ctx.getObjectUri(Note, { handle: "handle", id: "id" }), RouterError, ); assertThrows(() => ctx.getInboxUri(), RouterError); assertThrows(() => ctx.getInboxUri("handle"), RouterError); assertThrows(() => ctx.getOutboxUri("handle"), RouterError); assertThrows(() => ctx.getFollowingUri("handle"), RouterError); assertThrows(() => ctx.getFollowersUri("handle"), RouterError); assertThrows(() => ctx.getLikedUri("handle"), RouterError); assertThrows(() => ctx.getFeaturedUri("handle"), RouterError); assertThrows(() => ctx.getFeaturedTagsUri("handle"), RouterError); assertEquals(ctx.parseUri(new URL("https://example.com/")), null); assertEquals(ctx.parseUri(null), null); assertEquals(await ctx.getActorKeyPairs("handle"), []); await assertRejects( () => ctx.getDocumentLoader({ identifier: "handle" }), Error, "No actor key pairs dispatcher registered", ); await assertRejects( () => ctx.sendActivity({ identifier: "handle" }, [], new Create({})), Error, "No actor key pairs dispatcher registered", ); federation.setNodeInfoDispatcher("/nodeinfo/2.1", () => ({ software: { name: "Example", version: { major: 1, minor: 2, patch: 3 }, }, protocols: ["activitypub"], usage: { users: {}, localPosts: 123, localComments: 456, }, })); ctx = federation.createContext(new URL("https://example.com/"), 123); assertEquals( ctx.getNodeInfoUri(), new URL("https://example.com/nodeinfo/2.1"), ); federation .setActorDispatcher("/users/{identifier}", () => new Person({})) .setKeyPairsDispatcher(() => [ { privateKey: rsaPrivateKey2, publicKey: rsaPublicKey2.publicKey!, }, { privateKey: ed25519PrivateKey, publicKey: ed25519PublicKey.publicKey!, }, ]) .mapHandle((_, username) => username === "HANDLE" ? "handle" : null); ctx = federation.createContext(new URL("https://example.com/"), 123); assertEquals( ctx.getActorUri("handle"), new URL("https://example.com/users/handle"), ); assertEquals(ctx.parseUri(new URL("https://example.com/")), null); assertEquals( ctx.parseUri(new URL("https://example.com/users/handle")), { type: "actor", identifier: "handle", handle: "handle" }, ); assertEquals(ctx.parseUri(null), null); assertEquals( await ctx.getActorKeyPairs("handle"), [ { keyId: new URL("https://example.com/users/handle#main-key"), privateKey: rsaPrivateKey2, publicKey: rsaPublicKey2.publicKey!, cryptographicKey: rsaPublicKey2.clone({ id: new URL("https://example.com/users/handle#main-key"), owner: new URL("https://example.com/users/handle"), }), multikey: new Multikey({ id: new URL("https://example.com/users/handle#main-key"), controller: new URL("https://example.com/users/handle"), publicKey: rsaPublicKey2.publicKey!, }), }, { keyId: new URL("https://example.com/users/handle#key-2"), privateKey: ed25519PrivateKey, publicKey: ed25519PublicKey.publicKey!, cryptographicKey: ed25519PublicKey.clone({ id: new URL("https://example.com/users/handle#key-2"), owner: new URL("https://example.com/users/handle"), }), multikey: new Multikey({ id: new URL("https://example.com/users/handle#key-2"), controller: new URL("https://example.com/users/handle"), publicKey: ed25519PublicKey.publicKey!, }), }, ], ); const loader = await ctx.getDocumentLoader({ identifier: "handle" }); assertEquals(await loader("https://example.com/object"), { contextUrl: null, documentUrl: "https://example.com/object", document: true, }); const loader2 = await ctx.getDocumentLoader({ username: "HANDLE" }); assertEquals(await loader2("https://example.com/object"), { contextUrl: null, documentUrl: "https://example.com/object", document: true, }); const loader3 = ctx.getDocumentLoader({ keyId: new URL("https://example.com/key2"), privateKey: rsaPrivateKey2, }); assertEquals(await loader3("https://example.com/object"), { contextUrl: null, documentUrl: "https://example.com/object", document: true, }); assertEquals(await ctx.lookupObject("https://example.com/object"), null); await assertRejects( () => ctx.sendActivity({ identifier: "handle" }, [], new Create({})), TypeError, "The activity to send must have at least one actor property.", ); await ctx.sendActivity( { identifier: "handle" }, [], new Create({ actor: new URL("https://example.com/users/handle"), }), ); const federation2 = createFederation<number>({ kv, documentLoader: mockDocumentLoader, contextLoader: mockDocumentLoader, }); const ctx2 = federation2.createContext( new URL("https://example.com/"), 123, ); assertEquals( await ctx2.lookupObject("https://example.com/object"), new Object({ id: new URL("https://example.com/object"), name: "Fetched object", }), ); federation.setObjectDispatcher( Note, "/users/{identifier}/notes/{id}", (_ctx, values) => { return new Note({ summary: `Note ${values.id} by ${values.identifier}`, }); }, ); ctx = federation.createContext(new URL("https://example.com/"), 123); assertEquals( ctx.getObjectUri(Note, { identifier: "john", id: "123" }), new URL("https://example.com/users/john/notes/123"), ); assertEquals( ctx.parseUri(new URL("https://example.com/users/john/notes/123")), { type: "object", class: Note, typeId: new URL("https://www.w3.org/ns/activitystreams#Note"), values: { identifier: "john", id: "123" }, }, ); assertEquals(ctx.parseUri(null), null); federation.setInboxListeners("/users/{identifier}/inbox", "/inbox"); ctx = federation.createContext(new URL("https://example.com/"), 123); assertEquals(ctx.getInboxUri(), new URL("https://example.com/inbox")); assertEquals( ctx.getInboxUri("handle"), new URL("https://example.com/users/handle/inbox"), ); assertEquals( ctx.parseUri(new URL("https://example.com/inbox")), { type: "inbox", identifier: undefined, handle: undefined }, ); assertEquals( ctx.parseUri(new URL("https://example.com/users/handle/inbox")), { type: "inbox", identifier: "handle", handle: "handle" }, ); assertEquals(ctx.parseUri(null), null); federation.setOutboxDispatcher( "/users/{identifier}/outbox", () => ({ items: [] }), ); ctx = federation.createContext(new URL("https://example.com/"), 123); assertEquals( ctx.getOutboxUri("handle"), new URL("https://example.com/users/handle/outbox"), ); assertEquals( ctx.parseUri(new URL("https://example.com/users/handle/outbox")), { type: "outbox", identifier: "handle", handle: "handle" }, ); assertEquals(ctx.parseUri(null), null); federation.setFollowingDispatcher( "/users/{identifier}/following", () => ({ items: [] }), ); ctx = federation.createContext(new URL("https://example.com/"), 123); assertEquals( ctx.getFollowingUri("handle"), new URL("https://example.com/users/handle/following"), ); assertEquals( ctx.parseUri(new URL("https://example.com/users/handle/following")), { type: "following", identifier: "handle", handle: "handle" }, ); assertEquals(ctx.parseUri(null), null); federation.setFollowersDispatcher( "/users/{identifier}/followers", () => ({ items: [] }), ); ctx = federation.createContext(new URL("https://example.com/"), 123); assertEquals( ctx.getFollowersUri("handle"), new URL("https://example.com/users/handle/followers"), ); assertEquals( ctx.parseUri(new URL("https://example.com/users/handle/followers")), { type: "followers", identifier: "handle", handle: "handle" }, ); assertEquals(ctx.parseUri(null), null); federation.setLikedDispatcher( "/users/{identifier}/liked", () => ({ items: [] }), ); ctx = federation.createContext(new URL("https://example.com/"), 123); assertEquals( ctx.getLikedUri("handle"), new URL("https://example.com/users/handle/liked"), ); assertEquals( ctx.parseUri(new URL("https://example.com/users/handle/liked")), { type: "liked", identifier: "handle", handle: "handle" }, ); assertEquals(ctx.parseUri(null), null); federation.setFeaturedDispatcher( "/users/{identifier}/featured", () => ({ items: [] }), ); ctx = federation.createContext(new URL("https://example.com/"), 123); assertEquals( ctx.getFeaturedUri("handle"), new URL("https://example.com/users/handle/featured"), ); assertEquals( ctx.parseUri(new URL("https://example.com/users/handle/featured")), { type: "featured", identifier: "handle", handle: "handle" }, ); assertEquals(ctx.parseUri(null), null); federation.setFeaturedTagsDispatcher( "/users/{identifier}/tags", () => ({ items: [] }), ); ctx = federation.createContext(new URL("https://example.com/"), 123); assertEquals( ctx.getFeaturedTagsUri("handle"), new URL("https://example.com/users/handle/tags"), ); assertEquals( ctx.parseUri(new URL("https://example.com/users/handle/tags")), { type: "featuredTags", identifier: "handle", handle: "handle" }, ); assertEquals(ctx.parseUri(null), null); }); await t.step("RequestContext", async () => { const federation = createFederation<number>({ kv, documentLoader: mockDocumentLoader, }); const req = new Request("https://example.com/"); const ctx = federation.createContext(req, 123); assertEquals(ctx.request, req); assertEquals(ctx.url, new URL("https://example.com/")); assertEquals(ctx.origin, "https://example.com"); assertEquals(ctx.host, "example.com"); assertEquals(ctx.hostname, "example.com"); assertEquals(ctx.data, 123); await assertRejects( () => ctx.getActor("someone"), Error, ); await assertRejects( () => ctx.getObject(Note, { handle: "someone", id: "123" }), Error, ); assertEquals(await ctx.getSignedKey(), null); assertEquals(await ctx.getSignedKeyOwner(), null); // Multiple calls should return the same result: assertEquals(await ctx.getSignedKey(), null); assertEquals(await ctx.getSignedKeyOwner(), null); await assertRejects( () => ctx.getActor("someone"), Error, "No actor dispatcher registered", ); const signedReq = await signRequest( new Request("https://example.com/"), rsaPrivateKey2, rsaPublicKey2.id!, ); const signedCtx = federation.createContext(signedReq, 456); assertEquals(signedCtx.request, signedReq); assertEquals(signedCtx.url, new URL("https://example.com/")); assertEquals(signedCtx.data, 456); assertEquals(await signedCtx.getSignedKey(), rsaPublicKey2); assertEquals(await signedCtx.getSignedKeyOwner(), null); // Multiple calls should return the same result: assertEquals(await signedCtx.getSignedKey(), rsaPublicKey2); assertEquals(await signedCtx.getSignedKeyOwner(), null); const signedReq2 = await signRequest( new Request("https://example.com/"), rsaPrivateKey3, rsaPublicKey3.id!, ); const signedCtx2 = federation.createContext(signedReq2, 456); assertEquals(signedCtx2.request, signedReq2); assertEquals(signedCtx2.url, new URL("https://example.com/")); assertEquals(signedCtx2.data, 456); assertEquals(await signedCtx2.getSignedKey(), rsaPublicKey3); const expectedOwner = await lookupObject( "https://example.com/person2", { documentLoader: mockDocumentLoader, contextLoader: mockDocumentLoader }, ); assertEquals(await signedCtx2.getSignedKeyOwner(), expectedOwner); // Multiple calls should return the same result: assertEquals(await signedCtx2.getSignedKey(), rsaPublicKey3); assertEquals(await signedCtx2.getSignedKeyOwner(), expectedOwner); federation.setActorDispatcher( "/users/{identifier}", (_ctx, identifier) => new Person({ preferredUsername: identifier }), ); const ctx2 = federation.createContext(req, 789); assertEquals(ctx2.request, req); assertEquals(ctx2.url, new URL("https://example.com/")); assertEquals(ctx2.data, 789); assertEquals( await ctx2.getActor("john"), new Person({ preferredUsername: "john" }), ); federation.setObjectDispatcher( Note, "/users/{identifier}/notes/{id}", (_ctx, values) => { return new Note({ summary: `Note ${values.id} by ${values.identifier}`, }); }, ); const ctx3 = federation.createContext(req, 123); assertEquals(ctx3.request, req); assertEquals(ctx3.url, new URL("https://example.com/")); assertEquals(ctx3.data, 123); assertEquals( await ctx2.getObject(Note, { identifier: "john", id: "123" }), new Note({ summary: "Note 123 by john" }), ); }); mf.uninstall(); }); test("Federation.setInboxListeners()", async (t) => { const kv = new MemoryKvStore(); mf.install(); mf.mock("GET@/key2", async () => { return new Response( JSON.stringify( await rsaPublicKey2.toJsonLd({ contextLoader: mockDocumentLoader }), ), { headers: { "Content-Type": "application/activity+json" } }, ); }); mf.mock("GET@/person", async () => { return new Response( await Deno.readFile( join( dirname(import.meta.dirname!), "testing", "fixtures", "example.com", "person", ), ), { headers: { "Content-Type": "application/activity+json" } }, ); }); mf.mock("GET@/person2", async () => { return new Response( await Deno.readFile( join( dirname(import.meta.dirname!), "testing", "fixtures", "example.com", "person2", ), ), { headers: { "Content-Type": "application/activity+json" } }, ); }); await t.step("path match", () => { const federation = createFederation<void>({ kv, documentLoader: mockDocumentLoader, }); federation.setInboxDispatcher( "/users/{identifier}/inbox", () => ({ items: [] }), ); assertThrows( () => federation.setInboxListeners("/users/{identifier}/inbox2"), RouterError, ); }); await t.step("wrong variables in path", () => { const federation = createFederation<void>({ kv, documentLoader: mockDocumentLoader, }); assertThrows( () => federation.setInboxListeners( "/users/inbox" as `${string}{identifier}${string}`, ), RouterError, ); assertThrows( () => federation.setInboxListeners("/users/{identifier}/inbox/{id2}"), RouterError, ); assertThrows( () => federation.setInboxListeners("/users/{identifier}/inbox/{handle}"), RouterError, ); assertThrows( () => federation.setInboxListeners( "/users/{identifier2}/inbox" as `${string}{identifier}${string}`, ), RouterError, ); }); await t.step("on()", async () => { const authenticatedRequests: [string, string][] = []; const federation = createFederation<void>({ kv, documentLoader: mockDocumentLoader, authenticatedDocumentLoaderFactory(identity) { const docLoader = getAuthenticatedDocumentLoader(identity); return (url: string) => { const urlObj = new URL(url); authenticatedRequests.push([url, identity.keyId.href]); if (urlObj.host === "example.com") return docLoader(url); return mockDocumentLoader(url); }; }, }); const inbox: [Context<void>, Create][] = []; federation.setInboxListeners("/users/{identifier}/inbox", "/inbox") .on(Create, (ctx, create) => { inbox.push([ctx, create]); }); let response = await federation.fetch( new Request("https://example.com/inbox", { method: "POST" }), { contextData: undefined }, ); assertEquals(inbox, []); assertEquals(response.status, 404); federation .setActorDispatcher( "/users/{identifier}", (_, identifier) => identifier === "john" ? new Person({}) : null, ) .setKeyPairsDispatcher(() => [{ privateKey: rsaPrivateKey2, publicKey: rsaPublicKey2.publicKey!, }]); const options = { documentLoader: mockDocumentLoader, contextLoader: mockDocumentLoader, }; const activity = () => new Create({ id: new URL("https://example.com/activities/" + crypto.randomUUID()), actor: new URL("https://example.com/person2"), }); response = await federation.fetch( new Request( "https://example.com/inbox", { method: "POST", body: JSON.stringify(await activity().toJsonLd(options)), }, ), { contextData: undefined }, ); assertEquals(inbox, []); assertEquals(response.status, 401); response = await federation.fetch( new Request("https://example.com/users/no-one/inbox", { method: "POST" }), { contextData: undefined }, ); assertEquals(inbox, []); assertEquals(response.status, 404); response = await federation.fetch( new Request( "https://example.com/users/john/inbox", { method: "POST", body: JSON.stringify(await activity().toJsonLd(options)), }, ), { contextData: undefined }, ); assertEquals(inbox, []); assertEquals(response.status, 401); // Personal inbox + HTTP Signatures (RSA) const activityPayload = await activity().toJsonLd(options); let request = new Request("https://example.com/users/john/inbox", { method: "POST", headers: { "Content-Type": "application/activity+json" }, body: JSON.stringify(activityPayload), }); request = await signRequest( request, rsaPrivateKey3, new URL("https://example.com/person2#key3"), ); response = await federation.fetch(request, { contextData: undefined }); assertEquals(inbox.length, 1); assertEquals(inbox[0][1].actorId, new URL("https://example.com/person2")); assertEquals(response.status, 202); while (authenticatedRequests.length > 0) authenticatedRequests.shift(); assertEquals(authenticatedRequests, []); await inbox[0][0].documentLoader("https://example.com/person"); assertEquals(authenticatedRequests, [ ["https://example.com/person", "https://example.com/users/john#main-key"], ]); // Idempotence check response = await federation.fetch(request, { contextData: undefined }); assertEquals(inbox.length, 1); // Idempotence check with different origin (host) inbox.shift(); request = new Request("https://another.host/users/john/inbox", { method: "POST", headers: { "Content-Type": "application/activity+json" }, body: JSON.stringify(activityPayload), }); request = await signRequest( request, rsaPrivateKey3, new URL("https://example.com/person2#key3"), ); response = await federation.fetch(request, { contextData: undefined }); assertEquals(inbox.length, 1); assertEquals(inbox[0][1].actorId, new URL("https://example.com/person2")); assertEquals(response.status, 202); while (authenticatedRequests.length > 0) authenticatedRequests.shift(); assertEquals(authenticatedRequests, []); await inbox[0][0].documentLoader("https://example.com/person"); assertEquals(authenticatedRequests, [ [ "https://example.com/person", "https://another.host/users/john#main-key", ], ]); // Shared inbox + HTTP Signatures (RSA) inbox.shift(); request = new Request("https://example.com/inbox", { method: "POST", headers: { "Content-Type": "application/activity+json" }, body: JSON.stringify(await activity().toJsonLd(options)), }); request = await signRequest( request, rsaPrivateKey3, new URL("https://example.com/person2#key3"), ); response = await federation.fetch(request, { contextData: undefined }); assertEquals(inbox.length, 1); assertEquals(inbox[0][1].actorId, new URL("https://example.com/person2")); assertEquals(response.status, 202); while (authenticatedRequests.length > 0) authenticatedRequests.shift(); assertEquals(authenticatedRequests, []); await inbox[0][0].documentLoader("https://example.com/person"); assertEquals(authenticatedRequests, []); // Object Integrity Proofs (Ed25519) inbox.shift(); request = new Request("https://example.com/users/john/inbox", { method: "POST", headers: { "Content-Type": "application/activity+json" }, body: JSON.stringify( await (await signObject( activity(), ed25519PrivateKey, ed25519Multikey.id!, options, )).toJsonLd(options), ), }); response = await federation.fetch(request, { contextData: undefined }); assertEquals(inbox.length, 1); assertEquals(inbox[0][1].actorId, new URL("https://example.com/person2")); assertEquals(response.status, 202); while (authenticatedRequests.length > 0) authenticatedRequests.shift(); assertEquals(authenticatedRequests, []); await inbox[0][0].documentLoader("https://example.com/person"); assertEquals(authenticatedRequests, [ ["https://example.com/person", "https://example.com/users/john#main-key"], ]); }); await t.step("onError()", async () => { const federation = createFederation<void>({ kv, documentLoader: mockDocumentLoader, authenticatedDocumentLoaderFactory(identity) { const docLoader = getAuthenticatedDocumentLoader(identity); return (url: string) => { const urlObj = new URL(url); if (urlObj.host === "example.com") return docLoader(url); return mockDocumentLoader(url); }; }, }); federation .setActorDispatcher( "/users/{identifier}", (_, identifier) => identifier === "john" ? new Person({}) : null, ) .setKeyPairsDispatcher(() => [{ privateKey: rsaPrivateKey2, publicKey: rsaPublicKey2.publicKey!, }]); const error = new Error("test"); const errors: unknown[] = []; federation.setInboxListeners("/users/{identifier}/inbox", "/inbox") .on(Create, () => { throw error; }) .onError((_, e) => { errors.push(e); }); const activity = new Create({ actor: new URL("https://example.com/person"), }); let request = new Request("https://example.com/users/john/inbox", { method: "POST", headers: { "Content-Type": "application/activity+json" }, body: JSON.stringify( await activity.toJsonLd({ contextLoader: mockDocumentLoader }), ), }); request = await signRequest( request, rsaPrivateKey2, new URL("https://example.com/key2"), ); const response = await federation.fetch(request, { contextData: undefined, }); assertEquals(errors.length, 1); assertEquals(errors[0], error); assertEquals(response.status, 500); }); mf.uninstall(); }); test("Federation.setInboxDispatcher()", async (t) => { const kv = new MemoryKvStore(); await t.step("path match", () => { const federation = createFederation<void>({ kv, documentLoader: mockDocumentLoader, }); federation.setInboxListeners("/users/{identifier}/inbox"); assertThrows( () => federation.setInboxDispatcher( "/users/{identifier}/inbox2", () => ({ items: [] }), ), RouterError, ); }); await t.step("path match", () => { const federation = createFederation<void>({ kv, documentLoader: mockDocumentLoader, }); federation.setInboxListeners("/users/{identifier}/inbox"); federation.setInboxDispatcher( "/users/{identifier}/inbox", () => ({ items: [] }), ); }); await t.step("wrong variables in path", () => { const federation = createFederation<void>({ kv, documentLoader: mockDocumentLoader, }); assertThrows( () => federation.setInboxDispatcher( "/users/inbox" as `${string}{identifier}${string}`, () => ({ items: [] }), ), RouterError, ); assertThrows( () => federation.setInboxDispatcher( "/users/{identifier}/inbox/{identifier2}", () => ({ items: [] }), ), RouterError, ); assertThrows( () => federation.setInboxDispatcher( "/users/{identifier2}/inbox" as `${string}{identifier}${string}`, () => ({ items: [] }), ), RouterError, ); }); }); test("FederationImpl.sendActivity()", async (t) => { mf.install(); let verified: ("http" | "ld" | "proof")[] | null = null; let request: Request | null = null; mf.mock("POST@/inbox", async (req) => { verified = []; request = req.clone(); const options = { documentLoader: mockDocumentLoader, contextLoader: mockDocumentLoader, }; let json = await req.json(); if (await verifyJsonLd(json, options)) verified.push("ld"); json = detachSignature(json); let activity = await verifyObject(Activity, json, options); if (activity == null) { activity = await Activity.fromJsonLd(json, options); } else { verified.push("proof"); } const key = await verifyRequest(request, options); if (key != null && await doesActorOwnKey(activity, key, options)) { verified.push("http"); } if (verified.length > 0) return new Response(null, { status: 202 }); return new Response(null, { status: 401 }); }); const kv = new MemoryKvStore(); const federation = new FederationImpl<void>({ kv, contextLoader: mockDocumentLoader, }); await t.step("success", async () => { const activity = new Create({ actor: new URL("https://example.com/person"), }); const recipient = { id: new URL("https://example.com/recipient"), inboxId: new URL("https://example.com/inbox"), }; await federation.sendActivity( [{ privateKey: rsaPrivateKey2, keyId: rsaPublicKey2.id! }], recipient, activity, { contextData: undefined }, ); assertEquals(verified, ["http"]); assertInstanceOf(request, Request); assertEquals(request?.method, "POST"); assertEquals(request?.url, "https://example.com/inbox"); assertEquals( request?.headers.get("Content-Type"), "application/activity+json", ); verified = null; await federation.sendActivity( [{ privateKey: rsaPrivateKey3, keyId: rsaPublicKey3.id! }], recipient, activity.clone({ actor: new URL("https://example.com/person2"), }), { contextData: undefined }, ); assertEquals(verified, ["ld", "http"]); assertInstanceOf(request, Request); assertEquals(request?.method, "POST"); assertEquals(request?.url, "https://example.com/inbox"); assertEquals( request?.headers.get("Content-Type"), "application/activity+json", ); verified = null; await federation.sendActivity( [ { privateKey: ed25519PrivateKey, keyId: ed25519Multikey.id! }, ], recipient, activity.clone({ actor: new URL("https://example.com/person2"), }), { contextData: undefined }, ); assertEquals(verified, ["proof"]); assertInstanceOf(request, Request); assertEquals(request?.method, "POST"); assertEquals(request?.url, "https://example.com/inbox"); assertEquals( request?.headers.get("Content-Type"), "application/activity+json", ); verified = null; await federation.sendActivity( [ { privateKey: rsaPrivateKey3, keyId: rsaPublicKey3.id! }, { privateKey: ed25519PrivateKey, keyId: ed25519Multikey.id! }, ], recipient, activity.clone({ actor: new URL("https://example.com/person2"), }), { contextData: undefined }, ); assertEquals(verified, ["ld", "proof", "http"]); assertInstanceOf(request, Request); assertEquals(request?.method, "POST"); assertEquals(request?.url, "https://example.com/inbox"); assertEquals( request?.headers.get("Content-Type"), "application/activity+json", ); }); mf.uninstall(); }); test("ContextImpl.sendActivity()", async (t) => { mf.install(); let verified: ("http" | "ld" | "proof")[] | null = null; let request: Request | null = null; mf.mock("POST@/inbox", async (req) => { verified = []; request = req.clone(); const options = { async documentLoader(url: string) { const response = await federation.fetch( new Request(url), { contextData: undefined }, ); if (response.ok) { return { contextUrl: null, document: await response.json(), documentUrl: response.url, }; } return await mockDocumentLoader(url); }, contextLoader: mockDocumentLoader, keyCache: { async get(keyId: URL) { const ctx = federation.createContext( new URL("https://example.com/"), undefined, ); const keys = await ctx.getActorKeyPairs("1"); for (const key of keys) { if (key.keyId.href === keyId.href) { if (key.publicKey.algorithm.name === "Ed25519") { return key.multikey; } else return key.cryptographicKey; } } return undefined; }, async set(_keyId: URL, _key: CryptographicKey | Multikey | null) { }, } satisfies KeyCache, }; let json = await req.json(); if (await verifyJsonLd(json, options)) verified.push("ld"); json = detachSignature(json); let activity = await verifyObject(Activity, json, options); if (activity == null) { activity = await Activity.fromJsonLd(json, options); } else { verified.push("proof"); } const key = await verifyRequest(request, options); if (key != null && await doesActorOwnKey(activity, key, options)) { verified.push("http"); } if (verified.length > 0) return new Response(null, { status: 202 }); return new Response(null, { status: 401 }); }); const kv = new MemoryKvStore(); const federation = new FederationImpl<void>({ kv, contextLoader: mockDocumentLoader, }); federation .setActorDispatcher("/{identifier}", async (ctx, identifier) => { if (identifier !== "1") return null; const keys = await ctx.getActorKeyPairs(identifier); return new Person({ id: ctx.getActorUri(identifier), preferredUsername: "john", publicKey: keys[0].cryptographicKey, assertionMethods: keys.map((k) => k.multikey), }); }) .setKeyPairsDispatcher((_ctx, identifier) => { if (identifier !== "1") return []; return [ { privateKey: rsaPrivateKey2, publicKey: rsaPublicKey2.publicKey! }, { privateKey: ed25519PrivateKey, publicKey: ed25519PublicKey.publicKey!, }, ]; }) .mapHandle((_ctx, username) => username === "john" ? "1" : null); await t.step("success", async () => { const activity = new Create({ actor: new URL("https://example.com/person"), }); const ctx = new ContextImpl({ data: undefined, federation, url: new URL("https://example.com/"), documentLoader: fetchDocumentLoader, }); await ctx.sendActivity( [{ privateKey: rsaPrivateKey2, keyId: rsaPublicKey2.id! }], { id: new URL("https://example.com/recipient"), inboxId: new URL("https://example.com/inbox"), }, activity, ); assertEquals(verified, ["http"]); assertInstanceOf(request, Request); assertEquals(request?.method, "POST"); assertEquals(request?.url, "https://example.com/inbox"); assertEquals( request?.headers.get("Content-Type"), "application/activity+json", ); verified = null; await ctx.sendActivity( [{ privateKey: rsaPrivateKey3, keyId: rsaPublicKey3.id! }], { id: new URL("https://example.com/recipient"), inboxId: new URL("https://example.com/inbox"), }, activity.clone({ actor: new URL("https://example.com/person2"), }), ); assertEquals(verified, ["ld", "http"]); assertInstanceOf(request, Request); assertEquals(request?.method, "POST"); assertEquals(request?.url, "https://example.com/inbox"); assertEquals( request?.headers.get("Content-Type"), "application/activity+json", ); verified = null; await ctx.sendActivity( { identifier: "1" }, { id: new URL("https://example.com/recipient"), inboxId: new URL("https://example.com/inbox"), }, activity.clone({ actor: ctx.getActorUri("1") }), ); assertEquals(verified, ["ld", "proof", "http"]); assertInstanceOf(request, Request); assertEquals(request?.method, "POST"); assertEquals(request?.url, "https://example.com/inbox"); assertEquals( request?.headers.get("Content-Type"), "application/activity+json", ); verified = null; await ctx.sendActivity( { username: "john" }, { id: new URL("https://example.com/recipient"), inboxId: new URL("https://example.com/inbox"), }, activity.clone({ actor: ctx.getActorUri("1") }), ); assertEquals(verified, ["ld", "proof", "http"]); assertInstanceOf(request, Request); assertEquals(request?.method, "POST"); assertEquals(request?.url, "https://example.com/inbox"); assertEquals( request?.headers.get("Content-Type"), "application/activity+json", ); await assertRejects(() => ctx.sendActivity( { identifier: "not-found" }, { id: new URL("https://example.com/recipient"), inboxId: new URL("https://example.com/inbox"), }, activity.clone({ actor: ctx.getActorUri("1") }), ) ); await assertRejects(() => ctx.sendActivity( { username: "not-found" }, { id: new URL("https://example.com/recipient"), inboxId: new URL("https://example.com/inbox"), }, activity.clone({ actor: ctx.getActorUri("1") }), ) ); }); }); test("ContextImpl.routeActivity()", async () => { const federation = new FederationImpl({ kv: new MemoryKvStore(), }); const activities: [string | null, Activity][] = []; federation .setInboxListeners("/u/{identifier}/i", "/i") .on(Offer, (ctx, offer) => { activities.push([ctx.recipient, offer]); }); const ctx = new ContextImpl({ url: new URL("https://example.com/"), federation, data: undefined, documentLoader: mockDocumentLoader, }); // Unsigned & non-dereferenceable activity assertFalse( await ctx.routeActivity( null, new Offer({ actor: new URL("https://example.com/person"), }), ), ); assertEquals(activities, []); // Signed activity without recipient (shared inbox) const signedOffer = await signObject( new Offer({ actor: new URL("https://example.com/person2"), }), ed25519PrivateKey, ed25519Multikey.id!, ); assert(await ctx.routeActivity(null, signedOffer)); assertEquals(activities, [[null, signedOffer]]); // Signed activity with recipient (personal inbox) const signedInvite = await signObject( new Invite({ actor: new URL("https://example.com/person2"), }), ed25519PrivateKey, ed25519Multikey.id!, ); assert(await ctx.routeActivity("id", signedInvite)); assertEquals(activities, [[null, signedOffer], ["id", signedInvite]]); // Unsigned activity dereferenced to 404 assertFalse( await ctx.routeActivity( null, new Create({ id: new URL("https://example.com/not-found"), actor: new URL("https://example.com/person"), }), ), ); assertEquals(activities, [[null, signedOffer], ["id", signedInvite]]); // Unsigned activity dereferenced to 200, but not an Activity assertFalse( await ctx.routeActivity( null, new Create({ id: new URL("https://example.com/person"), actor: new URL("https://example.com/person"), }), ), ); assertEquals(activities, [[null, signedOffer], ["id", signedInvite]]); // Unsigned activity dereferenced to 200, but has a different id assertFalse( await ctx.routeActivity( null, new Announce({ id: new URL("https://example.com/announce#diffrent-id"), actor: new URL("https://example.com/person"), }), ), ); assertEquals(activities, [[null, signedOffer], ["id", signedInvite]]); // Unsigned activity dereferenced to 200, but has no actor assertFalse( await ctx.routeActivity( null, new Announce({ id: new URL("https://example.com/announce"), // Although the actor is set here, the fetched document has no actor. // See also src/testing/fixtures/example.com/announce actor: new URL("https://example.com/person"), }), ), ); assertEquals(activities, [[null, signedOffer], ["id", signedInvite]]); // Unsigned activity dereferenced to 200, but actor is cross-origin assertFalse( await ctx.routeActivity( null, new Create({ id: new URL("https://example.com/cross-origin-actor"), actor: new URL("https://cross-origin.com/actor"), }), ), ); assertEquals(activities, [[null, signedOffer], ["id", signedInvite]]); // Unsigned activity dereferenced to 200, but no inbox listener corresponds assert( await ctx.routeActivity( null, new Create({ id: new URL("https://example.com/create"), actor: new URL("https://example.com/person"), }), ), ); assertEquals(activities, [[null, signedOffer], ["id", signedInvite]]); // Unsigned activity dereferenced to 200 assert( await ctx.routeActivity( null, new Invite({ id: new URL("https://example.com/invite"), actor: new URL("https://example.com/person"), }), ), ); assertEquals( activities, [ [null, signedOffer], ["id", signedInvite], [ null, new Invite({ id: new URL("https://example.com/invite"), actor: new URL("https://example.com/person"), object: new URL("https://example.com/object"), }), ], ], ); }); test("InboxContextImpl.forwardActivity()", async (t) => { mf.install(); let verified: ("http" | "ld" | "proof")[] | null = null; let request: Request | null = null; mf.mock("POST@/inbox", async (req) => { verified = []; request = req.clone(); const options = { documentLoader: mockDocumentLoader, contextLoader: mockDocumentLoader, }; let json = await req.json(); if (await verifyJsonLd(json, options)) verified.push("ld"); json = detachSignature(json); let activity = await verifyObject(Activity, json, options); if (activity == null) { activity = await Activity.fromJsonLd(json, options); } else { verified.push("proof"); } const key = await verifyRequest(request, options); if (key != null && await doesActorOwnKey(activity, key, options)) { verified.push("http"); } if (verified.length > 0) return new Response(null, { status: 202 }); return new Response(null, { status: 401 }); }); const kv = new MemoryKvStore(); const federation = new FederationImpl<void>({ kv, contextLoader: mockDocumentLoader, }); await t.step("skip", async () => { const activity = { "@context": "https://www.w3.org/ns/activitystreams", "type": "Create", "id": "https://example.com/activity", "actor": "https://example.com/person2", }; const ctx = new InboxContextImpl( null, activity, "https://example.com/activity", "https://www.w3.org/ns/activitystreams#Create", { data: undefined, federation, url: new URL("https://example.com/"), documentLoader: fetchDocumentLoader, }, ); await ctx.forwardActivity( [{ privateKey: rsaPrivateKey2, keyId: rsaPublicKey2.id! }], { id: new URL("https://example.com/recipient"), inboxId: new URL("https://example.com/inbox"), }, { skipIfUnsigned: true }, ); assertEquals(verified, null); }); await t.step("unsigned", async () => { const activity = { "@context": "https://www.w3.org/ns/activitystreams", "type": "Create", "id": "https://example.com/activity", "actor": "https://example.com/person2", }; const ctx = new InboxContextImpl( null, activity, "https://example.com/activity", "https://www.w3.org/ns/activitystreams#Create", { data: undefined, federation, url: new URL("https://example.com/"), documentLoader: fetchDocumentLoader, }, ); await assertRejects(() => ctx.forwardActivity( [{ privateKey: rsaPrivateKey2, keyId: rsaPublicKey2.id! }], { id: new URL("https://example.com/recipient"), inboxId: new URL("https://example.com/inbox"), }, ) ); assertEquals(verified, []); }); await t.step("Object Integrity Proofs", async () => { const activity = await signObject( new Create({ id: new URL("https://example.com/activity"), actor: new URL("https://example.com/person2"), }), ed25519PrivateKey, ed25519Multikey.id!, { contextLoader: mockDocumentLoader, documentLoader: mockDocumentLoader }, ); const ctx = new InboxContextImpl( null, await activity.toJsonLd({ contextLoader: mockDocumentLoader }), activity.id?.href, getTypeId(activity).href, { data: undefined, federation, url: new URL("https://example.com/"), documentLoader: fetchDocumentLoader, }, ); await ctx.forwardActivity( [{ privateKey: rsaPrivateKey2, keyId: rsaPublicKey2.id! }], { id: new URL("https://example.com/recipient"), inboxId: new URL("https://example.com/inbox"), }, { skipIfUnsigned: true }, ); assertEquals(verified, ["proof"]); }); await t.step("LD Signatures", async () => { const activity = await signJsonLd( { "@context": "https://www.w3.org/ns/activitystreams", "type": "Create", "id": "https://example.com/activity", "actor": "https://example.com/person2", }, rsaPrivateKey3, rsaPublicKey3.id!, { contextLoader: mockDocumentLoader }, ); const ctx = new InboxContextImpl( null, activity, "https://example.com/activity", "https://www.w3.org/ns/activitystreams#Create", { data: undefined, federation, url: new URL("https://example.com/"), documentLoader: fetchDocumentLoader, }, ); await ctx.forwardActivity( [{ privateKey: rsaPrivateKey2, keyId: rsaPublicKey2.id! }], { id: new URL("https://example.com/recipient"), inboxId: new URL("https://example.com/inbox"), }, { skipIfUnsigned: true }, ); assertEquals(verified, ["ld"]); }); mf.uninstall(); });