@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.
- package/dist/cjs/actorObject.js +10 -0
- package/dist/cjs/node/actorRouter.js +39 -1
- package/dist/cjs/node/delivery.js +15 -7
- package/dist/cjs/node/inboundDispatch.js +8 -0
- package/dist/cjs/urls.js +1 -0
- package/dist/esm/actorObject.js +10 -0
- package/dist/esm/node/actorRouter.js +39 -1
- package/dist/esm/node/delivery.js +15 -7
- package/dist/esm/node/inboundDispatch.js +8 -0
- package/dist/esm/urls.js +1 -0
- package/dist/types/index.d.ts +6 -0
- package/dist/types/node/actorRouter.d.ts +9 -2
- package/dist/types/node/delivery.d.ts +2 -0
- package/dist/types/node/inboundDispatch.d.ts +11 -1
- package/dist/types/urls.d.ts +5 -0
- package/package.json +1 -1
- package/src/__tests__/actorObject.test.ts +13 -0
- package/src/__tests__/inboundDispatch.test.ts +24 -0
- package/src/__tests__/routers.test.ts +42 -0
- package/src/actorObject.ts +11 -0
- package/src/index.ts +6 -0
- package/src/node/actorRouter.ts +50 -2
- package/src/node/delivery.ts +47 -7
- package/src/node/inboundDispatch.ts +25 -1
- package/src/urls.ts +6 -0
package/dist/cjs/actorObject.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
362
|
-
//
|
|
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
|
|
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
|
|
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}
|
|
376
|
-
type
|
|
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
|
}
|
package/dist/esm/actorObject.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
357
|
-
//
|
|
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
|
|
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
|
|
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}
|
|
371
|
-
type
|
|
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
|
}
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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
|
|
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
|
package/dist/types/urls.d.ts
CHANGED
|
@@ -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.
|
|
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
|
+
});
|
package/src/actorObject.ts
CHANGED
|
@@ -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
|
/**
|
package/src/node/actorRouter.ts
CHANGED
|
@@ -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
|
|
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
|
}
|
package/src/node/delivery.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
729
|
-
//
|
|
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
|
|
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
|
|
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}
|
|
747
|
-
type
|
|
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
|
}
|