@oxy.so/federation 2.5.0 → 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.
@@ -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;
@@ -24,6 +24,7 @@ exports.ActorResolver = void 0;
24
24
  exports.createActorResolver = createActorResolver;
25
25
  const apUri_1 = require("../apUri");
26
26
  const networkIdentity_1 = require("../networkIdentity");
27
+ const boundedBody_1 = require("./boundedBody");
27
28
  /**
28
29
  * Minimum interval between background actor refreshes for the same actor.
29
30
  * Prevents refresh storms when a profile is viewed repeatedly in a short window.
@@ -43,38 +44,26 @@ const COLLECTION_BODY_MAX_BYTES = 64 * 1024;
43
44
  */
44
45
  const COLLECTION_WITHHELD_STATUSES = new Set([401, 403, 404, 410]);
45
46
  const ERROR_BODY_MAX_BYTES = 4 * 1024;
47
+ /**
48
+ * Deadline for reading a whole actor/collection/error body once its headers
49
+ * arrived. `signedFetch`'s timeout ends at the headers, so without this a remote
50
+ * that stalls mid-body would hold the resolver open.
51
+ */
52
+ const BODY_TIMEOUT_MS = 10000;
46
53
  async function readBoundedResponseBody(res, maxBytes) {
47
54
  const contentLength = res.headers.get('content-length');
48
55
  if (contentLength) {
49
56
  const declaredBytes = Number(contentLength);
50
57
  if (Number.isFinite(declaredBytes) && declaredBytes > maxBytes) {
51
58
  await res.body?.cancel().catch(() => { });
52
- throw new Error(`Remote response exceeds ${maxBytes} byte limit`);
59
+ throw new boundedBody_1.ResponseBodyTooLargeError(maxBytes);
53
60
  }
54
61
  }
55
62
  if (!res.body)
56
63
  return '';
57
64
  const reader = res.body.getReader();
58
- const decoder = new TextDecoder('utf-8', { fatal: true });
59
- let bytesRead = 0;
60
- let text = '';
61
- try {
62
- while (true) {
63
- const { done, value } = await reader.read();
64
- if (done)
65
- break;
66
- bytesRead += value.byteLength;
67
- if (bytesRead > maxBytes) {
68
- await reader.cancel().catch(() => { });
69
- throw new Error(`Remote response exceeds ${maxBytes} byte limit`);
70
- }
71
- text += decoder.decode(value, { stream: true });
72
- }
73
- return text + decoder.decode();
74
- }
75
- finally {
76
- reader.releaseLock();
77
- }
65
+ const body = await (0, boundedBody_1.readBodyChunks)({ read: () => reader.read(), cancel: () => void reader.cancel().catch(() => { }) }, { maxBytes, timeoutMs: BODY_TIMEOUT_MS });
66
+ return new TextDecoder('utf-8', { fatal: true }).decode(body);
78
67
  }
79
68
  async function readBoundedJson(res, maxBytes) {
80
69
  const body = await readBoundedResponseBody(res, maxBytes);
@@ -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
  }
@@ -0,0 +1,77 @@
1
+ "use strict";
2
+ /**
3
+ * The one bounded, deadlined body read behind every federation response body
4
+ * (an actor or collection `Response`, a failed delivery's preview).
5
+ *
6
+ * The transports bound a request up to its response headers; from there the
7
+ * reader owns the stream, and a remote that sends headers and then stalls (or
8
+ * trickles) the body would hold a plain read loop forever. This caps the bytes
9
+ * AND gives the whole body one deadline, cancelling the source on every early
10
+ * exit. It reads through a {@link BodyChunkSource} so a WHATWG reader and a
11
+ * Node async iterator share it.
12
+ */
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.ResponseBodyTimeoutError = exports.ResponseBodyTooLargeError = void 0;
15
+ exports.readBodyChunks = readBodyChunks;
16
+ /** Thrown when a body exceeds its byte cap under `overflow: 'reject'`. */
17
+ class ResponseBodyTooLargeError extends Error {
18
+ constructor(maxBytes) {
19
+ super(`Remote response exceeds ${maxBytes} byte limit`);
20
+ this.maxBytes = maxBytes;
21
+ this.name = 'ResponseBodyTooLargeError';
22
+ }
23
+ }
24
+ exports.ResponseBodyTooLargeError = ResponseBodyTooLargeError;
25
+ /** Thrown when a body does not end within its deadline; this attempt failed, a retry may not. */
26
+ class ResponseBodyTimeoutError extends Error {
27
+ constructor(timeoutMs) {
28
+ super(`Remote response body did not end within ${timeoutMs} ms`);
29
+ this.timeoutMs = timeoutMs;
30
+ this.name = 'ResponseBodyTimeoutError';
31
+ }
32
+ }
33
+ exports.ResponseBodyTimeoutError = ResponseBodyTimeoutError;
34
+ /**
35
+ * Read `source` into one Buffer, at most `maxBytes` and within `timeoutMs`.
36
+ * The deadline timer is cleared on every path; the source is cancelled on a
37
+ * cap, a deadline, a read error, or a truncated preview.
38
+ */
39
+ async function readBodyChunks(source, limits) {
40
+ const { maxBytes, timeoutMs, overflow = 'reject' } = limits;
41
+ const chunks = [];
42
+ let total = 0;
43
+ let timer;
44
+ const deadline = new Promise((_, reject) => {
45
+ timer = setTimeout(() => reject(new ResponseBodyTimeoutError(timeoutMs)), timeoutMs);
46
+ });
47
+ try {
48
+ for (;;) {
49
+ // `Promise.race` subscribes to both, so neither the deadline nor a read
50
+ // that rejects once the source is cancelled goes unhandled.
51
+ const { done, value } = await Promise.race([source.read(), deadline]);
52
+ if (done || !value)
53
+ break;
54
+ const buffer = Buffer.isBuffer(value)
55
+ ? value
56
+ : Buffer.from(value.buffer, value.byteOffset, value.byteLength);
57
+ if (total + buffer.length > maxBytes) {
58
+ if (overflow === 'reject')
59
+ throw new ResponseBodyTooLargeError(maxBytes);
60
+ chunks.push(buffer.subarray(0, maxBytes - total));
61
+ total = maxBytes;
62
+ source.cancel();
63
+ break;
64
+ }
65
+ total += buffer.length;
66
+ chunks.push(buffer);
67
+ }
68
+ }
69
+ catch (err) {
70
+ source.cancel();
71
+ throw err;
72
+ }
73
+ finally {
74
+ clearTimeout(timer);
75
+ }
76
+ return Buffer.concat(chunks, total);
77
+ }
@@ -28,11 +28,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
28
28
  exports.DeliveryAuthorizationDeniedError = void 0;
29
29
  exports.createDeliveryService = createDeliveryService;
30
30
  const apContext_1 = require("../apContext");
31
+ const boundedBody_1 = require("./boundedBody");
31
32
  const httpSignature_1 = require("../httpSignature");
32
33
  /** Total time budget for a single delivery POST (connect + response headers). */
33
34
  const DELIVER_ACTIVITY_TIMEOUT_MS = 15000;
34
35
  /** How many bytes of a failed-delivery response body are read for the debug log. */
35
36
  const DELIVERY_RESPONSE_PREVIEW_MAX_BYTES = 1024;
37
+ /** Deadline for reading that preview once the response headers arrived. */
38
+ const DELIVERY_RESPONSE_PREVIEW_TIMEOUT_MS = 5000;
36
39
  /** The ActivityStreams public collection — the `to` addressee of a public activity. */
37
40
  const AP_PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
38
41
  /** A definitive refusal: workers must discard, not retry or count a delivery. */
@@ -44,18 +47,20 @@ class DeliveryAuthorizationDeniedError extends Error {
44
47
  }
45
48
  }
46
49
  exports.DeliveryAuthorizationDeniedError = DeliveryAuthorizationDeniedError;
47
- /** Read a bounded prefix of a failed-delivery response body for the debug log. */
50
+ /**
51
+ * Read a bounded prefix of a failed-delivery response body for the debug log,
52
+ * within {@link DELIVERY_RESPONSE_PREVIEW_TIMEOUT_MS}: an inbox that stalls its
53
+ * body after the headers costs the worker that long, not forever.
54
+ */
48
55
  async function readResponsePreview(response) {
49
- const chunks = [];
50
- let totalBytes = 0;
56
+ const iterator = response[Symbol.asyncIterator]();
51
57
  try {
52
- for await (const chunk of response) {
53
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
54
- totalBytes += buffer.byteLength;
55
- chunks.push(buffer);
56
- if (totalBytes >= DELIVERY_RESPONSE_PREVIEW_MAX_BYTES)
57
- break;
58
- }
58
+ const preview = await (0, boundedBody_1.readBodyChunks)({ read: () => iterator.next(), cancel: () => response.destroy() }, {
59
+ maxBytes: DELIVERY_RESPONSE_PREVIEW_MAX_BYTES,
60
+ timeoutMs: DELIVERY_RESPONSE_PREVIEW_TIMEOUT_MS,
61
+ overflow: 'truncate',
62
+ });
63
+ return preview.toString('utf8');
59
64
  }
60
65
  catch {
61
66
  return '';
@@ -63,7 +68,6 @@ async function readResponsePreview(response) {
63
68
  finally {
64
69
  response.destroy();
65
70
  }
66
- return Buffer.concat(chunks).toString('utf8', 0, DELIVERY_RESPONSE_PREVIEW_MAX_BYTES);
67
71
  }
68
72
  /** Build the outbound delivery + follow-lifecycle service from an app's adapters. */
69
73
  function createDeliveryService(config) {
package/dist/cjs/urls.js CHANGED
@@ -35,6 +35,7 @@ function createUrlBuilders(domain, actorDomain = domain) {
35
35
  featured: (username) => `https://${domain}/ap/users/${username}/collections/featured`,
36
36
  followers: (username) => `https://${domain}/ap/users/${username}/followers`,
37
37
  following: (username) => `https://${domain}/ap/users/${username}/following`,
38
+ moderators: (username) => `https://${domain}/ap/users/${username}/moderators`,
38
39
  sharedInbox: () => `https://${domain}/ap/inbox`,
39
40
  };
40
41
  }
@@ -275,6 +275,16 @@ export function createLocalActorBuilder(config) {
275
275
  publicKeyPem: publicKey.publicKeyPem,
276
276
  },
277
277
  };
278
+ // A community is a FEP-1b12 `Group`: it names its moderators collection in
279
+ // `attributedTo` (Lemmy, PieFed; Mbin reads `moderators`) and says anybody
280
+ // may post into it. PeerTube refuses a `Group` without `attributedTo`, and a
281
+ // `Group` claiming `postingRestrictedToMods` is read as a channel by every
282
+ // implementation, ours included ({@link accountKindForActor}).
283
+ if (actorObject.type === LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND.community) {
284
+ actorObject.attributedTo = config.urls.moderators(username);
285
+ actorObject.moderators = config.urls.moderators(username);
286
+ actorObject.postingRestrictedToMods = false;
287
+ }
278
288
  const aliases = normalizeAlsoKnownAs(params.alsoKnownAs);
279
289
  if (aliases.length > 0) {
280
290
  actorObject.alsoKnownAs = aliases;
@@ -20,6 +20,7 @@
20
20
  */
21
21
  import { isSameFederationHost } from '../apUri.js';
22
22
  import { readProxyDeclarations, } from '../networkIdentity.js';
23
+ import { readBodyChunks, ResponseBodyTooLargeError } from './boundedBody.js';
23
24
  /**
24
25
  * Minimum interval between background actor refreshes for the same actor.
25
26
  * Prevents refresh storms when a profile is viewed repeatedly in a short window.
@@ -39,38 +40,26 @@ const COLLECTION_BODY_MAX_BYTES = 64 * 1024;
39
40
  */
40
41
  const COLLECTION_WITHHELD_STATUSES = new Set([401, 403, 404, 410]);
41
42
  const ERROR_BODY_MAX_BYTES = 4 * 1024;
43
+ /**
44
+ * Deadline for reading a whole actor/collection/error body once its headers
45
+ * arrived. `signedFetch`'s timeout ends at the headers, so without this a remote
46
+ * that stalls mid-body would hold the resolver open.
47
+ */
48
+ const BODY_TIMEOUT_MS = 10000;
42
49
  async function readBoundedResponseBody(res, maxBytes) {
43
50
  const contentLength = res.headers.get('content-length');
44
51
  if (contentLength) {
45
52
  const declaredBytes = Number(contentLength);
46
53
  if (Number.isFinite(declaredBytes) && declaredBytes > maxBytes) {
47
54
  await res.body?.cancel().catch(() => { });
48
- throw new Error(`Remote response exceeds ${maxBytes} byte limit`);
55
+ throw new ResponseBodyTooLargeError(maxBytes);
49
56
  }
50
57
  }
51
58
  if (!res.body)
52
59
  return '';
53
60
  const reader = res.body.getReader();
54
- const decoder = new TextDecoder('utf-8', { fatal: true });
55
- let bytesRead = 0;
56
- let text = '';
57
- try {
58
- while (true) {
59
- const { done, value } = await reader.read();
60
- if (done)
61
- break;
62
- bytesRead += value.byteLength;
63
- if (bytesRead > maxBytes) {
64
- await reader.cancel().catch(() => { });
65
- throw new Error(`Remote response exceeds ${maxBytes} byte limit`);
66
- }
67
- text += decoder.decode(value, { stream: true });
68
- }
69
- return text + decoder.decode();
70
- }
71
- finally {
72
- reader.releaseLock();
73
- }
61
+ const body = await readBodyChunks({ read: () => reader.read(), cancel: () => void reader.cancel().catch(() => { }) }, { maxBytes, timeoutMs: BODY_TIMEOUT_MS });
62
+ return new TextDecoder('utf-8', { fatal: true }).decode(body);
74
63
  }
75
64
  async function readBoundedJson(res, maxBytes) {
76
65
  const body = await readBoundedResponseBody(res, maxBytes);
@@ -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
  }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The one bounded, deadlined body read behind every federation response body
3
+ * (an actor or collection `Response`, a failed delivery's preview).
4
+ *
5
+ * The transports bound a request up to its response headers; from there the
6
+ * reader owns the stream, and a remote that sends headers and then stalls (or
7
+ * trickles) the body would hold a plain read loop forever. This caps the bytes
8
+ * AND gives the whole body one deadline, cancelling the source on every early
9
+ * exit. It reads through a {@link BodyChunkSource} so a WHATWG reader and a
10
+ * Node async iterator share it.
11
+ */
12
+ /** Thrown when a body exceeds its byte cap under `overflow: 'reject'`. */
13
+ export class ResponseBodyTooLargeError extends Error {
14
+ constructor(maxBytes) {
15
+ super(`Remote response exceeds ${maxBytes} byte limit`);
16
+ this.maxBytes = maxBytes;
17
+ this.name = 'ResponseBodyTooLargeError';
18
+ }
19
+ }
20
+ /** Thrown when a body does not end within its deadline; this attempt failed, a retry may not. */
21
+ export class ResponseBodyTimeoutError extends Error {
22
+ constructor(timeoutMs) {
23
+ super(`Remote response body did not end within ${timeoutMs} ms`);
24
+ this.timeoutMs = timeoutMs;
25
+ this.name = 'ResponseBodyTimeoutError';
26
+ }
27
+ }
28
+ /**
29
+ * Read `source` into one Buffer, at most `maxBytes` and within `timeoutMs`.
30
+ * The deadline timer is cleared on every path; the source is cancelled on a
31
+ * cap, a deadline, a read error, or a truncated preview.
32
+ */
33
+ export async function readBodyChunks(source, limits) {
34
+ const { maxBytes, timeoutMs, overflow = 'reject' } = limits;
35
+ const chunks = [];
36
+ let total = 0;
37
+ let timer;
38
+ const deadline = new Promise((_, reject) => {
39
+ timer = setTimeout(() => reject(new ResponseBodyTimeoutError(timeoutMs)), timeoutMs);
40
+ });
41
+ try {
42
+ for (;;) {
43
+ // `Promise.race` subscribes to both, so neither the deadline nor a read
44
+ // that rejects once the source is cancelled goes unhandled.
45
+ const { done, value } = await Promise.race([source.read(), deadline]);
46
+ if (done || !value)
47
+ break;
48
+ const buffer = Buffer.isBuffer(value)
49
+ ? value
50
+ : Buffer.from(value.buffer, value.byteOffset, value.byteLength);
51
+ if (total + buffer.length > maxBytes) {
52
+ if (overflow === 'reject')
53
+ throw new ResponseBodyTooLargeError(maxBytes);
54
+ chunks.push(buffer.subarray(0, maxBytes - total));
55
+ total = maxBytes;
56
+ source.cancel();
57
+ break;
58
+ }
59
+ total += buffer.length;
60
+ chunks.push(buffer);
61
+ }
62
+ }
63
+ catch (err) {
64
+ source.cancel();
65
+ throw err;
66
+ }
67
+ finally {
68
+ clearTimeout(timer);
69
+ }
70
+ return Buffer.concat(chunks, total);
71
+ }
@@ -24,11 +24,14 @@
24
24
  * call `deliverToFollowers` / `deliverActivity` / `queueDelivery` here.
25
25
  */
26
26
  import { AP_CONTEXT } from '../apContext.js';
27
+ import { readBodyChunks } from './boundedBody.js';
27
28
  import { signRequest } from '../httpSignature.js';
28
29
  /** Total time budget for a single delivery POST (connect + response headers). */
29
30
  const DELIVER_ACTIVITY_TIMEOUT_MS = 15000;
30
31
  /** How many bytes of a failed-delivery response body are read for the debug log. */
31
32
  const DELIVERY_RESPONSE_PREVIEW_MAX_BYTES = 1024;
33
+ /** Deadline for reading that preview once the response headers arrived. */
34
+ const DELIVERY_RESPONSE_PREVIEW_TIMEOUT_MS = 5000;
32
35
  /** The ActivityStreams public collection — the `to` addressee of a public activity. */
33
36
  const AP_PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
34
37
  /** A definitive refusal: workers must discard, not retry or count a delivery. */
@@ -39,18 +42,20 @@ export class DeliveryAuthorizationDeniedError extends Error {
39
42
  this.name = 'DeliveryAuthorizationDeniedError';
40
43
  }
41
44
  }
42
- /** Read a bounded prefix of a failed-delivery response body for the debug log. */
45
+ /**
46
+ * Read a bounded prefix of a failed-delivery response body for the debug log,
47
+ * within {@link DELIVERY_RESPONSE_PREVIEW_TIMEOUT_MS}: an inbox that stalls its
48
+ * body after the headers costs the worker that long, not forever.
49
+ */
43
50
  async function readResponsePreview(response) {
44
- const chunks = [];
45
- let totalBytes = 0;
51
+ const iterator = response[Symbol.asyncIterator]();
46
52
  try {
47
- for await (const chunk of response) {
48
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
49
- totalBytes += buffer.byteLength;
50
- chunks.push(buffer);
51
- if (totalBytes >= DELIVERY_RESPONSE_PREVIEW_MAX_BYTES)
52
- break;
53
- }
53
+ const preview = await readBodyChunks({ read: () => iterator.next(), cancel: () => response.destroy() }, {
54
+ maxBytes: DELIVERY_RESPONSE_PREVIEW_MAX_BYTES,
55
+ timeoutMs: DELIVERY_RESPONSE_PREVIEW_TIMEOUT_MS,
56
+ overflow: 'truncate',
57
+ });
58
+ return preview.toString('utf8');
54
59
  }
55
60
  catch {
56
61
  return '';
@@ -58,7 +63,6 @@ async function readResponsePreview(response) {
58
63
  finally {
59
64
  response.destroy();
60
65
  }
61
- return Buffer.concat(chunks).toString('utf8', 0, DELIVERY_RESPONSE_PREVIEW_MAX_BYTES);
62
66
  }
63
67
  /** Build the outbound delivery + follow-lifecycle service from an app's adapters. */
64
68
  export function createDeliveryService(config) {
package/dist/esm/urls.js CHANGED
@@ -30,6 +30,7 @@ export function createUrlBuilders(domain, actorDomain = domain) {
30
30
  featured: (username) => `https://${domain}/ap/users/${username}/collections/featured`,
31
31
  followers: (username) => `https://${domain}/ap/users/${username}/followers`,
32
32
  following: (username) => `https://${domain}/ap/users/${username}/following`,
33
+ moderators: (username) => `https://${domain}/ap/users/${username}/moderators`,
33
34
  sharedInbox: () => `https://${domain}/ap/inbox`,
34
35
  };
35
36
  }
@@ -226,6 +226,12 @@ export interface LocalBoostEventPayload {
226
226
  */
227
227
  export interface LocalDeleteEventPayload {
228
228
  _id: unknown;
229
+ /**
230
+ * The community (Oxy account id) the post was written in, captured with the
231
+ * id before the row is gone, so a community that relayed the post (FEP-1b12)
232
+ * can relay its deletion to the same followers. Absent for an ordinary post.
233
+ */
234
+ communityOxyUserId?: string | null;
229
235
  }
230
236
  /**
231
237
  * The shape a `post.like` / `post.unlike` event carries to outbound delivery.
@@ -8,7 +8,8 @@
8
8
  * signature verification (Phase 2, `trustForwardedHost`) and actor-match, then
9
9
  * 202 + async dispatch to the injected inbound dispatcher,
10
10
  * - `GET /users/:username/followers` + `/following` — the OXY follow graph
11
- * (local + bridged federated edges) as paginated `OrderedCollection`s.
11
+ * (local + bridged federated edges) as paginated `OrderedCollection`s,
12
+ * - `GET /users/:username/moderators` — a community `Group`'s moderators.
12
13
  *
13
14
  * The CONTENT routes (`outbox`, `featured`, per-post dereference) stay in the app,
14
15
  * mounted on the SAME `/ap/users/:username/*` prefix the actor advertises.
@@ -22,7 +23,7 @@ import { Router } from 'express';
22
23
  import type { AccountKind } from '@oxy.so/contracts';
23
24
  import type { User } from '@oxy.so/core';
24
25
  import type { UrlBuilders } from '../urls';
25
- import type { LocalActorBuilder } from '../actorObject';
26
+ import { type LocalActorBuilder } from '../actorObject';
26
27
  /** The resolved-user fields the actor + collection routes read. */
27
28
  export interface ActorRouteUser {
28
29
  _id?: string | null;
@@ -107,6 +108,12 @@ export interface ActorRouterConfig {
107
108
  };
108
109
  /** Fetch one page of a user's Oxy follow graph (followers OR following). */
109
110
  fetchFollowPage(userId: string, direction: 'followers' | 'following', offset: number, limit: number): Promise<FollowPage>;
111
+ /**
112
+ * A community's moderators (Oxy: its active members), served as the `Group`'s
113
+ * `attributedTo` collection at `GET /users/:username/moderators`. Omitted ⇒
114
+ * the route is not mounted; any account that is not a community 404s.
115
+ */
116
+ fetchModerators?(userId: string): Promise<User[]>;
110
117
  /** Actor HTTP response TTL in whole seconds, 0..86400. Defaults to 1800; 0 sends no-store. */
111
118
  httpCacheTtlSeconds?: number;
112
119
  /** Diagnostics sink. */
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The one bounded, deadlined body read behind every federation response body
3
+ * (an actor or collection `Response`, a failed delivery's preview).
4
+ *
5
+ * The transports bound a request up to its response headers; from there the
6
+ * reader owns the stream, and a remote that sends headers and then stalls (or
7
+ * trickles) the body would hold a plain read loop forever. This caps the bytes
8
+ * AND gives the whole body one deadline, cancelling the source on every early
9
+ * exit. It reads through a {@link BodyChunkSource} so a WHATWG reader and a
10
+ * Node async iterator share it.
11
+ */
12
+ /** One `read()` step: a WHATWG `ReadableStreamDefaultReader` and an async iterator both return it. */
13
+ interface BodyChunk {
14
+ done?: boolean;
15
+ value?: Uint8Array;
16
+ }
17
+ /** Where the chunks come from, and how to release the stream when a read ends early. */
18
+ export interface BodyChunkSource {
19
+ read(): Promise<BodyChunk>;
20
+ cancel(): void;
21
+ }
22
+ /** Limits for {@link readBodyChunks}. */
23
+ export interface BodyReadLimits {
24
+ /** Largest body accepted, in bytes. */
25
+ maxBytes: number;
26
+ /** Deadline for the WHOLE body, from the call until the source ends, in milliseconds. */
27
+ timeoutMs: number;
28
+ /**
29
+ * `reject` (the default) throws {@link ResponseBodyTooLargeError} past the cap;
30
+ * `truncate` stops there and returns the first `maxBytes` (a preview).
31
+ */
32
+ overflow?: 'reject' | 'truncate';
33
+ }
34
+ /** Thrown when a body exceeds its byte cap under `overflow: 'reject'`. */
35
+ export declare class ResponseBodyTooLargeError extends Error {
36
+ readonly maxBytes: number;
37
+ constructor(maxBytes: number);
38
+ }
39
+ /** Thrown when a body does not end within its deadline; this attempt failed, a retry may not. */
40
+ export declare class ResponseBodyTimeoutError extends Error {
41
+ readonly timeoutMs: number;
42
+ constructor(timeoutMs: number);
43
+ }
44
+ /**
45
+ * Read `source` into one Buffer, at most `maxBytes` and within `timeoutMs`.
46
+ * The deadline timer is cleared on every path; the source is cancelled on a
47
+ * cap, a deadline, a read error, or a truncated preview.
48
+ */
49
+ export declare function readBodyChunks(source: BodyChunkSource, limits: BodyReadLimits): Promise<Buffer>;
50
+ export {};
@@ -30,6 +30,11 @@ export interface UrlBuilders {
30
30
  followers(username: string): string;
31
31
  /** The actor's following collection. */
32
32
  following(username: string): string;
33
+ /**
34
+ * A community `Group`'s moderators collection — what its `attributedTo` names
35
+ * (Lemmy, PieFed and Mbin read the moderators from it).
36
+ */
37
+ moderators(username: string): string;
33
38
  /** The instance-wide shared inbox. */
34
39
  sharedInbox(): string;
35
40
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/federation",
3
- "version": "2.5.0",
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());
@@ -0,0 +1,57 @@
1
+ import { PassThrough } from 'node:stream';
2
+ import {
3
+ type BodyChunkSource,
4
+ readBodyChunks,
5
+ ResponseBodyTimeoutError,
6
+ ResponseBodyTooLargeError,
7
+ } from '../node/boundedBody';
8
+
9
+ function streamSource(): { stream: PassThrough; source: BodyChunkSource } {
10
+ const stream = new PassThrough();
11
+ const iterator = stream[Symbol.asyncIterator]();
12
+ return { stream, source: { read: () => iterator.next(), cancel: () => stream.destroy() } };
13
+ }
14
+
15
+ describe('readBodyChunks', () => {
16
+ it('reads a body that ends within its limits', async () => {
17
+ const { stream, source } = streamSource();
18
+ const pending = readBodyChunks(source, { maxBytes: 8, timeoutMs: 1_000 });
19
+ stream.write('ab');
20
+ stream.end('cd');
21
+ expect((await pending).toString()).toBe('abcd');
22
+ });
23
+
24
+ it('rejects one byte past the cap (or truncates a preview) and destroys the stream', async () => {
25
+ const rejected = streamSource();
26
+ const pending = readBodyChunks(rejected.source, { maxBytes: 3, timeoutMs: 1_000 });
27
+ rejected.stream.write('abcd');
28
+ await expect(pending).rejects.toBeInstanceOf(ResponseBodyTooLargeError);
29
+ expect(rejected.stream.destroyed).toBe(true);
30
+
31
+ const truncated = streamSource();
32
+ const preview = readBodyChunks(truncated.source, {
33
+ maxBytes: 3,
34
+ timeoutMs: 1_000,
35
+ overflow: 'truncate',
36
+ });
37
+ truncated.stream.write('abcd');
38
+ expect((await preview).toString()).toBe('abc');
39
+ expect(truncated.stream.destroyed).toBe(true);
40
+ });
41
+
42
+ it('throws ResponseBodyTimeoutError for a body that stalls and destroys the stream', async () => {
43
+ jest.useFakeTimers();
44
+ try {
45
+ const { stream, source } = streamSource();
46
+ const pending = readBodyChunks(source, { maxBytes: 1_024, timeoutMs: 5_000 });
47
+ stream.write('{"partial":');
48
+ const settled = expect(pending).rejects.toBeInstanceOf(ResponseBodyTimeoutError);
49
+ await jest.advanceTimersByTimeAsync(5_000);
50
+ await settled;
51
+ expect(stream.destroyed).toBe(true);
52
+ expect(jest.getTimerCount()).toBe(0);
53
+ } finally {
54
+ jest.useRealTimers();
55
+ }
56
+ });
57
+ });
@@ -588,3 +588,45 @@ describe('actor router — inbox POST', () => {
588
588
  expect(res.status).toBe(404);
589
589
  });
590
590
  });
591
+
592
+ describe('community moderators collection', () => {
593
+ const moderatorsApp = () =>
594
+ makeActorApp({
595
+ actorConfig: {
596
+ resolveUser: async (username) =>
597
+ username === 'forum'
598
+ ? { _id: 'u-forum', kind: 'community' }
599
+ : username === 'alice'
600
+ ? { _id: 'u-alice' }
601
+ : null,
602
+ fetchModerators: async (userId) =>
603
+ userId === 'u-forum'
604
+ ? [
605
+ { id: 'u-alice', username: 'alice' },
606
+ {
607
+ id: 'u-bob',
608
+ username: 'bob',
609
+ type: 'federated',
610
+ federation: { actorUri: REMOTE_ACTOR },
611
+ },
612
+ ]
613
+ : [],
614
+ },
615
+ });
616
+
617
+ it('serves a community Group its moderators as actor ids', async () => {
618
+ const res = await request(moderatorsApp()).get('/ap/users/forum/moderators');
619
+ expect(res.status).toBe(200);
620
+ expect(res.body).toMatchObject({
621
+ id: urls.moderators('forum'),
622
+ type: 'OrderedCollection',
623
+ totalItems: 2,
624
+ orderedItems: [urls.actor('alice'), REMOTE_ACTOR],
625
+ });
626
+ });
627
+
628
+ it('404s an account that is not a community', async () => {
629
+ const res = await request(moderatorsApp()).get('/ap/users/alice/moderators');
630
+ expect(res.status).toBe(404);
631
+ });
632
+ });
@@ -396,6 +396,17 @@ export function createLocalActorBuilder(config: LocalActorBuilderConfig): LocalA
396
396
  },
397
397
  };
398
398
 
399
+ // A community is a FEP-1b12 `Group`: it names its moderators collection in
400
+ // `attributedTo` (Lemmy, PieFed; Mbin reads `moderators`) and says anybody
401
+ // may post into it. PeerTube refuses a `Group` without `attributedTo`, and a
402
+ // `Group` claiming `postingRestrictedToMods` is read as a channel by every
403
+ // implementation, ours included ({@link accountKindForActor}).
404
+ if (actorObject.type === LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND.community) {
405
+ actorObject.attributedTo = config.urls.moderators(username);
406
+ actorObject.moderators = config.urls.moderators(username);
407
+ actorObject.postingRestrictedToMods = false;
408
+ }
409
+
399
410
  const aliases = normalizeAlsoKnownAs(params.alsoKnownAs);
400
411
  if (aliases.length > 0) {
401
412
  actorObject.alsoKnownAs = aliases;
package/src/index.ts CHANGED
@@ -303,6 +303,12 @@ export interface LocalBoostEventPayload {
303
303
  */
304
304
  export interface LocalDeleteEventPayload {
305
305
  _id: unknown;
306
+ /**
307
+ * The community (Oxy account id) the post was written in, captured with the
308
+ * id before the row is gone, so a community that relayed the post (FEP-1b12)
309
+ * can relay its deletion to the same followers. Absent for an ordinary post.
310
+ */
311
+ communityOxyUserId?: string | null;
306
312
  }
307
313
 
308
314
  /**
@@ -27,6 +27,7 @@ import {
27
27
  type NetworkIdentityCandidate,
28
28
  } from '../networkIdentity';
29
29
  import type { NormalizedExternalActor } from '../index';
30
+ import { readBodyChunks, ResponseBodyTooLargeError } from './boundedBody';
30
31
  import type { SignedFetch } from './signedFetch';
31
32
  import type { ReportActorGoneOutcome } from './identityBridge';
32
33
 
@@ -53,37 +54,31 @@ const COLLECTION_BODY_MAX_BYTES = 64 * 1024;
53
54
  const COLLECTION_WITHHELD_STATUSES: ReadonlySet<number> = new Set([401, 403, 404, 410]);
54
55
  const ERROR_BODY_MAX_BYTES = 4 * 1024;
55
56
 
57
+ /**
58
+ * Deadline for reading a whole actor/collection/error body once its headers
59
+ * arrived. `signedFetch`'s timeout ends at the headers, so without this a remote
60
+ * that stalls mid-body would hold the resolver open.
61
+ */
62
+ const BODY_TIMEOUT_MS = 10_000;
63
+
56
64
  async function readBoundedResponseBody(res: Response, maxBytes: number): Promise<string> {
57
65
  const contentLength = res.headers.get('content-length');
58
66
  if (contentLength) {
59
67
  const declaredBytes = Number(contentLength);
60
68
  if (Number.isFinite(declaredBytes) && declaredBytes > maxBytes) {
61
69
  await res.body?.cancel().catch(() => {});
62
- throw new Error(`Remote response exceeds ${maxBytes} byte limit`);
70
+ throw new ResponseBodyTooLargeError(maxBytes);
63
71
  }
64
72
  }
65
73
 
66
74
  if (!res.body) return '';
67
75
 
68
76
  const reader = res.body.getReader();
69
- const decoder = new TextDecoder('utf-8', { fatal: true });
70
- let bytesRead = 0;
71
- let text = '';
72
- try {
73
- while (true) {
74
- const { done, value } = await reader.read();
75
- if (done) break;
76
- bytesRead += value.byteLength;
77
- if (bytesRead > maxBytes) {
78
- await reader.cancel().catch(() => {});
79
- throw new Error(`Remote response exceeds ${maxBytes} byte limit`);
80
- }
81
- text += decoder.decode(value, { stream: true });
82
- }
83
- return text + decoder.decode();
84
- } finally {
85
- reader.releaseLock();
86
- }
77
+ const body = await readBodyChunks(
78
+ { read: () => reader.read(), cancel: () => void reader.cancel().catch(() => {}) },
79
+ { maxBytes, timeoutMs: BODY_TIMEOUT_MS },
80
+ );
81
+ return new TextDecoder('utf-8', { fatal: true }).decode(body);
87
82
  }
88
83
 
89
84
  async function readBoundedJson(res: Response, maxBytes: number): Promise<Record<string, unknown>> {
@@ -8,7 +8,8 @@
8
8
  * signature verification (Phase 2, `trustForwardedHost`) and actor-match, then
9
9
  * 202 + async dispatch to the injected inbound dispatcher,
10
10
  * - `GET /users/:username/followers` + `/following` — the OXY follow graph
11
- * (local + bridged federated edges) as paginated `OrderedCollection`s.
11
+ * (local + bridged federated edges) as paginated `OrderedCollection`s,
12
+ * - `GET /users/:username/moderators` — a community `Group`'s moderators.
12
13
  *
13
14
  * The CONTENT routes (`outbox`, `featured`, per-post dereference) stay in the app,
14
15
  * mounted on the SAME `/ap/users/:username/*` prefix the actor advertises.
@@ -27,7 +28,11 @@ import { discoveryCacheControl } from './httpCacheControl';
27
28
  import { verifyHttpSignature } from '../httpSignature';
28
29
  import type { UrlBuilders } from '../urls';
29
30
  import { INSTANCE_ACTOR_USERNAME, normalizeActorUsername } from '../urls';
30
- import type { LocalActorBuilder } from '../actorObject';
31
+ import {
32
+ LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND,
33
+ localActorTypeForAccountKind,
34
+ type LocalActorBuilder,
35
+ } from '../actorObject';
31
36
 
32
37
  /** Page size for the paginated followers/following collections (mirrors the outbox). */
33
38
  const FOLLOW_PAGE_SIZE = 20;
@@ -122,6 +127,12 @@ export interface ActorRouterConfig {
122
127
  offset: number,
123
128
  limit: number,
124
129
  ): Promise<FollowPage>;
130
+ /**
131
+ * A community's moderators (Oxy: its active members), served as the `Group`'s
132
+ * `attributedTo` collection at `GET /users/:username/moderators`. Omitted ⇒
133
+ * the route is not mounted; any account that is not a community 404s.
134
+ */
135
+ fetchModerators?(userId: string): Promise<User[]>;
125
136
  /** Actor HTTP response TTL in whole seconds, 0..86400. Defaults to 1800; 0 sends no-store. */
126
137
  httpCacheTtlSeconds?: number;
127
138
  /** Diagnostics sink. */
@@ -471,5 +482,42 @@ export function createActorRouter(config: ActorRouterConfig): Router {
471
482
  serveFollowCollection(req, res, 'following', urls.following),
472
483
  );
473
484
 
485
+ // GET /ap/users/:username/moderators — a community `Group`'s moderators, the
486
+ // collection its `attributedTo` names. One unpaged `OrderedCollection`, the
487
+ // shape Lemmy and PieFed serve and read back.
488
+ const { fetchModerators } = config;
489
+ if (fetchModerators) {
490
+ router.get('/users/:username/moderators', async (req: Request, res: Response) => {
491
+ if (!config.federationEnabled) return res.status(404).json({ error: 'Federation disabled' });
492
+ const username = getUsername(req);
493
+ try {
494
+ const user = await config.resolveUser(username);
495
+ if (
496
+ !user ||
497
+ !config.consent.isSharingEnabledFromUser(user) ||
498
+ localActorTypeForAccountKind(user.kind) !== LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND.community
499
+ ) {
500
+ return res.status(404).json({ error: 'User not found' });
501
+ }
502
+ const moderators = await fetchModerators(String(user._id || user.id));
503
+ const orderedItems = moderators
504
+ .map((member) => memberActorUri(member, urls))
505
+ .filter((uri): uri is string => uri !== null);
506
+ res.set('Content-Type', apContentType);
507
+ res.set('Cache-Control', httpCacheControl);
508
+ return res.json({
509
+ '@context': AP_CONTEXT,
510
+ id: urls.moderators(username),
511
+ type: 'OrderedCollection',
512
+ totalItems: orderedItems.length,
513
+ orderedItems,
514
+ });
515
+ } catch (err) {
516
+ logger.error('Moderators collection endpoint error:', err);
517
+ return res.status(500).json({ error: 'Internal server error' });
518
+ }
519
+ });
520
+ }
521
+
474
522
  return router;
475
523
  }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The one bounded, deadlined body read behind every federation response body
3
+ * (an actor or collection `Response`, a failed delivery's preview).
4
+ *
5
+ * The transports bound a request up to its response headers; from there the
6
+ * reader owns the stream, and a remote that sends headers and then stalls (or
7
+ * trickles) the body would hold a plain read loop forever. This caps the bytes
8
+ * AND gives the whole body one deadline, cancelling the source on every early
9
+ * exit. It reads through a {@link BodyChunkSource} so a WHATWG reader and a
10
+ * Node async iterator share it.
11
+ */
12
+
13
+ /** One `read()` step: a WHATWG `ReadableStreamDefaultReader` and an async iterator both return it. */
14
+ interface BodyChunk {
15
+ done?: boolean;
16
+ value?: Uint8Array;
17
+ }
18
+
19
+ /** Where the chunks come from, and how to release the stream when a read ends early. */
20
+ export interface BodyChunkSource {
21
+ read(): Promise<BodyChunk>;
22
+ cancel(): void;
23
+ }
24
+
25
+ /** Limits for {@link readBodyChunks}. */
26
+ export interface BodyReadLimits {
27
+ /** Largest body accepted, in bytes. */
28
+ maxBytes: number;
29
+ /** Deadline for the WHOLE body, from the call until the source ends, in milliseconds. */
30
+ timeoutMs: number;
31
+ /**
32
+ * `reject` (the default) throws {@link ResponseBodyTooLargeError} past the cap;
33
+ * `truncate` stops there and returns the first `maxBytes` (a preview).
34
+ */
35
+ overflow?: 'reject' | 'truncate';
36
+ }
37
+
38
+ /** Thrown when a body exceeds its byte cap under `overflow: 'reject'`. */
39
+ export class ResponseBodyTooLargeError extends Error {
40
+ constructor(public readonly maxBytes: number) {
41
+ super(`Remote response exceeds ${maxBytes} byte limit`);
42
+ this.name = 'ResponseBodyTooLargeError';
43
+ }
44
+ }
45
+
46
+ /** Thrown when a body does not end within its deadline; this attempt failed, a retry may not. */
47
+ export class ResponseBodyTimeoutError extends Error {
48
+ constructor(public readonly timeoutMs: number) {
49
+ super(`Remote response body did not end within ${timeoutMs} ms`);
50
+ this.name = 'ResponseBodyTimeoutError';
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Read `source` into one Buffer, at most `maxBytes` and within `timeoutMs`.
56
+ * The deadline timer is cleared on every path; the source is cancelled on a
57
+ * cap, a deadline, a read error, or a truncated preview.
58
+ */
59
+ export async function readBodyChunks(
60
+ source: BodyChunkSource,
61
+ limits: BodyReadLimits,
62
+ ): Promise<Buffer> {
63
+ const { maxBytes, timeoutMs, overflow = 'reject' } = limits;
64
+ const chunks: Buffer[] = [];
65
+ let total = 0;
66
+ let timer: ReturnType<typeof setTimeout> | undefined;
67
+ const deadline = new Promise<never>((_, reject) => {
68
+ timer = setTimeout(() => reject(new ResponseBodyTimeoutError(timeoutMs)), timeoutMs);
69
+ });
70
+ try {
71
+ for (;;) {
72
+ // `Promise.race` subscribes to both, so neither the deadline nor a read
73
+ // that rejects once the source is cancelled goes unhandled.
74
+ const { done, value } = await Promise.race([source.read(), deadline]);
75
+ if (done || !value) break;
76
+ const buffer = Buffer.isBuffer(value)
77
+ ? value
78
+ : Buffer.from(value.buffer, value.byteOffset, value.byteLength);
79
+ if (total + buffer.length > maxBytes) {
80
+ if (overflow === 'reject') throw new ResponseBodyTooLargeError(maxBytes);
81
+ chunks.push(buffer.subarray(0, maxBytes - total));
82
+ total = maxBytes;
83
+ source.cancel();
84
+ break;
85
+ }
86
+ total += buffer.length;
87
+ chunks.push(buffer);
88
+ }
89
+ } catch (err) {
90
+ source.cancel();
91
+ throw err;
92
+ } finally {
93
+ clearTimeout(timer);
94
+ }
95
+ return Buffer.concat(chunks, total);
96
+ }
@@ -26,6 +26,7 @@
26
26
 
27
27
  import type { AccountKind } from '@oxy.so/contracts';
28
28
  import { AP_CONTEXT } from '../apContext';
29
+ import { readBodyChunks } from './boundedBody';
29
30
  import { signRequest, type HttpSignatureSigner } from '../httpSignature';
30
31
  import type { UrlBuilders } from '../urls';
31
32
  import type { LocalActorBuilder } from '../actorObject';
@@ -34,6 +35,8 @@ import type { LocalActorBuilder } from '../actorObject';
34
35
  const DELIVER_ACTIVITY_TIMEOUT_MS = 15000;
35
36
  /** How many bytes of a failed-delivery response body are read for the debug log. */
36
37
  const DELIVERY_RESPONSE_PREVIEW_MAX_BYTES = 1024;
38
+ /** Deadline for reading that preview once the response headers arrived. */
39
+ const DELIVERY_RESPONSE_PREVIEW_TIMEOUT_MS = 5_000;
37
40
 
38
41
  /** The ActivityStreams public collection — the `to` addressee of a public activity. */
39
42
  const AP_PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
@@ -278,25 +281,28 @@ export interface DeliveryServiceConfig<TActor extends DeliveryActorFields> {
278
281
  isBlockedDomain(host: string): boolean;
279
282
  }
280
283
 
281
- /** Read a bounded prefix of a failed-delivery response body for the debug log. */
284
+ /**
285
+ * Read a bounded prefix of a failed-delivery response body for the debug log,
286
+ * within {@link DELIVERY_RESPONSE_PREVIEW_TIMEOUT_MS}: an inbox that stalls its
287
+ * body after the headers costs the worker that long, not forever.
288
+ */
282
289
  async function readResponsePreview(response: DeliveryResponseStream): Promise<string> {
283
- const chunks: Buffer[] = [];
284
- let totalBytes = 0;
285
-
290
+ const iterator = response[Symbol.asyncIterator]();
286
291
  try {
287
- for await (const chunk of response) {
288
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
289
- totalBytes += buffer.byteLength;
290
- chunks.push(buffer);
291
- if (totalBytes >= DELIVERY_RESPONSE_PREVIEW_MAX_BYTES) break;
292
- }
292
+ const preview = await readBodyChunks(
293
+ { read: () => iterator.next(), cancel: () => response.destroy() },
294
+ {
295
+ maxBytes: DELIVERY_RESPONSE_PREVIEW_MAX_BYTES,
296
+ timeoutMs: DELIVERY_RESPONSE_PREVIEW_TIMEOUT_MS,
297
+ overflow: 'truncate',
298
+ },
299
+ );
300
+ return preview.toString('utf8');
293
301
  } catch {
294
302
  return '';
295
303
  } finally {
296
304
  response.destroy();
297
305
  }
298
-
299
- return Buffer.concat(chunks).toString('utf8', 0, DELIVERY_RESPONSE_PREVIEW_MAX_BYTES);
300
306
  }
301
307
 
302
308
  /** The outbound delivery + follow-lifecycle service. */
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
  }