@oxy.so/federation 2.6.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 +22 -7
- package/dist/cjs/node/goneActor.js +43 -0
- package/dist/cjs/node/inboundDispatch.js +8 -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 +22 -7
- package/dist/esm/node/goneActor.js +39 -0
- package/dist/esm/node/inboundDispatch.js +8 -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 +11 -0
- package/dist/types/node/goneActor.d.ts +29 -0
- package/dist/types/node/inboundDispatch.d.ts +11 -1
- 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__/inboundDispatch.test.ts +24 -0
- package/src/__tests__/routers.test.ts +39 -0
- package/src/node/actorRouter.ts +28 -4
- package/src/node/delivery.ts +67 -7
- package/src/node/goneActor.ts +45 -0
- package/src/node/inboundDispatch.ts +25 -1
- 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. */
|
|
@@ -353,27 +354,28 @@ function createDeliveryService(config) {
|
|
|
353
354
|
}
|
|
354
355
|
return true;
|
|
355
356
|
}
|
|
356
|
-
|
|
357
|
+
/** Answer a remote Follow: `Accept` or `Reject`, one envelope for both. */
|
|
358
|
+
async function sendFollowResponse(type, localOxyUserId, localUsername, followActivityId, remoteActorUri) {
|
|
357
359
|
const actor = await config.store.findActorByUri(remoteActorUri);
|
|
358
360
|
if (!actor)
|
|
359
361
|
return;
|
|
360
362
|
// `inboxUrl` is schema-optional (atproto actors have none); an AP actor we
|
|
361
|
-
// are
|
|
362
|
-
//
|
|
363
|
+
// are answering always has one. When neither inbox is known, skip the
|
|
364
|
+
// outbound delivery.
|
|
363
365
|
const targetInbox = actor.sharedInboxUrl ?? actor.inboxUrl;
|
|
364
366
|
if (!targetInbox) {
|
|
365
|
-
logger.warn(`[FedSync] cannot send
|
|
367
|
+
logger.warn(`[FedSync] cannot send ${type}(Follow) to ${remoteActorUri}: actor has no inbox`);
|
|
366
368
|
return;
|
|
367
369
|
}
|
|
368
370
|
if (isBlockedUrl(targetInbox) || isBlockedUrl(remoteActorUri)) {
|
|
369
|
-
logger.warn(`[FedDeliver] refusing
|
|
371
|
+
logger.warn(`[FedDeliver] refusing ${type}(Follow) delivery to blocked origin ${remoteActorUri}`);
|
|
370
372
|
return;
|
|
371
373
|
}
|
|
372
374
|
const localActorUri = urls.actor(localUsername);
|
|
373
375
|
const activity = {
|
|
374
376
|
'@context': apContext_1.AP_CONTEXT,
|
|
375
|
-
id: `${localActorUri}
|
|
376
|
-
type
|
|
377
|
+
id: `${localActorUri}/${type === 'Accept' ? 'accepts' : 'rejects'}/${Date.now()}`,
|
|
378
|
+
type,
|
|
377
379
|
actor: localActorUri,
|
|
378
380
|
object: {
|
|
379
381
|
id: followActivityId,
|
|
@@ -387,6 +389,12 @@ function createDeliveryService(config) {
|
|
|
387
389
|
await queueDelivery(activity, targetInbox, localOxyUserId);
|
|
388
390
|
}
|
|
389
391
|
}
|
|
392
|
+
function sendAccept(localOxyUserId, localUsername, followActivityId, remoteActorUri) {
|
|
393
|
+
return sendFollowResponse('Accept', localOxyUserId, localUsername, followActivityId, remoteActorUri);
|
|
394
|
+
}
|
|
395
|
+
function sendReject(localOxyUserId, localUsername, followActivityId, remoteActorUri) {
|
|
396
|
+
return sendFollowResponse('Reject', localOxyUserId, localUsername, followActivityId, remoteActorUri);
|
|
397
|
+
}
|
|
390
398
|
async function federateActorUpdate(actorOxyUserId, username) {
|
|
391
399
|
if (!config.federationEnabled)
|
|
392
400
|
return;
|
|
@@ -432,6 +440,11 @@ function createDeliveryService(config) {
|
|
|
432
440
|
logger.error('Failed to federate actor update:', err);
|
|
433
441
|
}
|
|
434
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
|
+
}
|
|
435
448
|
return {
|
|
436
449
|
deliverActivity,
|
|
437
450
|
queueDelivery,
|
|
@@ -440,6 +453,8 @@ function createDeliveryService(config) {
|
|
|
440
453
|
sendFollow,
|
|
441
454
|
sendUndoFollow,
|
|
442
455
|
sendAccept,
|
|
456
|
+
sendReject,
|
|
443
457
|
federateActorUpdate,
|
|
458
|
+
federateActorDelete,
|
|
444
459
|
};
|
|
445
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
|
+
}
|
|
@@ -150,6 +150,14 @@ function createInboundDispatcher(config) {
|
|
|
150
150
|
logger.debug(`[Federation] ignoring self-follow from ${actorUri} to ${username}`);
|
|
151
151
|
return;
|
|
152
152
|
}
|
|
153
|
+
if (config.refusesFollower && (await config.refusesFollower(localUserId, followerOxyUserId))) {
|
|
154
|
+
if (!config.delivery.sendReject) {
|
|
155
|
+
throw new Error('refusesFollower is wired without delivery.sendReject');
|
|
156
|
+
}
|
|
157
|
+
await config.delivery.sendReject(localUserId, username, String(activity.id), actorUri);
|
|
158
|
+
logger.info(`Rejected follow from ${actorUri} to ${username}`);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
153
161
|
// Create the Oxy follow edge BEFORE sending Accept so a retry never spams
|
|
154
162
|
// Accepts: the bridge is idempotent (safe to re-run), but an Accept delivered
|
|
155
163
|
// before the edge was committed could be re-sent on every retry. On failure
|
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. */
|
|
@@ -348,27 +349,28 @@ export function createDeliveryService(config) {
|
|
|
348
349
|
}
|
|
349
350
|
return true;
|
|
350
351
|
}
|
|
351
|
-
|
|
352
|
+
/** Answer a remote Follow: `Accept` or `Reject`, one envelope for both. */
|
|
353
|
+
async function sendFollowResponse(type, localOxyUserId, localUsername, followActivityId, remoteActorUri) {
|
|
352
354
|
const actor = await config.store.findActorByUri(remoteActorUri);
|
|
353
355
|
if (!actor)
|
|
354
356
|
return;
|
|
355
357
|
// `inboxUrl` is schema-optional (atproto actors have none); an AP actor we
|
|
356
|
-
// are
|
|
357
|
-
//
|
|
358
|
+
// are answering always has one. When neither inbox is known, skip the
|
|
359
|
+
// outbound delivery.
|
|
358
360
|
const targetInbox = actor.sharedInboxUrl ?? actor.inboxUrl;
|
|
359
361
|
if (!targetInbox) {
|
|
360
|
-
logger.warn(`[FedSync] cannot send
|
|
362
|
+
logger.warn(`[FedSync] cannot send ${type}(Follow) to ${remoteActorUri}: actor has no inbox`);
|
|
361
363
|
return;
|
|
362
364
|
}
|
|
363
365
|
if (isBlockedUrl(targetInbox) || isBlockedUrl(remoteActorUri)) {
|
|
364
|
-
logger.warn(`[FedDeliver] refusing
|
|
366
|
+
logger.warn(`[FedDeliver] refusing ${type}(Follow) delivery to blocked origin ${remoteActorUri}`);
|
|
365
367
|
return;
|
|
366
368
|
}
|
|
367
369
|
const localActorUri = urls.actor(localUsername);
|
|
368
370
|
const activity = {
|
|
369
371
|
'@context': AP_CONTEXT,
|
|
370
|
-
id: `${localActorUri}
|
|
371
|
-
type
|
|
372
|
+
id: `${localActorUri}/${type === 'Accept' ? 'accepts' : 'rejects'}/${Date.now()}`,
|
|
373
|
+
type,
|
|
372
374
|
actor: localActorUri,
|
|
373
375
|
object: {
|
|
374
376
|
id: followActivityId,
|
|
@@ -382,6 +384,12 @@ export function createDeliveryService(config) {
|
|
|
382
384
|
await queueDelivery(activity, targetInbox, localOxyUserId);
|
|
383
385
|
}
|
|
384
386
|
}
|
|
387
|
+
function sendAccept(localOxyUserId, localUsername, followActivityId, remoteActorUri) {
|
|
388
|
+
return sendFollowResponse('Accept', localOxyUserId, localUsername, followActivityId, remoteActorUri);
|
|
389
|
+
}
|
|
390
|
+
function sendReject(localOxyUserId, localUsername, followActivityId, remoteActorUri) {
|
|
391
|
+
return sendFollowResponse('Reject', localOxyUserId, localUsername, followActivityId, remoteActorUri);
|
|
392
|
+
}
|
|
385
393
|
async function federateActorUpdate(actorOxyUserId, username) {
|
|
386
394
|
if (!config.federationEnabled)
|
|
387
395
|
return;
|
|
@@ -427,6 +435,11 @@ export function createDeliveryService(config) {
|
|
|
427
435
|
logger.error('Failed to federate actor update:', err);
|
|
428
436
|
}
|
|
429
437
|
}
|
|
438
|
+
async function federateActorDelete(actorOxyUserId, username) {
|
|
439
|
+
if (!config.federationEnabled)
|
|
440
|
+
return;
|
|
441
|
+
await deliverToFollowers(buildActorDeleteActivity(urls.actor(username)), actorOxyUserId, username);
|
|
442
|
+
}
|
|
430
443
|
return {
|
|
431
444
|
deliverActivity,
|
|
432
445
|
queueDelivery,
|
|
@@ -435,6 +448,8 @@ export function createDeliveryService(config) {
|
|
|
435
448
|
sendFollow,
|
|
436
449
|
sendUndoFollow,
|
|
437
450
|
sendAccept,
|
|
451
|
+
sendReject,
|
|
438
452
|
federateActorUpdate,
|
|
453
|
+
federateActorDelete,
|
|
439
454
|
};
|
|
440
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
|
+
}
|
|
@@ -145,6 +145,14 @@ export function createInboundDispatcher(config) {
|
|
|
145
145
|
logger.debug(`[Federation] ignoring self-follow from ${actorUri} to ${username}`);
|
|
146
146
|
return;
|
|
147
147
|
}
|
|
148
|
+
if (config.refusesFollower && (await config.refusesFollower(localUserId, followerOxyUserId))) {
|
|
149
|
+
if (!config.delivery.sendReject) {
|
|
150
|
+
throw new Error('refusesFollower is wired without delivery.sendReject');
|
|
151
|
+
}
|
|
152
|
+
await config.delivery.sendReject(localUserId, username, String(activity.id), actorUri);
|
|
153
|
+
logger.info(`Rejected follow from ${actorUri} to ${username}`);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
148
156
|
// Create the Oxy follow edge BEFORE sending Accept so a retry never spams
|
|
149
157
|
// Accepts: the bridge is idempotent (safe to re-run), but an Accept delivered
|
|
150
158
|
// before the edge was committed could be re-sent on every retry. On failure
|
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. */
|
|
@@ -266,8 +266,19 @@ export interface DeliveryService {
|
|
|
266
266
|
sendUndoFollow(localOxyUserId: string, localUsername: string, remoteActorUri: string): Promise<boolean>;
|
|
267
267
|
/** Send an Accept(Follow) back to a remote actor. */
|
|
268
268
|
sendAccept(localOxyUserId: string, localUsername: string, followActivityId: string, remoteActorUri: string): Promise<void>;
|
|
269
|
+
/** Send a Reject(Follow) back to a remote actor — the same envelope as the Accept. */
|
|
270
|
+
sendReject(localOxyUserId: string, localUsername: string, followActivityId: string, remoteActorUri: string): Promise<void>;
|
|
269
271
|
/** Rebroadcast the FULL actor document as an Update(Person) to remote followers. */
|
|
270
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>;
|
|
271
282
|
}
|
|
272
283
|
/** Build the outbound delivery + follow-lifecycle service from an app's adapters. */
|
|
273
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>;
|
|
@@ -108,6 +108,8 @@ export interface InboundFollowStore {
|
|
|
108
108
|
/** The delivery subset the inbound Follow uses (send the Accept back). */
|
|
109
109
|
export interface InboundDelivery {
|
|
110
110
|
sendAccept(localOxyUserId: string, localUsername: string, followActivityId: string, remoteActorUri: string): Promise<void>;
|
|
111
|
+
/** Send a Reject(Follow) back — required when {@link InboundDispatcherConfig.refusesFollower} is wired. */
|
|
112
|
+
sendReject?(localOxyUserId: string, localUsername: string, followActivityId: string, remoteActorUri: string): Promise<void>;
|
|
111
113
|
}
|
|
112
114
|
/** Adapters + hooks an {@link InboundDispatcher} is built from. */
|
|
113
115
|
export interface InboundDispatcherConfig {
|
|
@@ -132,8 +134,16 @@ export interface InboundDispatcherConfig {
|
|
|
132
134
|
actorResolver: InboundActorResolver;
|
|
133
135
|
/** The AP follow-record store. */
|
|
134
136
|
follows: InboundFollowStore;
|
|
135
|
-
/** The delivery service (send the Accept). */
|
|
137
|
+
/** The delivery service (send the Accept, or the Reject). */
|
|
136
138
|
delivery: InboundDelivery;
|
|
139
|
+
/**
|
|
140
|
+
* Whether the local account REFUSES this follower — a community that banned
|
|
141
|
+
* them. A refused Follow is answered with a `Reject` (the account exists and is
|
|
142
|
+
* public, so saying no reveals nothing), and no edge or follow row is written.
|
|
143
|
+
* Asked after the follower resolves, so the app answers by Oxy id. Absent ⇒
|
|
144
|
+
* every Follow is accepted, as before.
|
|
145
|
+
*/
|
|
146
|
+
refusesFollower?(localUserId: string, followerOxyUserId: string): Promise<boolean>;
|
|
137
147
|
/**
|
|
138
148
|
* Best-effort: notify the local user of a newly-accepted inbound follow.
|
|
139
149
|
* NEVER throws (it handles its own errors); a failure must not fail (and thus
|
|
@@ -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',
|
|
@@ -31,11 +31,13 @@ function makeRig(
|
|
|
31
31
|
validate?: (activity: Record<string, unknown>) => InboundActivityValidation;
|
|
32
32
|
blockedHosts?: string[];
|
|
33
33
|
withMoveHandler?: boolean;
|
|
34
|
+
refusedFollowers?: string[];
|
|
34
35
|
} = {},
|
|
35
36
|
) {
|
|
36
37
|
const moves: Array<{ activityId: string; oldActorUri: string; targetActorUri: string }> = [];
|
|
37
38
|
const bridgeFollowCalls: Array<[string, string]> = [];
|
|
38
39
|
const bridgeUnfollowCalls: Array<[string, string]> = [];
|
|
40
|
+
const rejectsSent: string[] = [];
|
|
39
41
|
const acceptsSent: Array<{
|
|
40
42
|
localOxyUserId: string;
|
|
41
43
|
localUsername: string;
|
|
@@ -109,7 +111,16 @@ function makeRig(
|
|
|
109
111
|
sendAccept: async (localOxyUserId, localUsername, followActivityId, remoteActorUri) => {
|
|
110
112
|
acceptsSent.push({ localOxyUserId, localUsername, followActivityId, remoteActorUri });
|
|
111
113
|
},
|
|
114
|
+
sendReject: async (_localOxyUserId, _localUsername, followActivityId) => {
|
|
115
|
+
rejectsSent.push(followActivityId);
|
|
116
|
+
},
|
|
112
117
|
},
|
|
118
|
+
...(overrides.refusedFollowers
|
|
119
|
+
? {
|
|
120
|
+
refusesFollower: async (_localUserId: string, followerOxyUserId: string) =>
|
|
121
|
+
overrides.refusedFollowers?.includes(followerOxyUserId) ?? false,
|
|
122
|
+
}
|
|
123
|
+
: {}),
|
|
113
124
|
onInboundFollowAccepted: async (localUserId, followerOxyUserId, actorUri) => {
|
|
114
125
|
onInboundFollowAcceptedCalls.push([localUserId, followerOxyUserId, actorUri]);
|
|
115
126
|
},
|
|
@@ -140,6 +151,7 @@ function makeRig(
|
|
|
140
151
|
bridgeFollowCalls,
|
|
141
152
|
bridgeUnfollowCalls,
|
|
142
153
|
acceptsSent,
|
|
154
|
+
rejectsSent,
|
|
143
155
|
inboundAcceptedUpserts,
|
|
144
156
|
deletedFollowIds,
|
|
145
157
|
contentActivities,
|
|
@@ -174,6 +186,18 @@ describe('inbound Follow', () => {
|
|
|
174
186
|
expect(rig.onInboundFollowAcceptedCalls).toEqual([['u-alice', 'u-bob', REMOTE_ACTOR]]);
|
|
175
187
|
});
|
|
176
188
|
|
|
189
|
+
it('answers a refused follower with Reject, writing nothing', async () => {
|
|
190
|
+
const rig = makeRig({ refusedFollowers: ['u-bob'] });
|
|
191
|
+
await rig.dispatcher.processInboxActivity(
|
|
192
|
+
{ type: 'Follow', id: 'f9', actor: REMOTE_ACTOR, object: LOCAL_ACTOR },
|
|
193
|
+
REMOTE_ACTOR,
|
|
194
|
+
);
|
|
195
|
+
expect(rig.rejectsSent).toEqual(['f9']);
|
|
196
|
+
expect(rig.acceptsSent).toEqual([]);
|
|
197
|
+
expect(rig.bridgeFollowCalls).toEqual([]);
|
|
198
|
+
expect(rig.inboundAcceptedUpserts).toEqual([]);
|
|
199
|
+
});
|
|
200
|
+
|
|
177
201
|
it('drops the Follow (no bridge/accept) when the target has sharing OFF', async () => {
|
|
178
202
|
const rig = makeRig({ sharingEnabled: false });
|
|
179
203
|
await rig.dispatcher.processInboxActivity(
|
|
@@ -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;
|
|
@@ -351,8 +352,24 @@ export interface DeliveryService {
|
|
|
351
352
|
followActivityId: string,
|
|
352
353
|
remoteActorUri: string,
|
|
353
354
|
): Promise<void>;
|
|
355
|
+
/** Send a Reject(Follow) back to a remote actor — the same envelope as the Accept. */
|
|
356
|
+
sendReject(
|
|
357
|
+
localOxyUserId: string,
|
|
358
|
+
localUsername: string,
|
|
359
|
+
followActivityId: string,
|
|
360
|
+
remoteActorUri: string,
|
|
361
|
+
): Promise<void>;
|
|
354
362
|
/** Rebroadcast the FULL actor document as an Update(Person) to remote followers. */
|
|
355
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>;
|
|
356
373
|
}
|
|
357
374
|
|
|
358
375
|
/** Build the outbound delivery + follow-lifecycle service from an app's adapters. */
|
|
@@ -716,7 +733,9 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
|
|
|
716
733
|
return true;
|
|
717
734
|
}
|
|
718
735
|
|
|
719
|
-
|
|
736
|
+
/** Answer a remote Follow: `Accept` or `Reject`, one envelope for both. */
|
|
737
|
+
async function sendFollowResponse(
|
|
738
|
+
type: 'Accept' | 'Reject',
|
|
720
739
|
localOxyUserId: string,
|
|
721
740
|
localUsername: string,
|
|
722
741
|
followActivityId: string,
|
|
@@ -725,16 +744,16 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
|
|
|
725
744
|
const actor = await config.store.findActorByUri(remoteActorUri);
|
|
726
745
|
if (!actor) return;
|
|
727
746
|
// `inboxUrl` is schema-optional (atproto actors have none); an AP actor we
|
|
728
|
-
// are
|
|
729
|
-
//
|
|
747
|
+
// are answering always has one. When neither inbox is known, skip the
|
|
748
|
+
// outbound delivery.
|
|
730
749
|
const targetInbox = actor.sharedInboxUrl ?? actor.inboxUrl;
|
|
731
750
|
if (!targetInbox) {
|
|
732
|
-
logger.warn(`[FedSync] cannot send
|
|
751
|
+
logger.warn(`[FedSync] cannot send ${type}(Follow) to ${remoteActorUri}: actor has no inbox`);
|
|
733
752
|
return;
|
|
734
753
|
}
|
|
735
754
|
if (isBlockedUrl(targetInbox) || isBlockedUrl(remoteActorUri)) {
|
|
736
755
|
logger.warn(
|
|
737
|
-
`[FedDeliver] refusing
|
|
756
|
+
`[FedDeliver] refusing ${type}(Follow) delivery to blocked origin ${remoteActorUri}`,
|
|
738
757
|
);
|
|
739
758
|
return;
|
|
740
759
|
}
|
|
@@ -743,8 +762,8 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
|
|
|
743
762
|
|
|
744
763
|
const activity: Record<string, unknown> = {
|
|
745
764
|
'@context': AP_CONTEXT,
|
|
746
|
-
id: `${localActorUri}
|
|
747
|
-
type
|
|
765
|
+
id: `${localActorUri}/${type === 'Accept' ? 'accepts' : 'rejects'}/${Date.now()}`,
|
|
766
|
+
type,
|
|
748
767
|
actor: localActorUri,
|
|
749
768
|
object: {
|
|
750
769
|
id: followActivityId,
|
|
@@ -760,6 +779,36 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
|
|
|
760
779
|
}
|
|
761
780
|
}
|
|
762
781
|
|
|
782
|
+
function sendAccept(
|
|
783
|
+
localOxyUserId: string,
|
|
784
|
+
localUsername: string,
|
|
785
|
+
followActivityId: string,
|
|
786
|
+
remoteActorUri: string,
|
|
787
|
+
): Promise<void> {
|
|
788
|
+
return sendFollowResponse(
|
|
789
|
+
'Accept',
|
|
790
|
+
localOxyUserId,
|
|
791
|
+
localUsername,
|
|
792
|
+
followActivityId,
|
|
793
|
+
remoteActorUri,
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function sendReject(
|
|
798
|
+
localOxyUserId: string,
|
|
799
|
+
localUsername: string,
|
|
800
|
+
followActivityId: string,
|
|
801
|
+
remoteActorUri: string,
|
|
802
|
+
): Promise<void> {
|
|
803
|
+
return sendFollowResponse(
|
|
804
|
+
'Reject',
|
|
805
|
+
localOxyUserId,
|
|
806
|
+
localUsername,
|
|
807
|
+
followActivityId,
|
|
808
|
+
remoteActorUri,
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
|
|
763
812
|
async function federateActorUpdate(actorOxyUserId: string, username: string): Promise<void> {
|
|
764
813
|
if (!config.federationEnabled) return;
|
|
765
814
|
if (!(await config.consent.isSharingEnabled(actorOxyUserId))) return;
|
|
@@ -811,6 +860,15 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
|
|
|
811
860
|
}
|
|
812
861
|
}
|
|
813
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
|
+
|
|
814
872
|
return {
|
|
815
873
|
deliverActivity,
|
|
816
874
|
queueDelivery,
|
|
@@ -819,6 +877,8 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
|
|
|
819
877
|
sendFollow,
|
|
820
878
|
sendUndoFollow,
|
|
821
879
|
sendAccept,
|
|
880
|
+
sendReject,
|
|
822
881
|
federateActorUpdate,
|
|
882
|
+
federateActorDelete,
|
|
823
883
|
};
|
|
824
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
|
+
}
|
|
@@ -127,6 +127,13 @@ export interface InboundDelivery {
|
|
|
127
127
|
followActivityId: string,
|
|
128
128
|
remoteActorUri: string,
|
|
129
129
|
): Promise<void>;
|
|
130
|
+
/** Send a Reject(Follow) back — required when {@link InboundDispatcherConfig.refusesFollower} is wired. */
|
|
131
|
+
sendReject?(
|
|
132
|
+
localOxyUserId: string,
|
|
133
|
+
localUsername: string,
|
|
134
|
+
followActivityId: string,
|
|
135
|
+
remoteActorUri: string,
|
|
136
|
+
): Promise<void>;
|
|
130
137
|
}
|
|
131
138
|
|
|
132
139
|
/** Adapters + hooks an {@link InboundDispatcher} is built from. */
|
|
@@ -152,8 +159,16 @@ export interface InboundDispatcherConfig {
|
|
|
152
159
|
actorResolver: InboundActorResolver;
|
|
153
160
|
/** The AP follow-record store. */
|
|
154
161
|
follows: InboundFollowStore;
|
|
155
|
-
/** The delivery service (send the Accept). */
|
|
162
|
+
/** The delivery service (send the Accept, or the Reject). */
|
|
156
163
|
delivery: InboundDelivery;
|
|
164
|
+
/**
|
|
165
|
+
* Whether the local account REFUSES this follower — a community that banned
|
|
166
|
+
* them. A refused Follow is answered with a `Reject` (the account exists and is
|
|
167
|
+
* public, so saying no reveals nothing), and no edge or follow row is written.
|
|
168
|
+
* Asked after the follower resolves, so the app answers by Oxy id. Absent ⇒
|
|
169
|
+
* every Follow is accepted, as before.
|
|
170
|
+
*/
|
|
171
|
+
refusesFollower?(localUserId: string, followerOxyUserId: string): Promise<boolean>;
|
|
157
172
|
/**
|
|
158
173
|
* Best-effort: notify the local user of a newly-accepted inbound follow.
|
|
159
174
|
* NEVER throws (it handles its own errors); a failure must not fail (and thus
|
|
@@ -306,6 +321,15 @@ export function createInboundDispatcher(config: InboundDispatcherConfig): Inboun
|
|
|
306
321
|
return;
|
|
307
322
|
}
|
|
308
323
|
|
|
324
|
+
if (config.refusesFollower && (await config.refusesFollower(localUserId, followerOxyUserId))) {
|
|
325
|
+
if (!config.delivery.sendReject) {
|
|
326
|
+
throw new Error('refusesFollower is wired without delivery.sendReject');
|
|
327
|
+
}
|
|
328
|
+
await config.delivery.sendReject(localUserId, username, String(activity.id), actorUri);
|
|
329
|
+
logger.info(`Rejected follow from ${actorUri} to ${username}`);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
309
333
|
// Create the Oxy follow edge BEFORE sending Accept so a retry never spams
|
|
310
334
|
// Accepts: the bridge is idempotent (safe to re-run), but an Accept delivered
|
|
311
335
|
// before the edge was committed could be re-sent on every retry. On failure
|
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,
|