@oxy.so/federation 2.7.0 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -0
- package/dist/cjs/actorObject.js +4 -1
- package/dist/cjs/groupCollections.js +61 -0
- package/dist/cjs/index.js +10 -1
- package/dist/cjs/node/actorRouter.js +33 -10
- package/dist/cjs/node/delivery.js +7 -0
- package/dist/cjs/node/goneActor.js +43 -0
- package/dist/cjs/node/index.js +8 -1
- package/dist/cjs/node/webfingerRouter.js +6 -1
- package/dist/esm/actorObject.js +4 -1
- package/dist/esm/groupCollections.js +55 -0
- package/dist/esm/index.js +5 -0
- package/dist/esm/node/actorRouter.js +33 -10
- package/dist/esm/node/delivery.js +7 -0
- package/dist/esm/node/goneActor.js +39 -0
- package/dist/esm/node/index.js +5 -0
- package/dist/esm/node/webfingerRouter.js +6 -1
- package/dist/types/groupCollections.d.ts +42 -0
- package/dist/types/index.d.ts +5 -0
- package/dist/types/node/actorRouter.d.ts +11 -2
- package/dist/types/node/delivery.d.ts +9 -0
- package/dist/types/node/goneActor.d.ts +29 -0
- package/dist/types/node/index.d.ts +5 -0
- package/dist/types/node/webfingerRouter.d.ts +7 -0
- package/package.json +1 -1
- package/src/__tests__/actorObject.test.ts +1 -0
- package/src/__tests__/delivery.test.ts +23 -0
- package/src/__tests__/routers.test.ts +72 -0
- package/src/actorObject.ts +4 -1
- package/src/groupCollections.ts +67 -0
- package/src/index.ts +11 -0
- package/src/node/actorRouter.ts +40 -16
- package/src/node/delivery.ts +20 -0
- package/src/node/goneActor.ts +45 -0
- package/src/node/index.ts +6 -0
- package/src/node/webfingerRouter.ts +14 -1
package/README.md
CHANGED
|
@@ -29,3 +29,39 @@ each request. Only whole seconds from 0 through 86400 are accepted. This does
|
|
|
29
29
|
not change the injected JRD cache: applications must also authorize its reads
|
|
30
30
|
and their user resolver against current policy. It cannot retract documents
|
|
31
31
|
already cached by remote consumers before this setting changes.
|
|
32
|
+
|
|
33
|
+
## Deleted actors
|
|
34
|
+
|
|
35
|
+
When a local account is deleted, tell the fediverse once and then answer as
|
|
36
|
+
gone, the way Mastodon handles account deletion:
|
|
37
|
+
|
|
38
|
+
1. Call `deliveryService.federateActorDelete(oxyUserId, username)` BEFORE
|
|
39
|
+
deleting the account's accepted follower rows, since they are its
|
|
40
|
+
recipients. It sends the actor's `Delete` of itself (`<actor>#delete`, a
|
|
41
|
+
deterministic id, so a retried run does not fan out twice) to each follower
|
|
42
|
+
shared inbox, signed with the actor's own key. The key must therefore still
|
|
43
|
+
be signable at send time; Oxy keeps it after the account is gone.
|
|
44
|
+
2. Pass `isActorGone(username)` to `createActorRouter` and
|
|
45
|
+
`createWebfingerRouter`. It is asked only for a username `resolveUser` found
|
|
46
|
+
nobody for. True answers `410 Gone` instead of 404 on the actor (with a
|
|
47
|
+
`Tombstone` body), its inbox, its collections and WebFinger. Mastodon deletes
|
|
48
|
+
an account whose WebFinger answers 410; Lemmy, PieFed and Mbin mark an actor
|
|
49
|
+
deleted when a refetch answers 410. A 404 tells none of them anything.
|
|
50
|
+
|
|
51
|
+
The app's own content routes (outbox, posts) can answer the same way with
|
|
52
|
+
`goneTombstone(id)`.
|
|
53
|
+
|
|
54
|
+
## Community collections
|
|
55
|
+
|
|
56
|
+
A local `Group` (an account whose kind is `community`, see
|
|
57
|
+
`isGroupActorKind`) serves its outbox and followers in the shapes Lemmy
|
|
58
|
+
deserializes, because Lemmy and PieFed read neither paging nor `first`:
|
|
59
|
+
|
|
60
|
+
- **Outbox:** one `OrderedCollection` with its latest `GROUP_OUTBOX_SIZE` (50)
|
|
61
|
+
`Announce { Create }` activities inline in `orderedItems`. The outbox is an
|
|
62
|
+
app content route; build it with `groupOutboxCollection`.
|
|
63
|
+
- **Followers:** `createActorRouter` serves a `Collection` with `totalItems` and
|
|
64
|
+
`items: []`, the count without the list, as Lemmy does
|
|
65
|
+
(`groupFollowersCollection`).
|
|
66
|
+
|
|
67
|
+
Every other actor keeps the paged `OrderedCollection` that Mastodon walks.
|
package/dist/cjs/actorObject.js
CHANGED
|
@@ -287,11 +287,14 @@ function createLocalActorBuilder(config) {
|
|
|
287
287
|
// `attributedTo` (Lemmy, PieFed; Mbin reads `moderators`) and says anybody
|
|
288
288
|
// may post into it. PeerTube refuses a `Group` without `attributedTo`, and a
|
|
289
289
|
// `Group` claiming `postingRestrictedToMods` is read as a channel by every
|
|
290
|
-
// implementation, ours included ({@link accountKindForActor}).
|
|
290
|
+
// implementation, ours included ({@link accountKindForActor}). `sensitive`
|
|
291
|
+
// is Lemmy's NSFW flag on a community, sent as Lemmy sends it; an Oxy
|
|
292
|
+
// community has no NSFW setting.
|
|
291
293
|
if (actorObject.type === exports.LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND.community) {
|
|
292
294
|
actorObject.attributedTo = config.urls.moderators(username);
|
|
293
295
|
actorObject.moderators = config.urls.moderators(username);
|
|
294
296
|
actorObject.postingRestrictedToMods = false;
|
|
297
|
+
actorObject.sensitive = false;
|
|
295
298
|
}
|
|
296
299
|
const aliases = normalizeAlsoKnownAs(params.alsoKnownAs);
|
|
297
300
|
if (aliases.length > 0) {
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* How a local `Group` (an Oxy community) publishes its outbox and followers:
|
|
4
|
+
* the shapes Lemmy itself serves and reads back, not the paged
|
|
5
|
+
* `OrderedCollection` + `first` link every other local actor serves.
|
|
6
|
+
*
|
|
7
|
+
* Lemmy deserializes a community's collections into fixed structs that know
|
|
8
|
+
* nothing of paging (`crates/apub/apub/src/protocol/collections/`):
|
|
9
|
+
* - outbox → `GroupOutbox { orderedItems: Vec<Announce> }`, `orderedItems`
|
|
10
|
+
* REQUIRED inline. A summary with only `first` fails to parse, so a server
|
|
11
|
+
* discovering the community backfills nothing. PieFed reads the same inline
|
|
12
|
+
* `orderedItems` and nothing else.
|
|
13
|
+
* - followers → `GroupFollowers { type: "Collection", totalItems, items }`,
|
|
14
|
+
* read for the subscriber count. Lemmy serves `items: []`, so the follower
|
|
15
|
+
* list stays private; this does the same.
|
|
16
|
+
*
|
|
17
|
+
* Mastodon reads `totalItems` from either shape, so a `Group` followed from
|
|
18
|
+
* Mastodon keeps its counts. `Person`, `Organization` and `Service` actors keep
|
|
19
|
+
* the paged shape Mastodon walks.
|
|
20
|
+
*/
|
|
21
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
exports.GROUP_OUTBOX_SIZE = void 0;
|
|
23
|
+
exports.isGroupActorKind = isGroupActorKind;
|
|
24
|
+
exports.groupOutboxCollection = groupOutboxCollection;
|
|
25
|
+
exports.groupFollowersCollection = groupFollowersCollection;
|
|
26
|
+
const actorObject_1 = require("./actorObject");
|
|
27
|
+
const apContext_1 = require("./apContext");
|
|
28
|
+
/** How many of its latest `Announce`s a `Group`'s outbox lists inline (Lemmy's own size). */
|
|
29
|
+
exports.GROUP_OUTBOX_SIZE = 50;
|
|
30
|
+
/**
|
|
31
|
+
* Whether a local account of this kind is announced as a `Group`
|
|
32
|
+
* ({@link LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND}) and so serves the Group
|
|
33
|
+
* collections: moderators, and the outbox and followers in the shapes above.
|
|
34
|
+
*/
|
|
35
|
+
function isGroupActorKind(kind) {
|
|
36
|
+
return (0, actorObject_1.localActorTypeForAccountKind)(kind) === actorObject_1.LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND.community;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* A `Group`'s outbox: one unpaged `OrderedCollection` of its latest
|
|
40
|
+
* `Announce { Create }` activities, newest first. Each item's own `@context` is
|
|
41
|
+
* dropped; the collection's covers it.
|
|
42
|
+
*/
|
|
43
|
+
function groupOutboxCollection(params) {
|
|
44
|
+
return {
|
|
45
|
+
'@context': apContext_1.AP_CONTEXT,
|
|
46
|
+
id: params.id,
|
|
47
|
+
type: 'OrderedCollection',
|
|
48
|
+
totalItems: params.totalItems,
|
|
49
|
+
orderedItems: params.orderedItems.map(({ '@context': _context, ...item }) => item),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** A `Group`'s followers: the count, and an empty `items` list. */
|
|
53
|
+
function groupFollowersCollection(params) {
|
|
54
|
+
return {
|
|
55
|
+
'@context': apContext_1.AP_CONTEXT,
|
|
56
|
+
id: params.id,
|
|
57
|
+
type: 'Collection',
|
|
58
|
+
totalItems: params.totalItems,
|
|
59
|
+
items: [],
|
|
60
|
+
};
|
|
61
|
+
}
|
package/dist/cjs/index.js
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* knowledge of any app's post shape.
|
|
23
23
|
*/
|
|
24
24
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
25
|
-
exports.LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND = exports.AP_ACTOR_TYPES = exports.isApActorType = exports.accountKindForActor = exports.localActorTypeForAccountKind = exports.normalizeAlsoKnownAs = exports.createLocalActorBuilder = exports.createDomainPolicy = exports.extractActorUriFromActivityId = exports.isSameFederationHost = exports.canonicalFederationHost = exports.AP_CONTEXT = exports.readProxyDeclarations = exports.upstreamHandleFromProxyOf = exports.upstreamHandleFromPreferredUsername = exports.upstreamHandleFromAutomatedActor = exports.upstreamHandleFromAlsoKnownAs = exports.upstreamHandleFromProfileField = exports.federatedUsernameFromUpstreamUrl = exports.parseUpstreamProfileUrl = exports.upstreamProfileUrl = exports.stripBridgeBoilerplate = exports.createBridgeRelabeller = exports.blueskyUsernameFromHandle = exports.BSKY_NETWORK_DOMAIN = exports.FEDERATION_NETWORKS = exports.INSTANCE_ACTOR_USERNAME = exports.normalizeActorUsername = exports.createUrlBuilders = exports.DEFAULT_SIGNED_CONTENT_TYPE = exports.HTTP_SIGNATURE_ALGORITHM = exports.verifyHttpSignature = exports.signRequest = void 0;
|
|
25
|
+
exports.groupFollowersCollection = exports.groupOutboxCollection = exports.isGroupActorKind = exports.GROUP_OUTBOX_SIZE = exports.LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND = exports.AP_ACTOR_TYPES = exports.isApActorType = exports.accountKindForActor = exports.localActorTypeForAccountKind = exports.normalizeAlsoKnownAs = exports.createLocalActorBuilder = exports.createDomainPolicy = exports.extractActorUriFromActivityId = exports.isSameFederationHost = exports.canonicalFederationHost = exports.AP_CONTEXT = exports.readProxyDeclarations = exports.upstreamHandleFromProxyOf = exports.upstreamHandleFromPreferredUsername = exports.upstreamHandleFromAutomatedActor = exports.upstreamHandleFromAlsoKnownAs = exports.upstreamHandleFromProfileField = exports.federatedUsernameFromUpstreamUrl = exports.parseUpstreamProfileUrl = exports.upstreamProfileUrl = exports.stripBridgeBoilerplate = exports.createBridgeRelabeller = exports.blueskyUsernameFromHandle = exports.BSKY_NETWORK_DOMAIN = exports.FEDERATION_NETWORKS = exports.INSTANCE_ACTOR_USERNAME = exports.normalizeActorUsername = exports.createUrlBuilders = exports.DEFAULT_SIGNED_CONTENT_TYPE = exports.HTTP_SIGNATURE_ALGORITHM = exports.verifyHttpSignature = exports.signRequest = void 0;
|
|
26
26
|
/**
|
|
27
27
|
* HTTP Signatures (draft-cavage) — the pure sign/verify crypto every Oxy app's
|
|
28
28
|
* ActivityPub federation shares. Private-key custody is injected; the key never
|
|
@@ -99,3 +99,12 @@ Object.defineProperty(exports, "accountKindForActor", { enumerable: true, get: f
|
|
|
99
99
|
Object.defineProperty(exports, "isApActorType", { enumerable: true, get: function () { return actorObject_1.isApActorType; } });
|
|
100
100
|
Object.defineProperty(exports, "AP_ACTOR_TYPES", { enumerable: true, get: function () { return actorObject_1.AP_ACTOR_TYPES; } });
|
|
101
101
|
Object.defineProperty(exports, "LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND", { enumerable: true, get: function () { return actorObject_1.LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND; } });
|
|
102
|
+
/**
|
|
103
|
+
* A local `Group`'s outbox and followers, in the shapes Lemmy serves and reads
|
|
104
|
+
* back; every other actor keeps the paged collections.
|
|
105
|
+
*/
|
|
106
|
+
var groupCollections_1 = require("./groupCollections");
|
|
107
|
+
Object.defineProperty(exports, "GROUP_OUTBOX_SIZE", { enumerable: true, get: function () { return groupCollections_1.GROUP_OUTBOX_SIZE; } });
|
|
108
|
+
Object.defineProperty(exports, "isGroupActorKind", { enumerable: true, get: function () { return groupCollections_1.isGroupActorKind; } });
|
|
109
|
+
Object.defineProperty(exports, "groupOutboxCollection", { enumerable: true, get: function () { return groupCollections_1.groupOutboxCollection; } });
|
|
110
|
+
Object.defineProperty(exports, "groupFollowersCollection", { enumerable: true, get: function () { return groupCollections_1.groupFollowersCollection; } });
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* signature verification (Phase 2, `trustForwardedHost`) and actor-match, then
|
|
10
10
|
* 202 + async dispatch to the injected inbound dispatcher,
|
|
11
11
|
* - `GET /users/:username/followers` + `/following` — the OXY follow graph
|
|
12
|
-
* (local + bridged federated edges) as paginated `OrderedCollection`s
|
|
12
|
+
* (local + bridged federated edges) as paginated `OrderedCollection`s; a
|
|
13
|
+
* community `Group`'s followers are only counted (`groupCollections.ts`),
|
|
13
14
|
* - `GET /users/:username/moderators` — a community `Group`'s moderators.
|
|
14
15
|
*
|
|
15
16
|
* The CONTENT routes (`outbox`, `featured`, per-post dereference) stay in the app,
|
|
@@ -27,7 +28,8 @@ const apContext_1 = require("../apContext");
|
|
|
27
28
|
const httpCacheControl_1 = require("./httpCacheControl");
|
|
28
29
|
const httpSignature_1 = require("../httpSignature");
|
|
29
30
|
const urls_1 = require("../urls");
|
|
30
|
-
const
|
|
31
|
+
const groupCollections_1 = require("../groupCollections");
|
|
32
|
+
const goneActor_1 = require("./goneActor");
|
|
31
33
|
/** Page size for the paginated followers/following collections (mirrors the outbox). */
|
|
32
34
|
const FOLLOW_PAGE_SIZE = 20;
|
|
33
35
|
/** Extract the `:username` param safely as a string. */
|
|
@@ -64,6 +66,18 @@ function createActorRouter(config) {
|
|
|
64
66
|
function wantsActivityPub(req) {
|
|
65
67
|
return config.wantsActivityPub(req.headers.accept);
|
|
66
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* The answer for a username nobody resolved to: 410 with the actor's
|
|
71
|
+
* `Tombstone` when it is a deleted actor, else the usual 404.
|
|
72
|
+
*/
|
|
73
|
+
async function userMissing(res, username) {
|
|
74
|
+
if (config.isActorGone && (await config.isActorGone(username))) {
|
|
75
|
+
res.set('Content-Type', apContentType);
|
|
76
|
+
res.set('Cache-Control', httpCacheControl);
|
|
77
|
+
return res.status(410).json((0, goneActor_1.goneTombstone)(urls.actor(username)));
|
|
78
|
+
}
|
|
79
|
+
return res.status(404).json({ error: 'User not found' });
|
|
80
|
+
}
|
|
67
81
|
/** Common inbox handler with HTTP signature verification. */
|
|
68
82
|
async function handleInbox(req, res) {
|
|
69
83
|
try {
|
|
@@ -121,7 +135,8 @@ function createActorRouter(config) {
|
|
|
121
135
|
}
|
|
122
136
|
/**
|
|
123
137
|
* Serve a user's followers OR following as a paginated `OrderedCollection` over
|
|
124
|
-
* the authoritative Oxy follow graph (local + bridged federated edges).
|
|
138
|
+
* the authoritative Oxy follow graph (local + bridged federated edges). A
|
|
139
|
+
* `Group`'s followers are the unpaged, itemless `Collection` Lemmy reads.
|
|
125
140
|
*/
|
|
126
141
|
async function serveFollowCollection(req, res, direction, collectionUrl) {
|
|
127
142
|
if (!config.federationEnabled)
|
|
@@ -131,14 +146,15 @@ function createActorRouter(config) {
|
|
|
131
146
|
try {
|
|
132
147
|
const user = await config.resolveUser(username);
|
|
133
148
|
if (!user)
|
|
134
|
-
return res
|
|
149
|
+
return userMissing(res, username);
|
|
135
150
|
if (!config.consent.isSharingEnabledFromUser(user)) {
|
|
136
151
|
return res.status(404).json({ error: 'User not found' });
|
|
137
152
|
}
|
|
138
153
|
const userId = String(user._id || user.id);
|
|
139
154
|
const rawCount = direction === 'followers' ? user._count?.followers : user._count?.following;
|
|
140
155
|
const profileTotal = typeof rawCount === 'number' ? rawCount : undefined;
|
|
141
|
-
|
|
156
|
+
const groupFollowers = direction === 'followers' && (0, groupCollections_1.isGroupActorKind)(user.kind);
|
|
157
|
+
if (!page || groupFollowers) {
|
|
142
158
|
// Prefer the profile `_count`; only when absent (the rare search fallback)
|
|
143
159
|
// fetch the authoritative total from a minimal graph list call. Fail-soft
|
|
144
160
|
// to 0 — never 500 the summary.
|
|
@@ -156,6 +172,9 @@ function createActorRouter(config) {
|
|
|
156
172
|
}
|
|
157
173
|
}
|
|
158
174
|
res.set('Content-Type', apContentType);
|
|
175
|
+
if (groupFollowers) {
|
|
176
|
+
return res.json((0, groupCollections_1.groupFollowersCollection)({ id: collectionUrl(username), totalItems }));
|
|
177
|
+
}
|
|
159
178
|
return res.json({
|
|
160
179
|
'@context': apContext_1.AP_CONTEXT,
|
|
161
180
|
id: collectionUrl(username),
|
|
@@ -251,7 +270,7 @@ function createActorRouter(config) {
|
|
|
251
270
|
}
|
|
252
271
|
const user = await config.resolveUser(username);
|
|
253
272
|
if (!user)
|
|
254
|
-
return res
|
|
273
|
+
return userMissing(res, username);
|
|
255
274
|
// Sharing OFF must be indistinguishable from a nonexistent user — same 404
|
|
256
275
|
// body, no separate error code. Derived from the already-resolved user.
|
|
257
276
|
if (!config.consent.isSharingEnabledFromUser(user)) {
|
|
@@ -299,9 +318,13 @@ function createActorRouter(config) {
|
|
|
299
318
|
// server drop it permanently rather than retry, so availability wins over
|
|
300
319
|
// gating freshness — the activity is processed and any consent decision is
|
|
301
320
|
// re-checked downstream by the id-based (fail-open) gates.
|
|
321
|
+
// A DELETED actor's inbox refuses delivery with 410, so the sender stops
|
|
322
|
+
// rather than treating it as a transient miss.
|
|
302
323
|
const username = getUsername(req);
|
|
303
324
|
const sharingState = await config.consent.getSharingStateByUsername(username);
|
|
304
|
-
if (sharingState === '
|
|
325
|
+
if (sharingState === 'unknown-user')
|
|
326
|
+
return userMissing(res, username);
|
|
327
|
+
if (sharingState === 'disabled') {
|
|
305
328
|
return res.status(404).json({ error: 'User not found' });
|
|
306
329
|
}
|
|
307
330
|
return handleInbox(req, res);
|
|
@@ -327,9 +350,9 @@ function createActorRouter(config) {
|
|
|
327
350
|
const username = getUsername(req);
|
|
328
351
|
try {
|
|
329
352
|
const user = await config.resolveUser(username);
|
|
330
|
-
if (!user
|
|
331
|
-
|
|
332
|
-
|
|
353
|
+
if (!user)
|
|
354
|
+
return userMissing(res, username);
|
|
355
|
+
if (!config.consent.isSharingEnabledFromUser(user) || !(0, groupCollections_1.isGroupActorKind)(user.kind)) {
|
|
333
356
|
return res.status(404).json({ error: 'User not found' });
|
|
334
357
|
}
|
|
335
358
|
const moderators = await fetchModerators(String(user._id || user.id));
|
|
@@ -30,6 +30,7 @@ exports.createDeliveryService = createDeliveryService;
|
|
|
30
30
|
const apContext_1 = require("../apContext");
|
|
31
31
|
const boundedBody_1 = require("./boundedBody");
|
|
32
32
|
const httpSignature_1 = require("../httpSignature");
|
|
33
|
+
const goneActor_1 = require("./goneActor");
|
|
33
34
|
/** Total time budget for a single delivery POST (connect + response headers). */
|
|
34
35
|
const DELIVER_ACTIVITY_TIMEOUT_MS = 15000;
|
|
35
36
|
/** How many bytes of a failed-delivery response body are read for the debug log. */
|
|
@@ -439,6 +440,11 @@ function createDeliveryService(config) {
|
|
|
439
440
|
logger.error('Failed to federate actor update:', err);
|
|
440
441
|
}
|
|
441
442
|
}
|
|
443
|
+
async function federateActorDelete(actorOxyUserId, username) {
|
|
444
|
+
if (!config.federationEnabled)
|
|
445
|
+
return;
|
|
446
|
+
await deliverToFollowers((0, goneActor_1.buildActorDeleteActivity)(urls.actor(username)), actorOxyUserId, username);
|
|
447
|
+
}
|
|
442
448
|
return {
|
|
443
449
|
deliverActivity,
|
|
444
450
|
queueDelivery,
|
|
@@ -449,5 +455,6 @@ function createDeliveryService(config) {
|
|
|
449
455
|
sendAccept,
|
|
450
456
|
sendReject,
|
|
451
457
|
federateActorUpdate,
|
|
458
|
+
federateActorDelete,
|
|
452
459
|
};
|
|
453
460
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* A DELETED local actor, as the fediverse is told about it.
|
|
4
|
+
*
|
|
5
|
+
* Two halves, following Mastodon's account deletion:
|
|
6
|
+
*
|
|
7
|
+
* 1. Once, the app sends `Delete{actor}`: the actor deletes itself, signed with
|
|
8
|
+
* its own key, to every follower's (shared) inbox. That is
|
|
9
|
+
* `DeliveryService.federateActorDelete`, built by {@link buildActorDeleteActivity}.
|
|
10
|
+
* 2. From then on the actor's URLs answer `410 Gone` instead of `404`. That is
|
|
11
|
+
* what a server that missed the Delete, or never acts on one, reads as
|
|
12
|
+
* permanent: Mastodon deletes its copy of an account whose WebFinger answers
|
|
13
|
+
* 410, and Lemmy, PieFed and Mbin mark a community or person deleted when a
|
|
14
|
+
* refetch answers 410 or a `Tombstone`. A 404 tells all of them nothing, so
|
|
15
|
+
* they keep the ghost profile and keep delivering to it.
|
|
16
|
+
*
|
|
17
|
+
* The routers ask the app's `isActorGone(username)` only after `resolveUser`
|
|
18
|
+
* found nobody, so a live account is never answered as gone.
|
|
19
|
+
*/
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.goneTombstone = goneTombstone;
|
|
22
|
+
exports.buildActorDeleteActivity = buildActorDeleteActivity;
|
|
23
|
+
const apContext_1 = require("../apContext");
|
|
24
|
+
/** The body of a `410 Gone` for a deleted object: a bare `Tombstone` naming it. */
|
|
25
|
+
function goneTombstone(id) {
|
|
26
|
+
return { '@context': apContext_1.AP_CONTEXT, id, type: 'Tombstone' };
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The actor's `Delete` of itself. The id is DETERMINISTIC (no timestamp), the
|
|
30
|
+
* shape Mastodon sends (`<actor>#delete`), so a retried run re-sends the same
|
|
31
|
+
* activity and a delivery queue that dedupes on activity id + inbox sends each
|
|
32
|
+
* inbox one copy.
|
|
33
|
+
*/
|
|
34
|
+
function buildActorDeleteActivity(actorUri) {
|
|
35
|
+
return {
|
|
36
|
+
'@context': apContext_1.AP_CONTEXT,
|
|
37
|
+
id: `${actorUri}#delete`,
|
|
38
|
+
type: 'Delete',
|
|
39
|
+
actor: actorUri,
|
|
40
|
+
to: ['https://www.w3.org/ns/activitystreams#Public'],
|
|
41
|
+
object: actorUri,
|
|
42
|
+
};
|
|
43
|
+
}
|
package/dist/cjs/node/index.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* actor + inbox + follow-graph Express routers.
|
|
19
19
|
*/
|
|
20
20
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
-
exports.createActorRouter = exports.createWebfingerRouter = exports.ActorResolutionPendingError = exports.createInboundDispatcher = exports.DeliveryAuthorizationDeniedError = exports.createDeliveryService = exports.ActorResolver = exports.createActorResolver = exports.createIdentityBridge = exports.createTcoLinkExpander = exports.createSignedFetch = void 0;
|
|
21
|
+
exports.createActorRouter = exports.goneTombstone = exports.buildActorDeleteActivity = exports.createWebfingerRouter = exports.ActorResolutionPendingError = exports.createInboundDispatcher = exports.DeliveryAuthorizationDeniedError = exports.createDeliveryService = exports.ActorResolver = exports.createActorResolver = exports.createIdentityBridge = exports.createTcoLinkExpander = exports.createSignedFetch = void 0;
|
|
22
22
|
var signedFetch_1 = require("./signedFetch");
|
|
23
23
|
Object.defineProperty(exports, "createSignedFetch", { enumerable: true, get: function () { return signedFetch_1.createSignedFetch; } });
|
|
24
24
|
/**
|
|
@@ -66,5 +66,12 @@ Object.defineProperty(exports, "createWebfingerRouter", { enumerable: true, get:
|
|
|
66
66
|
* The ActivityPub actor + inbox + follow-graph router (actor GET incl. the
|
|
67
67
|
* `instance` actor, inbox POST with HTTP-sig verify, followers/following pages).
|
|
68
68
|
*/
|
|
69
|
+
/**
|
|
70
|
+
* A deleted local actor: its `Delete` of itself, and the `Tombstone` its URLs
|
|
71
|
+
* answer `410 Gone` with afterwards.
|
|
72
|
+
*/
|
|
73
|
+
var goneActor_1 = require("./goneActor");
|
|
74
|
+
Object.defineProperty(exports, "buildActorDeleteActivity", { enumerable: true, get: function () { return goneActor_1.buildActorDeleteActivity; } });
|
|
75
|
+
Object.defineProperty(exports, "goneTombstone", { enumerable: true, get: function () { return goneActor_1.goneTombstone; } });
|
|
69
76
|
var actorRouter_1 = require("./actorRouter");
|
|
70
77
|
Object.defineProperty(exports, "createActorRouter", { enumerable: true, get: function () { return actorRouter_1.createActorRouter; } });
|
|
@@ -90,8 +90,13 @@ function createWebfingerRouter(config) {
|
|
|
90
90
|
return res.json(cached);
|
|
91
91
|
}
|
|
92
92
|
const user = await config.resolveUser(username);
|
|
93
|
-
if (!user)
|
|
93
|
+
if (!user) {
|
|
94
|
+
if (config.isActorGone && (await config.isActorGone(username))) {
|
|
95
|
+
res.set('Cache-Control', httpCacheControl);
|
|
96
|
+
return res.status(410).json({ error: 'User deleted' });
|
|
97
|
+
}
|
|
94
98
|
return res.status(404).json({ error: 'User not found' });
|
|
99
|
+
}
|
|
95
100
|
// Sharing OFF must be indistinguishable from a nonexistent user — same 404
|
|
96
101
|
// body, no separate error code. UNLIKE the other user-scoped surfaces,
|
|
97
102
|
// webfinger does a SECOND, uncached consent read here rather than reusing
|
package/dist/esm/actorObject.js
CHANGED
|
@@ -279,11 +279,14 @@ export function createLocalActorBuilder(config) {
|
|
|
279
279
|
// `attributedTo` (Lemmy, PieFed; Mbin reads `moderators`) and says anybody
|
|
280
280
|
// may post into it. PeerTube refuses a `Group` without `attributedTo`, and a
|
|
281
281
|
// `Group` claiming `postingRestrictedToMods` is read as a channel by every
|
|
282
|
-
// implementation, ours included ({@link accountKindForActor}).
|
|
282
|
+
// implementation, ours included ({@link accountKindForActor}). `sensitive`
|
|
283
|
+
// is Lemmy's NSFW flag on a community, sent as Lemmy sends it; an Oxy
|
|
284
|
+
// community has no NSFW setting.
|
|
283
285
|
if (actorObject.type === LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND.community) {
|
|
284
286
|
actorObject.attributedTo = config.urls.moderators(username);
|
|
285
287
|
actorObject.moderators = config.urls.moderators(username);
|
|
286
288
|
actorObject.postingRestrictedToMods = false;
|
|
289
|
+
actorObject.sensitive = false;
|
|
287
290
|
}
|
|
288
291
|
const aliases = normalizeAlsoKnownAs(params.alsoKnownAs);
|
|
289
292
|
if (aliases.length > 0) {
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a local `Group` (an Oxy community) publishes its outbox and followers:
|
|
3
|
+
* the shapes Lemmy itself serves and reads back, not the paged
|
|
4
|
+
* `OrderedCollection` + `first` link every other local actor serves.
|
|
5
|
+
*
|
|
6
|
+
* Lemmy deserializes a community's collections into fixed structs that know
|
|
7
|
+
* nothing of paging (`crates/apub/apub/src/protocol/collections/`):
|
|
8
|
+
* - outbox → `GroupOutbox { orderedItems: Vec<Announce> }`, `orderedItems`
|
|
9
|
+
* REQUIRED inline. A summary with only `first` fails to parse, so a server
|
|
10
|
+
* discovering the community backfills nothing. PieFed reads the same inline
|
|
11
|
+
* `orderedItems` and nothing else.
|
|
12
|
+
* - followers → `GroupFollowers { type: "Collection", totalItems, items }`,
|
|
13
|
+
* read for the subscriber count. Lemmy serves `items: []`, so the follower
|
|
14
|
+
* list stays private; this does the same.
|
|
15
|
+
*
|
|
16
|
+
* Mastodon reads `totalItems` from either shape, so a `Group` followed from
|
|
17
|
+
* Mastodon keeps its counts. `Person`, `Organization` and `Service` actors keep
|
|
18
|
+
* the paged shape Mastodon walks.
|
|
19
|
+
*/
|
|
20
|
+
import { LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND, localActorTypeForAccountKind } from './actorObject.js';
|
|
21
|
+
import { AP_CONTEXT } from './apContext.js';
|
|
22
|
+
/** How many of its latest `Announce`s a `Group`'s outbox lists inline (Lemmy's own size). */
|
|
23
|
+
export const GROUP_OUTBOX_SIZE = 50;
|
|
24
|
+
/**
|
|
25
|
+
* Whether a local account of this kind is announced as a `Group`
|
|
26
|
+
* ({@link LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND}) and so serves the Group
|
|
27
|
+
* collections: moderators, and the outbox and followers in the shapes above.
|
|
28
|
+
*/
|
|
29
|
+
export function isGroupActorKind(kind) {
|
|
30
|
+
return localActorTypeForAccountKind(kind) === LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND.community;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A `Group`'s outbox: one unpaged `OrderedCollection` of its latest
|
|
34
|
+
* `Announce { Create }` activities, newest first. Each item's own `@context` is
|
|
35
|
+
* dropped; the collection's covers it.
|
|
36
|
+
*/
|
|
37
|
+
export function groupOutboxCollection(params) {
|
|
38
|
+
return {
|
|
39
|
+
'@context': AP_CONTEXT,
|
|
40
|
+
id: params.id,
|
|
41
|
+
type: 'OrderedCollection',
|
|
42
|
+
totalItems: params.totalItems,
|
|
43
|
+
orderedItems: params.orderedItems.map(({ '@context': _context, ...item }) => item),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** A `Group`'s followers: the count, and an empty `items` list. */
|
|
47
|
+
export function groupFollowersCollection(params) {
|
|
48
|
+
return {
|
|
49
|
+
'@context': AP_CONTEXT,
|
|
50
|
+
id: params.id,
|
|
51
|
+
type: 'Collection',
|
|
52
|
+
totalItems: params.totalItems,
|
|
53
|
+
items: [],
|
|
54
|
+
};
|
|
55
|
+
}
|
package/dist/esm/index.js
CHANGED
|
@@ -63,3 +63,8 @@ export { canonicalFederationHost, isSameFederationHost, extractActorUriFromActiv
|
|
|
63
63
|
* follows the Oxy account kind ({@link LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND}).
|
|
64
64
|
*/
|
|
65
65
|
export { createLocalActorBuilder, normalizeAlsoKnownAs, localActorTypeForAccountKind, accountKindForActor, isApActorType, AP_ACTOR_TYPES, LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND, } from './actorObject.js';
|
|
66
|
+
/**
|
|
67
|
+
* A local `Group`'s outbox and followers, in the shapes Lemmy serves and reads
|
|
68
|
+
* back; every other actor keeps the paged collections.
|
|
69
|
+
*/
|
|
70
|
+
export { GROUP_OUTBOX_SIZE, isGroupActorKind, groupOutboxCollection, groupFollowersCollection, } from './groupCollections.js';
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
* signature verification (Phase 2, `trustForwardedHost`) and actor-match, then
|
|
9
9
|
* 202 + async dispatch to the injected inbound dispatcher,
|
|
10
10
|
* - `GET /users/:username/followers` + `/following` — the OXY follow graph
|
|
11
|
-
* (local + bridged federated edges) as paginated `OrderedCollection`s
|
|
11
|
+
* (local + bridged federated edges) as paginated `OrderedCollection`s; a
|
|
12
|
+
* community `Group`'s followers are only counted (`groupCollections.ts`),
|
|
12
13
|
* - `GET /users/:username/moderators` — a community `Group`'s moderators.
|
|
13
14
|
*
|
|
14
15
|
* The CONTENT routes (`outbox`, `featured`, per-post dereference) stay in the app,
|
|
@@ -24,7 +25,8 @@ import { AP_CONTEXT } from '../apContext.js';
|
|
|
24
25
|
import { discoveryCacheControl } from './httpCacheControl.js';
|
|
25
26
|
import { verifyHttpSignature } from '../httpSignature.js';
|
|
26
27
|
import { INSTANCE_ACTOR_USERNAME, normalizeActorUsername } from '../urls.js';
|
|
27
|
-
import {
|
|
28
|
+
import { groupFollowersCollection, isGroupActorKind } from '../groupCollections.js';
|
|
29
|
+
import { goneTombstone } from './goneActor.js';
|
|
28
30
|
/** Page size for the paginated followers/following collections (mirrors the outbox). */
|
|
29
31
|
const FOLLOW_PAGE_SIZE = 20;
|
|
30
32
|
/** Extract the `:username` param safely as a string. */
|
|
@@ -61,6 +63,18 @@ export function createActorRouter(config) {
|
|
|
61
63
|
function wantsActivityPub(req) {
|
|
62
64
|
return config.wantsActivityPub(req.headers.accept);
|
|
63
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* The answer for a username nobody resolved to: 410 with the actor's
|
|
68
|
+
* `Tombstone` when it is a deleted actor, else the usual 404.
|
|
69
|
+
*/
|
|
70
|
+
async function userMissing(res, username) {
|
|
71
|
+
if (config.isActorGone && (await config.isActorGone(username))) {
|
|
72
|
+
res.set('Content-Type', apContentType);
|
|
73
|
+
res.set('Cache-Control', httpCacheControl);
|
|
74
|
+
return res.status(410).json(goneTombstone(urls.actor(username)));
|
|
75
|
+
}
|
|
76
|
+
return res.status(404).json({ error: 'User not found' });
|
|
77
|
+
}
|
|
64
78
|
/** Common inbox handler with HTTP signature verification. */
|
|
65
79
|
async function handleInbox(req, res) {
|
|
66
80
|
try {
|
|
@@ -118,7 +132,8 @@ export function createActorRouter(config) {
|
|
|
118
132
|
}
|
|
119
133
|
/**
|
|
120
134
|
* Serve a user's followers OR following as a paginated `OrderedCollection` over
|
|
121
|
-
* the authoritative Oxy follow graph (local + bridged federated edges).
|
|
135
|
+
* the authoritative Oxy follow graph (local + bridged federated edges). A
|
|
136
|
+
* `Group`'s followers are the unpaged, itemless `Collection` Lemmy reads.
|
|
122
137
|
*/
|
|
123
138
|
async function serveFollowCollection(req, res, direction, collectionUrl) {
|
|
124
139
|
if (!config.federationEnabled)
|
|
@@ -128,14 +143,15 @@ export function createActorRouter(config) {
|
|
|
128
143
|
try {
|
|
129
144
|
const user = await config.resolveUser(username);
|
|
130
145
|
if (!user)
|
|
131
|
-
return res
|
|
146
|
+
return userMissing(res, username);
|
|
132
147
|
if (!config.consent.isSharingEnabledFromUser(user)) {
|
|
133
148
|
return res.status(404).json({ error: 'User not found' });
|
|
134
149
|
}
|
|
135
150
|
const userId = String(user._id || user.id);
|
|
136
151
|
const rawCount = direction === 'followers' ? user._count?.followers : user._count?.following;
|
|
137
152
|
const profileTotal = typeof rawCount === 'number' ? rawCount : undefined;
|
|
138
|
-
|
|
153
|
+
const groupFollowers = direction === 'followers' && isGroupActorKind(user.kind);
|
|
154
|
+
if (!page || groupFollowers) {
|
|
139
155
|
// Prefer the profile `_count`; only when absent (the rare search fallback)
|
|
140
156
|
// fetch the authoritative total from a minimal graph list call. Fail-soft
|
|
141
157
|
// to 0 — never 500 the summary.
|
|
@@ -153,6 +169,9 @@ export function createActorRouter(config) {
|
|
|
153
169
|
}
|
|
154
170
|
}
|
|
155
171
|
res.set('Content-Type', apContentType);
|
|
172
|
+
if (groupFollowers) {
|
|
173
|
+
return res.json(groupFollowersCollection({ id: collectionUrl(username), totalItems }));
|
|
174
|
+
}
|
|
156
175
|
return res.json({
|
|
157
176
|
'@context': AP_CONTEXT,
|
|
158
177
|
id: collectionUrl(username),
|
|
@@ -248,7 +267,7 @@ export function createActorRouter(config) {
|
|
|
248
267
|
}
|
|
249
268
|
const user = await config.resolveUser(username);
|
|
250
269
|
if (!user)
|
|
251
|
-
return res
|
|
270
|
+
return userMissing(res, username);
|
|
252
271
|
// Sharing OFF must be indistinguishable from a nonexistent user — same 404
|
|
253
272
|
// body, no separate error code. Derived from the already-resolved user.
|
|
254
273
|
if (!config.consent.isSharingEnabledFromUser(user)) {
|
|
@@ -296,9 +315,13 @@ export function createActorRouter(config) {
|
|
|
296
315
|
// server drop it permanently rather than retry, so availability wins over
|
|
297
316
|
// gating freshness — the activity is processed and any consent decision is
|
|
298
317
|
// re-checked downstream by the id-based (fail-open) gates.
|
|
318
|
+
// A DELETED actor's inbox refuses delivery with 410, so the sender stops
|
|
319
|
+
// rather than treating it as a transient miss.
|
|
299
320
|
const username = getUsername(req);
|
|
300
321
|
const sharingState = await config.consent.getSharingStateByUsername(username);
|
|
301
|
-
if (sharingState === '
|
|
322
|
+
if (sharingState === 'unknown-user')
|
|
323
|
+
return userMissing(res, username);
|
|
324
|
+
if (sharingState === 'disabled') {
|
|
302
325
|
return res.status(404).json({ error: 'User not found' });
|
|
303
326
|
}
|
|
304
327
|
return handleInbox(req, res);
|
|
@@ -324,9 +347,9 @@ export function createActorRouter(config) {
|
|
|
324
347
|
const username = getUsername(req);
|
|
325
348
|
try {
|
|
326
349
|
const user = await config.resolveUser(username);
|
|
327
|
-
if (!user
|
|
328
|
-
|
|
329
|
-
|
|
350
|
+
if (!user)
|
|
351
|
+
return userMissing(res, username);
|
|
352
|
+
if (!config.consent.isSharingEnabledFromUser(user) || !isGroupActorKind(user.kind)) {
|
|
330
353
|
return res.status(404).json({ error: 'User not found' });
|
|
331
354
|
}
|
|
332
355
|
const moderators = await fetchModerators(String(user._id || user.id));
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
import { AP_CONTEXT } from '../apContext.js';
|
|
27
27
|
import { readBodyChunks } from './boundedBody.js';
|
|
28
28
|
import { signRequest } from '../httpSignature.js';
|
|
29
|
+
import { buildActorDeleteActivity } from './goneActor.js';
|
|
29
30
|
/** Total time budget for a single delivery POST (connect + response headers). */
|
|
30
31
|
const DELIVER_ACTIVITY_TIMEOUT_MS = 15000;
|
|
31
32
|
/** How many bytes of a failed-delivery response body are read for the debug log. */
|
|
@@ -434,6 +435,11 @@ export function createDeliveryService(config) {
|
|
|
434
435
|
logger.error('Failed to federate actor update:', err);
|
|
435
436
|
}
|
|
436
437
|
}
|
|
438
|
+
async function federateActorDelete(actorOxyUserId, username) {
|
|
439
|
+
if (!config.federationEnabled)
|
|
440
|
+
return;
|
|
441
|
+
await deliverToFollowers(buildActorDeleteActivity(urls.actor(username)), actorOxyUserId, username);
|
|
442
|
+
}
|
|
437
443
|
return {
|
|
438
444
|
deliverActivity,
|
|
439
445
|
queueDelivery,
|
|
@@ -444,5 +450,6 @@ export function createDeliveryService(config) {
|
|
|
444
450
|
sendAccept,
|
|
445
451
|
sendReject,
|
|
446
452
|
federateActorUpdate,
|
|
453
|
+
federateActorDelete,
|
|
447
454
|
};
|
|
448
455
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A DELETED local actor, as the fediverse is told about it.
|
|
3
|
+
*
|
|
4
|
+
* Two halves, following Mastodon's account deletion:
|
|
5
|
+
*
|
|
6
|
+
* 1. Once, the app sends `Delete{actor}`: the actor deletes itself, signed with
|
|
7
|
+
* its own key, to every follower's (shared) inbox. That is
|
|
8
|
+
* `DeliveryService.federateActorDelete`, built by {@link buildActorDeleteActivity}.
|
|
9
|
+
* 2. From then on the actor's URLs answer `410 Gone` instead of `404`. That is
|
|
10
|
+
* what a server that missed the Delete, or never acts on one, reads as
|
|
11
|
+
* permanent: Mastodon deletes its copy of an account whose WebFinger answers
|
|
12
|
+
* 410, and Lemmy, PieFed and Mbin mark a community or person deleted when a
|
|
13
|
+
* refetch answers 410 or a `Tombstone`. A 404 tells all of them nothing, so
|
|
14
|
+
* they keep the ghost profile and keep delivering to it.
|
|
15
|
+
*
|
|
16
|
+
* The routers ask the app's `isActorGone(username)` only after `resolveUser`
|
|
17
|
+
* found nobody, so a live account is never answered as gone.
|
|
18
|
+
*/
|
|
19
|
+
import { AP_CONTEXT } from '../apContext.js';
|
|
20
|
+
/** The body of a `410 Gone` for a deleted object: a bare `Tombstone` naming it. */
|
|
21
|
+
export function goneTombstone(id) {
|
|
22
|
+
return { '@context': AP_CONTEXT, id, type: 'Tombstone' };
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The actor's `Delete` of itself. The id is DETERMINISTIC (no timestamp), the
|
|
26
|
+
* shape Mastodon sends (`<actor>#delete`), so a retried run re-sends the same
|
|
27
|
+
* activity and a delivery queue that dedupes on activity id + inbox sends each
|
|
28
|
+
* inbox one copy.
|
|
29
|
+
*/
|
|
30
|
+
export function buildActorDeleteActivity(actorUri) {
|
|
31
|
+
return {
|
|
32
|
+
'@context': AP_CONTEXT,
|
|
33
|
+
id: `${actorUri}#delete`,
|
|
34
|
+
type: 'Delete',
|
|
35
|
+
actor: actorUri,
|
|
36
|
+
to: ['https://www.w3.org/ns/activitystreams#Public'],
|
|
37
|
+
object: actorUri,
|
|
38
|
+
};
|
|
39
|
+
}
|
package/dist/esm/node/index.js
CHANGED
|
@@ -53,4 +53,9 @@ export { createWebfingerRouter, } from './webfingerRouter.js';
|
|
|
53
53
|
* The ActivityPub actor + inbox + follow-graph router (actor GET incl. the
|
|
54
54
|
* `instance` actor, inbox POST with HTTP-sig verify, followers/following pages).
|
|
55
55
|
*/
|
|
56
|
+
/**
|
|
57
|
+
* A deleted local actor: its `Delete` of itself, and the `Tombstone` its URLs
|
|
58
|
+
* answer `410 Gone` with afterwards.
|
|
59
|
+
*/
|
|
60
|
+
export { buildActorDeleteActivity, goneTombstone } from './goneActor.js';
|
|
56
61
|
export { createActorRouter, } from './actorRouter.js';
|