Unverified Commit a8a9b73b authored by Hong Minhee's avatar Hong Minhee
Browse files

Decouple actor URIs from WebFinger usernames

parent c6a27af0
Loading
Loading
Loading
Loading
+11 −0
Original line number Diff line number Diff line
@@ -8,6 +8,13 @@ Version 0.15.0

To be released.

 -  Actors, collections, and objects now can have their URIs that do not consist
    of a WebFinger username, which means actors can change their fediverse
    handles.

     -  Added `ActorCallbackSetters.mapHandle()` method.
     -  Added `ActorHandleMapper` type.

 -  Removed `expand` option of `Object.toJsonLd()` method, which was deprecated
    in version 0.14.0.  Use `format: "expand"` option instead.

@@ -22,6 +29,10 @@ To be released.

 -  Added `allowPrivateAddress` option to `CreateFederationOptions` interface.

 -  Fixed a bug where the WebFinger response had had a `subject` property
    with an unmatched URI to the requested resource when a non-`acct:` URI
    was given.

 -  Renamed the short option `-c` for `--compact` of `fedify lookup` command to
    `-C` to avoid conflict with the short option `-c` for `--cache-dir`.

+41 −0
Original line number Diff line number Diff line
@@ -325,3 +325,44 @@ dereferenceable URI of the actor with the bare handle `"john_doe"`.
> The `Context.getActorUri()` method does not guarantee that the actor
> URI is always dereferenceable for every argument.  Make sure that
> the argument is a valid bare handle before calling the method.


Decoupling actor URIs from WebFinger usernames
----------------------------------------------

*This API is available since Fedify 0.15.0.*

> [!TIP]
> The WebFinger username means the username part of the `acct:` URI or
> the fediverse handle.  For example, the WebFinger username of the
> `acct:fedify@hollo.social` URI or the `@fedify@hollo.social` handle
> is `fedify`.

By default, Fedify uses the bare handle as the WebFinger username.  However,
you can decouple the WebFinger username from the bare handle by registering
an actor handle mapper through the `~ActorCallbackSetters.mapHandle()` method:

~~~~ typescript
federation
  .setActorDispatcher("/users/{handle}", async (ctx, handle) => {
    // Since we map a WebFinger handle to the corresponding user's UUID below,
    // the `handle` parameter is the user's UUID, not the WebFinger username:
    const user = await findUserByUuid(handle);
    // Omitted for brevity; see the previous example for details.
  })
  .mapHandle(async (ctx, username) => {
    // Work with the database to find the WebFinger username by the handle.
    const user = await findUserByUsername(username);
    if (user == null) return null;  // Return null if the actor is not found.
    return user.uuid;
  });
~~~~

Decoupling the WebFinger username from the bare handle is useful when you want
to let users change their WebFinger username without breaking the existing
network, because changing the WebFinger username does not affect the actor URI.

> [!NOTE]
> We highly recommend you to set the actor's `preferredUsername` property to
> the corresponding WebFinger username so that peers can find the actor's
> fediverse handle by fetching the actor object.
+13 −0
Original line number Diff line number Diff line
@@ -41,6 +41,19 @@ export type ActorKeyPairsDispatcher<TContextData> = (
  handle: string,
) => CryptoKeyPair[] | Promise<CryptoKeyPair[]>;

/**
 * A callback that maps a WebFinger username to the corresponding actor's
 * internal handle, or `null` if the username is not found.
 * @typeParam TContextData The context data to pass to the {@link Context}.
 * @param context The context.
 * @param username The WebFinger username.
 * @since 0.15.0
 */
export type ActorHandleMapper<TContextData> = (
  context: Context<TContextData>,
  username: string,
) => string | null | Promise<string | null>;

/**
 * A callback that dispatches an object.
 *
+18 −0
Original line number Diff line number Diff line
@@ -23,6 +23,7 @@ import {
import { handleWebFinger } from "../webfinger/handler.ts";
import type {
  ActorDispatcher,
  ActorHandleMapper,
  ActorKeyPairsDispatcher,
  AuthorizePredicate,
  CollectionCounter,
@@ -1307,6 +1308,10 @@ class FederationImpl<TContextData> implements Federation<TContextData> {
        callbacks.keyPairsDispatcher = dispatcher;
        return setters;
      },
      mapHandle(mapper: ActorHandleMapper<TContextData>) {
        callbacks.handleMapper = mapper;
        return setters;
      },
      authorize(predicate: AuthorizePredicate<TContextData>) {
        callbacks.authorizePredicate = predicate;
        return setters;
@@ -2791,6 +2796,7 @@ export interface FederationFetchOptions<TContextData> {
interface ActorCallbacks<TContextData> {
  dispatcher?: ActorDispatcher<TContextData>;
  keyPairsDispatcher?: ActorKeyPairsDispatcher<TContextData>;
  handleMapper?: ActorHandleMapper<TContextData>;
  authorizePredicate?: AuthorizePredicate<TContextData>;
}

@@ -2818,6 +2824,18 @@ export interface ActorCallbackSetters<TContextData> {
    dispatcher: ActorKeyPairsDispatcher<TContextData>,
  ): ActorCallbackSetters<TContextData>;

  /**
   * Sets the callback function that maps a WebFinger username to
   * the corresponding actor's internal handle.  If it's omitted, the handle
   * is assumed to be the same as the WebFinger username, which makes your
   * actors have the immutable handles.  If you want to let your actors change
   * their fediverse handles, you should set this dispatcher.
   * @since 0.15.0
   */
  mapHandle(
    mapper: ActorHandleMapper<TContextData>,
  ): ActorCallbackSetters<TContextData>;

  /**
   * Specifies the conditions under which requests are authorized.
   * @param predicate A callback that returns whether a request is authorized.
+108 −6
Original line number Diff line number Diff line
import { assertEquals } from "@std/assert";
import type { ActorDispatcher } from "../federation/callback.ts";
import type {
  ActorDispatcher,
  ActorHandleMapper,
} from "../federation/callback.ts";
import { createRequestContext } from "../testing/context.ts";
import { test } from "../testing/mod.ts";
import type { Actor } from "../vocab/actor.ts";
@@ -27,14 +30,15 @@ test("handleWebFinger()", async () => {
    },
  });
  const actorDispatcher: ActorDispatcher<void> = (ctx, handle) => {
    if (handle !== "someone") return null;
    if (handle !== "someone" && handle !== "someone2") return null;
    return new Person({
      id: ctx.getActorUri(handle),
      name: "Someone",
      name: handle === "someone" ? "Someone" : "Someone 2",
      preferredUsername: handle === "someone" ? null : handle,
      urls: [
        new URL("https://example.com/@someone"),
        new URL("https://example.com/@" + handle),
        new Link({
          href: new URL("https://example.org/@someone"),
          href: new URL("https://example.org/@" + handle),
          rel: "alternate",
          mediaType: "text/html",
        }),
@@ -118,7 +122,43 @@ test("handleWebFinger()", async () => {
    onNotFound,
  });
  assertEquals(response.status, 200);
  assertEquals(await response.json(), expected);
  assertEquals(await response.json(), {
    ...expected,
    aliases: [],
    subject: "https://example.com/users/someone",
  });

  url.searchParams.set("resource", "https://example.com/users/someone2");
  request = new Request(url);
  response = await handleWebFinger(request, {
    context,
    actorDispatcher,
    onNotFound,
  });
  assertEquals(response.status, 200);
  const expected2 = {
    subject: "https://example.com/users/someone2",
    aliases: [
      "acct:someone2@example.com",
    ],
    links: [
      {
        href: "https://example.com/users/someone2",
        rel: "self",
        type: "application/activity+json",
      },
      {
        href: "https://example.com/@someone2",
        rel: "http://webfinger.net/rel/profile-page",
      },
      {
        href: "https://example.org/@someone2",
        rel: "alternate",
        type: "text/html",
      },
    ],
  };
  assertEquals(await response.json(), expected2);

  url.searchParams.set("resource", "acct:no-one@example.com");
  request = new Request(url);
@@ -151,4 +191,66 @@ test("handleWebFinger()", async () => {
  });
  assertEquals(response.status, 404);
  assertEquals(onNotFoundCalled, request);

  const actorHandleMapper: ActorHandleMapper<void> = (_ctx, username) => {
    return username === "foo"
      ? "someone"
      : username === "bar"
      ? "someone2"
      : null;
  };
  url.searchParams.set("resource", "acct:foo@example.com");
  request = new Request(url);
  response = await handleWebFinger(request, {
    context,
    actorDispatcher,
    actorHandleMapper,
    onNotFound,
  });
  assertEquals(response.status, 200);
  assertEquals(await response.json(), {
    ...expected,
    aliases: ["https://example.com/users/someone"],
    subject: "acct:foo@example.com",
  });

  url.searchParams.set("resource", "acct:bar@example.com");
  request = new Request(url);
  response = await handleWebFinger(request, {
    context,
    actorDispatcher,
    actorHandleMapper,
    onNotFound,
  });
  assertEquals(response.status, 200);
  assertEquals(await response.json(), {
    ...expected2,
    aliases: ["https://example.com/users/someone2"],
    subject: "acct:bar@example.com",
  });

  url.searchParams.set("resource", "https://example.com/users/someone");
  request = new Request(url);
  response = await handleWebFinger(request, {
    context,
    actorDispatcher,
    actorHandleMapper,
    onNotFound,
  });
  assertEquals(response.status, 200);
  assertEquals(await response.json(), {
    ...expected,
    aliases: [],
    subject: "https://example.com/users/someone",
  });

  url.searchParams.set("resource", "acct:baz@example.com");
  request = new Request(url);
  response = await handleWebFinger(request, {
    context,
    actorDispatcher,
    actorHandleMapper,
    onNotFound,
  });
  assertEquals(response.status, 404);
});
Loading