Skip to content

Commit ab2fa4a

Browse files
authored
Merge pull request #753 from dahlia/feature/fixed-path-actor-dispatchers
Support fixed-path actor dispatchers
2 parents 4aa6a88 + 44b1711 commit ab2fa4a

7 files changed

Lines changed: 213 additions & 7 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: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,44 @@ 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+
assertThrows(
39+
() =>
40+
createFederationBuilder<string>()
41+
.setActorDispatcher("/users/{identifier}", actorDispatcher)
42+
.mapActorAlias("/actor", "instance")
43+
.mapActorAlias("/bot", "instance"),
44+
RouterError,
45+
'Actor alias for "instance" already set.',
46+
);
47+
assertThrows(
48+
() =>
49+
createFederationBuilder<string>()
50+
.setActorDispatcher("/users/{identifier}", actorDispatcher)
51+
.mapActorAlias("/actor", "instance")
52+
.mapActorAlias("/actor", "bot"),
53+
RouterError,
54+
'Actor alias path "/actor" conflicts with existing route "actorAlias:instance".',
55+
);
56+
assertThrows(
57+
() =>
58+
createFederationBuilder<string>()
59+
.setActorDispatcher("/users/{identifier}", actorDispatcher)
60+
.mapActorAlias("/actor", ""),
61+
RouterError,
62+
"Identifier cannot be empty.",
63+
);
64+
builder.setActorDispatcher("/users/{identifier}", actorDispatcher)
65+
.mapActorAlias("/actor", "instance");
2966

3067
const inboxListener: InboxListener<string, Activity> = (
3168
_ctx,
@@ -83,6 +120,7 @@ test("FederationBuilder", async (t) => {
83120
"webfinger",
84121
);
85122
assertEquals(impl.router.route("/users/test123")?.name, "actor");
123+
assertEquals(impl.router.route("/actor")?.name, "actorAlias:instance");
86124
assertEquals(impl.router.route("/users/test123/inbox")?.name, "inbox");
87125
assertEquals(impl.router.route("/users/test123/outbox")?.name, "outbox");
88126
assertEquals(

packages/fedify/src/federation/builder.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ import type {
6363
} from "./handler.ts";
6464
import { Router, RouterError } from "./router.ts";
6565

66+
export const ACTOR_ALIAS_PREFIX = "actorAlias:";
67+
6668
function validateSingleIdentifierVariablePath(
6769
path: string,
6870
errorMessage: string,
@@ -522,6 +524,30 @@ export class FederationBuilderImpl<TContextData>
522524
callbacks.aliasMapper = mapper;
523525
return setters;
524526
},
527+
mapActorAlias: (path: `/${string}`, identifier: string) => {
528+
if (identifier === "") {
529+
throw new RouterError("Identifier cannot be empty.");
530+
}
531+
if (this.router.has(`${ACTOR_ALIAS_PREFIX}${identifier}`)) {
532+
throw new RouterError(
533+
`Actor alias for "${identifier}" already set.`,
534+
);
535+
}
536+
const variables = new Router().add(path, "temp");
537+
if (variables.size > 0) {
538+
throw new RouterError(
539+
"Path for actor alias must have no variables.",
540+
);
541+
}
542+
const existingRoute = this.router.route(path);
543+
if (existingRoute != null) {
544+
throw new RouterError(
545+
`Actor alias path "${path}" conflicts with existing route "${existingRoute.name}".`,
546+
);
547+
}
548+
this.router.add(path, `${ACTOR_ALIAS_PREFIX}${identifier}`);
549+
return setters;
550+
},
525551
authorize(predicate: AuthorizePredicate<TContextData>) {
526552
callbacks.authorizePredicate = predicate;
527553
return setters;

packages/fedify/src/federation/federation.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1108,6 +1108,22 @@ 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+
* @throws {RouterError} If the provided path or identifier is invalid or fails
1119+
* runtime validation.
1120+
* @since 2.3.0
1121+
*/
1122+
mapActorAlias(
1123+
path: `/${string}`,
1124+
identifier: string,
1125+
): ActorCallbackSetters<TContextData>;
1126+
11111127
/**
11121128
* Specifies the conditions under which requests are authorized.
11131129
* @param predicate A callback that returns whether a request is authorized.

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

Lines changed: 65 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,
@@ -299,8 +300,19 @@ test({
299300
new URL("https://example.com/nodeinfo/2.1"),
300301
);
301302

303+
assertThrows(
304+
() =>
305+
createFederation<number>({
306+
kv: new MemoryKvStore(),
307+
}).setActorDispatcher("/users/{identifier}", () => null)
308+
.mapActorAlias("/actor/{id}" as `/${string}`, "instance"),
309+
RouterError,
310+
"Path for actor alias must have no variables.",
311+
);
312+
302313
federation
303314
.setActorDispatcher("/users/{identifier}", () => new vocab.Person({}))
315+
.mapActorAlias("/bot", "bot")
304316
.setKeyPairsDispatcher(() => [
305317
{
306318
privateKey: rsaPrivateKey2,
@@ -317,11 +329,19 @@ test({
317329
ctx.getActorUri("handle"),
318330
new URL("https://example.com/users/handle"),
319331
);
332+
assertEquals(
333+
ctx.getActorUri("bot"),
334+
new URL("https://example.com/bot"),
335+
);
320336
assertEquals(ctx.parseUri(new URL("https://example.com/")), null);
321337
assertEquals(
322338
ctx.parseUri(new URL("https://example.com/users/handle")),
323339
{ type: "actor", identifier: "handle" },
324340
);
341+
assertEquals(
342+
ctx.parseUri(new URL("https://example.com/bot")),
343+
{ type: "actor", identifier: "bot" },
344+
);
325345
assertEquals(ctx.parseUri(null), null);
326346
assertEquals(
327347
await ctx.getActorKeyPairs("handle"),
@@ -1132,6 +1152,7 @@ test("Federation.fetch()", async (t) => {
11321152
});
11331153
},
11341154
)
1155+
.mapActorAlias("/bot", "bot")
11351156
.setKeyPairsDispatcher(() => {
11361157
return [
11371158
{ privateKey: rsaPrivateKey2, publicKey: rsaPublicKey2.publicKey! },
@@ -1170,6 +1191,50 @@ test("Federation.fetch()", async (t) => {
11701191
assertEquals(response.status, 406);
11711192
});
11721193

1194+
await t.step("GET actor alias", async () => {
1195+
const { federation, dispatches } = createTestContext();
1196+
1197+
const response = await federation.fetch(
1198+
new Request("https://example.com/bot", {
1199+
method: "GET",
1200+
headers: {
1201+
"Accept": "application/activity+json",
1202+
},
1203+
}),
1204+
{ contextData: undefined },
1205+
);
1206+
1207+
assertEquals(dispatches, ["bot"]);
1208+
assertEquals(response.status, 200);
1209+
const body = await response.json() as Record<string, unknown>;
1210+
assertEquals(body.id, "https://example.com/bot");
1211+
assertEquals(body.preferredUsername, "bot");
1212+
});
1213+
1214+
await t.step("WebFinger for actor alias", async () => {
1215+
const { federation } = createTestContext();
1216+
1217+
const response = await federation.fetch(
1218+
new Request(
1219+
"https://example.com/.well-known/webfinger?resource=acct:bot@example.com",
1220+
),
1221+
{ contextData: undefined },
1222+
);
1223+
1224+
assertEquals(response.status, 200);
1225+
const body = await response.json() as Record<string, unknown>;
1226+
assertEquals(body.subject, "acct:bot@example.com");
1227+
assertExists(body.links);
1228+
assert(Array.isArray(body.links));
1229+
const selfLink = (body.links as Record<string, unknown>[]).find((l) =>
1230+
l.rel === "self"
1231+
);
1232+
assertExists(selfLink);
1233+
assertEquals(selfLink.href, "https://example.com/bot");
1234+
assertExists(body.aliases);
1235+
assert((body.aliases as string[]).includes("https://example.com/bot"));
1236+
});
1237+
11731238
await t.step("POST with application/json", async () => {
11741239
const { federation, inbox } = createTestContext();
11751240

packages/fedify/src/federation/middleware.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@ import type {
112112
} from "./queue.ts";
113113
import { createExponentialBackoffPolicy, type RetryPolicy } from "./retry.ts";
114114
import { RouterError } from "./router.ts";
115+
import { ACTOR_ALIAS_PREFIX } from "./builder.ts";
116+
115117
import {
116118
extractInboxes,
117119
sendActivity,
@@ -1436,19 +1438,22 @@ export class FederationImpl<TContextData>
14361438
}
14371439
switch (routeName) {
14381440
case "actor":
1441+
case "actorAlias": {
1442+
const identifier = route.name.startsWith(ACTOR_ALIAS_PREFIX)
1443+
? route.name.substring(ACTOR_ALIAS_PREFIX.length)
1444+
: route.values.identifier;
14391445
context = this.#createContext(request, contextData, {
1440-
invokedFromActorDispatcher: {
1441-
identifier: route.values.identifier,
1442-
},
1446+
invokedFromActorDispatcher: { identifier },
14431447
});
14441448
return await handleActor(request, {
1445-
identifier: route.values.identifier,
1449+
identifier,
14461450
context,
14471451
actorDispatcher: this.actorCallbacks?.dispatcher,
14481452
authorizePredicate: this.actorCallbacks?.authorizePredicate,
14491453
onUnauthorized,
14501454
onNotFound,
14511455
});
1456+
}
14521457
case "object": {
14531458
const typeId = route.name.replace(/^object:/, "");
14541459
const callbacks = this.objectCallbacks[typeId];
@@ -1790,6 +1795,9 @@ export class ContextImpl<TContextData> implements Context<TContextData> {
17901795

17911796
getActorUri(identifier: string): URL {
17921797
const path = this.federation.router.build(
1798+
`${ACTOR_ALIAS_PREFIX}${identifier}`,
1799+
{},
1800+
) ?? this.federation.router.build(
17931801
"actor",
17941802
{ identifier },
17951803
);
@@ -1938,8 +1946,10 @@ export class ContextImpl<TContextData> implements Context<TContextData> {
19381946
identifier: undefined,
19391947
};
19401948
}
1941-
const identifier = route.values.identifier;
1942-
if (route.name === "actor") {
1949+
const identifier = route.name.startsWith(ACTOR_ALIAS_PREFIX)
1950+
? route.name.substring(ACTOR_ALIAS_PREFIX.length)
1951+
: route.values.identifier;
1952+
if (route.name === "actor" || route.name.startsWith(ACTOR_ALIAS_PREFIX)) {
19431953
return {
19441954
type: "actor",
19451955
identifier,

0 commit comments

Comments
 (0)