@oxy.so/federation 2.5.1 → 2.6.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/urls.js +1 -0
- package/dist/esm/actorObject.js +10 -0
- package/dist/esm/node/actorRouter.js +39 -1
- 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/urls.d.ts +5 -0
- package/package.json +1 -1
- package/src/__tests__/actorObject.test.ts +13 -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/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
|
}
|
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
|
}
|
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. */
|
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.6.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());
|
|
@@ -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/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
|
}
|