@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 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.
@@ -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 actorObject_1 = require("../actorObject");
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
- if (!page) {
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));
@@ -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 { LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND, localActorTypeForAccountKind, } from '../actorObject.js';
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
- if (!page) {
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>;
@@ -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 { type LocalActorBuilder } from '../actorObject';
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
- /** The service-scoped request transport oxy-api calls go through (`{ data }`-unwrapped). */
23
- export type ServiceRequest = <T>(method: ServiceRequestMethod, path: string, body?: unknown) => Promise<T>;
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.8.0",
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": "^1.0.0 || ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0"
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
  });
@@ -400,11 +400,14 @@ export function createLocalActorBuilder(config: LocalActorBuilderConfig): LocalA
400
400
  // `attributedTo` (Lemmy, PieFed; Mbin reads `moderators`) and says anybody
401
401
  // may post into it. PeerTube refuses a `Group` without `attributedTo`, and a
402
402
  // `Group` claiming `postingRestrictedToMods` is read as a channel by every
403
- // implementation, ours included ({@link accountKindForActor}).
403
+ // implementation, ours included ({@link accountKindForActor}). `sensitive`
404
+ // is Lemmy's NSFW flag on a community, sent as Lemmy sends it; an Oxy
405
+ // community has no NSFW setting.
404
406
  if (actorObject.type === LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND.community) {
405
407
  actorObject.attributedTo = config.urls.moderators(username);
406
408
  actorObject.moderators = config.urls.moderators(username);
407
409
  actorObject.postingRestrictedToMods = false;
410
+ actorObject.sensitive = false;
408
411
  }
409
412
 
410
413
  const aliases = normalizeAlsoKnownAs(params.alsoKnownAs);
@@ -0,0 +1,67 @@
1
+ /**
2
+ * How a local `Group` (an Oxy community) publishes its outbox and followers:
3
+ * the shapes Lemmy itself serves and reads back, not the paged
4
+ * `OrderedCollection` + `first` link every other local actor serves.
5
+ *
6
+ * Lemmy deserializes a community's collections into fixed structs that know
7
+ * nothing of paging (`crates/apub/apub/src/protocol/collections/`):
8
+ * - outbox → `GroupOutbox { orderedItems: Vec<Announce> }`, `orderedItems`
9
+ * REQUIRED inline. A summary with only `first` fails to parse, so a server
10
+ * discovering the community backfills nothing. PieFed reads the same inline
11
+ * `orderedItems` and nothing else.
12
+ * - followers → `GroupFollowers { type: "Collection", totalItems, items }`,
13
+ * read for the subscriber count. Lemmy serves `items: []`, so the follower
14
+ * list stays private; this does the same.
15
+ *
16
+ * Mastodon reads `totalItems` from either shape, so a `Group` followed from
17
+ * Mastodon keeps its counts. `Person`, `Organization` and `Service` actors keep
18
+ * the paged shape Mastodon walks.
19
+ */
20
+
21
+ import { LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND, localActorTypeForAccountKind } from './actorObject';
22
+ import { AP_CONTEXT } from './apContext';
23
+
24
+ /** How many of its latest `Announce`s a `Group`'s outbox lists inline (Lemmy's own size). */
25
+ export const GROUP_OUTBOX_SIZE = 50;
26
+
27
+ /**
28
+ * Whether a local account of this kind is announced as a `Group`
29
+ * ({@link LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND}) and so serves the Group
30
+ * collections: moderators, and the outbox and followers in the shapes above.
31
+ */
32
+ export function isGroupActorKind(kind: unknown): boolean {
33
+ return localActorTypeForAccountKind(kind) === LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND.community;
34
+ }
35
+
36
+ /**
37
+ * A `Group`'s outbox: one unpaged `OrderedCollection` of its latest
38
+ * `Announce { Create }` activities, newest first. Each item's own `@context` is
39
+ * dropped; the collection's covers it.
40
+ */
41
+ export function groupOutboxCollection(params: {
42
+ id: string;
43
+ totalItems: number;
44
+ orderedItems: readonly Record<string, unknown>[];
45
+ }): Record<string, unknown> {
46
+ return {
47
+ '@context': AP_CONTEXT,
48
+ id: params.id,
49
+ type: 'OrderedCollection',
50
+ totalItems: params.totalItems,
51
+ orderedItems: params.orderedItems.map(({ '@context': _context, ...item }) => item),
52
+ };
53
+ }
54
+
55
+ /** A `Group`'s followers: the count, and an empty `items` list. */
56
+ export function groupFollowersCollection(params: {
57
+ id: string;
58
+ totalItems: number;
59
+ }): Record<string, unknown> {
60
+ return {
61
+ '@context': AP_CONTEXT,
62
+ id: params.id,
63
+ type: 'Collection',
64
+ totalItems: params.totalItems,
65
+ items: [],
66
+ };
67
+ }
package/src/index.ts CHANGED
@@ -132,6 +132,17 @@ export {
132
132
  type ActorMediaResolver,
133
133
  } from './actorObject';
134
134
 
135
+ /**
136
+ * A local `Group`'s outbox and followers, in the shapes Lemmy serves and reads
137
+ * back; every other actor keeps the paged collections.
138
+ */
139
+ export {
140
+ GROUP_OUTBOX_SIZE,
141
+ isGroupActorKind,
142
+ groupOutboxCollection,
143
+ groupFollowersCollection,
144
+ } from './groupCollections';
145
+
135
146
  /**
136
147
  * Supported external networks, keyed by the transport a connector speaks.
137
148
  * `instagram-graph` is Meta's Graph API Business Discovery: a read-only
@@ -8,7 +8,8 @@
8
8
  * signature verification (Phase 2, `trustForwardedHost`) and actor-match, then
9
9
  * 202 + async dispatch to the injected inbound dispatcher,
10
10
  * - `GET /users/:username/followers` + `/following` — the OXY follow graph
11
- * (local + bridged federated edges) as paginated `OrderedCollection`s,
11
+ * (local + bridged federated edges) as paginated `OrderedCollection`s; a
12
+ * community `Group`'s followers are only counted (`groupCollections.ts`),
12
13
  * - `GET /users/:username/moderators` — a community `Group`'s moderators.
13
14
  *
14
15
  * The CONTENT routes (`outbox`, `featured`, per-post dereference) stay in the app,
@@ -28,11 +29,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
- LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND,
33
- localActorTypeForAccountKind,
34
- type LocalActorBuilder,
35
- } from '../actorObject';
32
+ import type { LocalActorBuilder } from '../actorObject';
33
+ import { groupFollowersCollection, isGroupActorKind } from '../groupCollections';
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
- if (!page) {
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
- /** The service-scoped request transport oxy-api calls go through (`{ data }`-unwrapped). */
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 {