Skip to content

Commit 56c1e6c

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. Fixes #752 Assisted-by: Gemini CLI:gemini-3.1-pro-preview Assisted-by: Codex:gpt-5.5
1 parent 4aa6a88 commit 56c1e6c

8 files changed

Lines changed: 160 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: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,48 @@ 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.
495+
496+
> [!TIP]
497+
> You can map multiple fixed paths to different sentinel identifiers by calling
498+
> the `~ActorCallbackSetters.mapActorAlias()` method multiple times.
499+
500+
459501
Decoupling actor URIs from WebFinger usernames
460502
----------------------------------------------
461503

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: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,7 @@ test({
301301

302302
federation
303303
.setActorDispatcher("/users/{identifier}", () => new vocab.Person({}))
304+
.mapActorAlias("/bot", "bot")
304305
.setKeyPairsDispatcher(() => [
305306
{
306307
privateKey: rsaPrivateKey2,
@@ -317,11 +318,19 @@ test({
317318
ctx.getActorUri("handle"),
318319
new URL("https://example.com/users/handle"),
319320
);
321+
assertEquals(
322+
ctx.getActorUri("bot"),
323+
new URL("https://example.com/bot"),
324+
);
320325
assertEquals(ctx.parseUri(new URL("https://example.com/")), null);
321326
assertEquals(
322327
ctx.parseUri(new URL("https://example.com/users/handle")),
323328
{ type: "actor", identifier: "handle" },
324329
);
330+
assertEquals(
331+
ctx.parseUri(new URL("https://example.com/bot")),
332+
{ type: "actor", identifier: "bot" },
333+
);
325334
assertEquals(ctx.parseUri(null), null);
326335
assertEquals(
327336
await ctx.getActorKeyPairs("handle"),
@@ -1132,6 +1141,7 @@ test("Federation.fetch()", async (t) => {
11321141
});
11331142
},
11341143
)
1144+
.mapActorAlias("/bot", "bot")
11351145
.setKeyPairsDispatcher(() => {
11361146
return [
11371147
{ privateKey: rsaPrivateKey2, publicKey: rsaPublicKey2.publicKey! },
@@ -1170,6 +1180,26 @@ test("Federation.fetch()", async (t) => {
11701180
assertEquals(response.status, 406);
11711181
});
11721182

1183+
await t.step("GET actor alias", async () => {
1184+
const { federation, dispatches } = createTestContext();
1185+
1186+
const response = await federation.fetch(
1187+
new Request("https://example.com/bot", {
1188+
method: "GET",
1189+
headers: {
1190+
"Accept": "application/activity+json",
1191+
},
1192+
}),
1193+
{ contextData: undefined },
1194+
);
1195+
1196+
assertEquals(dispatches, ["bot"]);
1197+
assertEquals(response.status, 200);
1198+
const body = await response.json() as Record<string, unknown>;
1199+
assertEquals(body.id, "https://example.com/bot");
1200+
assertEquals(body.preferredUsername, "bot");
1201+
});
1202+
11731203
await t.step("POST with application/json", async () => {
11741204
const { federation, inbox } = createTestContext();
11751205

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: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
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.
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+
23+
Resolves #752.

0 commit comments

Comments
 (0)