Loading CHANGES.md +11 −0 Original line number Diff line number Diff line Loading @@ -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. Loading @@ -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`. Loading docs/manual/actor.md +41 −0 Original line number Diff line number Diff line Loading @@ -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. src/federation/callback.ts +13 −0 Original line number Diff line number Diff line Loading @@ -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. * Loading src/federation/middleware.ts +18 −0 Original line number Diff line number Diff line Loading @@ -23,6 +23,7 @@ import { import { handleWebFinger } from "../webfinger/handler.ts"; import type { ActorDispatcher, ActorHandleMapper, ActorKeyPairsDispatcher, AuthorizePredicate, CollectionCounter, Loading Loading @@ -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; Loading Loading @@ -2791,6 +2796,7 @@ export interface FederationFetchOptions<TContextData> { interface ActorCallbacks<TContextData> { dispatcher?: ActorDispatcher<TContextData>; keyPairsDispatcher?: ActorKeyPairsDispatcher<TContextData>; handleMapper?: ActorHandleMapper<TContextData>; authorizePredicate?: AuthorizePredicate<TContextData>; } Loading Loading @@ -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. Loading src/webfinger/handler.test.ts +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"; Loading Loading @@ -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", }), Loading Loading @@ -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); Loading Loading @@ -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
CHANGES.md +11 −0 Original line number Diff line number Diff line Loading @@ -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. Loading @@ -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`. Loading
docs/manual/actor.md +41 −0 Original line number Diff line number Diff line Loading @@ -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.
src/federation/callback.ts +13 −0 Original line number Diff line number Diff line Loading @@ -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. * Loading
src/federation/middleware.ts +18 −0 Original line number Diff line number Diff line Loading @@ -23,6 +23,7 @@ import { import { handleWebFinger } from "../webfinger/handler.ts"; import type { ActorDispatcher, ActorHandleMapper, ActorKeyPairsDispatcher, AuthorizePredicate, CollectionCounter, Loading Loading @@ -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; Loading Loading @@ -2791,6 +2796,7 @@ export interface FederationFetchOptions<TContextData> { interface ActorCallbacks<TContextData> { dispatcher?: ActorDispatcher<TContextData>; keyPairsDispatcher?: ActorKeyPairsDispatcher<TContextData>; handleMapper?: ActorHandleMapper<TContextData>; authorizePredicate?: AuthorizePredicate<TContextData>; } Loading Loading @@ -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. Loading
src/webfinger/handler.test.ts +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"; Loading Loading @@ -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", }), Loading Loading @@ -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); Loading Loading @@ -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); });