Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
43 changes: 43 additions & 0 deletions docs/manual/actor.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
// ---cut-before---
federation
.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {
if (identifier === "bot") {
return new Person({
id: ctx.getActorUri(identifier),
preferredUsername: "bot",
// ...
});
}
// ...
})
.mapActorAlias("/bot", "bot");
~~~~
Comment thread
dahlia marked this conversation as resolved.

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
----------------------------------------------

Expand Down
23 changes: 22 additions & 1 deletion packages/fedify/src/federation/builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,27 @@ test("FederationBuilder", async (t) => {
const actorDispatcher: ActorDispatcher<string> = (_ctx, _identifier) => {
return null;
};
builder.setActorDispatcher("/users/{identifier}", actorDispatcher);
assertThrows(
() =>
createFederationBuilder<string>().setActorDispatcher(
"/users/{identifier}",
actorDispatcher,
)
.mapActorAlias("/actor/{id}", "instance"),
RouterError,
"Path for actor alias must have no variables.",
);
assertThrows(
() =>
createFederationBuilder<string>()
.setActorDispatcher("/users/{identifier}", actorDispatcher)
.mapActorAlias("/actor", "instance")
.mapActorAlias("/bot", "instance"),
RouterError,
'Actor alias for "instance" already set.',
);
builder.setActorDispatcher("/users/{identifier}", actorDispatcher)
.mapActorAlias("/actor", "instance");
Comment thread
dahlia marked this conversation as resolved.

const inboxListener: InboxListener<string, Activity> = (
_ctx,
Expand Down Expand Up @@ -83,6 +103,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(
Expand Down
17 changes: 17 additions & 0 deletions packages/fedify/src/federation/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ import type {
} from "./handler.ts";
import { Router, RouterError } from "./router.ts";

const ACTOR_ALIAS_PREFIX = "actorAlias:";

function validateSingleIdentifierVariablePath(
path: string,
errorMessage: string,
Expand Down Expand Up @@ -522,6 +524,21 @@ export class FederationBuilderImpl<TContextData>
callbacks.aliasMapper = mapper;
return setters;
},
mapActorAlias: (path: string, identifier: string) => {
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.",
);
}
this.router.add(path, `${ACTOR_ALIAS_PREFIX}${identifier}`);
return setters;
Comment thread
dahlia marked this conversation as resolved.
},
Comment thread
dahlia marked this conversation as resolved.
authorize(predicate: AuthorizePredicate<TContextData>) {
callbacks.authorizePredicate = predicate;
return setters;
Expand Down
16 changes: 16 additions & 0 deletions packages/fedify/src/federation/federation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1108,6 +1108,22 @@ export interface ActorCallbackSetters<TContextData> {
mapper: ActorAliasMapper<TContextData>,
): ActorCallbackSetters<TContextData>;

/**
* 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<TContextData>;
Comment thread
dahlia marked this conversation as resolved.

/**
* Specifies the conditions under which requests are authorized.
* @param predicate A callback that returns whether a request is authorized.
Expand Down
63 changes: 63 additions & 0 deletions packages/fedify/src/federation/middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { getTypeId, lookupObject } from "@fedify/vocab";
import {
assert,
assertEquals,
assertExists,
assertFalse,
assertInstanceOf,
assertNotEquals,
Expand Down Expand Up @@ -299,8 +300,19 @@ test({
new URL("https://example.com/nodeinfo/2.1"),
);

assertThrows(
() =>
createFederation<number>({
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,
Expand All @@ -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"),
Expand Down Expand Up @@ -1132,6 +1152,7 @@ test("Federation.fetch()", async (t) => {
});
},
)
.mapActorAlias("/bot", "bot")
.setKeyPairsDispatcher(() => {
return [
{ privateKey: rsaPrivateKey2, publicKey: rsaPublicKey2.publicKey! },
Expand Down Expand Up @@ -1170,6 +1191,48 @@ 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<string, unknown>;
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<string, unknown>;
assertEquals(body.subject, "acct:bot@example.com");
const selfLink = (body.links as Record<string, unknown>[]).find((l) =>
l.rel === "self"
);
Comment thread
dahlia marked this conversation as resolved.
assertExists(selfLink);
assertEquals(selfLink.href, "https://example.com/bot");
assertExists(body.aliases);
assert((body.aliases as string[]).includes("https://example.com/bot"));
Comment thread
dahlia marked this conversation as resolved.
});

await t.step("POST with application/json", async () => {
const { federation, inbox } = createTestContext();

Expand Down
31 changes: 22 additions & 9 deletions packages/fedify/src/federation/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ import type {
} from "./queue.ts";
import { createExponentialBackoffPolicy, type RetryPolicy } from "./retry.ts";
import { RouterError } from "./router.ts";

const ACTOR_ALIAS_PREFIX = "actorAlias:";
import {
extractInboxes,
sendActivity,
Expand Down Expand Up @@ -1436,19 +1438,22 @@ export class FederationImpl<TContextData>
}
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];
Expand Down Expand Up @@ -1789,10 +1794,16 @@ export class ContextImpl<TContextData> implements Context<TContextData> {
}

getActorUri(identifier: string): URL {
const path = this.federation.router.build(
"actor",
{ identifier },
let path = this.federation.router.build(
`${ACTOR_ALIAS_PREFIX}${identifier}`,
{},
);
if (path == null) {
path = this.federation.router.build(
"actor",
{ identifier },
);
}
if (path == null) {
throw new RouterError("No actor dispatcher registered.");
}
Expand Down Expand Up @@ -1938,8 +1949,10 @@ export class ContextImpl<TContextData> implements Context<TContextData> {
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,
Expand Down