@oxy.so/federation 2.7.0 → 2.9.0

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