@oxy.so/federation 2.8.0 → 2.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -0
- package/dist/cjs/actorObject.js +4 -1
- package/dist/cjs/groupCollections.js +61 -0
- package/dist/cjs/index.js +10 -1
- package/dist/cjs/node/actorRouter.js +11 -6
- package/dist/esm/actorObject.js +4 -1
- package/dist/esm/groupCollections.js +55 -0
- package/dist/esm/index.js +5 -0
- package/dist/esm/node/actorRouter.js +11 -6
- package/dist/types/groupCollections.d.ts +42 -0
- package/dist/types/index.d.ts +5 -0
- package/dist/types/node/actorRouter.d.ts +3 -2
- package/dist/types/node/identityBridge.d.ts +8 -2
- package/package.json +2 -2
- package/src/__tests__/actorObject.test.ts +1 -0
- package/src/__tests__/routers.test.ts +33 -0
- package/src/actorObject.ts +4 -1
- package/src/groupCollections.ts +67 -0
- package/src/index.ts +11 -0
- package/src/node/actorRouter.ts +12 -12
- package/src/node/identityBridge.ts +8 -3
package/README.md
CHANGED
|
@@ -50,3 +50,18 @@ gone, the way Mastodon handles account deletion:
|
|
|
50
50
|
|
|
51
51
|
The app's own content routes (outbox, posts) can answer the same way with
|
|
52
52
|
`goneTombstone(id)`.
|
|
53
|
+
|
|
54
|
+
## Community collections
|
|
55
|
+
|
|
56
|
+
A local `Group` (an account whose kind is `community`, see
|
|
57
|
+
`isGroupActorKind`) serves its outbox and followers in the shapes Lemmy
|
|
58
|
+
deserializes, because Lemmy and PieFed read neither paging nor `first`:
|
|
59
|
+
|
|
60
|
+
- **Outbox:** one `OrderedCollection` with its latest `GROUP_OUTBOX_SIZE` (50)
|
|
61
|
+
`Announce { Create }` activities inline in `orderedItems`. The outbox is an
|
|
62
|
+
app content route; build it with `groupOutboxCollection`.
|
|
63
|
+
- **Followers:** `createActorRouter` serves a `Collection` with `totalItems` and
|
|
64
|
+
`items: []`, the count without the list, as Lemmy does
|
|
65
|
+
(`groupFollowersCollection`).
|
|
66
|
+
|
|
67
|
+
Every other actor keeps the paged `OrderedCollection` that Mastodon walks.
|
package/dist/cjs/actorObject.js
CHANGED
|
@@ -287,11 +287,14 @@ function createLocalActorBuilder(config) {
|
|
|
287
287
|
// `attributedTo` (Lemmy, PieFed; Mbin reads `moderators`) and says anybody
|
|
288
288
|
// may post into it. PeerTube refuses a `Group` without `attributedTo`, and a
|
|
289
289
|
// `Group` claiming `postingRestrictedToMods` is read as a channel by every
|
|
290
|
-
// implementation, ours included ({@link accountKindForActor}).
|
|
290
|
+
// implementation, ours included ({@link accountKindForActor}). `sensitive`
|
|
291
|
+
// is Lemmy's NSFW flag on a community, sent as Lemmy sends it; an Oxy
|
|
292
|
+
// community has no NSFW setting.
|
|
291
293
|
if (actorObject.type === exports.LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND.community) {
|
|
292
294
|
actorObject.attributedTo = config.urls.moderators(username);
|
|
293
295
|
actorObject.moderators = config.urls.moderators(username);
|
|
294
296
|
actorObject.postingRestrictedToMods = false;
|
|
297
|
+
actorObject.sensitive = false;
|
|
295
298
|
}
|
|
296
299
|
const aliases = normalizeAlsoKnownAs(params.alsoKnownAs);
|
|
297
300
|
if (aliases.length > 0) {
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* How a local `Group` (an Oxy community) publishes its outbox and followers:
|
|
4
|
+
* the shapes Lemmy itself serves and reads back, not the paged
|
|
5
|
+
* `OrderedCollection` + `first` link every other local actor serves.
|
|
6
|
+
*
|
|
7
|
+
* Lemmy deserializes a community's collections into fixed structs that know
|
|
8
|
+
* nothing of paging (`crates/apub/apub/src/protocol/collections/`):
|
|
9
|
+
* - outbox → `GroupOutbox { orderedItems: Vec<Announce> }`, `orderedItems`
|
|
10
|
+
* REQUIRED inline. A summary with only `first` fails to parse, so a server
|
|
11
|
+
* discovering the community backfills nothing. PieFed reads the same inline
|
|
12
|
+
* `orderedItems` and nothing else.
|
|
13
|
+
* - followers → `GroupFollowers { type: "Collection", totalItems, items }`,
|
|
14
|
+
* read for the subscriber count. Lemmy serves `items: []`, so the follower
|
|
15
|
+
* list stays private; this does the same.
|
|
16
|
+
*
|
|
17
|
+
* Mastodon reads `totalItems` from either shape, so a `Group` followed from
|
|
18
|
+
* Mastodon keeps its counts. `Person`, `Organization` and `Service` actors keep
|
|
19
|
+
* the paged shape Mastodon walks.
|
|
20
|
+
*/
|
|
21
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
exports.GROUP_OUTBOX_SIZE = void 0;
|
|
23
|
+
exports.isGroupActorKind = isGroupActorKind;
|
|
24
|
+
exports.groupOutboxCollection = groupOutboxCollection;
|
|
25
|
+
exports.groupFollowersCollection = groupFollowersCollection;
|
|
26
|
+
const actorObject_1 = require("./actorObject");
|
|
27
|
+
const apContext_1 = require("./apContext");
|
|
28
|
+
/** How many of its latest `Announce`s a `Group`'s outbox lists inline (Lemmy's own size). */
|
|
29
|
+
exports.GROUP_OUTBOX_SIZE = 50;
|
|
30
|
+
/**
|
|
31
|
+
* Whether a local account of this kind is announced as a `Group`
|
|
32
|
+
* ({@link LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND}) and so serves the Group
|
|
33
|
+
* collections: moderators, and the outbox and followers in the shapes above.
|
|
34
|
+
*/
|
|
35
|
+
function isGroupActorKind(kind) {
|
|
36
|
+
return (0, actorObject_1.localActorTypeForAccountKind)(kind) === actorObject_1.LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND.community;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* A `Group`'s outbox: one unpaged `OrderedCollection` of its latest
|
|
40
|
+
* `Announce { Create }` activities, newest first. Each item's own `@context` is
|
|
41
|
+
* dropped; the collection's covers it.
|
|
42
|
+
*/
|
|
43
|
+
function groupOutboxCollection(params) {
|
|
44
|
+
return {
|
|
45
|
+
'@context': apContext_1.AP_CONTEXT,
|
|
46
|
+
id: params.id,
|
|
47
|
+
type: 'OrderedCollection',
|
|
48
|
+
totalItems: params.totalItems,
|
|
49
|
+
orderedItems: params.orderedItems.map(({ '@context': _context, ...item }) => item),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** A `Group`'s followers: the count, and an empty `items` list. */
|
|
53
|
+
function groupFollowersCollection(params) {
|
|
54
|
+
return {
|
|
55
|
+
'@context': apContext_1.AP_CONTEXT,
|
|
56
|
+
id: params.id,
|
|
57
|
+
type: 'Collection',
|
|
58
|
+
totalItems: params.totalItems,
|
|
59
|
+
items: [],
|
|
60
|
+
};
|
|
61
|
+
}
|
package/dist/cjs/index.js
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* knowledge of any app's post shape.
|
|
23
23
|
*/
|
|
24
24
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
25
|
-
exports.LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND = exports.AP_ACTOR_TYPES = exports.isApActorType = exports.accountKindForActor = exports.localActorTypeForAccountKind = exports.normalizeAlsoKnownAs = exports.createLocalActorBuilder = exports.createDomainPolicy = exports.extractActorUriFromActivityId = exports.isSameFederationHost = exports.canonicalFederationHost = exports.AP_CONTEXT = exports.readProxyDeclarations = exports.upstreamHandleFromProxyOf = exports.upstreamHandleFromPreferredUsername = exports.upstreamHandleFromAutomatedActor = exports.upstreamHandleFromAlsoKnownAs = exports.upstreamHandleFromProfileField = exports.federatedUsernameFromUpstreamUrl = exports.parseUpstreamProfileUrl = exports.upstreamProfileUrl = exports.stripBridgeBoilerplate = exports.createBridgeRelabeller = exports.blueskyUsernameFromHandle = exports.BSKY_NETWORK_DOMAIN = exports.FEDERATION_NETWORKS = exports.INSTANCE_ACTOR_USERNAME = exports.normalizeActorUsername = exports.createUrlBuilders = exports.DEFAULT_SIGNED_CONTENT_TYPE = exports.HTTP_SIGNATURE_ALGORITHM = exports.verifyHttpSignature = exports.signRequest = void 0;
|
|
25
|
+
exports.groupFollowersCollection = exports.groupOutboxCollection = exports.isGroupActorKind = exports.GROUP_OUTBOX_SIZE = exports.LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND = exports.AP_ACTOR_TYPES = exports.isApActorType = exports.accountKindForActor = exports.localActorTypeForAccountKind = exports.normalizeAlsoKnownAs = exports.createLocalActorBuilder = exports.createDomainPolicy = exports.extractActorUriFromActivityId = exports.isSameFederationHost = exports.canonicalFederationHost = exports.AP_CONTEXT = exports.readProxyDeclarations = exports.upstreamHandleFromProxyOf = exports.upstreamHandleFromPreferredUsername = exports.upstreamHandleFromAutomatedActor = exports.upstreamHandleFromAlsoKnownAs = exports.upstreamHandleFromProfileField = exports.federatedUsernameFromUpstreamUrl = exports.parseUpstreamProfileUrl = exports.upstreamProfileUrl = exports.stripBridgeBoilerplate = exports.createBridgeRelabeller = exports.blueskyUsernameFromHandle = exports.BSKY_NETWORK_DOMAIN = exports.FEDERATION_NETWORKS = exports.INSTANCE_ACTOR_USERNAME = exports.normalizeActorUsername = exports.createUrlBuilders = exports.DEFAULT_SIGNED_CONTENT_TYPE = exports.HTTP_SIGNATURE_ALGORITHM = exports.verifyHttpSignature = exports.signRequest = void 0;
|
|
26
26
|
/**
|
|
27
27
|
* HTTP Signatures (draft-cavage) — the pure sign/verify crypto every Oxy app's
|
|
28
28
|
* ActivityPub federation shares. Private-key custody is injected; the key never
|
|
@@ -99,3 +99,12 @@ Object.defineProperty(exports, "accountKindForActor", { enumerable: true, get: f
|
|
|
99
99
|
Object.defineProperty(exports, "isApActorType", { enumerable: true, get: function () { return actorObject_1.isApActorType; } });
|
|
100
100
|
Object.defineProperty(exports, "AP_ACTOR_TYPES", { enumerable: true, get: function () { return actorObject_1.AP_ACTOR_TYPES; } });
|
|
101
101
|
Object.defineProperty(exports, "LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND", { enumerable: true, get: function () { return actorObject_1.LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND; } });
|
|
102
|
+
/**
|
|
103
|
+
* A local `Group`'s outbox and followers, in the shapes Lemmy serves and reads
|
|
104
|
+
* back; every other actor keeps the paged collections.
|
|
105
|
+
*/
|
|
106
|
+
var groupCollections_1 = require("./groupCollections");
|
|
107
|
+
Object.defineProperty(exports, "GROUP_OUTBOX_SIZE", { enumerable: true, get: function () { return groupCollections_1.GROUP_OUTBOX_SIZE; } });
|
|
108
|
+
Object.defineProperty(exports, "isGroupActorKind", { enumerable: true, get: function () { return groupCollections_1.isGroupActorKind; } });
|
|
109
|
+
Object.defineProperty(exports, "groupOutboxCollection", { enumerable: true, get: function () { return groupCollections_1.groupOutboxCollection; } });
|
|
110
|
+
Object.defineProperty(exports, "groupFollowersCollection", { enumerable: true, get: function () { return groupCollections_1.groupFollowersCollection; } });
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* signature verification (Phase 2, `trustForwardedHost`) and actor-match, then
|
|
10
10
|
* 202 + async dispatch to the injected inbound dispatcher,
|
|
11
11
|
* - `GET /users/:username/followers` + `/following` — the OXY follow graph
|
|
12
|
-
* (local + bridged federated edges) as paginated `OrderedCollection`s
|
|
12
|
+
* (local + bridged federated edges) as paginated `OrderedCollection`s; a
|
|
13
|
+
* community `Group`'s followers are only counted (`groupCollections.ts`),
|
|
13
14
|
* - `GET /users/:username/moderators` — a community `Group`'s moderators.
|
|
14
15
|
*
|
|
15
16
|
* The CONTENT routes (`outbox`, `featured`, per-post dereference) stay in the app,
|
|
@@ -27,7 +28,7 @@ const apContext_1 = require("../apContext");
|
|
|
27
28
|
const httpCacheControl_1 = require("./httpCacheControl");
|
|
28
29
|
const httpSignature_1 = require("../httpSignature");
|
|
29
30
|
const urls_1 = require("../urls");
|
|
30
|
-
const
|
|
31
|
+
const groupCollections_1 = require("../groupCollections");
|
|
31
32
|
const goneActor_1 = require("./goneActor");
|
|
32
33
|
/** Page size for the paginated followers/following collections (mirrors the outbox). */
|
|
33
34
|
const FOLLOW_PAGE_SIZE = 20;
|
|
@@ -134,7 +135,8 @@ function createActorRouter(config) {
|
|
|
134
135
|
}
|
|
135
136
|
/**
|
|
136
137
|
* Serve a user's followers OR following as a paginated `OrderedCollection` over
|
|
137
|
-
* the authoritative Oxy follow graph (local + bridged federated edges).
|
|
138
|
+
* the authoritative Oxy follow graph (local + bridged federated edges). A
|
|
139
|
+
* `Group`'s followers are the unpaged, itemless `Collection` Lemmy reads.
|
|
138
140
|
*/
|
|
139
141
|
async function serveFollowCollection(req, res, direction, collectionUrl) {
|
|
140
142
|
if (!config.federationEnabled)
|
|
@@ -151,7 +153,8 @@ function createActorRouter(config) {
|
|
|
151
153
|
const userId = String(user._id || user.id);
|
|
152
154
|
const rawCount = direction === 'followers' ? user._count?.followers : user._count?.following;
|
|
153
155
|
const profileTotal = typeof rawCount === 'number' ? rawCount : undefined;
|
|
154
|
-
|
|
156
|
+
const groupFollowers = direction === 'followers' && (0, groupCollections_1.isGroupActorKind)(user.kind);
|
|
157
|
+
if (!page || groupFollowers) {
|
|
155
158
|
// Prefer the profile `_count`; only when absent (the rare search fallback)
|
|
156
159
|
// fetch the authoritative total from a minimal graph list call. Fail-soft
|
|
157
160
|
// to 0 — never 500 the summary.
|
|
@@ -169,6 +172,9 @@ function createActorRouter(config) {
|
|
|
169
172
|
}
|
|
170
173
|
}
|
|
171
174
|
res.set('Content-Type', apContentType);
|
|
175
|
+
if (groupFollowers) {
|
|
176
|
+
return res.json((0, groupCollections_1.groupFollowersCollection)({ id: collectionUrl(username), totalItems }));
|
|
177
|
+
}
|
|
172
178
|
return res.json({
|
|
173
179
|
'@context': apContext_1.AP_CONTEXT,
|
|
174
180
|
id: collectionUrl(username),
|
|
@@ -346,8 +352,7 @@ function createActorRouter(config) {
|
|
|
346
352
|
const user = await config.resolveUser(username);
|
|
347
353
|
if (!user)
|
|
348
354
|
return userMissing(res, username);
|
|
349
|
-
if (!config.consent.isSharingEnabledFromUser(user) ||
|
|
350
|
-
(0, actorObject_1.localActorTypeForAccountKind)(user.kind) !== actorObject_1.LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND.community) {
|
|
355
|
+
if (!config.consent.isSharingEnabledFromUser(user) || !(0, groupCollections_1.isGroupActorKind)(user.kind)) {
|
|
351
356
|
return res.status(404).json({ error: 'User not found' });
|
|
352
357
|
}
|
|
353
358
|
const moderators = await fetchModerators(String(user._id || user.id));
|
package/dist/esm/actorObject.js
CHANGED
|
@@ -279,11 +279,14 @@ export function createLocalActorBuilder(config) {
|
|
|
279
279
|
// `attributedTo` (Lemmy, PieFed; Mbin reads `moderators`) and says anybody
|
|
280
280
|
// may post into it. PeerTube refuses a `Group` without `attributedTo`, and a
|
|
281
281
|
// `Group` claiming `postingRestrictedToMods` is read as a channel by every
|
|
282
|
-
// implementation, ours included ({@link accountKindForActor}).
|
|
282
|
+
// implementation, ours included ({@link accountKindForActor}). `sensitive`
|
|
283
|
+
// is Lemmy's NSFW flag on a community, sent as Lemmy sends it; an Oxy
|
|
284
|
+
// community has no NSFW setting.
|
|
283
285
|
if (actorObject.type === LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND.community) {
|
|
284
286
|
actorObject.attributedTo = config.urls.moderators(username);
|
|
285
287
|
actorObject.moderators = config.urls.moderators(username);
|
|
286
288
|
actorObject.postingRestrictedToMods = false;
|
|
289
|
+
actorObject.sensitive = false;
|
|
287
290
|
}
|
|
288
291
|
const aliases = normalizeAlsoKnownAs(params.alsoKnownAs);
|
|
289
292
|
if (aliases.length > 0) {
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a local `Group` (an Oxy community) publishes its outbox and followers:
|
|
3
|
+
* the shapes Lemmy itself serves and reads back, not the paged
|
|
4
|
+
* `OrderedCollection` + `first` link every other local actor serves.
|
|
5
|
+
*
|
|
6
|
+
* Lemmy deserializes a community's collections into fixed structs that know
|
|
7
|
+
* nothing of paging (`crates/apub/apub/src/protocol/collections/`):
|
|
8
|
+
* - outbox → `GroupOutbox { orderedItems: Vec<Announce> }`, `orderedItems`
|
|
9
|
+
* REQUIRED inline. A summary with only `first` fails to parse, so a server
|
|
10
|
+
* discovering the community backfills nothing. PieFed reads the same inline
|
|
11
|
+
* `orderedItems` and nothing else.
|
|
12
|
+
* - followers → `GroupFollowers { type: "Collection", totalItems, items }`,
|
|
13
|
+
* read for the subscriber count. Lemmy serves `items: []`, so the follower
|
|
14
|
+
* list stays private; this does the same.
|
|
15
|
+
*
|
|
16
|
+
* Mastodon reads `totalItems` from either shape, so a `Group` followed from
|
|
17
|
+
* Mastodon keeps its counts. `Person`, `Organization` and `Service` actors keep
|
|
18
|
+
* the paged shape Mastodon walks.
|
|
19
|
+
*/
|
|
20
|
+
import { LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND, localActorTypeForAccountKind } from './actorObject.js';
|
|
21
|
+
import { AP_CONTEXT } from './apContext.js';
|
|
22
|
+
/** How many of its latest `Announce`s a `Group`'s outbox lists inline (Lemmy's own size). */
|
|
23
|
+
export const GROUP_OUTBOX_SIZE = 50;
|
|
24
|
+
/**
|
|
25
|
+
* Whether a local account of this kind is announced as a `Group`
|
|
26
|
+
* ({@link LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND}) and so serves the Group
|
|
27
|
+
* collections: moderators, and the outbox and followers in the shapes above.
|
|
28
|
+
*/
|
|
29
|
+
export function isGroupActorKind(kind) {
|
|
30
|
+
return localActorTypeForAccountKind(kind) === LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND.community;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A `Group`'s outbox: one unpaged `OrderedCollection` of its latest
|
|
34
|
+
* `Announce { Create }` activities, newest first. Each item's own `@context` is
|
|
35
|
+
* dropped; the collection's covers it.
|
|
36
|
+
*/
|
|
37
|
+
export function groupOutboxCollection(params) {
|
|
38
|
+
return {
|
|
39
|
+
'@context': AP_CONTEXT,
|
|
40
|
+
id: params.id,
|
|
41
|
+
type: 'OrderedCollection',
|
|
42
|
+
totalItems: params.totalItems,
|
|
43
|
+
orderedItems: params.orderedItems.map(({ '@context': _context, ...item }) => item),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** A `Group`'s followers: the count, and an empty `items` list. */
|
|
47
|
+
export function groupFollowersCollection(params) {
|
|
48
|
+
return {
|
|
49
|
+
'@context': AP_CONTEXT,
|
|
50
|
+
id: params.id,
|
|
51
|
+
type: 'Collection',
|
|
52
|
+
totalItems: params.totalItems,
|
|
53
|
+
items: [],
|
|
54
|
+
};
|
|
55
|
+
}
|
package/dist/esm/index.js
CHANGED
|
@@ -63,3 +63,8 @@ export { canonicalFederationHost, isSameFederationHost, extractActorUriFromActiv
|
|
|
63
63
|
* follows the Oxy account kind ({@link LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND}).
|
|
64
64
|
*/
|
|
65
65
|
export { createLocalActorBuilder, normalizeAlsoKnownAs, localActorTypeForAccountKind, accountKindForActor, isApActorType, AP_ACTOR_TYPES, LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND, } from './actorObject.js';
|
|
66
|
+
/**
|
|
67
|
+
* A local `Group`'s outbox and followers, in the shapes Lemmy serves and reads
|
|
68
|
+
* back; every other actor keeps the paged collections.
|
|
69
|
+
*/
|
|
70
|
+
export { GROUP_OUTBOX_SIZE, isGroupActorKind, groupOutboxCollection, groupFollowersCollection, } from './groupCollections.js';
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
* signature verification (Phase 2, `trustForwardedHost`) and actor-match, then
|
|
9
9
|
* 202 + async dispatch to the injected inbound dispatcher,
|
|
10
10
|
* - `GET /users/:username/followers` + `/following` — the OXY follow graph
|
|
11
|
-
* (local + bridged federated edges) as paginated `OrderedCollection`s
|
|
11
|
+
* (local + bridged federated edges) as paginated `OrderedCollection`s; a
|
|
12
|
+
* community `Group`'s followers are only counted (`groupCollections.ts`),
|
|
12
13
|
* - `GET /users/:username/moderators` — a community `Group`'s moderators.
|
|
13
14
|
*
|
|
14
15
|
* The CONTENT routes (`outbox`, `featured`, per-post dereference) stay in the app,
|
|
@@ -24,7 +25,7 @@ import { AP_CONTEXT } from '../apContext.js';
|
|
|
24
25
|
import { discoveryCacheControl } from './httpCacheControl.js';
|
|
25
26
|
import { verifyHttpSignature } from '../httpSignature.js';
|
|
26
27
|
import { INSTANCE_ACTOR_USERNAME, normalizeActorUsername } from '../urls.js';
|
|
27
|
-
import {
|
|
28
|
+
import { groupFollowersCollection, isGroupActorKind } from '../groupCollections.js';
|
|
28
29
|
import { goneTombstone } from './goneActor.js';
|
|
29
30
|
/** Page size for the paginated followers/following collections (mirrors the outbox). */
|
|
30
31
|
const FOLLOW_PAGE_SIZE = 20;
|
|
@@ -131,7 +132,8 @@ export function createActorRouter(config) {
|
|
|
131
132
|
}
|
|
132
133
|
/**
|
|
133
134
|
* Serve a user's followers OR following as a paginated `OrderedCollection` over
|
|
134
|
-
* the authoritative Oxy follow graph (local + bridged federated edges).
|
|
135
|
+
* the authoritative Oxy follow graph (local + bridged federated edges). A
|
|
136
|
+
* `Group`'s followers are the unpaged, itemless `Collection` Lemmy reads.
|
|
135
137
|
*/
|
|
136
138
|
async function serveFollowCollection(req, res, direction, collectionUrl) {
|
|
137
139
|
if (!config.federationEnabled)
|
|
@@ -148,7 +150,8 @@ export function createActorRouter(config) {
|
|
|
148
150
|
const userId = String(user._id || user.id);
|
|
149
151
|
const rawCount = direction === 'followers' ? user._count?.followers : user._count?.following;
|
|
150
152
|
const profileTotal = typeof rawCount === 'number' ? rawCount : undefined;
|
|
151
|
-
|
|
153
|
+
const groupFollowers = direction === 'followers' && isGroupActorKind(user.kind);
|
|
154
|
+
if (!page || groupFollowers) {
|
|
152
155
|
// Prefer the profile `_count`; only when absent (the rare search fallback)
|
|
153
156
|
// fetch the authoritative total from a minimal graph list call. Fail-soft
|
|
154
157
|
// to 0 — never 500 the summary.
|
|
@@ -166,6 +169,9 @@ export function createActorRouter(config) {
|
|
|
166
169
|
}
|
|
167
170
|
}
|
|
168
171
|
res.set('Content-Type', apContentType);
|
|
172
|
+
if (groupFollowers) {
|
|
173
|
+
return res.json(groupFollowersCollection({ id: collectionUrl(username), totalItems }));
|
|
174
|
+
}
|
|
169
175
|
return res.json({
|
|
170
176
|
'@context': AP_CONTEXT,
|
|
171
177
|
id: collectionUrl(username),
|
|
@@ -343,8 +349,7 @@ export function createActorRouter(config) {
|
|
|
343
349
|
const user = await config.resolveUser(username);
|
|
344
350
|
if (!user)
|
|
345
351
|
return userMissing(res, username);
|
|
346
|
-
if (!config.consent.isSharingEnabledFromUser(user) ||
|
|
347
|
-
localActorTypeForAccountKind(user.kind) !== LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND.community) {
|
|
352
|
+
if (!config.consent.isSharingEnabledFromUser(user) || !isGroupActorKind(user.kind)) {
|
|
348
353
|
return res.status(404).json({ error: 'User not found' });
|
|
349
354
|
}
|
|
350
355
|
const moderators = await fetchModerators(String(user._id || user.id));
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a local `Group` (an Oxy community) publishes its outbox and followers:
|
|
3
|
+
* the shapes Lemmy itself serves and reads back, not the paged
|
|
4
|
+
* `OrderedCollection` + `first` link every other local actor serves.
|
|
5
|
+
*
|
|
6
|
+
* Lemmy deserializes a community's collections into fixed structs that know
|
|
7
|
+
* nothing of paging (`crates/apub/apub/src/protocol/collections/`):
|
|
8
|
+
* - outbox → `GroupOutbox { orderedItems: Vec<Announce> }`, `orderedItems`
|
|
9
|
+
* REQUIRED inline. A summary with only `first` fails to parse, so a server
|
|
10
|
+
* discovering the community backfills nothing. PieFed reads the same inline
|
|
11
|
+
* `orderedItems` and nothing else.
|
|
12
|
+
* - followers → `GroupFollowers { type: "Collection", totalItems, items }`,
|
|
13
|
+
* read for the subscriber count. Lemmy serves `items: []`, so the follower
|
|
14
|
+
* list stays private; this does the same.
|
|
15
|
+
*
|
|
16
|
+
* Mastodon reads `totalItems` from either shape, so a `Group` followed from
|
|
17
|
+
* Mastodon keeps its counts. `Person`, `Organization` and `Service` actors keep
|
|
18
|
+
* the paged shape Mastodon walks.
|
|
19
|
+
*/
|
|
20
|
+
/** How many of its latest `Announce`s a `Group`'s outbox lists inline (Lemmy's own size). */
|
|
21
|
+
export declare const GROUP_OUTBOX_SIZE = 50;
|
|
22
|
+
/**
|
|
23
|
+
* Whether a local account of this kind is announced as a `Group`
|
|
24
|
+
* ({@link LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND}) and so serves the Group
|
|
25
|
+
* collections: moderators, and the outbox and followers in the shapes above.
|
|
26
|
+
*/
|
|
27
|
+
export declare function isGroupActorKind(kind: unknown): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* A `Group`'s outbox: one unpaged `OrderedCollection` of its latest
|
|
30
|
+
* `Announce { Create }` activities, newest first. Each item's own `@context` is
|
|
31
|
+
* dropped; the collection's covers it.
|
|
32
|
+
*/
|
|
33
|
+
export declare function groupOutboxCollection(params: {
|
|
34
|
+
id: string;
|
|
35
|
+
totalItems: number;
|
|
36
|
+
orderedItems: readonly Record<string, unknown>[];
|
|
37
|
+
}): Record<string, unknown>;
|
|
38
|
+
/** A `Group`'s followers: the count, and an empty `items` list. */
|
|
39
|
+
export declare function groupFollowersCollection(params: {
|
|
40
|
+
id: string;
|
|
41
|
+
totalItems: number;
|
|
42
|
+
}): Record<string, unknown>;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -63,6 +63,11 @@ export { canonicalFederationHost, isSameFederationHost, extractActorUriFromActiv
|
|
|
63
63
|
* follows the Oxy account kind ({@link LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND}).
|
|
64
64
|
*/
|
|
65
65
|
export { createLocalActorBuilder, normalizeAlsoKnownAs, localActorTypeForAccountKind, accountKindForActor, type ActorKindSignals, isApActorType, AP_ACTOR_TYPES, LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND, type ApActorType, type LocalActorType, type LocalActorBuilder, type LocalActorBuilderConfig, type BuildLocalActorParams, type ActorMediaResolver, } from './actorObject';
|
|
66
|
+
/**
|
|
67
|
+
* A local `Group`'s outbox and followers, in the shapes Lemmy serves and reads
|
|
68
|
+
* back; every other actor keeps the paged collections.
|
|
69
|
+
*/
|
|
70
|
+
export { GROUP_OUTBOX_SIZE, isGroupActorKind, groupOutboxCollection, groupFollowersCollection, } from './groupCollections';
|
|
66
71
|
/**
|
|
67
72
|
* Supported external networks, keyed by the transport a connector speaks.
|
|
68
73
|
* `instagram-graph` is Meta's Graph API Business Discovery: a read-only
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
* signature verification (Phase 2, `trustForwardedHost`) and actor-match, then
|
|
9
9
|
* 202 + async dispatch to the injected inbound dispatcher,
|
|
10
10
|
* - `GET /users/:username/followers` + `/following` — the OXY follow graph
|
|
11
|
-
* (local + bridged federated edges) as paginated `OrderedCollection`s
|
|
11
|
+
* (local + bridged federated edges) as paginated `OrderedCollection`s; a
|
|
12
|
+
* community `Group`'s followers are only counted (`groupCollections.ts`),
|
|
12
13
|
* - `GET /users/:username/moderators` — a community `Group`'s moderators.
|
|
13
14
|
*
|
|
14
15
|
* The CONTENT routes (`outbox`, `featured`, per-post dereference) stay in the app,
|
|
@@ -23,7 +24,7 @@ import { Router } from 'express';
|
|
|
23
24
|
import type { AccountKind } from '@oxy.so/contracts';
|
|
24
25
|
import type { User } from '@oxy.so/core';
|
|
25
26
|
import type { UrlBuilders } from '../urls';
|
|
26
|
-
import {
|
|
27
|
+
import type { LocalActorBuilder } from '../actorObject';
|
|
27
28
|
import { type IsActorGone } from './goneActor';
|
|
28
29
|
/** The resolved-user fields the actor + collection routes read. */
|
|
29
30
|
export interface ActorRouteUser {
|
|
@@ -16,11 +16,17 @@
|
|
|
16
16
|
* pipeline). The banner mirror is best-effort: a failure there must never discard
|
|
17
17
|
* an already-successful user resolution.
|
|
18
18
|
*/
|
|
19
|
+
import { type ResponsePayload } from '@oxy.so/core';
|
|
19
20
|
import type { NormalizedExternalActor } from '../index';
|
|
20
21
|
/** The HTTP methods the service-scoped oxy-api transport is invoked with. */
|
|
21
22
|
export type ServiceRequestMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
22
|
-
/**
|
|
23
|
-
|
|
23
|
+
/**
|
|
24
|
+
* The service-scoped request transport oxy-api calls go through: core's
|
|
25
|
+
* `serviceRequest`, whose `T` is the `{ data }`-unwrapped payload. Typed as core
|
|
26
|
+
* types it, so an app passes `serviceRequest` straight through and a `T` naming
|
|
27
|
+
* the envelope fails to compile here as it does there.
|
|
28
|
+
*/
|
|
29
|
+
export type ServiceRequest = <T>(method: ServiceRequestMethod, path: string, body?: unknown) => Promise<ResponsePayload<T>>;
|
|
24
30
|
/** Minimal logging sink the identity bridge writes to. */
|
|
25
31
|
export interface IdentityBridgeLogger {
|
|
26
32
|
info(message: string, meta?: unknown): void;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxy.so/federation",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.10.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",
|
|
@@ -80,7 +80,7 @@
|
|
|
80
80
|
},
|
|
81
81
|
"dependencies": {
|
|
82
82
|
"@oxy.so/contracts": "^1.0.0 || ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || 4.11.0-oxy-one.0",
|
|
83
|
-
"@oxy.so/core": "^
|
|
83
|
+
"@oxy.so/core": "^6.14.0"
|
|
84
84
|
},
|
|
85
85
|
"peerDependencies": {
|
|
86
86
|
"express": "^4.18.0 || ^5.0.0"
|
|
@@ -222,6 +222,7 @@ describe('actor type by Oxy account kind', () => {
|
|
|
222
222
|
expect(community.attributedTo).toBe('https://mention.earth/ap/users/nate/moderators');
|
|
223
223
|
expect(community.moderators).toBe(community.attributedTo);
|
|
224
224
|
expect(community.postingRestrictedToMods).toBe(false);
|
|
225
|
+
expect(community.sensitive).toBe(false);
|
|
225
226
|
// Read back by the same table: an open Group is a community, not a channel.
|
|
226
227
|
expect(accountKindForActor(community)).toBe('community');
|
|
227
228
|
const person = buildActor(PARAMS);
|
|
@@ -6,6 +6,8 @@ import { createInboundDispatcher } from '../node/inboundDispatch';
|
|
|
6
6
|
import { createWebfingerRouter, type WebfingerRouterConfig } from '../node/webfingerRouter';
|
|
7
7
|
import { createActorRouter, type ActorRouterConfig } from '../node/actorRouter';
|
|
8
8
|
import { createUrlBuilders } from '../urls';
|
|
9
|
+
import { AP_CONTEXT } from '../apContext';
|
|
10
|
+
import { groupOutboxCollection } from '../groupCollections';
|
|
9
11
|
import { signRequest, type HttpSignatureSigner } from '../httpSignature';
|
|
10
12
|
|
|
11
13
|
/**
|
|
@@ -668,4 +670,35 @@ describe('community moderators collection', () => {
|
|
|
668
670
|
const res = await request(moderatorsApp()).get('/ap/users/alice/moderators');
|
|
669
671
|
expect(res.status).toBe(404);
|
|
670
672
|
});
|
|
673
|
+
|
|
674
|
+
// Lemmy's `GroupFollowers { type: Collection, totalItems, items }` and
|
|
675
|
+
// `GroupOutbox { type: OrderedCollection, totalItems, orderedItems }`: no
|
|
676
|
+
// paging, items inline. A Person keeps the paged shape Mastodon walks.
|
|
677
|
+
it("serves a Group's followers and outbox in the shapes Lemmy deserializes", async () => {
|
|
678
|
+
const followers = await request(moderatorsApp()).get('/ap/users/forum/followers?page=true');
|
|
679
|
+
expect(followers.body).toEqual({
|
|
680
|
+
'@context': AP_CONTEXT,
|
|
681
|
+
id: urls.followers('forum'),
|
|
682
|
+
type: 'Collection',
|
|
683
|
+
totalItems: 3,
|
|
684
|
+
items: [],
|
|
685
|
+
});
|
|
686
|
+
const announce = { id: `${urls.actor('forum')}/relays/1`, type: 'Announce' };
|
|
687
|
+
expect(
|
|
688
|
+
groupOutboxCollection({
|
|
689
|
+
id: urls.outbox('forum'),
|
|
690
|
+
totalItems: 1,
|
|
691
|
+
orderedItems: [{ '@context': AP_CONTEXT, ...announce }],
|
|
692
|
+
}),
|
|
693
|
+
).toEqual({
|
|
694
|
+
'@context': AP_CONTEXT,
|
|
695
|
+
id: urls.outbox('forum'),
|
|
696
|
+
type: 'OrderedCollection',
|
|
697
|
+
totalItems: 1,
|
|
698
|
+
orderedItems: [announce],
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
const person = await request(moderatorsApp()).get('/ap/users/alice/followers');
|
|
702
|
+
expect(person.body).toMatchObject({ type: 'OrderedCollection', first: expect.any(String) });
|
|
703
|
+
});
|
|
671
704
|
});
|
package/src/actorObject.ts
CHANGED
|
@@ -400,11 +400,14 @@ export function createLocalActorBuilder(config: LocalActorBuilderConfig): LocalA
|
|
|
400
400
|
// `attributedTo` (Lemmy, PieFed; Mbin reads `moderators`) and says anybody
|
|
401
401
|
// may post into it. PeerTube refuses a `Group` without `attributedTo`, and a
|
|
402
402
|
// `Group` claiming `postingRestrictedToMods` is read as a channel by every
|
|
403
|
-
// implementation, ours included ({@link accountKindForActor}).
|
|
403
|
+
// implementation, ours included ({@link accountKindForActor}). `sensitive`
|
|
404
|
+
// is Lemmy's NSFW flag on a community, sent as Lemmy sends it; an Oxy
|
|
405
|
+
// community has no NSFW setting.
|
|
404
406
|
if (actorObject.type === LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND.community) {
|
|
405
407
|
actorObject.attributedTo = config.urls.moderators(username);
|
|
406
408
|
actorObject.moderators = config.urls.moderators(username);
|
|
407
409
|
actorObject.postingRestrictedToMods = false;
|
|
410
|
+
actorObject.sensitive = false;
|
|
408
411
|
}
|
|
409
412
|
|
|
410
413
|
const aliases = normalizeAlsoKnownAs(params.alsoKnownAs);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a local `Group` (an Oxy community) publishes its outbox and followers:
|
|
3
|
+
* the shapes Lemmy itself serves and reads back, not the paged
|
|
4
|
+
* `OrderedCollection` + `first` link every other local actor serves.
|
|
5
|
+
*
|
|
6
|
+
* Lemmy deserializes a community's collections into fixed structs that know
|
|
7
|
+
* nothing of paging (`crates/apub/apub/src/protocol/collections/`):
|
|
8
|
+
* - outbox → `GroupOutbox { orderedItems: Vec<Announce> }`, `orderedItems`
|
|
9
|
+
* REQUIRED inline. A summary with only `first` fails to parse, so a server
|
|
10
|
+
* discovering the community backfills nothing. PieFed reads the same inline
|
|
11
|
+
* `orderedItems` and nothing else.
|
|
12
|
+
* - followers → `GroupFollowers { type: "Collection", totalItems, items }`,
|
|
13
|
+
* read for the subscriber count. Lemmy serves `items: []`, so the follower
|
|
14
|
+
* list stays private; this does the same.
|
|
15
|
+
*
|
|
16
|
+
* Mastodon reads `totalItems` from either shape, so a `Group` followed from
|
|
17
|
+
* Mastodon keeps its counts. `Person`, `Organization` and `Service` actors keep
|
|
18
|
+
* the paged shape Mastodon walks.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND, localActorTypeForAccountKind } from './actorObject';
|
|
22
|
+
import { AP_CONTEXT } from './apContext';
|
|
23
|
+
|
|
24
|
+
/** How many of its latest `Announce`s a `Group`'s outbox lists inline (Lemmy's own size). */
|
|
25
|
+
export const GROUP_OUTBOX_SIZE = 50;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Whether a local account of this kind is announced as a `Group`
|
|
29
|
+
* ({@link LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND}) and so serves the Group
|
|
30
|
+
* collections: moderators, and the outbox and followers in the shapes above.
|
|
31
|
+
*/
|
|
32
|
+
export function isGroupActorKind(kind: unknown): boolean {
|
|
33
|
+
return localActorTypeForAccountKind(kind) === LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND.community;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A `Group`'s outbox: one unpaged `OrderedCollection` of its latest
|
|
38
|
+
* `Announce { Create }` activities, newest first. Each item's own `@context` is
|
|
39
|
+
* dropped; the collection's covers it.
|
|
40
|
+
*/
|
|
41
|
+
export function groupOutboxCollection(params: {
|
|
42
|
+
id: string;
|
|
43
|
+
totalItems: number;
|
|
44
|
+
orderedItems: readonly Record<string, unknown>[];
|
|
45
|
+
}): Record<string, unknown> {
|
|
46
|
+
return {
|
|
47
|
+
'@context': AP_CONTEXT,
|
|
48
|
+
id: params.id,
|
|
49
|
+
type: 'OrderedCollection',
|
|
50
|
+
totalItems: params.totalItems,
|
|
51
|
+
orderedItems: params.orderedItems.map(({ '@context': _context, ...item }) => item),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A `Group`'s followers: the count, and an empty `items` list. */
|
|
56
|
+
export function groupFollowersCollection(params: {
|
|
57
|
+
id: string;
|
|
58
|
+
totalItems: number;
|
|
59
|
+
}): Record<string, unknown> {
|
|
60
|
+
return {
|
|
61
|
+
'@context': AP_CONTEXT,
|
|
62
|
+
id: params.id,
|
|
63
|
+
type: 'Collection',
|
|
64
|
+
totalItems: params.totalItems,
|
|
65
|
+
items: [],
|
|
66
|
+
};
|
|
67
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -132,6 +132,17 @@ export {
|
|
|
132
132
|
type ActorMediaResolver,
|
|
133
133
|
} from './actorObject';
|
|
134
134
|
|
|
135
|
+
/**
|
|
136
|
+
* A local `Group`'s outbox and followers, in the shapes Lemmy serves and reads
|
|
137
|
+
* back; every other actor keeps the paged collections.
|
|
138
|
+
*/
|
|
139
|
+
export {
|
|
140
|
+
GROUP_OUTBOX_SIZE,
|
|
141
|
+
isGroupActorKind,
|
|
142
|
+
groupOutboxCollection,
|
|
143
|
+
groupFollowersCollection,
|
|
144
|
+
} from './groupCollections';
|
|
145
|
+
|
|
135
146
|
/**
|
|
136
147
|
* Supported external networks, keyed by the transport a connector speaks.
|
|
137
148
|
* `instagram-graph` is Meta's Graph API Business Discovery: a read-only
|
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; a
|
|
12
|
+
* community `Group`'s followers are only counted (`groupCollections.ts`),
|
|
12
13
|
* - `GET /users/:username/moderators` — a community `Group`'s moderators.
|
|
13
14
|
*
|
|
14
15
|
* The CONTENT routes (`outbox`, `featured`, per-post dereference) stay in the app,
|
|
@@ -28,11 +29,8 @@ import { discoveryCacheControl } from './httpCacheControl';
|
|
|
28
29
|
import { verifyHttpSignature } from '../httpSignature';
|
|
29
30
|
import type { UrlBuilders } from '../urls';
|
|
30
31
|
import { INSTANCE_ACTOR_USERNAME, normalizeActorUsername } from '../urls';
|
|
31
|
-
import {
|
|
32
|
-
|
|
33
|
-
localActorTypeForAccountKind,
|
|
34
|
-
type LocalActorBuilder,
|
|
35
|
-
} from '../actorObject';
|
|
32
|
+
import type { LocalActorBuilder } from '../actorObject';
|
|
33
|
+
import { groupFollowersCollection, isGroupActorKind } from '../groupCollections';
|
|
36
34
|
import { goneTombstone, type IsActorGone } from './goneActor';
|
|
37
35
|
|
|
38
36
|
/** Page size for the paginated followers/following collections (mirrors the outbox). */
|
|
@@ -270,7 +268,8 @@ export function createActorRouter(config: ActorRouterConfig): Router {
|
|
|
270
268
|
|
|
271
269
|
/**
|
|
272
270
|
* Serve a user's followers OR following as a paginated `OrderedCollection` over
|
|
273
|
-
* the authoritative Oxy follow graph (local + bridged federated edges).
|
|
271
|
+
* the authoritative Oxy follow graph (local + bridged federated edges). A
|
|
272
|
+
* `Group`'s followers are the unpaged, itemless `Collection` Lemmy reads.
|
|
274
273
|
*/
|
|
275
274
|
async function serveFollowCollection(
|
|
276
275
|
req: Request,
|
|
@@ -297,7 +296,8 @@ export function createActorRouter(config: ActorRouterConfig): Router {
|
|
|
297
296
|
direction === 'followers' ? user._count?.followers : user._count?.following;
|
|
298
297
|
const profileTotal = typeof rawCount === 'number' ? rawCount : undefined;
|
|
299
298
|
|
|
300
|
-
|
|
299
|
+
const groupFollowers = direction === 'followers' && isGroupActorKind(user.kind);
|
|
300
|
+
if (!page || groupFollowers) {
|
|
301
301
|
// Prefer the profile `_count`; only when absent (the rare search fallback)
|
|
302
302
|
// fetch the authoritative total from a minimal graph list call. Fail-soft
|
|
303
303
|
// to 0 — never 500 the summary.
|
|
@@ -315,6 +315,9 @@ export function createActorRouter(config: ActorRouterConfig): Router {
|
|
|
315
315
|
}
|
|
316
316
|
|
|
317
317
|
res.set('Content-Type', apContentType);
|
|
318
|
+
if (groupFollowers) {
|
|
319
|
+
return res.json(groupFollowersCollection({ id: collectionUrl(username), totalItems }));
|
|
320
|
+
}
|
|
318
321
|
return res.json({
|
|
319
322
|
'@context': AP_CONTEXT,
|
|
320
323
|
id: collectionUrl(username),
|
|
@@ -517,10 +520,7 @@ export function createActorRouter(config: ActorRouterConfig): Router {
|
|
|
517
520
|
try {
|
|
518
521
|
const user = await config.resolveUser(username);
|
|
519
522
|
if (!user) return userMissing(res, username);
|
|
520
|
-
if (
|
|
521
|
-
!config.consent.isSharingEnabledFromUser(user) ||
|
|
522
|
-
localActorTypeForAccountKind(user.kind) !== LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND.community
|
|
523
|
-
) {
|
|
523
|
+
if (!config.consent.isSharingEnabledFromUser(user) || !isGroupActorKind(user.kind)) {
|
|
524
524
|
return res.status(404).json({ error: 'User not found' });
|
|
525
525
|
}
|
|
526
526
|
const moderators = await fetchModerators(String(user._id || user.id));
|
|
@@ -17,18 +17,23 @@
|
|
|
17
17
|
* an already-successful user resolution.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { getErrorMessage, getErrorStatus } from '@oxy.so/core';
|
|
20
|
+
import { getErrorMessage, getErrorStatus, type ResponsePayload } from '@oxy.so/core';
|
|
21
21
|
import type { NormalizedExternalActor } from '../index';
|
|
22
22
|
|
|
23
23
|
/** The HTTP methods the service-scoped oxy-api transport is invoked with. */
|
|
24
24
|
export type ServiceRequestMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
25
25
|
|
|
26
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* The service-scoped request transport oxy-api calls go through: core's
|
|
28
|
+
* `serviceRequest`, whose `T` is the `{ data }`-unwrapped payload. Typed as core
|
|
29
|
+
* types it, so an app passes `serviceRequest` straight through and a `T` naming
|
|
30
|
+
* the envelope fails to compile here as it does there.
|
|
31
|
+
*/
|
|
27
32
|
export type ServiceRequest = <T>(
|
|
28
33
|
method: ServiceRequestMethod,
|
|
29
34
|
path: string,
|
|
30
35
|
body?: unknown,
|
|
31
|
-
) => Promise<T
|
|
36
|
+
) => Promise<ResponsePayload<T>>;
|
|
32
37
|
|
|
33
38
|
/** Minimal logging sink the identity bridge writes to. */
|
|
34
39
|
export interface IdentityBridgeLogger {
|