Loading CHANGES.md +3 −0 Original line number Diff line number Diff line Loading @@ -28,6 +28,9 @@ To be released. - Added `AuthenticatedDocumentLoaderFactory` type. - Added `authenticatedDocumentLoaderFactory` option to `new Federation()` constructor. - `Context.documentLoader` property now returns an authenticated document loader in personal inbox listeners. (Note that it's not affected in shared inbox listeners.) - Added singular accessors to `Object`'s `icon` and `image` properties. Loading docs/manual/context.md +10 −0 Original line number Diff line number Diff line Loading @@ -174,3 +174,13 @@ In the above example, the `getFollowing()` method takes the `documentLoader` which is authenticated as the actor with a handle of `john`. If the `actor` allows `john` to see the following collection, the `getFollowing()` method returns the following collection. > [!TIP] > Inside a personal inbox listener, the `Context.documentLoader` property is > automatically set to an authenticated `DocumentLoader` object that is > identified by the inbox owner's key. So you don't need to call the > `Context.getDocumentLoader()` method in the personal inbox listener, > but just passing the `Context` object to dereferencing accessors is enough. > > See the [*`Context.documentLoader` on an inbox listener* > section](./inbox.md#context.documentloader-on-an-inbox-listener) for details. docs/manual/inbox.md +20 −0 Original line number Diff line number Diff line Loading @@ -76,6 +76,26 @@ multiple inbox listeners for different activity types. [shared inbox]: https://www.w3.org/TR/activitypub/#shared-inbox-delivery `Context.documentLoader` on an inbox listener --------------------------------------------- The `Context.documentLoader` property carries a `DocumentLoader` object that you can use to fetch a remote document. If a request is made to a shared inbox, the `Context.documentLoader` property is set to the default `documentLoader` that is specified in the `new Federation()` constructor. However, if a request is made to a personal inbox, the `Context.documentLoader` property is set to an authenticated `DocumentLoader` object that is identified by the inbox owner's key. This means that you can pass the `Context` object to dereferencing accessors[^1] inside a personal inbox listener so that they can fetch remote documents with the correct authentication. [^1]: See the [*Object IDs and remote objects* section](./vocab.md#object-ids-and-remote-objects) if you are not familiar with dereferencing accessors. Error handling -------------- Loading federation/middleware.test.ts +62 −1 Original line number Diff line number Diff line Loading @@ -6,9 +6,13 @@ import { assertStrictEquals, assertThrows, } from "@std/assert"; import { dirname, join } from "@std/path"; import * as mf from "mock_fetch"; import { verify } from "../httpsig/mod.ts"; import { FetchError } from "../runtime/docloader.ts"; import { FetchError, getAuthenticatedDocumentLoader, } from "../runtime/docloader.ts"; import { mockDocumentLoader } from "../testing/docloader.ts"; import { privateKey2, publicKey2 } from "../testing/keys.ts"; import { Create, Person } from "../vocab/vocab.ts"; Loading Loading @@ -168,10 +172,46 @@ Deno.test("Federation.createContext()", async (t) => { Deno.test("Federation.setInboxListeners()", async (t) => { const kv = await Deno.openKv(":memory:"); mf.install(); mf.mock("GET@/key2", async () => { return new Response( JSON.stringify( await publicKey2.toJsonLd({ documentLoader: 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" } }, ); }); await t.step("on()", async () => { const authenticatedRequests: [string, string][] = []; const federation = new Federation<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/{handle}/inbox", "/inbox") Loading Loading @@ -236,6 +276,13 @@ Deno.test("Federation.setInboxListeners()", async (t) => { assertEquals(inbox[0][1], activity); 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"], ]); inbox.shift(); request = new Request("https://example.com/inbox", { method: "POST", Loading @@ -253,12 +300,25 @@ Deno.test("Federation.setInboxListeners()", async (t) => { assertEquals(inbox.length, 1); assertEquals(inbox[0][1], activity); assertEquals(response.status, 202); while (authenticatedRequests.length > 0) authenticatedRequests.shift(); assertEquals(authenticatedRequests, []); await inbox[0][0].documentLoader("https://example.com/person"); assertEquals(authenticatedRequests, []); }); await t.step("onError()", async () => { const federation = new Federation<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( Loading Loading @@ -301,5 +361,6 @@ Deno.test("Federation.setInboxListeners()", async (t) => { assertEquals(response.status, 500); }); mf.uninstall(); kv.close(); }); federation/middleware.ts +8 −1 Original line number Diff line number Diff line Loading @@ -732,7 +732,7 @@ export class Federation<TContextData> { const response = onNotFound(request); return response instanceof Promise ? await response : response; } const context = this.createContext(request, contextData); let context = this.createContext(request, contextData); switch (route.name) { case "webfinger": return await handleWebFinger(request, { Loading Loading @@ -764,6 +764,13 @@ export class Federation<TContextData> { onNotAcceptable, }); case "inbox": context = { ...context, documentLoader: await context.getDocumentLoader({ handle: route.values.handle, }), }; // falls through case "sharedInbox": return await handleInbox(request, { handle: route.values.handle ?? null, Loading Loading
CHANGES.md +3 −0 Original line number Diff line number Diff line Loading @@ -28,6 +28,9 @@ To be released. - Added `AuthenticatedDocumentLoaderFactory` type. - Added `authenticatedDocumentLoaderFactory` option to `new Federation()` constructor. - `Context.documentLoader` property now returns an authenticated document loader in personal inbox listeners. (Note that it's not affected in shared inbox listeners.) - Added singular accessors to `Object`'s `icon` and `image` properties. Loading
docs/manual/context.md +10 −0 Original line number Diff line number Diff line Loading @@ -174,3 +174,13 @@ In the above example, the `getFollowing()` method takes the `documentLoader` which is authenticated as the actor with a handle of `john`. If the `actor` allows `john` to see the following collection, the `getFollowing()` method returns the following collection. > [!TIP] > Inside a personal inbox listener, the `Context.documentLoader` property is > automatically set to an authenticated `DocumentLoader` object that is > identified by the inbox owner's key. So you don't need to call the > `Context.getDocumentLoader()` method in the personal inbox listener, > but just passing the `Context` object to dereferencing accessors is enough. > > See the [*`Context.documentLoader` on an inbox listener* > section](./inbox.md#context.documentloader-on-an-inbox-listener) for details.
docs/manual/inbox.md +20 −0 Original line number Diff line number Diff line Loading @@ -76,6 +76,26 @@ multiple inbox listeners for different activity types. [shared inbox]: https://www.w3.org/TR/activitypub/#shared-inbox-delivery `Context.documentLoader` on an inbox listener --------------------------------------------- The `Context.documentLoader` property carries a `DocumentLoader` object that you can use to fetch a remote document. If a request is made to a shared inbox, the `Context.documentLoader` property is set to the default `documentLoader` that is specified in the `new Federation()` constructor. However, if a request is made to a personal inbox, the `Context.documentLoader` property is set to an authenticated `DocumentLoader` object that is identified by the inbox owner's key. This means that you can pass the `Context` object to dereferencing accessors[^1] inside a personal inbox listener so that they can fetch remote documents with the correct authentication. [^1]: See the [*Object IDs and remote objects* section](./vocab.md#object-ids-and-remote-objects) if you are not familiar with dereferencing accessors. Error handling -------------- Loading
federation/middleware.test.ts +62 −1 Original line number Diff line number Diff line Loading @@ -6,9 +6,13 @@ import { assertStrictEquals, assertThrows, } from "@std/assert"; import { dirname, join } from "@std/path"; import * as mf from "mock_fetch"; import { verify } from "../httpsig/mod.ts"; import { FetchError } from "../runtime/docloader.ts"; import { FetchError, getAuthenticatedDocumentLoader, } from "../runtime/docloader.ts"; import { mockDocumentLoader } from "../testing/docloader.ts"; import { privateKey2, publicKey2 } from "../testing/keys.ts"; import { Create, Person } from "../vocab/vocab.ts"; Loading Loading @@ -168,10 +172,46 @@ Deno.test("Federation.createContext()", async (t) => { Deno.test("Federation.setInboxListeners()", async (t) => { const kv = await Deno.openKv(":memory:"); mf.install(); mf.mock("GET@/key2", async () => { return new Response( JSON.stringify( await publicKey2.toJsonLd({ documentLoader: 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" } }, ); }); await t.step("on()", async () => { const authenticatedRequests: [string, string][] = []; const federation = new Federation<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/{handle}/inbox", "/inbox") Loading Loading @@ -236,6 +276,13 @@ Deno.test("Federation.setInboxListeners()", async (t) => { assertEquals(inbox[0][1], activity); 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"], ]); inbox.shift(); request = new Request("https://example.com/inbox", { method: "POST", Loading @@ -253,12 +300,25 @@ Deno.test("Federation.setInboxListeners()", async (t) => { assertEquals(inbox.length, 1); assertEquals(inbox[0][1], activity); assertEquals(response.status, 202); while (authenticatedRequests.length > 0) authenticatedRequests.shift(); assertEquals(authenticatedRequests, []); await inbox[0][0].documentLoader("https://example.com/person"); assertEquals(authenticatedRequests, []); }); await t.step("onError()", async () => { const federation = new Federation<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( Loading Loading @@ -301,5 +361,6 @@ Deno.test("Federation.setInboxListeners()", async (t) => { assertEquals(response.status, 500); }); mf.uninstall(); kv.close(); });
federation/middleware.ts +8 −1 Original line number Diff line number Diff line Loading @@ -732,7 +732,7 @@ export class Federation<TContextData> { const response = onNotFound(request); return response instanceof Promise ? await response : response; } const context = this.createContext(request, contextData); let context = this.createContext(request, contextData); switch (route.name) { case "webfinger": return await handleWebFinger(request, { Loading Loading @@ -764,6 +764,13 @@ export class Federation<TContextData> { onNotAcceptable, }); case "inbox": context = { ...context, documentLoader: await context.getDocumentLoader({ handle: route.values.handle, }), }; // falls through case "sharedInbox": return await handleInbox(request, { handle: route.values.handle ?? null, Loading