Unverified Commit 07d4b5c6 authored by Hong Minhee's avatar Hong Minhee
Browse files

Context passed to inbox listener now has auth doc loader

parent dbda4291
Loading
Loading
Loading
Loading
+3 −0
Original line number Diff line number Diff line
@@ -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.

+10 −0
Original line number Diff line number Diff line
@@ -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.
+20 −0
Original line number Diff line number Diff line
@@ -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
--------------

+62 −1
Original line number Diff line number Diff line
@@ -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";
@@ -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")
@@ -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",
@@ -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(
@@ -301,5 +361,6 @@ Deno.test("Federation.setInboxListeners()", async (t) => {
    assertEquals(response.status, 500);
  });

  mf.uninstall();
  kv.close();
});
+8 −1
Original line number Diff line number Diff line
@@ -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, {
@@ -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,