Skip to content

Commit 977bbf1

Browse files
authored
Merge pull request #680 from dahlia/issue-644-actor-tombstone
Support tombstones in actor dispatchers and request contexts
2 parents 290cbf4 + 9e725ee commit 977bbf1

15 files changed

Lines changed: 409 additions & 22 deletions

File tree

CHANGES.md

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

1111
### @fedify/fedify
1212

13+
- Allowed actor dispatchers to return `Tombstone` for deleted accounts.
14+
Fedify now serves those actor URIs as `410 Gone` with the serialized
15+
tombstone body, and the corresponding WebFinger lookups also return
16+
`410 Gone` instead of pretending the account was never handled.
17+
Added a `RequestContext.getActor()` overload that can return those
18+
tombstones to application code when called with
19+
`{ tombstone: "passthrough" }`.
20+
[[#644], [#680]]
21+
1322
- Added `DoubleKnockOptions.maxRedirection` to configure the maximum number
1423
of redirects followed by `doubleKnock()`.
1524
`getAuthenticatedDocumentLoader()` now also respects
1625
`GetAuthenticatedDocumentLoaderOptions.maxRedirection`.
1726

27+
[#644]: https://github.com/fedify-dev/fedify/issues/644
28+
[#680]: https://github.com/fedify-dev/fedify/pull/680
29+
1830
### @fedify/vocab-runtime
1931

2032
- Added `DocumentLoaderFactoryOptions.maxRedirection` to configure the

docs/manual/actor.md

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ by its identifier. Since the actor dispatcher is the most significant part of
1414
Fedify, it is the first thing you need to do to make Fedify work.
1515

1616
An actor dispatcher is a callback function that takes a `Context` object and
17-
an identifier, and returns an actor object. The actor object can be one of
18-
the following:
17+
an identifier, and returns an actor object, a `Tombstone`, or `null`.
18+
Live actor objects can be one of the following:
1919

2020
- `Application`
2121
- `Group`
@@ -25,7 +25,7 @@ the following:
2525

2626
The below example shows how to register an actor dispatcher:
2727

28-
~~~~ typescript{7-15} twoslash
28+
~~~~ typescript{8-16} twoslash
2929
// @noErrors: 2451 2345
3030
import type { Federation } from "@fedify/fedify";
3131
const federation = null as unknown as Federation<void>;
@@ -54,6 +54,32 @@ In the above example, the `~Federatable.setActorDispatcher()` method registers
5454
an actor dispatcher for the `/users/{identifier}` path. This pattern syntax
5555
follows the [URI Template] specification.
5656

57+
If the actor exists but should be represented as deleted, return a `Tombstone`
58+
instead of `null`:
59+
60+
~~~~ typescript twoslash
61+
// @noErrors: 2345
62+
import { type Federation } from "@fedify/fedify";
63+
import { Tombstone } from "@fedify/vocab";
64+
const federation = null as unknown as Federation<void>;
65+
const deletedAt = Temporal.Instant.from("2024-01-15T00:00:00Z");
66+
// ---cut-before---
67+
federation.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {
68+
if (identifier !== "alice") return null;
69+
return new Tombstone({
70+
id: ctx.getActorUri(identifier),
71+
deleted: deletedAt,
72+
});
73+
});
74+
~~~~
75+
76+
When an actor dispatcher returns a `Tombstone`, Fedify responds from the actor
77+
endpoint with `410 Gone` and the serialized tombstone body. WebFinger for the
78+
same account also responds with `410 Gone`.
79+
80+
Use `null` only when the identifier is not handled at all and the request
81+
should fall through to the next middleware or `onNotFound` handler.
82+
5783
> [!TIP]
5884
> By registering the actor dispatcher, `Federation.fetch()` automatically
5985
> deals with [WebFinger] requests for the actor.

docs/manual/context.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,26 @@ if (actor != null) {
214214
}
215215
~~~~
216216

217+
By default, `RequestContext.getActor()` suppresses tombstoned actors and returns
218+
`null` for them. If you need to distinguish a deleted actor from a missing
219+
identifier, pass `{ tombstone: "passthrough" }`:
220+
221+
~~~~ typescript twoslash
222+
import { type Federation } from "@fedify/fedify";
223+
import { Tombstone } from "@fedify/vocab";
224+
const federation = null as unknown as Federation<void>;
225+
const request = new Request("");
226+
const identifier: string = "";
227+
// ---cut-before---
228+
const ctx = federation.createContext(request, undefined);
229+
const actor = await ctx.getActor(identifier, {
230+
tombstone: "passthrough",
231+
});
232+
if (actor instanceof Tombstone) {
233+
console.log(`${identifier} was deleted at ${actor.deleted}`);
234+
}
235+
~~~~
236+
217237
> [!NOTE]
218238
> The `RequestContext.getActor()` method is only available when the actor
219239
> dispatcher is registered to the `Federation` object. If the actor dispatcher

docs/manual/webfinger.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,10 @@ The WebFinger links dispatcher receives two parameters:
198198
> route.
199199
200200
> [!NOTE]
201+
> If your actor dispatcher returns a `Tombstone` for a deleted account,
202+
> Fedify responds to the corresponding WebFinger lookup with `410 Gone`
203+
> instead of a JRD document. This matches the actor endpoint behavior,
204+
> which also returns `410 Gone` for the tombstoned actor URI.
201205
> Before the introduction of `~Federatable.setWebFingerLinksDispatcher()` in
202206
> Fedify 1.9.0, WebFinger responses could only be customized through
203207
> `~Federatable.setActorDispatcher()` by setting the actor's `url` property.

docs/tutorial/basics.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -471,8 +471,9 @@ In the above code, we use the `~Federatable.setActorDispatcher()` method to set
471471
an actor dispatcher for the server. The first argument is the path pattern
472472
for the actor, and the second argument is a callback function that takes
473473
a `Context` object and the actor's identifier. The callback function should
474-
return an `Actor` object or `null` if the actor is not found. In this case,
475-
we return a `Person` object for the actor *me*.
474+
return an `Actor` object, a `Tombstone` if the account is gone, or `null` if
475+
the actor is not found. In this case, we return a `Person` object for the
476+
actor *me*.
476477
477478
Alright, we have an actor on the server. Let's see if it works by querying
478479
WebFinger for the actor. Run the server by executing the following command:

packages/fedify/src/federation/builder.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type {
66
Object,
77
Recipient,
88
} from "@fedify/vocab";
9-
import { getTypeId } from "@fedify/vocab";
9+
import { getTypeId, Tombstone } from "@fedify/vocab";
1010
import { getLogger } from "@logtape/logtape";
1111
import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
1212
import type { Tracer } from "@opentelemetry/api";
@@ -265,6 +265,7 @@ export class FederationBuilderImpl<TContextData>
265265
"Context.getActorUri(identifier).",
266266
);
267267
}
268+
if (actor instanceof Tombstone) return actor;
268269
if (
269270
this.followingCallbacks != null &&
270271
this.followingCallbacks.dispatcher != null

packages/fedify/src/federation/callback.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Activity, Actor, Object } from "@fedify/vocab";
1+
import type { Activity, Actor, Object, Tombstone } from "@fedify/vocab";
22
import type { Link } from "@fedify/webfinger";
33
import type { VerifyRequestFailureReason } from "../sig/http.ts";
44
import type { NodeInfo } from "../nodeinfo/types.ts";
@@ -28,7 +28,7 @@ export type WebFingerLinksDispatcher<TContextData> = (
2828
) => readonly Link[] | Promise<readonly Link[]>;
2929

3030
/**
31-
* A callback that dispatches an {@link Actor} object.
31+
* A callback that dispatches an {@link Actor} object or a {@link Tombstone}.
3232
*
3333
* @template TContextData The context data to pass to the {@link Context}.
3434
* @param context The request context.
@@ -37,7 +37,7 @@ export type WebFingerLinksDispatcher<TContextData> = (
3737
export type ActorDispatcher<TContextData> = (
3838
context: RequestContext<TContextData>,
3939
identifier: string,
40-
) => Actor | null | Promise<Actor | null>;
40+
) => Actor | Tombstone | null | Promise<Actor | Tombstone | null>;
4141

4242
/**
4343
* A callback that dispatches key pairs for an actor.

packages/fedify/src/federation/context.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
Multikey,
99
Object,
1010
Recipient,
11+
Tombstone,
1112
TraverseCollectionOptions,
1213
} from "@fedify/vocab";
1314
import type { DocumentLoader } from "@fedify/vocab-runtime";
@@ -437,6 +438,20 @@ export interface Context<TContextData> {
437438
): URL;
438439
}
439440

441+
/**
442+
* Options for {@link RequestContext.getActor}.
443+
* @since 2.2.0
444+
*/
445+
export interface GetActorOptions {
446+
/**
447+
* Controls how tombstoned actors are returned.
448+
*
449+
* By default, tombstones are suppressed and returned as `null`. Set this to
450+
* `"passthrough"` to receive a {@link Tombstone} result instead.
451+
*/
452+
readonly tombstone?: "suppress" | "passthrough";
453+
}
454+
440455
/**
441456
* A context for a request.
442457
*/
@@ -470,6 +485,54 @@ export interface RequestContext<TContextData> extends Context<TContextData> {
470485
*/
471486
getActor(identifier: string): Promise<Actor | null>;
472487

488+
/**
489+
* Gets an {@link Actor} object or {@link Tombstone} for the given
490+
* identifier.
491+
* @param identifier The actor's identifier.
492+
* @param options Options for getting the actor. Set
493+
* `options.tombstone` to `"passthrough"` to receive
494+
* tombstoned actors instead of `null`.
495+
* @returns The actor object, a tombstone, or `null` if the actor is not
496+
* found.
497+
* @throws {Error} If no actor dispatcher is available.
498+
* @since 2.2.0
499+
*/
500+
getActor(
501+
identifier: string,
502+
options: GetActorOptions & { readonly tombstone: "passthrough" },
503+
): Promise<Actor | Tombstone | null>;
504+
505+
/**
506+
* Gets an {@link Actor} object for the given identifier.
507+
* @param identifier The actor's identifier.
508+
* @param options Options for getting the actor.
509+
* @returns The actor object, or `null` if the actor is not found.
510+
* Tombstoned actors are suppressed unless `options.tombstone` is
511+
* `"passthrough"`.
512+
* @throws {Error} If no actor dispatcher is available.
513+
* @since 2.2.0
514+
*/
515+
getActor(
516+
identifier: string,
517+
options: GetActorOptions & { readonly tombstone?: "suppress" | undefined },
518+
): Promise<Actor | null>;
519+
520+
/**
521+
* Gets an {@link Actor} object or {@link Tombstone} for the given
522+
* identifier.
523+
* @param identifier The actor's identifier.
524+
* @param options Options for getting the actor.
525+
* @returns The actor object, a tombstone, or `null` if the actor is not
526+
* found. This broad overload is used when the caller passes an
527+
* options value whose `tombstone` mode is not known statically.
528+
* @throws {Error} If no actor dispatcher is available.
529+
* @since 2.2.0
530+
*/
531+
getActor(
532+
identifier: string,
533+
options: GetActorOptions,
534+
): Promise<Actor | Tombstone | null>;
535+
473536
/**
474537
* Gets an object of the given class with the given values.
475538
* @param cls The class to instantiate.

packages/fedify/src/federation/federation.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,9 @@ export interface Federatable<TContextData> {
114114
* based on URI Template
115115
* ([RFC 6570](https://tools.ietf.org/html/rfc6570)). The path
116116
* must have one variable: `{identifier}`.
117-
* @param dispatcher An actor dispatcher callback to register.
117+
* @param dispatcher An actor dispatcher callback to register. It may return
118+
* an actor, a `Tombstone`, or `null` if the actor is not
119+
* found.
118120
* @returns An object with methods to set other actor dispatcher callbacks.
119121
* @throws {RouterError} Thrown if the path pattern is invalid.
120122
*/

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

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
Note,
1010
type Object,
1111
Person,
12+
Tombstone,
1213
} from "@fedify/vocab";
1314
import { FetchError } from "@fedify/vocab-runtime";
1415
import { assert, assertEquals } from "@std/assert";
@@ -68,6 +69,7 @@ const WRAPPER_QUOTE_CONTEXT_TERMS = {
6869

6970
test("handleActor()", async () => {
7071
const federation = createFederation<void>({ kv: new MemoryKvStore() });
72+
const deletedAt = Temporal.Instant.from("2024-01-15T00:00:00Z");
7173
let context = createRequestContext<void>({
7274
federation,
7375
data: undefined,
@@ -83,6 +85,13 @@ test("handleActor()", async () => {
8385
name: "Someone",
8486
});
8587
};
88+
const tombstoneDispatcher: ActorDispatcher<void> = (ctx, identifier) => {
89+
if (identifier !== "gone") return null;
90+
return new Tombstone({
91+
id: ctx.getActorUri(identifier),
92+
deleted: deletedAt,
93+
});
94+
};
8695
let onNotFoundCalled: Request | null = null;
8796
const onNotFound = (request: Request) => {
8897
onNotFoundCalled = request;
@@ -294,6 +303,53 @@ test("handleActor()", async () => {
294303
});
295304
assertEquals(onNotFoundCalled, null);
296305
assertEquals(onUnauthorizedCalled, null);
306+
307+
onNotFoundCalled = null;
308+
response = await handleActor(
309+
context.request,
310+
{
311+
context,
312+
identifier: "gone",
313+
actorDispatcher: tombstoneDispatcher,
314+
authorizePredicate: () => false,
315+
onNotFound,
316+
onUnauthorized,
317+
},
318+
);
319+
assertEquals(response.status, 401);
320+
assertEquals(onNotFoundCalled, null);
321+
assertEquals(onUnauthorizedCalled, context.request);
322+
323+
onUnauthorizedCalled = null;
324+
response = await handleActor(
325+
context.request,
326+
{
327+
context,
328+
identifier: "gone",
329+
actorDispatcher: tombstoneDispatcher,
330+
authorizePredicate: () => true,
331+
onNotFound,
332+
onUnauthorized,
333+
},
334+
);
335+
assertEquals(response.status, 410);
336+
assertEquals(
337+
response.headers.get("Content-Type"),
338+
"application/activity+json",
339+
);
340+
assertEquals(response.headers.get("Vary"), "Accept");
341+
assertEquals(await response.json(), {
342+
"@context": [
343+
"https://www.w3.org/ns/activitystreams",
344+
"https://w3id.org/security/data-integrity/v1",
345+
"https://gotosocial.org/ns",
346+
],
347+
id: "https://example.com/users/gone",
348+
type: "Tombstone",
349+
deleted: "2024-01-15T00:00:00Z",
350+
});
351+
assertEquals(onNotFoundCalled, null);
352+
assertEquals(onUnauthorizedCalled, null);
297353
});
298354

299355
test("handleObject()", async () => {

0 commit comments

Comments
 (0)