@oxy.so/federation 2.5.1 → 2.7.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.
@@ -283,6 +283,16 @@ function createLocalActorBuilder(config) {
283
283
  publicKeyPem: publicKey.publicKeyPem,
284
284
  },
285
285
  };
286
+ // A community is a FEP-1b12 `Group`: it names its moderators collection in
287
+ // `attributedTo` (Lemmy, PieFed; Mbin reads `moderators`) and says anybody
288
+ // may post into it. PeerTube refuses a `Group` without `attributedTo`, and a
289
+ // `Group` claiming `postingRestrictedToMods` is read as a channel by every
290
+ // implementation, ours included ({@link accountKindForActor}).
291
+ if (actorObject.type === exports.LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND.community) {
292
+ actorObject.attributedTo = config.urls.moderators(username);
293
+ actorObject.moderators = config.urls.moderators(username);
294
+ actorObject.postingRestrictedToMods = false;
295
+ }
286
296
  const aliases = normalizeAlsoKnownAs(params.alsoKnownAs);
287
297
  if (aliases.length > 0) {
288
298
  actorObject.alsoKnownAs = aliases;
@@ -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,
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,
15
16
  * mounted on the SAME `/ap/users/:username/*` prefix the actor advertises.
@@ -26,6 +27,7 @@ const apContext_1 = require("../apContext");
26
27
  const httpCacheControl_1 = require("./httpCacheControl");
27
28
  const httpSignature_1 = require("../httpSignature");
28
29
  const urls_1 = require("../urls");
30
+ const actorObject_1 = require("../actorObject");
29
31
  /** Page size for the paginated followers/following collections (mirrors the outbox). */
30
32
  const FOLLOW_PAGE_SIZE = 20;
31
33
  /** Extract the `:username` param safely as a string. */
@@ -314,5 +316,41 @@ function createActorRouter(config) {
314
316
  router.get('/users/:username/followers', (req, res) => serveFollowCollection(req, res, 'followers', urls.followers));
315
317
  // GET /ap/users/:username/following — Following collection (Oxy graph: local + federated).
316
318
  router.get('/users/:username/following', (req, res) => serveFollowCollection(req, res, 'following', urls.following));
319
+ // GET /ap/users/:username/moderators — a community `Group`'s moderators, the
320
+ // collection its `attributedTo` names. One unpaged `OrderedCollection`, the
321
+ // shape Lemmy and PieFed serve and read back.
322
+ const { fetchModerators } = config;
323
+ if (fetchModerators) {
324
+ router.get('/users/:username/moderators', async (req, res) => {
325
+ if (!config.federationEnabled)
326
+ return res.status(404).json({ error: 'Federation disabled' });
327
+ const username = getUsername(req);
328
+ try {
329
+ 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) {
333
+ return res.status(404).json({ error: 'User not found' });
334
+ }
335
+ const moderators = await fetchModerators(String(user._id || user.id));
336
+ const orderedItems = moderators
337
+ .map((member) => memberActorUri(member, urls))
338
+ .filter((uri) => uri !== null);
339
+ res.set('Content-Type', apContentType);
340
+ res.set('Cache-Control', httpCacheControl);
341
+ return res.json({
342
+ '@context': apContext_1.AP_CONTEXT,
343
+ id: urls.moderators(username),
344
+ type: 'OrderedCollection',
345
+ totalItems: orderedItems.length,
346
+ orderedItems,
347
+ });
348
+ }
349
+ catch (err) {
350
+ logger.error('Moderators collection endpoint error:', err);
351
+ return res.status(500).json({ error: 'Internal server error' });
352
+ }
353
+ });
354
+ }
317
355
  return router;
318
356
  }
@@ -353,27 +353,28 @@ function createDeliveryService(config) {
353
353
  }
354
354
  return true;
355
355
  }
356
- async function sendAccept(localOxyUserId, localUsername, followActivityId, remoteActorUri) {
356
+ /** Answer a remote Follow: `Accept` or `Reject`, one envelope for both. */
357
+ async function sendFollowResponse(type, localOxyUserId, localUsername, followActivityId, remoteActorUri) {
357
358
  const actor = await config.store.findActorByUri(remoteActorUri);
358
359
  if (!actor)
359
360
  return;
360
361
  // `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.
362
+ // are answering always has one. When neither inbox is known, skip the
363
+ // outbound delivery.
363
364
  const targetInbox = actor.sharedInboxUrl ?? actor.inboxUrl;
364
365
  if (!targetInbox) {
365
- logger.warn(`[FedSync] cannot send Accept(Follow) to ${remoteActorUri}: actor has no inbox`);
366
+ logger.warn(`[FedSync] cannot send ${type}(Follow) to ${remoteActorUri}: actor has no inbox`);
366
367
  return;
367
368
  }
368
369
  if (isBlockedUrl(targetInbox) || isBlockedUrl(remoteActorUri)) {
369
- logger.warn(`[FedDeliver] refusing Accept(Follow) delivery to blocked origin ${remoteActorUri}`);
370
+ logger.warn(`[FedDeliver] refusing ${type}(Follow) delivery to blocked origin ${remoteActorUri}`);
370
371
  return;
371
372
  }
372
373
  const localActorUri = urls.actor(localUsername);
373
374
  const activity = {
374
375
  '@context': apContext_1.AP_CONTEXT,
375
- id: `${localActorUri}/accepts/${Date.now()}`,
376
- type: 'Accept',
376
+ id: `${localActorUri}/${type === 'Accept' ? 'accepts' : 'rejects'}/${Date.now()}`,
377
+ type,
377
378
  actor: localActorUri,
378
379
  object: {
379
380
  id: followActivityId,
@@ -387,6 +388,12 @@ function createDeliveryService(config) {
387
388
  await queueDelivery(activity, targetInbox, localOxyUserId);
388
389
  }
389
390
  }
391
+ function sendAccept(localOxyUserId, localUsername, followActivityId, remoteActorUri) {
392
+ return sendFollowResponse('Accept', localOxyUserId, localUsername, followActivityId, remoteActorUri);
393
+ }
394
+ function sendReject(localOxyUserId, localUsername, followActivityId, remoteActorUri) {
395
+ return sendFollowResponse('Reject', localOxyUserId, localUsername, followActivityId, remoteActorUri);
396
+ }
390
397
  async function federateActorUpdate(actorOxyUserId, username) {
391
398
  if (!config.federationEnabled)
392
399
  return;
@@ -440,6 +447,7 @@ function createDeliveryService(config) {
440
447
  sendFollow,
441
448
  sendUndoFollow,
442
449
  sendAccept,
450
+ sendReject,
443
451
  federateActorUpdate,
444
452
  };
445
453
  }
@@ -150,6 +150,14 @@ function createInboundDispatcher(config) {
150
150
  logger.debug(`[Federation] ignoring self-follow from ${actorUri} to ${username}`);
151
151
  return;
152
152
  }
153
+ if (config.refusesFollower && (await config.refusesFollower(localUserId, followerOxyUserId))) {
154
+ if (!config.delivery.sendReject) {
155
+ throw new Error('refusesFollower is wired without delivery.sendReject');
156
+ }
157
+ await config.delivery.sendReject(localUserId, username, String(activity.id), actorUri);
158
+ logger.info(`Rejected follow from ${actorUri} to ${username}`);
159
+ return;
160
+ }
153
161
  // Create the Oxy follow edge BEFORE sending Accept so a retry never spams
154
162
  // Accepts: the bridge is idempotent (safe to re-run), but an Accept delivered
155
163
  // before the edge was committed could be re-sent on every retry. On failure
package/dist/cjs/urls.js CHANGED
@@ -35,6 +35,7 @@ function createUrlBuilders(domain, actorDomain = domain) {
35
35
  featured: (username) => `https://${domain}/ap/users/${username}/collections/featured`,
36
36
  followers: (username) => `https://${domain}/ap/users/${username}/followers`,
37
37
  following: (username) => `https://${domain}/ap/users/${username}/following`,
38
+ moderators: (username) => `https://${domain}/ap/users/${username}/moderators`,
38
39
  sharedInbox: () => `https://${domain}/ap/inbox`,
39
40
  };
40
41
  }
@@ -275,6 +275,16 @@ export function createLocalActorBuilder(config) {
275
275
  publicKeyPem: publicKey.publicKeyPem,
276
276
  },
277
277
  };
278
+ // A community is a FEP-1b12 `Group`: it names its moderators collection in
279
+ // `attributedTo` (Lemmy, PieFed; Mbin reads `moderators`) and says anybody
280
+ // may post into it. PeerTube refuses a `Group` without `attributedTo`, and a
281
+ // `Group` claiming `postingRestrictedToMods` is read as a channel by every
282
+ // implementation, ours included ({@link accountKindForActor}).
283
+ if (actorObject.type === LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND.community) {
284
+ actorObject.attributedTo = config.urls.moderators(username);
285
+ actorObject.moderators = config.urls.moderators(username);
286
+ actorObject.postingRestrictedToMods = false;
287
+ }
278
288
  const aliases = normalizeAlsoKnownAs(params.alsoKnownAs);
279
289
  if (aliases.length > 0) {
280
290
  actorObject.alsoKnownAs = aliases;
@@ -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,
12
+ * - `GET /users/:username/moderators` — a community `Group`'s moderators.
12
13
  *
13
14
  * The CONTENT routes (`outbox`, `featured`, per-post dereference) stay in the app,
14
15
  * mounted on the SAME `/ap/users/:username/*` prefix the actor advertises.
@@ -23,6 +24,7 @@ import { AP_CONTEXT } from '../apContext.js';
23
24
  import { discoveryCacheControl } from './httpCacheControl.js';
24
25
  import { verifyHttpSignature } from '../httpSignature.js';
25
26
  import { INSTANCE_ACTOR_USERNAME, normalizeActorUsername } from '../urls.js';
27
+ import { LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND, localActorTypeForAccountKind, } from '../actorObject.js';
26
28
  /** Page size for the paginated followers/following collections (mirrors the outbox). */
27
29
  const FOLLOW_PAGE_SIZE = 20;
28
30
  /** Extract the `:username` param safely as a string. */
@@ -311,5 +313,41 @@ export function createActorRouter(config) {
311
313
  router.get('/users/:username/followers', (req, res) => serveFollowCollection(req, res, 'followers', urls.followers));
312
314
  // GET /ap/users/:username/following — Following collection (Oxy graph: local + federated).
313
315
  router.get('/users/:username/following', (req, res) => serveFollowCollection(req, res, 'following', urls.following));
316
+ // GET /ap/users/:username/moderators — a community `Group`'s moderators, the
317
+ // collection its `attributedTo` names. One unpaged `OrderedCollection`, the
318
+ // shape Lemmy and PieFed serve and read back.
319
+ const { fetchModerators } = config;
320
+ if (fetchModerators) {
321
+ router.get('/users/:username/moderators', async (req, res) => {
322
+ if (!config.federationEnabled)
323
+ return res.status(404).json({ error: 'Federation disabled' });
324
+ const username = getUsername(req);
325
+ try {
326
+ 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) {
330
+ return res.status(404).json({ error: 'User not found' });
331
+ }
332
+ const moderators = await fetchModerators(String(user._id || user.id));
333
+ const orderedItems = moderators
334
+ .map((member) => memberActorUri(member, urls))
335
+ .filter((uri) => uri !== null);
336
+ res.set('Content-Type', apContentType);
337
+ res.set('Cache-Control', httpCacheControl);
338
+ return res.json({
339
+ '@context': AP_CONTEXT,
340
+ id: urls.moderators(username),
341
+ type: 'OrderedCollection',
342
+ totalItems: orderedItems.length,
343
+ orderedItems,
344
+ });
345
+ }
346
+ catch (err) {
347
+ logger.error('Moderators collection endpoint error:', err);
348
+ return res.status(500).json({ error: 'Internal server error' });
349
+ }
350
+ });
351
+ }
314
352
  return router;
315
353
  }
@@ -348,27 +348,28 @@ export function createDeliveryService(config) {
348
348
  }
349
349
  return true;
350
350
  }
351
- async function sendAccept(localOxyUserId, localUsername, followActivityId, remoteActorUri) {
351
+ /** Answer a remote Follow: `Accept` or `Reject`, one envelope for both. */
352
+ async function sendFollowResponse(type, localOxyUserId, localUsername, followActivityId, remoteActorUri) {
352
353
  const actor = await config.store.findActorByUri(remoteActorUri);
353
354
  if (!actor)
354
355
  return;
355
356
  // `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.
357
+ // are answering always has one. When neither inbox is known, skip the
358
+ // outbound delivery.
358
359
  const targetInbox = actor.sharedInboxUrl ?? actor.inboxUrl;
359
360
  if (!targetInbox) {
360
- logger.warn(`[FedSync] cannot send Accept(Follow) to ${remoteActorUri}: actor has no inbox`);
361
+ logger.warn(`[FedSync] cannot send ${type}(Follow) to ${remoteActorUri}: actor has no inbox`);
361
362
  return;
362
363
  }
363
364
  if (isBlockedUrl(targetInbox) || isBlockedUrl(remoteActorUri)) {
364
- logger.warn(`[FedDeliver] refusing Accept(Follow) delivery to blocked origin ${remoteActorUri}`);
365
+ logger.warn(`[FedDeliver] refusing ${type}(Follow) delivery to blocked origin ${remoteActorUri}`);
365
366
  return;
366
367
  }
367
368
  const localActorUri = urls.actor(localUsername);
368
369
  const activity = {
369
370
  '@context': AP_CONTEXT,
370
- id: `${localActorUri}/accepts/${Date.now()}`,
371
- type: 'Accept',
371
+ id: `${localActorUri}/${type === 'Accept' ? 'accepts' : 'rejects'}/${Date.now()}`,
372
+ type,
372
373
  actor: localActorUri,
373
374
  object: {
374
375
  id: followActivityId,
@@ -382,6 +383,12 @@ export function createDeliveryService(config) {
382
383
  await queueDelivery(activity, targetInbox, localOxyUserId);
383
384
  }
384
385
  }
386
+ function sendAccept(localOxyUserId, localUsername, followActivityId, remoteActorUri) {
387
+ return sendFollowResponse('Accept', localOxyUserId, localUsername, followActivityId, remoteActorUri);
388
+ }
389
+ function sendReject(localOxyUserId, localUsername, followActivityId, remoteActorUri) {
390
+ return sendFollowResponse('Reject', localOxyUserId, localUsername, followActivityId, remoteActorUri);
391
+ }
385
392
  async function federateActorUpdate(actorOxyUserId, username) {
386
393
  if (!config.federationEnabled)
387
394
  return;
@@ -435,6 +442,7 @@ export function createDeliveryService(config) {
435
442
  sendFollow,
436
443
  sendUndoFollow,
437
444
  sendAccept,
445
+ sendReject,
438
446
  federateActorUpdate,
439
447
  };
440
448
  }
@@ -145,6 +145,14 @@ export function createInboundDispatcher(config) {
145
145
  logger.debug(`[Federation] ignoring self-follow from ${actorUri} to ${username}`);
146
146
  return;
147
147
  }
148
+ if (config.refusesFollower && (await config.refusesFollower(localUserId, followerOxyUserId))) {
149
+ if (!config.delivery.sendReject) {
150
+ throw new Error('refusesFollower is wired without delivery.sendReject');
151
+ }
152
+ await config.delivery.sendReject(localUserId, username, String(activity.id), actorUri);
153
+ logger.info(`Rejected follow from ${actorUri} to ${username}`);
154
+ return;
155
+ }
148
156
  // Create the Oxy follow edge BEFORE sending Accept so a retry never spams
149
157
  // Accepts: the bridge is idempotent (safe to re-run), but an Accept delivered
150
158
  // before the edge was committed could be re-sent on every retry. On failure
package/dist/esm/urls.js CHANGED
@@ -30,6 +30,7 @@ export function createUrlBuilders(domain, actorDomain = domain) {
30
30
  featured: (username) => `https://${domain}/ap/users/${username}/collections/featured`,
31
31
  followers: (username) => `https://${domain}/ap/users/${username}/followers`,
32
32
  following: (username) => `https://${domain}/ap/users/${username}/following`,
33
+ moderators: (username) => `https://${domain}/ap/users/${username}/moderators`,
33
34
  sharedInbox: () => `https://${domain}/ap/inbox`,
34
35
  };
35
36
  }
@@ -226,6 +226,12 @@ export interface LocalBoostEventPayload {
226
226
  */
227
227
  export interface LocalDeleteEventPayload {
228
228
  _id: unknown;
229
+ /**
230
+ * The community (Oxy account id) the post was written in, captured with the
231
+ * id before the row is gone, so a community that relayed the post (FEP-1b12)
232
+ * can relay its deletion to the same followers. Absent for an ordinary post.
233
+ */
234
+ communityOxyUserId?: string | null;
229
235
  }
230
236
  /**
231
237
  * The shape a `post.like` / `post.unlike` event carries to outbound delivery.
@@ -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,
12
+ * - `GET /users/:username/moderators` — a community `Group`'s moderators.
12
13
  *
13
14
  * The CONTENT routes (`outbox`, `featured`, per-post dereference) stay in the app,
14
15
  * mounted on the SAME `/ap/users/:username/*` prefix the actor advertises.
@@ -22,7 +23,7 @@ import { Router } from 'express';
22
23
  import type { AccountKind } from '@oxy.so/contracts';
23
24
  import type { User } from '@oxy.so/core';
24
25
  import type { UrlBuilders } from '../urls';
25
- import type { LocalActorBuilder } from '../actorObject';
26
+ import { type LocalActorBuilder } from '../actorObject';
26
27
  /** The resolved-user fields the actor + collection routes read. */
27
28
  export interface ActorRouteUser {
28
29
  _id?: string | null;
@@ -107,6 +108,12 @@ export interface ActorRouterConfig {
107
108
  };
108
109
  /** Fetch one page of a user's Oxy follow graph (followers OR following). */
109
110
  fetchFollowPage(userId: string, direction: 'followers' | 'following', offset: number, limit: number): Promise<FollowPage>;
111
+ /**
112
+ * A community's moderators (Oxy: its active members), served as the `Group`'s
113
+ * `attributedTo` collection at `GET /users/:username/moderators`. Omitted ⇒
114
+ * the route is not mounted; any account that is not a community 404s.
115
+ */
116
+ fetchModerators?(userId: string): Promise<User[]>;
110
117
  /** Actor HTTP response TTL in whole seconds, 0..86400. Defaults to 1800; 0 sends no-store. */
111
118
  httpCacheTtlSeconds?: number;
112
119
  /** Diagnostics sink. */
@@ -266,6 +266,8 @@ 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>;
271
273
  }
@@ -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
@@ -30,6 +30,11 @@ export interface UrlBuilders {
30
30
  followers(username: string): string;
31
31
  /** The actor's following collection. */
32
32
  following(username: string): string;
33
+ /**
34
+ * A community `Group`'s moderators collection — what its `attributedTo` names
35
+ * (Lemmy, PieFed and Mbin read the moderators from it).
36
+ */
37
+ moderators(username: string): string;
33
38
  /** The instance-wide shared inbox. */
34
39
  sharedInbox(): string;
35
40
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/federation",
3
- "version": "2.5.1",
3
+ "version": "2.7.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",
@@ -216,6 +216,19 @@ describe('actor type by Oxy account kind', () => {
216
216
  ]);
217
217
  });
218
218
 
219
+ it('describes a community as an open FEP-1b12 Group naming its moderators', () => {
220
+ const community = buildActor({ ...PARAMS, kind: 'community' });
221
+ expect(community.type).toBe('Group');
222
+ expect(community.attributedTo).toBe('https://mention.earth/ap/users/nate/moderators');
223
+ expect(community.moderators).toBe(community.attributedTo);
224
+ expect(community.postingRestrictedToMods).toBe(false);
225
+ // Read back by the same table: an open Group is a community, not a channel.
226
+ expect(accountKindForActor(community)).toBe('community');
227
+ const person = buildActor(PARAMS);
228
+ expect(person).not.toHaveProperty('attributedTo');
229
+ expect(person).not.toHaveProperty('postingRestrictedToMods');
230
+ });
231
+
219
232
  it('covers every account kind contracts defines, with more than one answer', () => {
220
233
  // Coverage: driven by the upstream vocabulary, so a new kind fails here.
221
234
  expect(Object.keys(LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND).sort()).toEqual([...ACCOUNT_KINDS].sort());
@@ -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(
@@ -588,3 +588,45 @@ describe('actor router — inbox POST', () => {
588
588
  expect(res.status).toBe(404);
589
589
  });
590
590
  });
591
+
592
+ describe('community moderators collection', () => {
593
+ const moderatorsApp = () =>
594
+ makeActorApp({
595
+ actorConfig: {
596
+ resolveUser: async (username) =>
597
+ username === 'forum'
598
+ ? { _id: 'u-forum', kind: 'community' }
599
+ : username === 'alice'
600
+ ? { _id: 'u-alice' }
601
+ : null,
602
+ fetchModerators: async (userId) =>
603
+ userId === 'u-forum'
604
+ ? [
605
+ { id: 'u-alice', username: 'alice' },
606
+ {
607
+ id: 'u-bob',
608
+ username: 'bob',
609
+ type: 'federated',
610
+ federation: { actorUri: REMOTE_ACTOR },
611
+ },
612
+ ]
613
+ : [],
614
+ },
615
+ });
616
+
617
+ it('serves a community Group its moderators as actor ids', async () => {
618
+ const res = await request(moderatorsApp()).get('/ap/users/forum/moderators');
619
+ expect(res.status).toBe(200);
620
+ expect(res.body).toMatchObject({
621
+ id: urls.moderators('forum'),
622
+ type: 'OrderedCollection',
623
+ totalItems: 2,
624
+ orderedItems: [urls.actor('alice'), REMOTE_ACTOR],
625
+ });
626
+ });
627
+
628
+ it('404s an account that is not a community', async () => {
629
+ const res = await request(moderatorsApp()).get('/ap/users/alice/moderators');
630
+ expect(res.status).toBe(404);
631
+ });
632
+ });
@@ -396,6 +396,17 @@ export function createLocalActorBuilder(config: LocalActorBuilderConfig): LocalA
396
396
  },
397
397
  };
398
398
 
399
+ // A community is a FEP-1b12 `Group`: it names its moderators collection in
400
+ // `attributedTo` (Lemmy, PieFed; Mbin reads `moderators`) and says anybody
401
+ // may post into it. PeerTube refuses a `Group` without `attributedTo`, and a
402
+ // `Group` claiming `postingRestrictedToMods` is read as a channel by every
403
+ // implementation, ours included ({@link accountKindForActor}).
404
+ if (actorObject.type === LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND.community) {
405
+ actorObject.attributedTo = config.urls.moderators(username);
406
+ actorObject.moderators = config.urls.moderators(username);
407
+ actorObject.postingRestrictedToMods = false;
408
+ }
409
+
399
410
  const aliases = normalizeAlsoKnownAs(params.alsoKnownAs);
400
411
  if (aliases.length > 0) {
401
412
  actorObject.alsoKnownAs = aliases;
package/src/index.ts CHANGED
@@ -303,6 +303,12 @@ export interface LocalBoostEventPayload {
303
303
  */
304
304
  export interface LocalDeleteEventPayload {
305
305
  _id: unknown;
306
+ /**
307
+ * The community (Oxy account id) the post was written in, captured with the
308
+ * id before the row is gone, so a community that relayed the post (FEP-1b12)
309
+ * can relay its deletion to the same followers. Absent for an ordinary post.
310
+ */
311
+ communityOxyUserId?: string | null;
306
312
  }
307
313
 
308
314
  /**
@@ -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,
12
+ * - `GET /users/:username/moderators` — a community `Group`'s moderators.
12
13
  *
13
14
  * The CONTENT routes (`outbox`, `featured`, per-post dereference) stay in the app,
14
15
  * mounted on the SAME `/ap/users/:username/*` prefix the actor advertises.
@@ -27,7 +28,11 @@ import { discoveryCacheControl } from './httpCacheControl';
27
28
  import { verifyHttpSignature } from '../httpSignature';
28
29
  import type { UrlBuilders } from '../urls';
29
30
  import { INSTANCE_ACTOR_USERNAME, normalizeActorUsername } from '../urls';
30
- import type { LocalActorBuilder } from '../actorObject';
31
+ import {
32
+ LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND,
33
+ localActorTypeForAccountKind,
34
+ type LocalActorBuilder,
35
+ } from '../actorObject';
31
36
 
32
37
  /** Page size for the paginated followers/following collections (mirrors the outbox). */
33
38
  const FOLLOW_PAGE_SIZE = 20;
@@ -122,6 +127,12 @@ export interface ActorRouterConfig {
122
127
  offset: number,
123
128
  limit: number,
124
129
  ): Promise<FollowPage>;
130
+ /**
131
+ * A community's moderators (Oxy: its active members), served as the `Group`'s
132
+ * `attributedTo` collection at `GET /users/:username/moderators`. Omitted ⇒
133
+ * the route is not mounted; any account that is not a community 404s.
134
+ */
135
+ fetchModerators?(userId: string): Promise<User[]>;
125
136
  /** Actor HTTP response TTL in whole seconds, 0..86400. Defaults to 1800; 0 sends no-store. */
126
137
  httpCacheTtlSeconds?: number;
127
138
  /** Diagnostics sink. */
@@ -471,5 +482,42 @@ export function createActorRouter(config: ActorRouterConfig): Router {
471
482
  serveFollowCollection(req, res, 'following', urls.following),
472
483
  );
473
484
 
485
+ // GET /ap/users/:username/moderators — a community `Group`'s moderators, the
486
+ // collection its `attributedTo` names. One unpaged `OrderedCollection`, the
487
+ // shape Lemmy and PieFed serve and read back.
488
+ const { fetchModerators } = config;
489
+ if (fetchModerators) {
490
+ router.get('/users/:username/moderators', async (req: Request, res: Response) => {
491
+ if (!config.federationEnabled) return res.status(404).json({ error: 'Federation disabled' });
492
+ const username = getUsername(req);
493
+ try {
494
+ 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
+ ) {
500
+ return res.status(404).json({ error: 'User not found' });
501
+ }
502
+ const moderators = await fetchModerators(String(user._id || user.id));
503
+ const orderedItems = moderators
504
+ .map((member) => memberActorUri(member, urls))
505
+ .filter((uri): uri is string => uri !== null);
506
+ res.set('Content-Type', apContentType);
507
+ res.set('Cache-Control', httpCacheControl);
508
+ return res.json({
509
+ '@context': AP_CONTEXT,
510
+ id: urls.moderators(username),
511
+ type: 'OrderedCollection',
512
+ totalItems: orderedItems.length,
513
+ orderedItems,
514
+ });
515
+ } catch (err) {
516
+ logger.error('Moderators collection endpoint error:', err);
517
+ return res.status(500).json({ error: 'Internal server error' });
518
+ }
519
+ });
520
+ }
521
+
474
522
  return router;
475
523
  }
@@ -351,6 +351,13 @@ export interface DeliveryService {
351
351
  followActivityId: string,
352
352
  remoteActorUri: string,
353
353
  ): Promise<void>;
354
+ /** Send a Reject(Follow) back to a remote actor — the same envelope as the Accept. */
355
+ sendReject(
356
+ localOxyUserId: string,
357
+ localUsername: string,
358
+ followActivityId: string,
359
+ remoteActorUri: string,
360
+ ): Promise<void>;
354
361
  /** Rebroadcast the FULL actor document as an Update(Person) to remote followers. */
355
362
  federateActorUpdate(actorOxyUserId: string, username: string): Promise<void>;
356
363
  }
@@ -716,7 +723,9 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
716
723
  return true;
717
724
  }
718
725
 
719
- async function sendAccept(
726
+ /** Answer a remote Follow: `Accept` or `Reject`, one envelope for both. */
727
+ async function sendFollowResponse(
728
+ type: 'Accept' | 'Reject',
720
729
  localOxyUserId: string,
721
730
  localUsername: string,
722
731
  followActivityId: string,
@@ -725,16 +734,16 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
725
734
  const actor = await config.store.findActorByUri(remoteActorUri);
726
735
  if (!actor) return;
727
736
  // `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.
737
+ // are answering always has one. When neither inbox is known, skip the
738
+ // outbound delivery.
730
739
  const targetInbox = actor.sharedInboxUrl ?? actor.inboxUrl;
731
740
  if (!targetInbox) {
732
- logger.warn(`[FedSync] cannot send Accept(Follow) to ${remoteActorUri}: actor has no inbox`);
741
+ logger.warn(`[FedSync] cannot send ${type}(Follow) to ${remoteActorUri}: actor has no inbox`);
733
742
  return;
734
743
  }
735
744
  if (isBlockedUrl(targetInbox) || isBlockedUrl(remoteActorUri)) {
736
745
  logger.warn(
737
- `[FedDeliver] refusing Accept(Follow) delivery to blocked origin ${remoteActorUri}`,
746
+ `[FedDeliver] refusing ${type}(Follow) delivery to blocked origin ${remoteActorUri}`,
738
747
  );
739
748
  return;
740
749
  }
@@ -743,8 +752,8 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
743
752
 
744
753
  const activity: Record<string, unknown> = {
745
754
  '@context': AP_CONTEXT,
746
- id: `${localActorUri}/accepts/${Date.now()}`,
747
- type: 'Accept',
755
+ id: `${localActorUri}/${type === 'Accept' ? 'accepts' : 'rejects'}/${Date.now()}`,
756
+ type,
748
757
  actor: localActorUri,
749
758
  object: {
750
759
  id: followActivityId,
@@ -760,6 +769,36 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
760
769
  }
761
770
  }
762
771
 
772
+ function sendAccept(
773
+ localOxyUserId: string,
774
+ localUsername: string,
775
+ followActivityId: string,
776
+ remoteActorUri: string,
777
+ ): Promise<void> {
778
+ return sendFollowResponse(
779
+ 'Accept',
780
+ localOxyUserId,
781
+ localUsername,
782
+ followActivityId,
783
+ remoteActorUri,
784
+ );
785
+ }
786
+
787
+ function sendReject(
788
+ localOxyUserId: string,
789
+ localUsername: string,
790
+ followActivityId: string,
791
+ remoteActorUri: string,
792
+ ): Promise<void> {
793
+ return sendFollowResponse(
794
+ 'Reject',
795
+ localOxyUserId,
796
+ localUsername,
797
+ followActivityId,
798
+ remoteActorUri,
799
+ );
800
+ }
801
+
763
802
  async function federateActorUpdate(actorOxyUserId: string, username: string): Promise<void> {
764
803
  if (!config.federationEnabled) return;
765
804
  if (!(await config.consent.isSharingEnabled(actorOxyUserId))) return;
@@ -819,6 +858,7 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
819
858
  sendFollow,
820
859
  sendUndoFollow,
821
860
  sendAccept,
861
+ sendReject,
822
862
  federateActorUpdate,
823
863
  };
824
864
  }
@@ -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/urls.ts CHANGED
@@ -31,6 +31,11 @@ export interface UrlBuilders {
31
31
  followers(username: string): string;
32
32
  /** The actor's following collection. */
33
33
  following(username: string): string;
34
+ /**
35
+ * A community `Group`'s moderators collection — what its `attributedTo` names
36
+ * (Lemmy, PieFed and Mbin read the moderators from it).
37
+ */
38
+ moderators(username: string): string;
34
39
  /** The instance-wide shared inbox. */
35
40
  sharedInbox(): string;
36
41
  }
@@ -49,6 +54,7 @@ export function createUrlBuilders(domain: string, actorDomain: string = domain):
49
54
  featured: (username) => `https://${domain}/ap/users/${username}/collections/featured`,
50
55
  followers: (username) => `https://${domain}/ap/users/${username}/followers`,
51
56
  following: (username) => `https://${domain}/ap/users/${username}/following`,
57
+ moderators: (username) => `https://${domain}/ap/users/${username}/moderators`,
52
58
  sharedInbox: () => `https://${domain}/ap/inbox`,
53
59
  };
54
60
  }