@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.
@@ -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
@@ -0,0 +1,42 @@
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
+ /** How many of its latest `Announce`s a `Group`'s outbox lists inline (Lemmy's own size). */
21
+ export declare const GROUP_OUTBOX_SIZE = 50;
22
+ /**
23
+ * Whether a local account of this kind is announced as a `Group`
24
+ * ({@link LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND}) and so serves the Group
25
+ * collections: moderators, and the outbox and followers in the shapes above.
26
+ */
27
+ export declare function isGroupActorKind(kind: unknown): boolean;
28
+ /**
29
+ * A `Group`'s outbox: one unpaged `OrderedCollection` of its latest
30
+ * `Announce { Create }` activities, newest first. Each item's own `@context` is
31
+ * dropped; the collection's covers it.
32
+ */
33
+ export declare function groupOutboxCollection(params: {
34
+ id: string;
35
+ totalItems: number;
36
+ orderedItems: readonly Record<string, unknown>[];
37
+ }): Record<string, unknown>;
38
+ /** A `Group`'s followers: the count, and an empty `items` list. */
39
+ export declare function groupFollowersCollection(params: {
40
+ id: string;
41
+ totalItems: number;
42
+ }): Record<string, unknown>;
@@ -63,6 +63,11 @@ 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, type ActorKindSignals, isApActorType, AP_ACTOR_TYPES, LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND, type ApActorType, type LocalActorType, type LocalActorBuilder, type LocalActorBuilderConfig, type BuildLocalActorParams, type ActorMediaResolver, } from './actorObject';
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';
66
71
  /**
67
72
  * Supported external networks, keyed by the transport a connector speaks.
68
73
  * `instagram-graph` is Meta's Graph API Business Discovery: a read-only
@@ -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,
@@ -23,7 +24,8 @@ import { Router } from 'express';
23
24
  import type { AccountKind } from '@oxy.so/contracts';
24
25
  import type { User } from '@oxy.so/core';
25
26
  import type { UrlBuilders } from '../urls';
26
- import { type LocalActorBuilder } from '../actorObject';
27
+ import type { LocalActorBuilder } from '../actorObject';
28
+ import { type IsActorGone } from './goneActor';
27
29
  /** The resolved-user fields the actor + collection routes read. */
28
30
  export interface ActorRouteUser {
29
31
  _id?: string | null;
@@ -114,6 +116,13 @@ export interface ActorRouterConfig {
114
116
  * the route is not mounted; any account that is not a community 404s.
115
117
  */
116
118
  fetchModerators?(userId: string): Promise<User[]>;
119
+ /**
120
+ * Whether a username that resolved to nobody names a DELETED local actor
121
+ * (`goneActor.ts`). True ⇒ the actor, its inbox and its collections answer
122
+ * `410 Gone` (the actor with a `Tombstone`) instead of 404. Omitted ⇒ every
123
+ * miss is a 404.
124
+ */
125
+ isActorGone?: IsActorGone;
117
126
  /** Actor HTTP response TTL in whole seconds, 0..86400. Defaults to 1800; 0 sends no-store. */
118
127
  httpCacheTtlSeconds?: number;
119
128
  /** Diagnostics sink. */
@@ -270,6 +270,15 @@ export interface DeliveryService {
270
270
  sendReject(localOxyUserId: string, localUsername: string, followActivityId: string, remoteActorUri: string): Promise<void>;
271
271
  /** Rebroadcast the FULL actor document as an Update(Person) to remote followers. */
272
272
  federateActorUpdate(actorOxyUserId: string, username: string): Promise<void>;
273
+ /**
274
+ * Tell every remote follower the actor is gone: its `Delete` of itself
275
+ * (`goneActor.ts`), one per (shared) inbox, signed with its own key. Needs the
276
+ * accepted follower rows, so call it BEFORE deleting them. Asks no consent and
277
+ * resolves no profile, since the account is already gone at its identity
278
+ * provider; it needs only the handle and the key the handle addresses. Throws
279
+ * when it cannot queue, so the caller's job retries.
280
+ */
281
+ federateActorDelete(actorOxyUserId: string, username: string): Promise<void>;
273
282
  }
274
283
  /** Build the outbound delivery + follow-lifecycle service from an app's adapters. */
275
284
  export declare function createDeliveryService<TActor extends DeliveryActorFields>(config: DeliveryServiceConfig<TActor>): DeliveryService;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * A DELETED local actor, as the fediverse is told about it.
3
+ *
4
+ * Two halves, following Mastodon's account deletion:
5
+ *
6
+ * 1. Once, the app sends `Delete{actor}`: the actor deletes itself, signed with
7
+ * its own key, to every follower's (shared) inbox. That is
8
+ * `DeliveryService.federateActorDelete`, built by {@link buildActorDeleteActivity}.
9
+ * 2. From then on the actor's URLs answer `410 Gone` instead of `404`. That is
10
+ * what a server that missed the Delete, or never acts on one, reads as
11
+ * permanent: Mastodon deletes its copy of an account whose WebFinger answers
12
+ * 410, and Lemmy, PieFed and Mbin mark a community or person deleted when a
13
+ * refetch answers 410 or a `Tombstone`. A 404 tells all of them nothing, so
14
+ * they keep the ghost profile and keep delivering to it.
15
+ *
16
+ * The routers ask the app's `isActorGone(username)` only after `resolveUser`
17
+ * found nobody, so a live account is never answered as gone.
18
+ */
19
+ /** Does `username` name a local actor that was deleted? Asked only for a username that resolved to nobody. */
20
+ export type IsActorGone = (username: string) => Promise<boolean>;
21
+ /** The body of a `410 Gone` for a deleted object: a bare `Tombstone` naming it. */
22
+ export declare function goneTombstone(id: string): Record<string, unknown>;
23
+ /**
24
+ * The actor's `Delete` of itself. The id is DETERMINISTIC (no timestamp), the
25
+ * shape Mastodon sends (`<actor>#delete`), so a retried run re-sends the same
26
+ * activity and a delivery queue that dedupes on activity id + inbox sends each
27
+ * inbox one copy.
28
+ */
29
+ export declare function buildActorDeleteActivity(actorUri: string): Record<string, unknown>;
@@ -53,4 +53,9 @@ export { createWebfingerRouter, type WebfingerRouterConfig, type WebfingerSharin
53
53
  * The ActivityPub actor + inbox + follow-graph router (actor GET incl. the
54
54
  * `instance` actor, inbox POST with HTTP-sig verify, followers/following pages).
55
55
  */
56
+ /**
57
+ * A deleted local actor: its `Delete` of itself, and the `Tombstone` its URLs
58
+ * answer `410 Gone` with afterwards.
59
+ */
60
+ export { buildActorDeleteActivity, goneTombstone, type IsActorGone } from './goneActor';
56
61
  export { createActorRouter, type ActorRouterConfig, type ActorRouteUser, type ActorSharingState, type ActorRouterLogger, type FollowPage, } from './actorRouter';
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import { Router } from 'express';
15
15
  import type { UrlBuilders } from '../urls';
16
+ import type { IsActorGone } from './goneActor';
16
17
  /** The tri-state consent read for a username with no already-resolved user object. */
17
18
  export type WebfingerSharingState = 'enabled' | 'disabled' | 'unknown-user' | 'unavailable';
18
19
  /** The resolved-user fields the webfinger consent fallback reads. */
@@ -55,6 +56,12 @@ export interface WebfingerRouterConfig {
55
56
  get(username: string): Promise<WebfingerJrd | null>;
56
57
  set(username: string, jrd: WebfingerJrd): void;
57
58
  };
59
+ /**
60
+ * Whether a username that resolved to nobody names a DELETED local actor
61
+ * (`goneActor.ts`). True ⇒ `410 Gone` instead of 404, which is what makes
62
+ * Mastodon delete its copy of the account. Omitted ⇒ every miss is a 404.
63
+ */
64
+ isActorGone?: IsActorGone;
58
65
  /** HTTP response TTL in whole seconds, 0..86400. Defaults to 3600; 0 sends no-store. */
59
66
  httpCacheTtlSeconds?: number;
60
67
  /** Diagnostics sink. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/federation",
3
- "version": "2.7.0",
3
+ "version": "2.9.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",
@@ -222,6 +222,7 @@ describe('actor type by Oxy account kind', () => {
222
222
  expect(community.attributedTo).toBe('https://mention.earth/ap/users/nate/moderators');
223
223
  expect(community.moderators).toBe(community.attributedTo);
224
224
  expect(community.postingRestrictedToMods).toBe(false);
225
+ expect(community.sensitive).toBe(false);
225
226
  // Read back by the same table: an open Group is a community, not a channel.
226
227
  expect(accountKindForActor(community)).toBe('community');
227
228
  const person = buildActor(PARAMS);
@@ -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',
@@ -6,6 +6,8 @@ import { createInboundDispatcher } from '../node/inboundDispatch';
6
6
  import { createWebfingerRouter, type WebfingerRouterConfig } from '../node/webfingerRouter';
7
7
  import { createActorRouter, type ActorRouterConfig } from '../node/actorRouter';
8
8
  import { createUrlBuilders } from '../urls';
9
+ import { AP_CONTEXT } from '../apContext';
10
+ import { groupOutboxCollection } from '../groupCollections';
9
11
  import { signRequest, type HttpSignatureSigner } from '../httpSignature';
10
12
 
11
13
  /**
@@ -210,6 +212,16 @@ describe('webfinger router', () => {
210
212
  expect(res.body.links[0].href).toBe(urls.actor('alice'));
211
213
  });
212
214
 
215
+ it('answers 410 for a deleted actor, and still 404 for an unknown one', async () => {
216
+ const app = makeWebfingerApp({ isActorGone: async (username) => username === 'gone' });
217
+ const gone = await request(app).get('/.well-known/webfinger?resource=acct:gone@mention.earth');
218
+ expect(gone.status).toBe(410);
219
+ const unknown = await request(app).get(
220
+ '/.well-known/webfinger?resource=acct:ghost@mention.earth',
221
+ );
222
+ expect(unknown.status).toBe(404);
223
+ });
224
+
213
225
  it('404s a sharing-disabled user indistinguishably', async () => {
214
226
  const app = makeWebfingerApp({
215
227
  consent: {
@@ -589,6 +601,35 @@ describe('actor router — inbox POST', () => {
589
601
  });
590
602
  });
591
603
 
604
+ describe('actor router — a deleted actor', () => {
605
+ it('answers 410 with a Tombstone on the actor, its inbox and collections; an unknown user stays 404', async () => {
606
+ const app = makeActorApp({
607
+ actorConfig: { isActorGone: async (username) => username === 'gone' },
608
+ });
609
+ const actor = await request(app)
610
+ .get('/ap/users/gone')
611
+ .set('Accept', 'application/activity+json');
612
+ expect(actor.status).toBe(410);
613
+ expect(actor.body).toMatchObject({ id: urls.actor('gone'), type: 'Tombstone' });
614
+
615
+ const inbox = await request(app)
616
+ .post('/ap/users/gone/inbox')
617
+ .set('Content-Type', 'application/activity+json')
618
+ .send({ type: 'Follow' });
619
+ expect(inbox.status).toBe(410);
620
+
621
+ const followers = await request(app)
622
+ .get('/ap/users/gone/followers')
623
+ .set('Accept', 'application/activity+json');
624
+ expect(followers.status).toBe(410);
625
+
626
+ const unknown = await request(app)
627
+ .get('/ap/users/ghost')
628
+ .set('Accept', 'application/activity+json');
629
+ expect(unknown.status).toBe(404);
630
+ });
631
+ });
632
+
592
633
  describe('community moderators collection', () => {
593
634
  const moderatorsApp = () =>
594
635
  makeActorApp({
@@ -629,4 +670,35 @@ describe('community moderators collection', () => {
629
670
  const res = await request(moderatorsApp()).get('/ap/users/alice/moderators');
630
671
  expect(res.status).toBe(404);
631
672
  });
673
+
674
+ // Lemmy's `GroupFollowers { type: Collection, totalItems, items }` and
675
+ // `GroupOutbox { type: OrderedCollection, totalItems, orderedItems }`: no
676
+ // paging, items inline. A Person keeps the paged shape Mastodon walks.
677
+ it("serves a Group's followers and outbox in the shapes Lemmy deserializes", async () => {
678
+ const followers = await request(moderatorsApp()).get('/ap/users/forum/followers?page=true');
679
+ expect(followers.body).toEqual({
680
+ '@context': AP_CONTEXT,
681
+ id: urls.followers('forum'),
682
+ type: 'Collection',
683
+ totalItems: 3,
684
+ items: [],
685
+ });
686
+ const announce = { id: `${urls.actor('forum')}/relays/1`, type: 'Announce' };
687
+ expect(
688
+ groupOutboxCollection({
689
+ id: urls.outbox('forum'),
690
+ totalItems: 1,
691
+ orderedItems: [{ '@context': AP_CONTEXT, ...announce }],
692
+ }),
693
+ ).toEqual({
694
+ '@context': AP_CONTEXT,
695
+ id: urls.outbox('forum'),
696
+ type: 'OrderedCollection',
697
+ totalItems: 1,
698
+ orderedItems: [announce],
699
+ });
700
+
701
+ const person = await request(moderatorsApp()).get('/ap/users/alice/followers');
702
+ expect(person.body).toMatchObject({ type: 'OrderedCollection', first: expect.any(String) });
703
+ });
632
704
  });
@@ -400,11 +400,14 @@ export function createLocalActorBuilder(config: LocalActorBuilderConfig): LocalA
400
400
  // `attributedTo` (Lemmy, PieFed; Mbin reads `moderators`) and says anybody
401
401
  // may post into it. PeerTube refuses a `Group` without `attributedTo`, and a
402
402
  // `Group` claiming `postingRestrictedToMods` is read as a channel by every
403
- // implementation, ours included ({@link accountKindForActor}).
403
+ // implementation, ours included ({@link accountKindForActor}). `sensitive`
404
+ // is Lemmy's NSFW flag on a community, sent as Lemmy sends it; an Oxy
405
+ // community has no NSFW setting.
404
406
  if (actorObject.type === LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND.community) {
405
407
  actorObject.attributedTo = config.urls.moderators(username);
406
408
  actorObject.moderators = config.urls.moderators(username);
407
409
  actorObject.postingRestrictedToMods = false;
410
+ actorObject.sensitive = false;
408
411
  }
409
412
 
410
413
  const aliases = normalizeAlsoKnownAs(params.alsoKnownAs);
@@ -0,0 +1,67 @@
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
+
21
+ import { LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND, localActorTypeForAccountKind } from './actorObject';
22
+ import { AP_CONTEXT } from './apContext';
23
+
24
+ /** How many of its latest `Announce`s a `Group`'s outbox lists inline (Lemmy's own size). */
25
+ export const GROUP_OUTBOX_SIZE = 50;
26
+
27
+ /**
28
+ * Whether a local account of this kind is announced as a `Group`
29
+ * ({@link LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND}) and so serves the Group
30
+ * collections: moderators, and the outbox and followers in the shapes above.
31
+ */
32
+ export function isGroupActorKind(kind: unknown): boolean {
33
+ return localActorTypeForAccountKind(kind) === LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND.community;
34
+ }
35
+
36
+ /**
37
+ * A `Group`'s outbox: one unpaged `OrderedCollection` of its latest
38
+ * `Announce { Create }` activities, newest first. Each item's own `@context` is
39
+ * dropped; the collection's covers it.
40
+ */
41
+ export function groupOutboxCollection(params: {
42
+ id: string;
43
+ totalItems: number;
44
+ orderedItems: readonly Record<string, unknown>[];
45
+ }): Record<string, unknown> {
46
+ return {
47
+ '@context': AP_CONTEXT,
48
+ id: params.id,
49
+ type: 'OrderedCollection',
50
+ totalItems: params.totalItems,
51
+ orderedItems: params.orderedItems.map(({ '@context': _context, ...item }) => item),
52
+ };
53
+ }
54
+
55
+ /** A `Group`'s followers: the count, and an empty `items` list. */
56
+ export function groupFollowersCollection(params: {
57
+ id: string;
58
+ totalItems: number;
59
+ }): Record<string, unknown> {
60
+ return {
61
+ '@context': AP_CONTEXT,
62
+ id: params.id,
63
+ type: 'Collection',
64
+ totalItems: params.totalItems,
65
+ items: [],
66
+ };
67
+ }
package/src/index.ts CHANGED
@@ -132,6 +132,17 @@ export {
132
132
  type ActorMediaResolver,
133
133
  } from './actorObject';
134
134
 
135
+ /**
136
+ * A local `Group`'s outbox and followers, in the shapes Lemmy serves and reads
137
+ * back; every other actor keeps the paged collections.
138
+ */
139
+ export {
140
+ GROUP_OUTBOX_SIZE,
141
+ isGroupActorKind,
142
+ groupOutboxCollection,
143
+ groupFollowersCollection,
144
+ } from './groupCollections';
145
+
135
146
  /**
136
147
  * Supported external networks, keyed by the transport a connector speaks.
137
148
  * `instagram-graph` is Meta's Graph API Business Discovery: a read-only
@@ -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,
@@ -28,11 +29,9 @@ import { discoveryCacheControl } from './httpCacheControl';
28
29
  import { verifyHttpSignature } from '../httpSignature';
29
30
  import type { UrlBuilders } from '../urls';
30
31
  import { INSTANCE_ACTOR_USERNAME, normalizeActorUsername } from '../urls';
31
- import {
32
- LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND,
33
- localActorTypeForAccountKind,
34
- type LocalActorBuilder,
35
- } from '../actorObject';
32
+ import type { LocalActorBuilder } from '../actorObject';
33
+ import { groupFollowersCollection, isGroupActorKind } from '../groupCollections';
34
+ import { goneTombstone, type IsActorGone } from './goneActor';
36
35
 
37
36
  /** Page size for the paginated followers/following collections (mirrors the outbox). */
38
37
  const FOLLOW_PAGE_SIZE = 20;
@@ -133,6 +132,13 @@ export interface ActorRouterConfig {
133
132
  * the route is not mounted; any account that is not a community 404s.
134
133
  */
135
134
  fetchModerators?(userId: string): Promise<User[]>;
135
+ /**
136
+ * Whether a username that resolved to nobody names a DELETED local actor
137
+ * (`goneActor.ts`). True ⇒ the actor, its inbox and its collections answer
138
+ * `410 Gone` (the actor with a `Tombstone`) instead of 404. Omitted ⇒ every
139
+ * miss is a 404.
140
+ */
141
+ isActorGone?: IsActorGone;
136
142
  /** Actor HTTP response TTL in whole seconds, 0..86400. Defaults to 1800; 0 sends no-store. */
137
143
  httpCacheTtlSeconds?: number;
138
144
  /** Diagnostics sink. */
@@ -178,6 +184,19 @@ export function createActorRouter(config: ActorRouterConfig): Router {
178
184
  return config.wantsActivityPub(req.headers.accept);
179
185
  }
180
186
 
187
+ /**
188
+ * The answer for a username nobody resolved to: 410 with the actor's
189
+ * `Tombstone` when it is a deleted actor, else the usual 404.
190
+ */
191
+ async function userMissing(res: Response, username: string): Promise<Response> {
192
+ if (config.isActorGone && (await config.isActorGone(username))) {
193
+ res.set('Content-Type', apContentType);
194
+ res.set('Cache-Control', httpCacheControl);
195
+ return res.status(410).json(goneTombstone(urls.actor(username)));
196
+ }
197
+ return res.status(404).json({ error: 'User not found' });
198
+ }
199
+
181
200
  /** Common inbox handler with HTTP signature verification. */
182
201
  async function handleInbox(req: InboxRequest, res: Response): Promise<Response> {
183
202
  try {
@@ -249,7 +268,8 @@ export function createActorRouter(config: ActorRouterConfig): Router {
249
268
 
250
269
  /**
251
270
  * Serve a user's followers OR following as a paginated `OrderedCollection` over
252
- * the authoritative Oxy follow graph (local + bridged federated edges).
271
+ * the authoritative Oxy follow graph (local + bridged federated edges). A
272
+ * `Group`'s followers are the unpaged, itemless `Collection` Lemmy reads.
253
273
  */
254
274
  async function serveFollowCollection(
255
275
  req: Request,
@@ -264,7 +284,7 @@ export function createActorRouter(config: ActorRouterConfig): Router {
264
284
 
265
285
  try {
266
286
  const user = await config.resolveUser(username);
267
- if (!user) return res.status(404).json({ error: 'User not found' });
287
+ if (!user) return userMissing(res, username);
268
288
 
269
289
  if (!config.consent.isSharingEnabledFromUser(user)) {
270
290
  return res.status(404).json({ error: 'User not found' });
@@ -276,7 +296,8 @@ export function createActorRouter(config: ActorRouterConfig): Router {
276
296
  direction === 'followers' ? user._count?.followers : user._count?.following;
277
297
  const profileTotal = typeof rawCount === 'number' ? rawCount : undefined;
278
298
 
279
- if (!page) {
299
+ const groupFollowers = direction === 'followers' && isGroupActorKind(user.kind);
300
+ if (!page || groupFollowers) {
280
301
  // Prefer the profile `_count`; only when absent (the rare search fallback)
281
302
  // fetch the authoritative total from a minimal graph list call. Fail-soft
282
303
  // to 0 — never 500 the summary.
@@ -294,6 +315,9 @@ export function createActorRouter(config: ActorRouterConfig): Router {
294
315
  }
295
316
 
296
317
  res.set('Content-Type', apContentType);
318
+ if (groupFollowers) {
319
+ return res.json(groupFollowersCollection({ id: collectionUrl(username), totalItems }));
320
+ }
297
321
  return res.json({
298
322
  '@context': AP_CONTEXT,
299
323
  id: collectionUrl(username),
@@ -403,7 +427,7 @@ export function createActorRouter(config: ActorRouterConfig): Router {
403
427
  }
404
428
 
405
429
  const user = await config.resolveUser(username);
406
- if (!user) return res.status(404).json({ error: 'User not found' });
430
+ if (!user) return userMissing(res, username);
407
431
 
408
432
  // Sharing OFF must be indistinguishable from a nonexistent user — same 404
409
433
  // body, no separate error code. Derived from the already-resolved user.
@@ -457,9 +481,12 @@ export function createActorRouter(config: ActorRouterConfig): Router {
457
481
  // server drop it permanently rather than retry, so availability wins over
458
482
  // gating freshness — the activity is processed and any consent decision is
459
483
  // re-checked downstream by the id-based (fail-open) gates.
484
+ // A DELETED actor's inbox refuses delivery with 410, so the sender stops
485
+ // rather than treating it as a transient miss.
460
486
  const username = getUsername(req);
461
487
  const sharingState = await config.consent.getSharingStateByUsername(username);
462
- if (sharingState === 'disabled' || sharingState === 'unknown-user') {
488
+ if (sharingState === 'unknown-user') return userMissing(res, username);
489
+ if (sharingState === 'disabled') {
463
490
  return res.status(404).json({ error: 'User not found' });
464
491
  }
465
492
 
@@ -492,11 +519,8 @@ export function createActorRouter(config: ActorRouterConfig): Router {
492
519
  const username = getUsername(req);
493
520
  try {
494
521
  const user = await config.resolveUser(username);
495
- if (
496
- !user ||
497
- !config.consent.isSharingEnabledFromUser(user) ||
498
- localActorTypeForAccountKind(user.kind) !== LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND.community
499
- ) {
522
+ if (!user) return userMissing(res, username);
523
+ if (!config.consent.isSharingEnabledFromUser(user) || !isGroupActorKind(user.kind)) {
500
524
  return res.status(404).json({ error: 'User not found' });
501
525
  }
502
526
  const moderators = await fetchModerators(String(user._id || user.id));
@@ -30,6 +30,7 @@ import { readBodyChunks } from './boundedBody';
30
30
  import { signRequest, type HttpSignatureSigner } from '../httpSignature';
31
31
  import type { UrlBuilders } from '../urls';
32
32
  import type { LocalActorBuilder } from '../actorObject';
33
+ import { buildActorDeleteActivity } from './goneActor';
33
34
 
34
35
  /** Total time budget for a single delivery POST (connect + response headers). */
35
36
  const DELIVER_ACTIVITY_TIMEOUT_MS = 15000;
@@ -360,6 +361,15 @@ export interface DeliveryService {
360
361
  ): Promise<void>;
361
362
  /** Rebroadcast the FULL actor document as an Update(Person) to remote followers. */
362
363
  federateActorUpdate(actorOxyUserId: string, username: string): Promise<void>;
364
+ /**
365
+ * Tell every remote follower the actor is gone: its `Delete` of itself
366
+ * (`goneActor.ts`), one per (shared) inbox, signed with its own key. Needs the
367
+ * accepted follower rows, so call it BEFORE deleting them. Asks no consent and
368
+ * resolves no profile, since the account is already gone at its identity
369
+ * provider; it needs only the handle and the key the handle addresses. Throws
370
+ * when it cannot queue, so the caller's job retries.
371
+ */
372
+ federateActorDelete(actorOxyUserId: string, username: string): Promise<void>;
363
373
  }
364
374
 
365
375
  /** Build the outbound delivery + follow-lifecycle service from an app's adapters. */
@@ -850,6 +860,15 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
850
860
  }
851
861
  }
852
862
 
863
+ async function federateActorDelete(actorOxyUserId: string, username: string): Promise<void> {
864
+ if (!config.federationEnabled) return;
865
+ await deliverToFollowers(
866
+ buildActorDeleteActivity(urls.actor(username)),
867
+ actorOxyUserId,
868
+ username,
869
+ );
870
+ }
871
+
853
872
  return {
854
873
  deliverActivity,
855
874
  queueDelivery,
@@ -860,5 +879,6 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
860
879
  sendAccept,
861
880
  sendReject,
862
881
  federateActorUpdate,
882
+ federateActorDelete,
863
883
  };
864
884
  }