Skip to content

Commit ca15ae2

Browse files
committed
Support fixed-path actor dispatchers
Added `mapActorAlias()` method to `ActorCallbackSetters` interface to support mapping fixed paths to sentinel identifiers for the actor dispatcher. This enables exposing a single, instance-level actor at a fixed path (like `/actor` for a relay or `/bot` for a bot) without leaking the sentinel identifier into the actor's URI. When an alias is mapped, the router will use it for resolving inbound requests and `Context.getActorUri()` will use it for constructing outbound actor URIs. WebFinger responses for the actor will also use the aliased URI. Fixes #752 Assisted-by: Gemini CLI:gemini-3.1-pro-preview Assisted-by: Gemini CLI:gemini-3-flash-preview Assisted-by: Codex:gpt-5.5
1 parent 4aa6a88 commit ca15ae2

8 files changed

Lines changed: 185 additions & 10 deletions

File tree

CHANGES.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ To be released.
1010

1111
### @fedify/fedify
1212

13+
- Added `mapActorAlias()` method to `ActorCallbackSetters` interface to
14+
support fixed-path actor dispatchers. This is useful for exposing a
15+
single, instance-level actor at a fixed path, such as `/actor` for a relay
16+
or `/bot` for a bot, without leaking a sentinel identifier into the actor's
17+
URI. [[#752], [#753]]
18+
1319
- Added optional `MessageQueue.getDepth()` support, using the new
1420
`MessageQueueDepth` return type, for reporting queue backlog depth.
1521
`InProcessMessageQueue` can now report queued messages, including ready
@@ -18,6 +24,8 @@ To be released.
1824

1925
[#735]: https://github.com/fedify-dev/fedify/issues/735
2026
[#748]: https://github.com/fedify-dev/fedify/pull/748
27+
[#752]: https://github.com/fedify-dev/fedify/issues/752
28+
[#753]: https://github.com/fedify-dev/fedify/pull/753
2129

2230
### @fedify/amqp
2331

docs/manual/actor.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,49 @@ ctx.getActorUri("2bd304f9-36b3-44f0-bf0b-29124aafcbb4")
456456
> the argument is a valid identifier before calling the method.
457457
458458

459+
Fixed-path actor URIs
460+
---------------------
461+
462+
*This API is available since Fedify 2.3.0.*
463+
464+
In some cases, you may want to expose a single, instance-level actor at a fixed
465+
path, such as `/actor` for a relay or `/bot` for a bot, without leaking a
466+
sentinel identifier like `__instance__` into the actor's URI.
467+
468+
You can alias a fixed path to a sentinel identifier by calling
469+
the `~ActorCallbackSetters.mapActorAlias()` method:
470+
471+
~~~~ typescript
472+
// @noErrors: 2345 2391
473+
import { type Federation } from "@fedify/fedify";
474+
import { Person } from "@fedify/vocab";
475+
const federation = null as unknown as Federation<void>;
476+
// ---cut-before---
477+
federation
478+
.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {
479+
if (identifier === "bot") {
480+
return new Person({
481+
id: ctx.getActorUri(identifier),
482+
preferredUsername: "bot",
483+
// ...
484+
});
485+
}
486+
// ...
487+
})
488+
.mapActorAlias("/bot", "bot");
489+
~~~~
490+
491+
Once the alias is registered, `Context.getActorUri("bot")` will return
492+
`https://example.com/bot` rather than `https://example.com/users/bot`.
493+
Incoming requests to `/bot` will also correctly resolve the identifier to
494+
`"bot"` and trigger the actor dispatcher. WebFinger responses for the actor
495+
will also use the fixed path for the `self` link and the aliases.
496+
497+
> [!TIP]
498+
> You can map multiple fixed paths to different sentinel identifiers by calling
499+
> the `~ActorCallbackSetters.mapActorAlias()` method multiple times.
500+
501+
459502
Decoupling actor URIs from WebFinger usernames
460503
----------------------------------------------
461504

packages/fedify/src/federation/builder.test.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,18 @@ test("FederationBuilder", async (t) => {
2525
const actorDispatcher: ActorDispatcher<string> = (_ctx, _identifier) => {
2626
return null;
2727
};
28-
builder.setActorDispatcher("/users/{identifier}", actorDispatcher);
28+
assertThrows(
29+
() =>
30+
createFederationBuilder<string>().setActorDispatcher(
31+
"/users/{identifier}",
32+
actorDispatcher,
33+
)
34+
.mapActorAlias("/actor/{id}", "instance"),
35+
RouterError,
36+
"Path for actor alias must have no variables.",
37+
);
38+
builder.setActorDispatcher("/users/{identifier}", actorDispatcher)
39+
.mapActorAlias("/actor", "instance");
2940

3041
const inboxListener: InboxListener<string, Activity> = (
3142
_ctx,
@@ -83,6 +94,7 @@ test("FederationBuilder", async (t) => {
8394
"webfinger",
8495
);
8596
assertEquals(impl.router.route("/users/test123")?.name, "actor");
97+
assertEquals(impl.router.route("/actor")?.name, "actorAlias:instance");
8698
assertEquals(impl.router.route("/users/test123/inbox")?.name, "inbox");
8799
assertEquals(impl.router.route("/users/test123/outbox")?.name, "outbox");
88100
assertEquals(

packages/fedify/src/federation/builder.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,16 @@ export class FederationBuilderImpl<TContextData>
522522
callbacks.aliasMapper = mapper;
523523
return setters;
524524
},
525+
mapActorAlias: (path: string, identifier: string) => {
526+
const variables = new Router().add(path, "temp");
527+
if (variables.size > 0) {
528+
throw new RouterError(
529+
"Path for actor alias must have no variables.",
530+
);
531+
}
532+
this.router.add(path, `actorAlias:${identifier}`);
533+
return setters;
534+
},
525535
authorize(predicate: AuthorizePredicate<TContextData>) {
526536
callbacks.authorizePredicate = predicate;
527537
return setters;

packages/fedify/src/federation/federation.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1108,6 +1108,20 @@ export interface ActorCallbackSetters<TContextData> {
11081108
mapper: ActorAliasMapper<TContextData>,
11091109
): ActorCallbackSetters<TContextData>;
11101110

1111+
/**
1112+
* Maps a fixed path to a sentinel identifier. It is useful for exposing
1113+
* a single, instance-level actor at a fixed path, such as `/actor` for
1114+
* a relay or `/bot` for a bot.
1115+
* @param path The fixed path to map to the identifier.
1116+
* @param identifier The sentinel identifier to map the path to.
1117+
* @returns The setters object so that settings can be chained.
1118+
* @since 2.3.0
1119+
*/
1120+
mapActorAlias(
1121+
path: string,
1122+
identifier: string,
1123+
): ActorCallbackSetters<TContextData>;
1124+
11111125
/**
11121126
* Specifies the conditions under which requests are authorized.
11131127
* @param predicate A callback that returns whether a request is authorized.

packages/fedify/src/federation/middleware.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { getTypeId, lookupObject } from "@fedify/vocab";
99
import {
1010
assert,
1111
assertEquals,
12+
assertExists,
1213
assertFalse,
1314
assertInstanceOf,
1415
assertNotEquals,
@@ -301,6 +302,7 @@ test({
301302

302303
federation
303304
.setActorDispatcher("/users/{identifier}", () => new vocab.Person({}))
305+
.mapActorAlias("/bot", "bot")
304306
.setKeyPairsDispatcher(() => [
305307
{
306308
privateKey: rsaPrivateKey2,
@@ -317,11 +319,19 @@ test({
317319
ctx.getActorUri("handle"),
318320
new URL("https://example.com/users/handle"),
319321
);
322+
assertEquals(
323+
ctx.getActorUri("bot"),
324+
new URL("https://example.com/bot"),
325+
);
320326
assertEquals(ctx.parseUri(new URL("https://example.com/")), null);
321327
assertEquals(
322328
ctx.parseUri(new URL("https://example.com/users/handle")),
323329
{ type: "actor", identifier: "handle" },
324330
);
331+
assertEquals(
332+
ctx.parseUri(new URL("https://example.com/bot")),
333+
{ type: "actor", identifier: "bot" },
334+
);
325335
assertEquals(ctx.parseUri(null), null);
326336
assertEquals(
327337
await ctx.getActorKeyPairs("handle"),
@@ -1132,6 +1142,7 @@ test("Federation.fetch()", async (t) => {
11321142
});
11331143
},
11341144
)
1145+
.mapActorAlias("/bot", "bot")
11351146
.setKeyPairsDispatcher(() => {
11361147
return [
11371148
{ privateKey: rsaPrivateKey2, publicKey: rsaPublicKey2.publicKey! },
@@ -1170,6 +1181,47 @@ test("Federation.fetch()", async (t) => {
11701181
assertEquals(response.status, 406);
11711182
});
11721183

1184+
await t.step("GET actor alias", async () => {
1185+
const { federation, dispatches } = createTestContext();
1186+
1187+
const response = await federation.fetch(
1188+
new Request("https://example.com/bot", {
1189+
method: "GET",
1190+
headers: {
1191+
"Accept": "application/activity+json",
1192+
},
1193+
}),
1194+
{ contextData: undefined },
1195+
);
1196+
1197+
assertEquals(dispatches, ["bot"]);
1198+
assertEquals(response.status, 200);
1199+
const body = await response.json() as Record<string, unknown>;
1200+
assertEquals(body.id, "https://example.com/bot");
1201+
assertEquals(body.preferredUsername, "bot");
1202+
});
1203+
1204+
await t.step("WebFinger for actor alias", async () => {
1205+
const { federation } = createTestContext();
1206+
1207+
const response = await federation.fetch(
1208+
new Request(
1209+
"https://example.com/.well-known/webfinger?resource=acct:bot@example.com",
1210+
),
1211+
{ contextData: undefined },
1212+
);
1213+
1214+
assertEquals(response.status, 200);
1215+
const body = await response.json() as Record<string, unknown>;
1216+
assertEquals(body.subject, "acct:bot@example.com");
1217+
const selfLink = (body.links as Record<string, unknown>[]).find((l) =>
1218+
l.rel === "self"
1219+
);
1220+
assertExists(selfLink);
1221+
assertEquals(selfLink.href, "https://example.com/bot");
1222+
assert((body.aliases as string[]).includes("https://example.com/bot"));
1223+
});
1224+
11731225
await t.step("POST with application/json", async () => {
11741226
const { federation, inbox } = createTestContext();
11751227

packages/fedify/src/federation/middleware.ts

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1436,19 +1436,22 @@ export class FederationImpl<TContextData>
14361436
}
14371437
switch (routeName) {
14381438
case "actor":
1439+
case "actorAlias": {
1440+
const identifier = route.name.startsWith("actorAlias:")
1441+
? route.name.substring("actorAlias:".length)
1442+
: route.values.identifier;
14391443
context = this.#createContext(request, contextData, {
1440-
invokedFromActorDispatcher: {
1441-
identifier: route.values.identifier,
1442-
},
1444+
invokedFromActorDispatcher: { identifier },
14431445
});
14441446
return await handleActor(request, {
1445-
identifier: route.values.identifier,
1447+
identifier,
14461448
context,
14471449
actorDispatcher: this.actorCallbacks?.dispatcher,
14481450
authorizePredicate: this.actorCallbacks?.authorizePredicate,
14491451
onUnauthorized,
14501452
onNotFound,
14511453
});
1454+
}
14521455
case "object": {
14531456
const typeId = route.name.replace(/^object:/, "");
14541457
const callbacks = this.objectCallbacks[typeId];
@@ -1789,10 +1792,16 @@ export class ContextImpl<TContextData> implements Context<TContextData> {
17891792
}
17901793

17911794
getActorUri(identifier: string): URL {
1792-
const path = this.federation.router.build(
1793-
"actor",
1794-
{ identifier },
1795+
let path = this.federation.router.build(
1796+
`actorAlias:${identifier}`,
1797+
{},
17951798
);
1799+
if (path == null) {
1800+
path = this.federation.router.build(
1801+
"actor",
1802+
{ identifier },
1803+
);
1804+
}
17961805
if (path == null) {
17971806
throw new RouterError("No actor dispatcher registered.");
17981807
}
@@ -1938,8 +1947,10 @@ export class ContextImpl<TContextData> implements Context<TContextData> {
19381947
identifier: undefined,
19391948
};
19401949
}
1941-
const identifier = route.values.identifier;
1942-
if (route.name === "actor") {
1950+
const identifier = route.name.startsWith("actorAlias:")
1951+
? route.name.substring("actorAlias:".length)
1952+
: route.values.identifier;
1953+
if (route.name === "actor" || route.name.startsWith("actorAlias:")) {
19431954
return {
19441955
type: "actor",
19451956
identifier,

pr-description.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
This PR adds `mapActorAlias()` to `ActorCallbackSetters`. An actor dispatcher
2+
can now map a fixed path, such as `/actor` or `/bot`, to an internal sentinel
3+
identifier.
4+
5+
Some applications expose a single instance-level actor at a fixed path. Before
6+
this change, they had to use a sentinel identifier such as `__instance__`, and
7+
that identifier became part of the actor URI.
8+
9+
`mapActorAlias()` keeps the sentinel internal. Incoming actor requests resolve
10+
through the alias map. `Context.getActorUri()` uses the same map when building
11+
outbound actor URIs. WebFinger responses for the actor also use the aliased URI.
12+
13+
Implementation details:
14+
15+
- Aliases register a router route named `actorAlias:{identifier}`.
16+
- Alias paths must be fixed. Paths containing route variables are rejected.
17+
- `Context.getActorUri()` tries the `actorAlias:{identifier}` route first,
18+
then falls back to the standard `actor` route.
19+
- `Context.parseUri()` and `fetch()` routing now recognize `actorAlias:`
20+
route names and read the identifier from the route name instead of route
21+
values.
22+
- WebFinger responses use `Context.getActorUri()` for the `self` link and
23+
aliases, so they automatically use the aliased path.
24+
25+
Resolves #752.

0 commit comments

Comments
 (0)