@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 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.status(404).json({ error: 'User not found' });
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.status(404).json({ error: 'User not found' });
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 === 'disabled' || sharingState === 'unknown-user') {
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
- !config.consent.isSharingEnabledFromUser(user) ||
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
- async function sendAccept(localOxyUserId, localUsername, followActivityId, remoteActorUri) {
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 sending Accept(Follow) to always has one. When neither inbox is known the
362
- // local follow is already removed — just skip the outbound delivery.
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 Accept(Follow) to ${remoteActorUri}: actor has no inbox`);
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 Accept(Follow) delivery to blocked origin ${remoteActorUri}`);
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}/accepts/${Date.now()}`,
376
- type: 'Accept',
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
@@ -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.status(404).json({ error: 'User not found' });
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.status(404).json({ error: 'User not found' });
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 === 'disabled' || sharingState === 'unknown-user') {
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
- !config.consent.isSharingEnabledFromUser(user) ||
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
- async function sendAccept(localOxyUserId, localUsername, followActivityId, remoteActorUri) {
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 sending Accept(Follow) to always has one. When neither inbox is known the
357
- // local follow is already removed — just skip the outbound delivery.
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 Accept(Follow) to ${remoteActorUri}: actor has no inbox`);
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 Accept(Follow) delivery to blocked origin ${remoteActorUri}`);
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}/accepts/${Date.now()}`,
371
- type: 'Accept',
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
@@ -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.6.0",
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({
@@ -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.status(404).json({ error: 'User not found' });
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.status(404).json({ error: 'User not found' });
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 === 'disabled' || sharingState === 'unknown-user') {
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
  ) {
@@ -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
- async function sendAccept(
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 sending Accept(Follow) to always has one. When neither inbox is known the
729
- // local follow is already removed — just skip the outbound delivery.
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 Accept(Follow) to ${remoteActorUri}: actor has no inbox`);
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 Accept(Follow) delivery to blocked origin ${remoteActorUri}`,
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}/accepts/${Date.now()}`,
747
- type: 'Accept',
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) return res.status(404).json({ error: 'User not found' });
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,