diff --git a/CHANGES.md b/CHANGES.md index a101698bc..c12781771 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -10,6 +10,12 @@ To be released. ### @fedify/fedify + - Added `mapActorAlias()` method to `ActorCallbackSetters` interface to + support fixed-path actor dispatchers. This is useful for exposing a + single, instance-level actor at a fixed path, such as `/actor` for a relay + or `/bot` for a bot, without leaking a sentinel identifier into the actor's + URI. [[#752], [#753]] + - Added optional `MessageQueue.getDepth()` support, using the new `MessageQueueDepth` return type, for reporting queue backlog depth. `InProcessMessageQueue` can now report queued messages, including ready @@ -18,6 +24,8 @@ To be released. [#735]: https://github.com/fedify-dev/fedify/issues/735 [#748]: https://github.com/fedify-dev/fedify/pull/748 +[#752]: https://github.com/fedify-dev/fedify/issues/752 +[#753]: https://github.com/fedify-dev/fedify/pull/753 ### @fedify/amqp diff --git a/docs/manual/actor.md b/docs/manual/actor.md index c694a3ddd..b5e7bf37a 100644 --- a/docs/manual/actor.md +++ b/docs/manual/actor.md @@ -456,6 +456,49 @@ ctx.getActorUri("2bd304f9-36b3-44f0-bf0b-29124aafcbb4") > the argument is a valid identifier before calling the method. +Fixed-path actor URIs +--------------------- + +*This API is available since Fedify 2.3.0.* + +In some cases, you may want to expose a single, instance-level actor at a fixed +path, such as `/actor` for a relay or `/bot` for a bot, without leaking a +sentinel identifier like `__instance__` into the actor's URI. + +You can alias a fixed path to a sentinel identifier by calling +the `~ActorCallbackSetters.mapActorAlias()` method: + +~~~~ typescript +// @noErrors: 2345 2391 +import { type Federation } from "@fedify/fedify"; +import { Person } from "@fedify/vocab"; +const federation = null as unknown as Federation; +// ---cut-before--- +federation + .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { + if (identifier === "bot") { + return new Person({ + id: ctx.getActorUri(identifier), + preferredUsername: "bot", + // ... + }); + } + // ... + }) + .mapActorAlias("/bot", "bot"); +~~~~ + +Once the alias is registered, `Context.getActorUri("bot")` will return +`https://example.com/bot` rather than `https://example.com/users/bot`. +Incoming requests to `/bot` will also correctly resolve the identifier to +`"bot"` and trigger the actor dispatcher. WebFinger responses for the actor +will also use the fixed path for the `self` link and the aliases. + +> [!TIP] +> You can map multiple fixed paths to different sentinel identifiers by calling +> the `~ActorCallbackSetters.mapActorAlias()` method multiple times. + + Decoupling actor URIs from WebFinger usernames ---------------------------------------------- diff --git a/packages/fedify/src/federation/builder.test.ts b/packages/fedify/src/federation/builder.test.ts index b5d8a1a99..9d691c40d 100644 --- a/packages/fedify/src/federation/builder.test.ts +++ b/packages/fedify/src/federation/builder.test.ts @@ -25,7 +25,44 @@ test("FederationBuilder", async (t) => { const actorDispatcher: ActorDispatcher = (_ctx, _identifier) => { return null; }; - builder.setActorDispatcher("/users/{identifier}", actorDispatcher); + assertThrows( + () => + createFederationBuilder().setActorDispatcher( + "/users/{identifier}", + actorDispatcher, + ) + .mapActorAlias("/actor/{id}", "instance"), + RouterError, + "Path for actor alias must have no variables.", + ); + assertThrows( + () => + createFederationBuilder() + .setActorDispatcher("/users/{identifier}", actorDispatcher) + .mapActorAlias("/actor", "instance") + .mapActorAlias("/bot", "instance"), + RouterError, + 'Actor alias for "instance" already set.', + ); + assertThrows( + () => + createFederationBuilder() + .setActorDispatcher("/users/{identifier}", actorDispatcher) + .mapActorAlias("/actor", "instance") + .mapActorAlias("/actor", "bot"), + RouterError, + 'Actor alias path "/actor" conflicts with existing route "actorAlias:instance".', + ); + assertThrows( + () => + createFederationBuilder() + .setActorDispatcher("/users/{identifier}", actorDispatcher) + .mapActorAlias("/actor", ""), + RouterError, + "Identifier cannot be empty.", + ); + builder.setActorDispatcher("/users/{identifier}", actorDispatcher) + .mapActorAlias("/actor", "instance"); const inboxListener: InboxListener = ( _ctx, @@ -83,6 +120,7 @@ test("FederationBuilder", async (t) => { "webfinger", ); assertEquals(impl.router.route("/users/test123")?.name, "actor"); + assertEquals(impl.router.route("/actor")?.name, "actorAlias:instance"); assertEquals(impl.router.route("/users/test123/inbox")?.name, "inbox"); assertEquals(impl.router.route("/users/test123/outbox")?.name, "outbox"); assertEquals( diff --git a/packages/fedify/src/federation/builder.ts b/packages/fedify/src/federation/builder.ts index cb1253604..da5cf9845 100644 --- a/packages/fedify/src/federation/builder.ts +++ b/packages/fedify/src/federation/builder.ts @@ -63,6 +63,8 @@ import type { } from "./handler.ts"; import { Router, RouterError } from "./router.ts"; +export const ACTOR_ALIAS_PREFIX = "actorAlias:"; + function validateSingleIdentifierVariablePath( path: string, errorMessage: string, @@ -522,6 +524,30 @@ export class FederationBuilderImpl callbacks.aliasMapper = mapper; return setters; }, + mapActorAlias: (path: `/${string}`, identifier: string) => { + if (identifier === "") { + throw new RouterError("Identifier cannot be empty."); + } + if (this.router.has(`${ACTOR_ALIAS_PREFIX}${identifier}`)) { + throw new RouterError( + `Actor alias for "${identifier}" already set.`, + ); + } + const variables = new Router().add(path, "temp"); + if (variables.size > 0) { + throw new RouterError( + "Path for actor alias must have no variables.", + ); + } + const existingRoute = this.router.route(path); + if (existingRoute != null) { + throw new RouterError( + `Actor alias path "${path}" conflicts with existing route "${existingRoute.name}".`, + ); + } + this.router.add(path, `${ACTOR_ALIAS_PREFIX}${identifier}`); + return setters; + }, authorize(predicate: AuthorizePredicate) { callbacks.authorizePredicate = predicate; return setters; diff --git a/packages/fedify/src/federation/federation.ts b/packages/fedify/src/federation/federation.ts index 6dad929aa..fa91f2253 100644 --- a/packages/fedify/src/federation/federation.ts +++ b/packages/fedify/src/federation/federation.ts @@ -1108,6 +1108,22 @@ export interface ActorCallbackSetters { mapper: ActorAliasMapper, ): ActorCallbackSetters; + /** + * Maps a fixed path to a sentinel identifier. It is useful for exposing + * a single, instance-level actor at a fixed path, such as `/actor` for + * a relay or `/bot` for a bot. + * @param path The fixed path to map to the identifier. + * @param identifier The sentinel identifier to map the path to. + * @returns The setters object so that settings can be chained. + * @throws {RouterError} If the provided path or identifier is invalid or fails + * runtime validation. + * @since 2.3.0 + */ + mapActorAlias( + path: `/${string}`, + identifier: string, + ): ActorCallbackSetters; + /** * Specifies the conditions under which requests are authorized. * @param predicate A callback that returns whether a request is authorized. diff --git a/packages/fedify/src/federation/middleware.test.ts b/packages/fedify/src/federation/middleware.test.ts index a32b804d9..9390a1bf8 100644 --- a/packages/fedify/src/federation/middleware.test.ts +++ b/packages/fedify/src/federation/middleware.test.ts @@ -9,6 +9,7 @@ import { getTypeId, lookupObject } from "@fedify/vocab"; import { assert, assertEquals, + assertExists, assertFalse, assertInstanceOf, assertNotEquals, @@ -299,8 +300,19 @@ test({ new URL("https://example.com/nodeinfo/2.1"), ); + assertThrows( + () => + createFederation({ + kv: new MemoryKvStore(), + }).setActorDispatcher("/users/{identifier}", () => null) + .mapActorAlias("/actor/{id}" as `/${string}`, "instance"), + RouterError, + "Path for actor alias must have no variables.", + ); + federation .setActorDispatcher("/users/{identifier}", () => new vocab.Person({})) + .mapActorAlias("/bot", "bot") .setKeyPairsDispatcher(() => [ { privateKey: rsaPrivateKey2, @@ -317,11 +329,19 @@ test({ ctx.getActorUri("handle"), new URL("https://example.com/users/handle"), ); + assertEquals( + ctx.getActorUri("bot"), + new URL("https://example.com/bot"), + ); assertEquals(ctx.parseUri(new URL("https://example.com/")), null); assertEquals( ctx.parseUri(new URL("https://example.com/users/handle")), { type: "actor", identifier: "handle" }, ); + assertEquals( + ctx.parseUri(new URL("https://example.com/bot")), + { type: "actor", identifier: "bot" }, + ); assertEquals(ctx.parseUri(null), null); assertEquals( await ctx.getActorKeyPairs("handle"), @@ -1132,6 +1152,7 @@ test("Federation.fetch()", async (t) => { }); }, ) + .mapActorAlias("/bot", "bot") .setKeyPairsDispatcher(() => { return [ { privateKey: rsaPrivateKey2, publicKey: rsaPublicKey2.publicKey! }, @@ -1170,6 +1191,50 @@ test("Federation.fetch()", async (t) => { assertEquals(response.status, 406); }); + await t.step("GET actor alias", async () => { + const { federation, dispatches } = createTestContext(); + + const response = await federation.fetch( + new Request("https://example.com/bot", { + method: "GET", + headers: { + "Accept": "application/activity+json", + }, + }), + { contextData: undefined }, + ); + + assertEquals(dispatches, ["bot"]); + assertEquals(response.status, 200); + const body = await response.json() as Record; + assertEquals(body.id, "https://example.com/bot"); + assertEquals(body.preferredUsername, "bot"); + }); + + await t.step("WebFinger for actor alias", async () => { + const { federation } = createTestContext(); + + const response = await federation.fetch( + new Request( + "https://example.com/.well-known/webfinger?resource=acct:bot@example.com", + ), + { contextData: undefined }, + ); + + assertEquals(response.status, 200); + const body = await response.json() as Record; + assertEquals(body.subject, "acct:bot@example.com"); + assertExists(body.links); + assert(Array.isArray(body.links)); + const selfLink = (body.links as Record[]).find((l) => + l.rel === "self" + ); + assertExists(selfLink); + assertEquals(selfLink.href, "https://example.com/bot"); + assertExists(body.aliases); + assert((body.aliases as string[]).includes("https://example.com/bot")); + }); + await t.step("POST with application/json", async () => { const { federation, inbox } = createTestContext(); diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index 8a9560bc2..27b8a6dcc 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -112,6 +112,8 @@ import type { } from "./queue.ts"; import { createExponentialBackoffPolicy, type RetryPolicy } from "./retry.ts"; import { RouterError } from "./router.ts"; +import { ACTOR_ALIAS_PREFIX } from "./builder.ts"; + import { extractInboxes, sendActivity, @@ -1436,19 +1438,22 @@ export class FederationImpl } switch (routeName) { case "actor": + case "actorAlias": { + const identifier = route.name.startsWith(ACTOR_ALIAS_PREFIX) + ? route.name.substring(ACTOR_ALIAS_PREFIX.length) + : route.values.identifier; context = this.#createContext(request, contextData, { - invokedFromActorDispatcher: { - identifier: route.values.identifier, - }, + invokedFromActorDispatcher: { identifier }, }); return await handleActor(request, { - identifier: route.values.identifier, + identifier, context, actorDispatcher: this.actorCallbacks?.dispatcher, authorizePredicate: this.actorCallbacks?.authorizePredicate, onUnauthorized, onNotFound, }); + } case "object": { const typeId = route.name.replace(/^object:/, ""); const callbacks = this.objectCallbacks[typeId]; @@ -1790,6 +1795,9 @@ export class ContextImpl implements Context { getActorUri(identifier: string): URL { const path = this.federation.router.build( + `${ACTOR_ALIAS_PREFIX}${identifier}`, + {}, + ) ?? this.federation.router.build( "actor", { identifier }, ); @@ -1938,8 +1946,10 @@ export class ContextImpl implements Context { identifier: undefined, }; } - const identifier = route.values.identifier; - if (route.name === "actor") { + const identifier = route.name.startsWith(ACTOR_ALIAS_PREFIX) + ? route.name.substring(ACTOR_ALIAS_PREFIX.length) + : route.values.identifier; + if (route.name === "actor" || route.name.startsWith(ACTOR_ALIAS_PREFIX)) { return { type: "actor", identifier,