@oxy.so/federation 2.7.0 → 2.8.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 +21 -0
- package/dist/cjs/node/actorRouter.js +23 -5
- 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/node/actorRouter.js +23 -5
- 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/node/actorRouter.d.ts +8 -0
- 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__/delivery.test.ts +23 -0
- package/src/__tests__/routers.test.ts +39 -0
- package/src/node/actorRouter.ts +28 -4
- 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,24 @@ 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)`.
|
|
@@ -28,6 +28,7 @@ const httpCacheControl_1 = require("./httpCacheControl");
|
|
|
28
28
|
const httpSignature_1 = require("../httpSignature");
|
|
29
29
|
const urls_1 = require("../urls");
|
|
30
30
|
const actorObject_1 = require("../actorObject");
|
|
31
|
+
const goneActor_1 = require("./goneActor");
|
|
31
32
|
/** Page size for the paginated followers/following collections (mirrors the outbox). */
|
|
32
33
|
const FOLLOW_PAGE_SIZE = 20;
|
|
33
34
|
/** Extract the `:username` param safely as a string. */
|
|
@@ -64,6 +65,18 @@ function createActorRouter(config) {
|
|
|
64
65
|
function wantsActivityPub(req) {
|
|
65
66
|
return config.wantsActivityPub(req.headers.accept);
|
|
66
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* The answer for a username nobody resolved to: 410 with the actor's
|
|
70
|
+
* `Tombstone` when it is a deleted actor, else the usual 404.
|
|
71
|
+
*/
|
|
72
|
+
async function userMissing(res, username) {
|
|
73
|
+
if (config.isActorGone && (await config.isActorGone(username))) {
|
|
74
|
+
res.set('Content-Type', apContentType);
|
|
75
|
+
res.set('Cache-Control', httpCacheControl);
|
|
76
|
+
return res.status(410).json((0, goneActor_1.goneTombstone)(urls.actor(username)));
|
|
77
|
+
}
|
|
78
|
+
return res.status(404).json({ error: 'User not found' });
|
|
79
|
+
}
|
|
67
80
|
/** Common inbox handler with HTTP signature verification. */
|
|
68
81
|
async function handleInbox(req, res) {
|
|
69
82
|
try {
|
|
@@ -131,7 +144,7 @@ function createActorRouter(config) {
|
|
|
131
144
|
try {
|
|
132
145
|
const user = await config.resolveUser(username);
|
|
133
146
|
if (!user)
|
|
134
|
-
return res
|
|
147
|
+
return userMissing(res, username);
|
|
135
148
|
if (!config.consent.isSharingEnabledFromUser(user)) {
|
|
136
149
|
return res.status(404).json({ error: 'User not found' });
|
|
137
150
|
}
|
|
@@ -251,7 +264,7 @@ function createActorRouter(config) {
|
|
|
251
264
|
}
|
|
252
265
|
const user = await config.resolveUser(username);
|
|
253
266
|
if (!user)
|
|
254
|
-
return res
|
|
267
|
+
return userMissing(res, username);
|
|
255
268
|
// Sharing OFF must be indistinguishable from a nonexistent user — same 404
|
|
256
269
|
// body, no separate error code. Derived from the already-resolved user.
|
|
257
270
|
if (!config.consent.isSharingEnabledFromUser(user)) {
|
|
@@ -299,9 +312,13 @@ function createActorRouter(config) {
|
|
|
299
312
|
// server drop it permanently rather than retry, so availability wins over
|
|
300
313
|
// gating freshness — the activity is processed and any consent decision is
|
|
301
314
|
// re-checked downstream by the id-based (fail-open) gates.
|
|
315
|
+
// A DELETED actor's inbox refuses delivery with 410, so the sender stops
|
|
316
|
+
// rather than treating it as a transient miss.
|
|
302
317
|
const username = getUsername(req);
|
|
303
318
|
const sharingState = await config.consent.getSharingStateByUsername(username);
|
|
304
|
-
if (sharingState === '
|
|
319
|
+
if (sharingState === 'unknown-user')
|
|
320
|
+
return userMissing(res, username);
|
|
321
|
+
if (sharingState === 'disabled') {
|
|
305
322
|
return res.status(404).json({ error: 'User not found' });
|
|
306
323
|
}
|
|
307
324
|
return handleInbox(req, res);
|
|
@@ -327,8 +344,9 @@ function createActorRouter(config) {
|
|
|
327
344
|
const username = getUsername(req);
|
|
328
345
|
try {
|
|
329
346
|
const user = await config.resolveUser(username);
|
|
330
|
-
if (!user
|
|
331
|
-
|
|
347
|
+
if (!user)
|
|
348
|
+
return userMissing(res, username);
|
|
349
|
+
if (!config.consent.isSharingEnabledFromUser(user) ||
|
|
332
350
|
(0, actorObject_1.localActorTypeForAccountKind)(user.kind) !== actorObject_1.LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND.community) {
|
|
333
351
|
return res.status(404).json({ error: 'User not found' });
|
|
334
352
|
}
|
|
@@ -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
|
|
@@ -25,6 +25,7 @@ import { discoveryCacheControl } from './httpCacheControl.js';
|
|
|
25
25
|
import { verifyHttpSignature } from '../httpSignature.js';
|
|
26
26
|
import { INSTANCE_ACTOR_USERNAME, normalizeActorUsername } from '../urls.js';
|
|
27
27
|
import { LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND, localActorTypeForAccountKind, } from '../actorObject.js';
|
|
28
|
+
import { goneTombstone } from './goneActor.js';
|
|
28
29
|
/** Page size for the paginated followers/following collections (mirrors the outbox). */
|
|
29
30
|
const FOLLOW_PAGE_SIZE = 20;
|
|
30
31
|
/** Extract the `:username` param safely as a string. */
|
|
@@ -61,6 +62,18 @@ export function createActorRouter(config) {
|
|
|
61
62
|
function wantsActivityPub(req) {
|
|
62
63
|
return config.wantsActivityPub(req.headers.accept);
|
|
63
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* The answer for a username nobody resolved to: 410 with the actor's
|
|
67
|
+
* `Tombstone` when it is a deleted actor, else the usual 404.
|
|
68
|
+
*/
|
|
69
|
+
async function userMissing(res, username) {
|
|
70
|
+
if (config.isActorGone && (await config.isActorGone(username))) {
|
|
71
|
+
res.set('Content-Type', apContentType);
|
|
72
|
+
res.set('Cache-Control', httpCacheControl);
|
|
73
|
+
return res.status(410).json(goneTombstone(urls.actor(username)));
|
|
74
|
+
}
|
|
75
|
+
return res.status(404).json({ error: 'User not found' });
|
|
76
|
+
}
|
|
64
77
|
/** Common inbox handler with HTTP signature verification. */
|
|
65
78
|
async function handleInbox(req, res) {
|
|
66
79
|
try {
|
|
@@ -128,7 +141,7 @@ export function createActorRouter(config) {
|
|
|
128
141
|
try {
|
|
129
142
|
const user = await config.resolveUser(username);
|
|
130
143
|
if (!user)
|
|
131
|
-
return res
|
|
144
|
+
return userMissing(res, username);
|
|
132
145
|
if (!config.consent.isSharingEnabledFromUser(user)) {
|
|
133
146
|
return res.status(404).json({ error: 'User not found' });
|
|
134
147
|
}
|
|
@@ -248,7 +261,7 @@ export function createActorRouter(config) {
|
|
|
248
261
|
}
|
|
249
262
|
const user = await config.resolveUser(username);
|
|
250
263
|
if (!user)
|
|
251
|
-
return res
|
|
264
|
+
return userMissing(res, username);
|
|
252
265
|
// Sharing OFF must be indistinguishable from a nonexistent user — same 404
|
|
253
266
|
// body, no separate error code. Derived from the already-resolved user.
|
|
254
267
|
if (!config.consent.isSharingEnabledFromUser(user)) {
|
|
@@ -296,9 +309,13 @@ export function createActorRouter(config) {
|
|
|
296
309
|
// server drop it permanently rather than retry, so availability wins over
|
|
297
310
|
// gating freshness — the activity is processed and any consent decision is
|
|
298
311
|
// re-checked downstream by the id-based (fail-open) gates.
|
|
312
|
+
// A DELETED actor's inbox refuses delivery with 410, so the sender stops
|
|
313
|
+
// rather than treating it as a transient miss.
|
|
299
314
|
const username = getUsername(req);
|
|
300
315
|
const sharingState = await config.consent.getSharingStateByUsername(username);
|
|
301
|
-
if (sharingState === '
|
|
316
|
+
if (sharingState === 'unknown-user')
|
|
317
|
+
return userMissing(res, username);
|
|
318
|
+
if (sharingState === 'disabled') {
|
|
302
319
|
return res.status(404).json({ error: 'User not found' });
|
|
303
320
|
}
|
|
304
321
|
return handleInbox(req, res);
|
|
@@ -324,8 +341,9 @@ export function createActorRouter(config) {
|
|
|
324
341
|
const username = getUsername(req);
|
|
325
342
|
try {
|
|
326
343
|
const user = await config.resolveUser(username);
|
|
327
|
-
if (!user
|
|
328
|
-
|
|
344
|
+
if (!user)
|
|
345
|
+
return userMissing(res, username);
|
|
346
|
+
if (!config.consent.isSharingEnabledFromUser(user) ||
|
|
329
347
|
localActorTypeForAccountKind(user.kind) !== LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND.community) {
|
|
330
348
|
return res.status(404).json({ error: 'User not found' });
|
|
331
349
|
}
|
|
@@ -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';
|
|
@@ -87,8 +87,13 @@ export function createWebfingerRouter(config) {
|
|
|
87
87
|
return res.json(cached);
|
|
88
88
|
}
|
|
89
89
|
const user = await config.resolveUser(username);
|
|
90
|
-
if (!user)
|
|
90
|
+
if (!user) {
|
|
91
|
+
if (config.isActorGone && (await config.isActorGone(username))) {
|
|
92
|
+
res.set('Cache-Control', httpCacheControl);
|
|
93
|
+
return res.status(410).json({ error: 'User deleted' });
|
|
94
|
+
}
|
|
91
95
|
return res.status(404).json({ error: 'User not found' });
|
|
96
|
+
}
|
|
92
97
|
// Sharing OFF must be indistinguishable from a nonexistent user — same 404
|
|
93
98
|
// body, no separate error code. UNLIKE the other user-scoped surfaces,
|
|
94
99
|
// webfinger does a SECOND, uncached consent read here rather than reusing
|
|
@@ -24,6 +24,7 @@ import type { AccountKind } from '@oxy.so/contracts';
|
|
|
24
24
|
import type { User } from '@oxy.so/core';
|
|
25
25
|
import type { UrlBuilders } from '../urls';
|
|
26
26
|
import { type LocalActorBuilder } from '../actorObject';
|
|
27
|
+
import { type IsActorGone } from './goneActor';
|
|
27
28
|
/** The resolved-user fields the actor + collection routes read. */
|
|
28
29
|
export interface ActorRouteUser {
|
|
29
30
|
_id?: string | null;
|
|
@@ -114,6 +115,13 @@ export interface ActorRouterConfig {
|
|
|
114
115
|
* the route is not mounted; any account that is not a community 404s.
|
|
115
116
|
*/
|
|
116
117
|
fetchModerators?(userId: string): Promise<User[]>;
|
|
118
|
+
/**
|
|
119
|
+
* Whether a username that resolved to nobody names a DELETED local actor
|
|
120
|
+
* (`goneActor.ts`). True ⇒ the actor, its inbox and its collections answer
|
|
121
|
+
* `410 Gone` (the actor with a `Tombstone`) instead of 404. Omitted ⇒ every
|
|
122
|
+
* miss is a 404.
|
|
123
|
+
*/
|
|
124
|
+
isActorGone?: IsActorGone;
|
|
117
125
|
/** Actor HTTP response TTL in whole seconds, 0..86400. Defaults to 1800; 0 sends no-store. */
|
|
118
126
|
httpCacheTtlSeconds?: number;
|
|
119
127
|
/** Diagnostics sink. */
|
|
@@ -270,6 +270,15 @@ export interface DeliveryService {
|
|
|
270
270
|
sendReject(localOxyUserId: string, localUsername: string, followActivityId: string, remoteActorUri: string): Promise<void>;
|
|
271
271
|
/** Rebroadcast the FULL actor document as an Update(Person) to remote followers. */
|
|
272
272
|
federateActorUpdate(actorOxyUserId: string, username: string): Promise<void>;
|
|
273
|
+
/**
|
|
274
|
+
* Tell every remote follower the actor is gone: its `Delete` of itself
|
|
275
|
+
* (`goneActor.ts`), one per (shared) inbox, signed with its own key. Needs the
|
|
276
|
+
* accepted follower rows, so call it BEFORE deleting them. Asks no consent and
|
|
277
|
+
* resolves no profile, since the account is already gone at its identity
|
|
278
|
+
* provider; it needs only the handle and the key the handle addresses. Throws
|
|
279
|
+
* when it cannot queue, so the caller's job retries.
|
|
280
|
+
*/
|
|
281
|
+
federateActorDelete(actorOxyUserId: string, username: string): Promise<void>;
|
|
273
282
|
}
|
|
274
283
|
/** Build the outbound delivery + follow-lifecycle service from an app's adapters. */
|
|
275
284
|
export declare function createDeliveryService<TActor extends DeliveryActorFields>(config: DeliveryServiceConfig<TActor>): DeliveryService;
|
|
@@ -0,0 +1,29 @@
|
|
|
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
|
+
/** Does `username` name a local actor that was deleted? Asked only for a username that resolved to nobody. */
|
|
20
|
+
export type IsActorGone = (username: string) => Promise<boolean>;
|
|
21
|
+
/** The body of a `410 Gone` for a deleted object: a bare `Tombstone` naming it. */
|
|
22
|
+
export declare function goneTombstone(id: string): Record<string, unknown>;
|
|
23
|
+
/**
|
|
24
|
+
* The actor's `Delete` of itself. The id is DETERMINISTIC (no timestamp), the
|
|
25
|
+
* shape Mastodon sends (`<actor>#delete`), so a retried run re-sends the same
|
|
26
|
+
* activity and a delivery queue that dedupes on activity id + inbox sends each
|
|
27
|
+
* inbox one copy.
|
|
28
|
+
*/
|
|
29
|
+
export declare function buildActorDeleteActivity(actorUri: string): Record<string, unknown>;
|
|
@@ -53,4 +53,9 @@ export { createWebfingerRouter, type WebfingerRouterConfig, type WebfingerSharin
|
|
|
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, type IsActorGone } from './goneActor';
|
|
56
61
|
export { createActorRouter, type ActorRouterConfig, type ActorRouteUser, type ActorSharingState, type ActorRouterLogger, type FollowPage, } from './actorRouter';
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { Router } from 'express';
|
|
15
15
|
import type { UrlBuilders } from '../urls';
|
|
16
|
+
import type { IsActorGone } from './goneActor';
|
|
16
17
|
/** The tri-state consent read for a username with no already-resolved user object. */
|
|
17
18
|
export type WebfingerSharingState = 'enabled' | 'disabled' | 'unknown-user' | 'unavailable';
|
|
18
19
|
/** The resolved-user fields the webfinger consent fallback reads. */
|
|
@@ -55,6 +56,12 @@ export interface WebfingerRouterConfig {
|
|
|
55
56
|
get(username: string): Promise<WebfingerJrd | null>;
|
|
56
57
|
set(username: string, jrd: WebfingerJrd): void;
|
|
57
58
|
};
|
|
59
|
+
/**
|
|
60
|
+
* Whether a username that resolved to nobody names a DELETED local actor
|
|
61
|
+
* (`goneActor.ts`). True ⇒ `410 Gone` instead of 404, which is what makes
|
|
62
|
+
* Mastodon delete its copy of the account. Omitted ⇒ every miss is a 404.
|
|
63
|
+
*/
|
|
64
|
+
isActorGone?: IsActorGone;
|
|
58
65
|
/** HTTP response TTL in whole seconds, 0..86400. Defaults to 3600; 0 sends no-store. */
|
|
59
66
|
httpCacheTtlSeconds?: number;
|
|
60
67
|
/** Diagnostics sink. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxy.so/federation",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.8.0",
|
|
4
4
|
"description": "Oxy Federation — the app-agnostic ActivityPub identity + follow engine substrate: the network-connector contract, normalized cross-network DTOs, HTTP signatures, actor resolution, the outbound delivery transport + follow lifecycle, the inbound dispatcher, and the webfinger/actor/inbox Express routers. Domain-parameterized so every Oxy app backend federates under its own domain.",
|
|
5
5
|
"main": "dist/cjs/index.js",
|
|
6
6
|
"module": "dist/esm/index.js",
|
|
@@ -471,6 +471,29 @@ describe('federateActorUpdate', () => {
|
|
|
471
471
|
});
|
|
472
472
|
});
|
|
473
473
|
|
|
474
|
+
describe('federateActorDelete', () => {
|
|
475
|
+
it("sends the actor's own Delete, once per shared inbox, with no consent or profile read", async () => {
|
|
476
|
+
const shared = 'https://shared.example/inbox';
|
|
477
|
+
const rig = makeRig({
|
|
478
|
+
sharingEnabled: false,
|
|
479
|
+
followerActorUris: ['a', 'b'],
|
|
480
|
+
followerInboxes: { a: { sharedInboxUrl: shared }, b: { sharedInboxUrl: shared } },
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
await rig.service.federateActorDelete('u-alice', 'alice');
|
|
484
|
+
|
|
485
|
+
expect(rig.enqueued).toHaveLength(1);
|
|
486
|
+
expect(rig.enqueued[0].activityJson).toEqual({
|
|
487
|
+
'@context': AP_CONTEXT,
|
|
488
|
+
id: `${urls.actor('alice')}#delete`,
|
|
489
|
+
type: 'Delete',
|
|
490
|
+
actor: urls.actor('alice'),
|
|
491
|
+
to: ['https://www.w3.org/ns/activitystreams#Public'],
|
|
492
|
+
object: urls.actor('alice'),
|
|
493
|
+
});
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
|
|
474
497
|
describe('live delivery authorization', () => {
|
|
475
498
|
const activity = {
|
|
476
499
|
type: 'Create',
|
|
@@ -210,6 +210,16 @@ describe('webfinger router', () => {
|
|
|
210
210
|
expect(res.body.links[0].href).toBe(urls.actor('alice'));
|
|
211
211
|
});
|
|
212
212
|
|
|
213
|
+
it('answers 410 for a deleted actor, and still 404 for an unknown one', async () => {
|
|
214
|
+
const app = makeWebfingerApp({ isActorGone: async (username) => username === 'gone' });
|
|
215
|
+
const gone = await request(app).get('/.well-known/webfinger?resource=acct:gone@mention.earth');
|
|
216
|
+
expect(gone.status).toBe(410);
|
|
217
|
+
const unknown = await request(app).get(
|
|
218
|
+
'/.well-known/webfinger?resource=acct:ghost@mention.earth',
|
|
219
|
+
);
|
|
220
|
+
expect(unknown.status).toBe(404);
|
|
221
|
+
});
|
|
222
|
+
|
|
213
223
|
it('404s a sharing-disabled user indistinguishably', async () => {
|
|
214
224
|
const app = makeWebfingerApp({
|
|
215
225
|
consent: {
|
|
@@ -589,6 +599,35 @@ describe('actor router — inbox POST', () => {
|
|
|
589
599
|
});
|
|
590
600
|
});
|
|
591
601
|
|
|
602
|
+
describe('actor router — a deleted actor', () => {
|
|
603
|
+
it('answers 410 with a Tombstone on the actor, its inbox and collections; an unknown user stays 404', async () => {
|
|
604
|
+
const app = makeActorApp({
|
|
605
|
+
actorConfig: { isActorGone: async (username) => username === 'gone' },
|
|
606
|
+
});
|
|
607
|
+
const actor = await request(app)
|
|
608
|
+
.get('/ap/users/gone')
|
|
609
|
+
.set('Accept', 'application/activity+json');
|
|
610
|
+
expect(actor.status).toBe(410);
|
|
611
|
+
expect(actor.body).toMatchObject({ id: urls.actor('gone'), type: 'Tombstone' });
|
|
612
|
+
|
|
613
|
+
const inbox = await request(app)
|
|
614
|
+
.post('/ap/users/gone/inbox')
|
|
615
|
+
.set('Content-Type', 'application/activity+json')
|
|
616
|
+
.send({ type: 'Follow' });
|
|
617
|
+
expect(inbox.status).toBe(410);
|
|
618
|
+
|
|
619
|
+
const followers = await request(app)
|
|
620
|
+
.get('/ap/users/gone/followers')
|
|
621
|
+
.set('Accept', 'application/activity+json');
|
|
622
|
+
expect(followers.status).toBe(410);
|
|
623
|
+
|
|
624
|
+
const unknown = await request(app)
|
|
625
|
+
.get('/ap/users/ghost')
|
|
626
|
+
.set('Accept', 'application/activity+json');
|
|
627
|
+
expect(unknown.status).toBe(404);
|
|
628
|
+
});
|
|
629
|
+
});
|
|
630
|
+
|
|
592
631
|
describe('community moderators collection', () => {
|
|
593
632
|
const moderatorsApp = () =>
|
|
594
633
|
makeActorApp({
|
package/src/node/actorRouter.ts
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
localActorTypeForAccountKind,
|
|
34
34
|
type LocalActorBuilder,
|
|
35
35
|
} from '../actorObject';
|
|
36
|
+
import { goneTombstone, type IsActorGone } from './goneActor';
|
|
36
37
|
|
|
37
38
|
/** Page size for the paginated followers/following collections (mirrors the outbox). */
|
|
38
39
|
const FOLLOW_PAGE_SIZE = 20;
|
|
@@ -133,6 +134,13 @@ export interface ActorRouterConfig {
|
|
|
133
134
|
* the route is not mounted; any account that is not a community 404s.
|
|
134
135
|
*/
|
|
135
136
|
fetchModerators?(userId: string): Promise<User[]>;
|
|
137
|
+
/**
|
|
138
|
+
* Whether a username that resolved to nobody names a DELETED local actor
|
|
139
|
+
* (`goneActor.ts`). True ⇒ the actor, its inbox and its collections answer
|
|
140
|
+
* `410 Gone` (the actor with a `Tombstone`) instead of 404. Omitted ⇒ every
|
|
141
|
+
* miss is a 404.
|
|
142
|
+
*/
|
|
143
|
+
isActorGone?: IsActorGone;
|
|
136
144
|
/** Actor HTTP response TTL in whole seconds, 0..86400. Defaults to 1800; 0 sends no-store. */
|
|
137
145
|
httpCacheTtlSeconds?: number;
|
|
138
146
|
/** Diagnostics sink. */
|
|
@@ -178,6 +186,19 @@ export function createActorRouter(config: ActorRouterConfig): Router {
|
|
|
178
186
|
return config.wantsActivityPub(req.headers.accept);
|
|
179
187
|
}
|
|
180
188
|
|
|
189
|
+
/**
|
|
190
|
+
* The answer for a username nobody resolved to: 410 with the actor's
|
|
191
|
+
* `Tombstone` when it is a deleted actor, else the usual 404.
|
|
192
|
+
*/
|
|
193
|
+
async function userMissing(res: Response, username: string): Promise<Response> {
|
|
194
|
+
if (config.isActorGone && (await config.isActorGone(username))) {
|
|
195
|
+
res.set('Content-Type', apContentType);
|
|
196
|
+
res.set('Cache-Control', httpCacheControl);
|
|
197
|
+
return res.status(410).json(goneTombstone(urls.actor(username)));
|
|
198
|
+
}
|
|
199
|
+
return res.status(404).json({ error: 'User not found' });
|
|
200
|
+
}
|
|
201
|
+
|
|
181
202
|
/** Common inbox handler with HTTP signature verification. */
|
|
182
203
|
async function handleInbox(req: InboxRequest, res: Response): Promise<Response> {
|
|
183
204
|
try {
|
|
@@ -264,7 +285,7 @@ export function createActorRouter(config: ActorRouterConfig): Router {
|
|
|
264
285
|
|
|
265
286
|
try {
|
|
266
287
|
const user = await config.resolveUser(username);
|
|
267
|
-
if (!user) return res
|
|
288
|
+
if (!user) return userMissing(res, username);
|
|
268
289
|
|
|
269
290
|
if (!config.consent.isSharingEnabledFromUser(user)) {
|
|
270
291
|
return res.status(404).json({ error: 'User not found' });
|
|
@@ -403,7 +424,7 @@ export function createActorRouter(config: ActorRouterConfig): Router {
|
|
|
403
424
|
}
|
|
404
425
|
|
|
405
426
|
const user = await config.resolveUser(username);
|
|
406
|
-
if (!user) return res
|
|
427
|
+
if (!user) return userMissing(res, username);
|
|
407
428
|
|
|
408
429
|
// Sharing OFF must be indistinguishable from a nonexistent user — same 404
|
|
409
430
|
// body, no separate error code. Derived from the already-resolved user.
|
|
@@ -457,9 +478,12 @@ export function createActorRouter(config: ActorRouterConfig): Router {
|
|
|
457
478
|
// server drop it permanently rather than retry, so availability wins over
|
|
458
479
|
// gating freshness — the activity is processed and any consent decision is
|
|
459
480
|
// re-checked downstream by the id-based (fail-open) gates.
|
|
481
|
+
// A DELETED actor's inbox refuses delivery with 410, so the sender stops
|
|
482
|
+
// rather than treating it as a transient miss.
|
|
460
483
|
const username = getUsername(req);
|
|
461
484
|
const sharingState = await config.consent.getSharingStateByUsername(username);
|
|
462
|
-
if (sharingState === '
|
|
485
|
+
if (sharingState === 'unknown-user') return userMissing(res, username);
|
|
486
|
+
if (sharingState === 'disabled') {
|
|
463
487
|
return res.status(404).json({ error: 'User not found' });
|
|
464
488
|
}
|
|
465
489
|
|
|
@@ -492,8 +516,8 @@ export function createActorRouter(config: ActorRouterConfig): Router {
|
|
|
492
516
|
const username = getUsername(req);
|
|
493
517
|
try {
|
|
494
518
|
const user = await config.resolveUser(username);
|
|
519
|
+
if (!user) return userMissing(res, username);
|
|
495
520
|
if (
|
|
496
|
-
!user ||
|
|
497
521
|
!config.consent.isSharingEnabledFromUser(user) ||
|
|
498
522
|
localActorTypeForAccountKind(user.kind) !== LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND.community
|
|
499
523
|
) {
|
package/src/node/delivery.ts
CHANGED
|
@@ -30,6 +30,7 @@ import { readBodyChunks } from './boundedBody';
|
|
|
30
30
|
import { signRequest, type HttpSignatureSigner } from '../httpSignature';
|
|
31
31
|
import type { UrlBuilders } from '../urls';
|
|
32
32
|
import type { LocalActorBuilder } from '../actorObject';
|
|
33
|
+
import { buildActorDeleteActivity } from './goneActor';
|
|
33
34
|
|
|
34
35
|
/** Total time budget for a single delivery POST (connect + response headers). */
|
|
35
36
|
const DELIVER_ACTIVITY_TIMEOUT_MS = 15000;
|
|
@@ -360,6 +361,15 @@ export interface DeliveryService {
|
|
|
360
361
|
): Promise<void>;
|
|
361
362
|
/** Rebroadcast the FULL actor document as an Update(Person) to remote followers. */
|
|
362
363
|
federateActorUpdate(actorOxyUserId: string, username: string): Promise<void>;
|
|
364
|
+
/**
|
|
365
|
+
* Tell every remote follower the actor is gone: its `Delete` of itself
|
|
366
|
+
* (`goneActor.ts`), one per (shared) inbox, signed with its own key. Needs the
|
|
367
|
+
* accepted follower rows, so call it BEFORE deleting them. Asks no consent and
|
|
368
|
+
* resolves no profile, since the account is already gone at its identity
|
|
369
|
+
* provider; it needs only the handle and the key the handle addresses. Throws
|
|
370
|
+
* when it cannot queue, so the caller's job retries.
|
|
371
|
+
*/
|
|
372
|
+
federateActorDelete(actorOxyUserId: string, username: string): Promise<void>;
|
|
363
373
|
}
|
|
364
374
|
|
|
365
375
|
/** Build the outbound delivery + follow-lifecycle service from an app's adapters. */
|
|
@@ -850,6 +860,15 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
|
|
|
850
860
|
}
|
|
851
861
|
}
|
|
852
862
|
|
|
863
|
+
async function federateActorDelete(actorOxyUserId: string, username: string): Promise<void> {
|
|
864
|
+
if (!config.federationEnabled) return;
|
|
865
|
+
await deliverToFollowers(
|
|
866
|
+
buildActorDeleteActivity(urls.actor(username)),
|
|
867
|
+
actorOxyUserId,
|
|
868
|
+
username,
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
|
|
853
872
|
return {
|
|
854
873
|
deliverActivity,
|
|
855
874
|
queueDelivery,
|
|
@@ -860,5 +879,6 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
|
|
|
860
879
|
sendAccept,
|
|
861
880
|
sendReject,
|
|
862
881
|
federateActorUpdate,
|
|
882
|
+
federateActorDelete,
|
|
863
883
|
};
|
|
864
884
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
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
|
+
|
|
20
|
+
import { AP_CONTEXT } from '../apContext';
|
|
21
|
+
|
|
22
|
+
/** Does `username` name a local actor that was deleted? Asked only for a username that resolved to nobody. */
|
|
23
|
+
export type IsActorGone = (username: string) => Promise<boolean>;
|
|
24
|
+
|
|
25
|
+
/** The body of a `410 Gone` for a deleted object: a bare `Tombstone` naming it. */
|
|
26
|
+
export function goneTombstone(id: string): Record<string, unknown> {
|
|
27
|
+
return { '@context': AP_CONTEXT, id, type: 'Tombstone' };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The actor's `Delete` of itself. The id is DETERMINISTIC (no timestamp), the
|
|
32
|
+
* shape Mastodon sends (`<actor>#delete`), so a retried run re-sends the same
|
|
33
|
+
* activity and a delivery queue that dedupes on activity id + inbox sends each
|
|
34
|
+
* inbox one copy.
|
|
35
|
+
*/
|
|
36
|
+
export function buildActorDeleteActivity(actorUri: string): Record<string, unknown> {
|
|
37
|
+
return {
|
|
38
|
+
'@context': AP_CONTEXT,
|
|
39
|
+
id: `${actorUri}#delete`,
|
|
40
|
+
type: 'Delete',
|
|
41
|
+
actor: actorUri,
|
|
42
|
+
to: ['https://www.w3.org/ns/activitystreams#Public'],
|
|
43
|
+
object: actorUri,
|
|
44
|
+
};
|
|
45
|
+
}
|
package/src/node/index.ts
CHANGED
|
@@ -141,6 +141,12 @@ export {
|
|
|
141
141
|
* The ActivityPub actor + inbox + follow-graph router (actor GET incl. the
|
|
142
142
|
* `instance` actor, inbox POST with HTTP-sig verify, followers/following pages).
|
|
143
143
|
*/
|
|
144
|
+
/**
|
|
145
|
+
* A deleted local actor: its `Delete` of itself, and the `Tombstone` its URLs
|
|
146
|
+
* answer `410 Gone` with afterwards.
|
|
147
|
+
*/
|
|
148
|
+
export { buildActorDeleteActivity, goneTombstone, type IsActorGone } from './goneActor';
|
|
149
|
+
|
|
144
150
|
export {
|
|
145
151
|
createActorRouter,
|
|
146
152
|
type ActorRouterConfig,
|
|
@@ -17,6 +17,7 @@ import { discoveryCacheControl } from './httpCacheControl';
|
|
|
17
17
|
import { isSameFederationHost } from '../apUri';
|
|
18
18
|
import type { UrlBuilders } from '../urls';
|
|
19
19
|
import { INSTANCE_ACTOR_USERNAME, normalizeActorUsername } from '../urls';
|
|
20
|
+
import type { IsActorGone } from './goneActor';
|
|
20
21
|
|
|
21
22
|
/** Default HTTP response max-age. The injected JRD cache has its own policy. */
|
|
22
23
|
const WEBFINGER_CACHE_TTL = 3600;
|
|
@@ -65,6 +66,12 @@ export interface WebfingerRouterConfig {
|
|
|
65
66
|
get(username: string): Promise<WebfingerJrd | null>;
|
|
66
67
|
set(username: string, jrd: WebfingerJrd): void;
|
|
67
68
|
};
|
|
69
|
+
/**
|
|
70
|
+
* Whether a username that resolved to nobody names a DELETED local actor
|
|
71
|
+
* (`goneActor.ts`). True ⇒ `410 Gone` instead of 404, which is what makes
|
|
72
|
+
* Mastodon delete its copy of the account. Omitted ⇒ every miss is a 404.
|
|
73
|
+
*/
|
|
74
|
+
isActorGone?: IsActorGone;
|
|
68
75
|
/** HTTP response TTL in whole seconds, 0..86400. Defaults to 3600; 0 sends no-store. */
|
|
69
76
|
httpCacheTtlSeconds?: number;
|
|
70
77
|
/** Diagnostics sink. */
|
|
@@ -147,7 +154,13 @@ export function createWebfingerRouter(config: WebfingerRouterConfig): Router {
|
|
|
147
154
|
}
|
|
148
155
|
|
|
149
156
|
const user = await config.resolveUser(username);
|
|
150
|
-
if (!user)
|
|
157
|
+
if (!user) {
|
|
158
|
+
if (config.isActorGone && (await config.isActorGone(username))) {
|
|
159
|
+
res.set('Cache-Control', httpCacheControl);
|
|
160
|
+
return res.status(410).json({ error: 'User deleted' });
|
|
161
|
+
}
|
|
162
|
+
return res.status(404).json({ error: 'User not found' });
|
|
163
|
+
}
|
|
151
164
|
|
|
152
165
|
// Sharing OFF must be indistinguishable from a nonexistent user — same 404
|
|
153
166
|
// body, no separate error code. UNLIKE the other user-scoped surfaces,
|