@oxy.so/federation 2.4.2 → 2.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/actorObject.js +51 -1
- package/dist/cjs/index.js +2 -1
- package/dist/cjs/node/actorResolver.js +10 -21
- package/dist/cjs/node/boundedBody.js +77 -0
- package/dist/cjs/node/delivery.js +15 -11
- package/dist/esm/actorObject.js +50 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/node/actorResolver.js +10 -21
- package/dist/esm/node/boundedBody.js +71 -0
- package/dist/esm/node/delivery.js +15 -11
- package/dist/types/actorObject.d.ts +33 -16
- package/dist/types/index.d.ts +1 -1
- package/dist/types/node/boundedBody.d.ts +50 -0
- package/package.json +1 -1
- package/src/__tests__/actorObject.test.ts +25 -5
- package/src/__tests__/boundedBody.test.ts +57 -0
- package/src/actorObject.ts +72 -16
- package/src/index.ts +2 -0
- package/src/node/actorResolver.ts +14 -19
- package/src/node/boundedBody.ts +96 -0
- package/src/node/delivery.ts +18 -12
package/dist/cjs/actorObject.js
CHANGED
|
@@ -22,6 +22,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
22
22
|
exports.LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND = exports.AP_ACTOR_TYPES = void 0;
|
|
23
23
|
exports.isApActorType = isApActorType;
|
|
24
24
|
exports.localActorTypeForAccountKind = localActorTypeForAccountKind;
|
|
25
|
+
exports.accountKindForActor = accountKindForActor;
|
|
25
26
|
exports.normalizeAlsoKnownAs = normalizeAlsoKnownAs;
|
|
26
27
|
exports.createLocalActorBuilder = createLocalActorBuilder;
|
|
27
28
|
const contracts_1 = require("@oxy.so/contracts");
|
|
@@ -46,7 +47,10 @@ function isApActorType(value) {
|
|
|
46
47
|
return typeof value === 'string' && exports.AP_ACTOR_TYPES.includes(value);
|
|
47
48
|
}
|
|
48
49
|
/**
|
|
49
|
-
* Oxy account kind → the AS2 actor type the fediverse is told about it
|
|
50
|
+
* Oxy account kind → the AS2 actor type the fediverse is told about it — the
|
|
51
|
+
* ONE table both directions read: what Oxy announces for its own accounts here,
|
|
52
|
+
* and, through {@link accountKindForActor}, what it records a remote actor as.
|
|
53
|
+
* Changing how a kind federates is an edit to this table and nowhere else.
|
|
50
54
|
*
|
|
51
55
|
* `satisfies Record<AccountKind, LocalActorType>` is the load-bearing part: a
|
|
52
56
|
* kind added to `@oxy.so/contracts` fails THIS build rather than silently
|
|
@@ -78,6 +82,8 @@ function isApActorType(value) {
|
|
|
78
82
|
* accepted by all four implementations' whitelists and compared in none of
|
|
79
83
|
* them — neither `bot?` nor `group?` in Mastodon, `bot_account = false` in
|
|
80
84
|
* Lemmy, `isBot` false in Misskey, an ordinary account in PeerTube.
|
|
85
|
+
* - **`community` → `Group`.** A forum its members post into and that
|
|
86
|
+
* re-broadcasts them: exactly what Lemmy, Mbin and Friendica mean by `Group`.
|
|
81
87
|
*
|
|
82
88
|
* What this does NOT do: it does not stop a remote instance offering a reply box
|
|
83
89
|
* under a channel's post. NO actor type gates that in any of the four — Mastodon
|
|
@@ -92,6 +98,7 @@ exports.LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND = {
|
|
|
92
98
|
project: 'Organization',
|
|
93
99
|
bot: 'Service',
|
|
94
100
|
channel: 'Organization',
|
|
101
|
+
community: 'Group',
|
|
95
102
|
};
|
|
96
103
|
/**
|
|
97
104
|
* The AS2 actor type for an Oxy account kind, defaulting to `Person`.
|
|
@@ -108,6 +115,49 @@ exports.LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND = {
|
|
|
108
115
|
function localActorTypeForAccountKind(kind) {
|
|
109
116
|
return (0, contracts_1.isAccountKind)(kind) ? exports.LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND[kind] : 'Person';
|
|
110
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* The inverse of {@link LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND}, derived from it. Three
|
|
120
|
+
* kinds share `Organization`, so a type reads back as the FIRST kind the table
|
|
121
|
+
* lists for it (`Organization` → `organization`); nothing on the wire tells a
|
|
122
|
+
* remote project from a remote organization.
|
|
123
|
+
*/
|
|
124
|
+
const ACCOUNT_KIND_BY_LOCAL_ACTOR_TYPE = (() => {
|
|
125
|
+
const inverse = new Map();
|
|
126
|
+
for (const [kind, type] of Object.entries(exports.LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND)) {
|
|
127
|
+
if (!inverse.has(type))
|
|
128
|
+
inverse.set(type, kind);
|
|
129
|
+
}
|
|
130
|
+
return inverse;
|
|
131
|
+
})();
|
|
132
|
+
/**
|
|
133
|
+
* AS2 actor types Oxy never announces but receives, and the announced type each
|
|
134
|
+
* is read as. `Application` is a non-human software actor — what `Service`
|
|
135
|
+
* already says, and half of Mastodon's own `bot?`.
|
|
136
|
+
*/
|
|
137
|
+
const INBOUND_ACTOR_TYPE_ALIASES = new Map([
|
|
138
|
+
['Application', 'Service'],
|
|
139
|
+
]);
|
|
140
|
+
/**
|
|
141
|
+
* The kind a `Group` that only its moderators may post into is recorded as.
|
|
142
|
+
* Such a group forwards nobody: it PUBLISHES (a PeerTube video channel, a
|
|
143
|
+
* WordPress multi-author blog), which is what an Oxy channel is. The one
|
|
144
|
+
* inbound signal the table above has no column for.
|
|
145
|
+
*/
|
|
146
|
+
const MODERATED_GROUP_KIND = 'channel';
|
|
147
|
+
/**
|
|
148
|
+
* The Oxy account kind a REMOTE actor is recorded as, read from the same table
|
|
149
|
+
* the local announcement uses ({@link LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND}). A type
|
|
150
|
+
* the table does not know (absent, misspelt, an extension type) is `personal`,
|
|
151
|
+
* the same default {@link localActorTypeForAccountKind} announces.
|
|
152
|
+
*/
|
|
153
|
+
function accountKindForActor(actor) {
|
|
154
|
+
if (typeof actor.type !== 'string')
|
|
155
|
+
return 'personal';
|
|
156
|
+
const type = INBOUND_ACTOR_TYPE_ALIASES.get(actor.type) ?? actor.type;
|
|
157
|
+
if (type === exports.LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND.community && actor.postingRestrictedToMods === true)
|
|
158
|
+
return MODERATED_GROUP_KIND;
|
|
159
|
+
return ACCOUNT_KIND_BY_LOCAL_ACTOR_TYPE.get(type) ?? 'personal';
|
|
160
|
+
}
|
|
111
161
|
/** Map common image extensions to a MIME type for an actor image `mediaType`. */
|
|
112
162
|
const IMAGE_MEDIA_TYPE_BY_EXT = {
|
|
113
163
|
png: 'image/png',
|
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.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.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
|
|
@@ -95,6 +95,7 @@ var actorObject_1 = require("./actorObject");
|
|
|
95
95
|
Object.defineProperty(exports, "createLocalActorBuilder", { enumerable: true, get: function () { return actorObject_1.createLocalActorBuilder; } });
|
|
96
96
|
Object.defineProperty(exports, "normalizeAlsoKnownAs", { enumerable: true, get: function () { return actorObject_1.normalizeAlsoKnownAs; } });
|
|
97
97
|
Object.defineProperty(exports, "localActorTypeForAccountKind", { enumerable: true, get: function () { return actorObject_1.localActorTypeForAccountKind; } });
|
|
98
|
+
Object.defineProperty(exports, "accountKindForActor", { enumerable: true, get: function () { return actorObject_1.accountKindForActor; } });
|
|
98
99
|
Object.defineProperty(exports, "isApActorType", { enumerable: true, get: function () { return actorObject_1.isApActorType; } });
|
|
99
100
|
Object.defineProperty(exports, "AP_ACTOR_TYPES", { enumerable: true, get: function () { return actorObject_1.AP_ACTOR_TYPES; } });
|
|
100
101
|
Object.defineProperty(exports, "LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND", { enumerable: true, get: function () { return actorObject_1.LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND; } });
|
|
@@ -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
|
|
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
|
|
59
|
-
|
|
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);
|
|
@@ -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
|
-
/**
|
|
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
|
|
50
|
-
let totalBytes = 0;
|
|
56
|
+
const iterator = response[Symbol.asyncIterator]();
|
|
51
57
|
try {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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/esm/actorObject.js
CHANGED
|
@@ -39,7 +39,10 @@ export function isApActorType(value) {
|
|
|
39
39
|
return typeof value === 'string' && AP_ACTOR_TYPES.includes(value);
|
|
40
40
|
}
|
|
41
41
|
/**
|
|
42
|
-
* Oxy account kind → the AS2 actor type the fediverse is told about it
|
|
42
|
+
* Oxy account kind → the AS2 actor type the fediverse is told about it — the
|
|
43
|
+
* ONE table both directions read: what Oxy announces for its own accounts here,
|
|
44
|
+
* and, through {@link accountKindForActor}, what it records a remote actor as.
|
|
45
|
+
* Changing how a kind federates is an edit to this table and nowhere else.
|
|
43
46
|
*
|
|
44
47
|
* `satisfies Record<AccountKind, LocalActorType>` is the load-bearing part: a
|
|
45
48
|
* kind added to `@oxy.so/contracts` fails THIS build rather than silently
|
|
@@ -71,6 +74,8 @@ export function isApActorType(value) {
|
|
|
71
74
|
* accepted by all four implementations' whitelists and compared in none of
|
|
72
75
|
* them — neither `bot?` nor `group?` in Mastodon, `bot_account = false` in
|
|
73
76
|
* Lemmy, `isBot` false in Misskey, an ordinary account in PeerTube.
|
|
77
|
+
* - **`community` → `Group`.** A forum its members post into and that
|
|
78
|
+
* re-broadcasts them: exactly what Lemmy, Mbin and Friendica mean by `Group`.
|
|
74
79
|
*
|
|
75
80
|
* What this does NOT do: it does not stop a remote instance offering a reply box
|
|
76
81
|
* under a channel's post. NO actor type gates that in any of the four — Mastodon
|
|
@@ -85,6 +90,7 @@ export const LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND = {
|
|
|
85
90
|
project: 'Organization',
|
|
86
91
|
bot: 'Service',
|
|
87
92
|
channel: 'Organization',
|
|
93
|
+
community: 'Group',
|
|
88
94
|
};
|
|
89
95
|
/**
|
|
90
96
|
* The AS2 actor type for an Oxy account kind, defaulting to `Person`.
|
|
@@ -101,6 +107,49 @@ export const LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND = {
|
|
|
101
107
|
export function localActorTypeForAccountKind(kind) {
|
|
102
108
|
return isAccountKind(kind) ? LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND[kind] : 'Person';
|
|
103
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* The inverse of {@link LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND}, derived from it. Three
|
|
112
|
+
* kinds share `Organization`, so a type reads back as the FIRST kind the table
|
|
113
|
+
* lists for it (`Organization` → `organization`); nothing on the wire tells a
|
|
114
|
+
* remote project from a remote organization.
|
|
115
|
+
*/
|
|
116
|
+
const ACCOUNT_KIND_BY_LOCAL_ACTOR_TYPE = (() => {
|
|
117
|
+
const inverse = new Map();
|
|
118
|
+
for (const [kind, type] of Object.entries(LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND)) {
|
|
119
|
+
if (!inverse.has(type))
|
|
120
|
+
inverse.set(type, kind);
|
|
121
|
+
}
|
|
122
|
+
return inverse;
|
|
123
|
+
})();
|
|
124
|
+
/**
|
|
125
|
+
* AS2 actor types Oxy never announces but receives, and the announced type each
|
|
126
|
+
* is read as. `Application` is a non-human software actor — what `Service`
|
|
127
|
+
* already says, and half of Mastodon's own `bot?`.
|
|
128
|
+
*/
|
|
129
|
+
const INBOUND_ACTOR_TYPE_ALIASES = new Map([
|
|
130
|
+
['Application', 'Service'],
|
|
131
|
+
]);
|
|
132
|
+
/**
|
|
133
|
+
* The kind a `Group` that only its moderators may post into is recorded as.
|
|
134
|
+
* Such a group forwards nobody: it PUBLISHES (a PeerTube video channel, a
|
|
135
|
+
* WordPress multi-author blog), which is what an Oxy channel is. The one
|
|
136
|
+
* inbound signal the table above has no column for.
|
|
137
|
+
*/
|
|
138
|
+
const MODERATED_GROUP_KIND = 'channel';
|
|
139
|
+
/**
|
|
140
|
+
* The Oxy account kind a REMOTE actor is recorded as, read from the same table
|
|
141
|
+
* the local announcement uses ({@link LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND}). A type
|
|
142
|
+
* the table does not know (absent, misspelt, an extension type) is `personal`,
|
|
143
|
+
* the same default {@link localActorTypeForAccountKind} announces.
|
|
144
|
+
*/
|
|
145
|
+
export function accountKindForActor(actor) {
|
|
146
|
+
if (typeof actor.type !== 'string')
|
|
147
|
+
return 'personal';
|
|
148
|
+
const type = INBOUND_ACTOR_TYPE_ALIASES.get(actor.type) ?? actor.type;
|
|
149
|
+
if (type === LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND.community && actor.postingRestrictedToMods === true)
|
|
150
|
+
return MODERATED_GROUP_KIND;
|
|
151
|
+
return ACCOUNT_KIND_BY_LOCAL_ACTOR_TYPE.get(type) ?? 'personal';
|
|
152
|
+
}
|
|
104
153
|
/** Map common image extensions to a MIME type for an actor image `mediaType`. */
|
|
105
154
|
const IMAGE_MEDIA_TYPE_BY_EXT = {
|
|
106
155
|
png: 'image/png',
|
package/dist/esm/index.js
CHANGED
|
@@ -62,4 +62,4 @@ export { canonicalFederationHost, isSameFederationHost, extractActorUriFromActiv
|
|
|
62
62
|
* byte-identical across apps, with media resolution injected. The actor `type`
|
|
63
63
|
* follows the Oxy account kind ({@link LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND}).
|
|
64
64
|
*/
|
|
65
|
-
export { createLocalActorBuilder, normalizeAlsoKnownAs, localActorTypeForAccountKind, isApActorType, AP_ACTOR_TYPES, LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND, } from './actorObject.js';
|
|
65
|
+
export { createLocalActorBuilder, normalizeAlsoKnownAs, localActorTypeForAccountKind, accountKindForActor, isApActorType, AP_ACTOR_TYPES, LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND, } from './actorObject.js';
|
|
@@ -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
|
|
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
|
|
55
|
-
|
|
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);
|
|
@@ -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
|
-
/**
|
|
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
|
|
45
|
-
let totalBytes = 0;
|
|
51
|
+
const iterator = response[Symbol.asyncIterator]();
|
|
46
52
|
try {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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) {
|
|
@@ -36,28 +36,29 @@ export declare function isApActorType(value: unknown): value is ApActorType;
|
|
|
36
36
|
/**
|
|
37
37
|
* The AS2 actor types this engine will announce for a LOCAL user actor.
|
|
38
38
|
*
|
|
39
|
-
* Narrower than AS2's five actor types, and narrow ON PURPOSE
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* `fe0da961`), not against the spec:
|
|
39
|
+
* Narrower than AS2's five actor types, and narrow ON PURPOSE. Both decisions
|
|
40
|
+
* were checked against real receiving implementations (mastodon `a3649295`,
|
|
41
|
+
* lemmy `4ce92433`, misskey `b95e4841`, peertube `fe0da961`), not against the
|
|
42
|
+
* spec:
|
|
44
43
|
*
|
|
45
44
|
* - **`Application`** is the INSTANCE actor's type (see the actor router's
|
|
46
45
|
* `/ap/users/instance` branch), reserved by convention for the software
|
|
47
46
|
* itself. No account is the software.
|
|
48
|
-
* - **`Group`** is
|
|
49
|
-
* "a collective of actors" but as a FORWARDING
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
* `Group` would advertise a protocol this engine does not implement.
|
|
47
|
+
* - **`Group`** is announced ONLY for a `community`, because in the deployed
|
|
48
|
+
* fediverse it is not read as "a collective of actors" but as a FORWARDING
|
|
49
|
+
* actor. Lemmy reclassifies a remote `Group` as a community: followable, the
|
|
50
|
+
* Follow is Accepted, and then it never shows a post unless the actor
|
|
51
|
+
* `Announce`s its members' posts. PeerTube rejects a `Group` outright unless
|
|
52
|
+
* it carries `attributedTo` naming a `Person`. An account that authors its
|
|
53
|
+
* own posts (a project, a channel) must therefore never claim it; a community
|
|
54
|
+
* is the one kind whose nature IS forwarding.
|
|
57
55
|
*/
|
|
58
|
-
export type LocalActorType = Extract<ApActorType, 'Person' | 'Organization' | 'Service'>;
|
|
56
|
+
export type LocalActorType = Extract<ApActorType, 'Person' | 'Organization' | 'Service' | 'Group'>;
|
|
59
57
|
/**
|
|
60
|
-
* Oxy account kind → the AS2 actor type the fediverse is told about it
|
|
58
|
+
* Oxy account kind → the AS2 actor type the fediverse is told about it — the
|
|
59
|
+
* ONE table both directions read: what Oxy announces for its own accounts here,
|
|
60
|
+
* and, through {@link accountKindForActor}, what it records a remote actor as.
|
|
61
|
+
* Changing how a kind federates is an edit to this table and nowhere else.
|
|
61
62
|
*
|
|
62
63
|
* `satisfies Record<AccountKind, LocalActorType>` is the load-bearing part: a
|
|
63
64
|
* kind added to `@oxy.so/contracts` fails THIS build rather than silently
|
|
@@ -89,6 +90,8 @@ export type LocalActorType = Extract<ApActorType, 'Person' | 'Organization' | 'S
|
|
|
89
90
|
* accepted by all four implementations' whitelists and compared in none of
|
|
90
91
|
* them — neither `bot?` nor `group?` in Mastodon, `bot_account = false` in
|
|
91
92
|
* Lemmy, `isBot` false in Misskey, an ordinary account in PeerTube.
|
|
93
|
+
* - **`community` → `Group`.** A forum its members post into and that
|
|
94
|
+
* re-broadcasts them: exactly what Lemmy, Mbin and Friendica mean by `Group`.
|
|
92
95
|
*
|
|
93
96
|
* What this does NOT do: it does not stop a remote instance offering a reply box
|
|
94
97
|
* under a channel's post. NO actor type gates that in any of the four — Mastodon
|
|
@@ -103,6 +106,7 @@ export declare const LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND: {
|
|
|
103
106
|
readonly project: "Organization";
|
|
104
107
|
readonly bot: "Service";
|
|
105
108
|
readonly channel: "Organization";
|
|
109
|
+
readonly community: "Group";
|
|
106
110
|
};
|
|
107
111
|
/**
|
|
108
112
|
* The AS2 actor type for an Oxy account kind, defaulting to `Person`.
|
|
@@ -117,6 +121,19 @@ export declare const LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND: {
|
|
|
117
121
|
* existed.
|
|
118
122
|
*/
|
|
119
123
|
export declare function localActorTypeForAccountKind(kind: unknown): LocalActorType;
|
|
124
|
+
/** The fields of a remote actor document that decide its kind. */
|
|
125
|
+
export interface ActorKindSignals {
|
|
126
|
+
type?: unknown;
|
|
127
|
+
/** Lemmy's and PeerTube's flag: only moderators may post into this `Group`. */
|
|
128
|
+
postingRestrictedToMods?: unknown;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* The Oxy account kind a REMOTE actor is recorded as, read from the same table
|
|
132
|
+
* the local announcement uses ({@link LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND}). A type
|
|
133
|
+
* the table does not know (absent, misspelt, an extension type) is `personal`,
|
|
134
|
+
* the same default {@link localActorTypeForAccountKind} announces.
|
|
135
|
+
*/
|
|
136
|
+
export declare function accountKindForActor(actor: ActorKindSignals): AccountKind;
|
|
120
137
|
/**
|
|
121
138
|
* App-supplied media resolution for the actor `icon`/`image`. Each function
|
|
122
139
|
* resolves a stored reference (Oxy file id or URL) to a FINAL, ready-to-serve
|
package/dist/types/index.d.ts
CHANGED
|
@@ -62,7 +62,7 @@ export { canonicalFederationHost, isSameFederationHost, extractActorUriFromActiv
|
|
|
62
62
|
* byte-identical across apps, with media resolution injected. The actor `type`
|
|
63
63
|
* follows the Oxy account kind ({@link LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND}).
|
|
64
64
|
*/
|
|
65
|
-
export { createLocalActorBuilder, normalizeAlsoKnownAs, localActorTypeForAccountKind, isApActorType, AP_ACTOR_TYPES, LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND, type ApActorType, type LocalActorType, type LocalActorBuilder, type LocalActorBuilderConfig, type BuildLocalActorParams, type ActorMediaResolver, } from './actorObject';
|
|
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
66
|
/**
|
|
67
67
|
* Supported external networks, keyed by the transport a connector speaks.
|
|
68
68
|
* `instagram-graph` is Meta's Graph API Business Discovery: a read-only
|
|
@@ -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 {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxy.so/federation",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.1",
|
|
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",
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
isApActorType,
|
|
8
8
|
LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND,
|
|
9
9
|
localActorTypeForAccountKind,
|
|
10
|
+
accountKindForActor,
|
|
10
11
|
type ActorMediaResolver,
|
|
11
12
|
} from '../index';
|
|
12
13
|
|
|
@@ -198,6 +199,7 @@ describe('actor type by Oxy account kind', () => {
|
|
|
198
199
|
expect(localActorTypeForAccountKind('project')).toBe('Organization');
|
|
199
200
|
expect(localActorTypeForAccountKind('bot')).toBe('Service');
|
|
200
201
|
expect(localActorTypeForAccountKind('channel')).toBe('Organization');
|
|
202
|
+
expect(localActorTypeForAccountKind('community')).toBe('Group');
|
|
201
203
|
});
|
|
202
204
|
|
|
203
205
|
it('never announces a human-curated channel as automated', () => {
|
|
@@ -207,10 +209,11 @@ describe('actor type by Oxy account kind', () => {
|
|
|
207
209
|
// A channel is curated by people; only `bot` may claim automation.
|
|
208
210
|
expect(localActorTypeForAccountKind('channel')).not.toBe('Service');
|
|
209
211
|
expect(localActorTypeForAccountKind('bot')).toBe('Service');
|
|
210
|
-
// `Group` is
|
|
211
|
-
//
|
|
212
|
-
|
|
213
|
-
|
|
212
|
+
// `Group` is announced for a community alone: Lemmy reads it as a forwarding
|
|
213
|
+
// community, and PeerTube rejects a `Group` lacking `attributedTo` outright.
|
|
214
|
+
expect(ACCOUNT_KINDS.filter((kind) => localActorTypeForAccountKind(kind) === 'Group')).toEqual([
|
|
215
|
+
'community',
|
|
216
|
+
]);
|
|
214
217
|
});
|
|
215
218
|
|
|
216
219
|
it('covers every account kind contracts defines, with more than one answer', () => {
|
|
@@ -219,13 +222,30 @@ describe('actor type by Oxy account kind', () => {
|
|
|
219
222
|
// Vacuity floor: a mapping that returned one constant would pass every
|
|
220
223
|
// single-kind assertion above and fail this.
|
|
221
224
|
const answers = ACCOUNT_KINDS.map((kind) => localActorTypeForAccountKind(kind));
|
|
222
|
-
expect(new Set(answers).size).toBe(
|
|
225
|
+
expect(new Set(answers).size).toBe(4);
|
|
223
226
|
// And specifically: the two kinds either side of the channel decision differ.
|
|
224
227
|
expect(localActorTypeForAccountKind('channel')).not.toBe(
|
|
225
228
|
localActorTypeForAccountKind('personal'),
|
|
226
229
|
);
|
|
227
230
|
});
|
|
228
231
|
|
|
232
|
+
it('reads every announced type back as a kind announced the same way', () => {
|
|
233
|
+
for (const kind of ACCOUNT_KINDS) {
|
|
234
|
+
const type = localActorTypeForAccountKind(kind);
|
|
235
|
+
expect(localActorTypeForAccountKind(accountKindForActor({ type }))).toBe(type);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('reads the actor types and flags Oxy never announces', () => {
|
|
240
|
+
expect(accountKindForActor({ type: 'Application' })).toBe('bot');
|
|
241
|
+
expect(accountKindForActor({ type: 'Group', postingRestrictedToMods: true })).toBe('channel');
|
|
242
|
+
expect(accountKindForActor({ type: 'Group', postingRestrictedToMods: false })).toBe(
|
|
243
|
+
'community',
|
|
244
|
+
);
|
|
245
|
+
expect(accountKindForActor({ type: 'Tombstone' })).toBe('personal');
|
|
246
|
+
expect(accountKindForActor({})).toBe('personal');
|
|
247
|
+
});
|
|
248
|
+
|
|
229
249
|
it('falls back to Person for an absent, null, or unrecognized kind', () => {
|
|
230
250
|
expect(localActorTypeForAccountKind(undefined)).toBe('Person');
|
|
231
251
|
expect(localActorTypeForAccountKind(null)).toBe('Person');
|
|
@@ -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
|
+
});
|
package/src/actorObject.ts
CHANGED
|
@@ -49,29 +49,30 @@ export function isApActorType(value: unknown): value is ApActorType {
|
|
|
49
49
|
/**
|
|
50
50
|
* The AS2 actor types this engine will announce for a LOCAL user actor.
|
|
51
51
|
*
|
|
52
|
-
* Narrower than AS2's five actor types, and narrow ON PURPOSE
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
* `fe0da961`), not against the spec:
|
|
52
|
+
* Narrower than AS2's five actor types, and narrow ON PURPOSE. Both decisions
|
|
53
|
+
* were checked against real receiving implementations (mastodon `a3649295`,
|
|
54
|
+
* lemmy `4ce92433`, misskey `b95e4841`, peertube `fe0da961`), not against the
|
|
55
|
+
* spec:
|
|
57
56
|
*
|
|
58
57
|
* - **`Application`** is the INSTANCE actor's type (see the actor router's
|
|
59
58
|
* `/ap/users/instance` branch), reserved by convention for the software
|
|
60
59
|
* itself. No account is the software.
|
|
61
|
-
* - **`Group`** is
|
|
62
|
-
* "a collective of actors" but as a FORWARDING
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* `Group` would advertise a protocol this engine does not implement.
|
|
60
|
+
* - **`Group`** is announced ONLY for a `community`, because in the deployed
|
|
61
|
+
* fediverse it is not read as "a collective of actors" but as a FORWARDING
|
|
62
|
+
* actor. Lemmy reclassifies a remote `Group` as a community: followable, the
|
|
63
|
+
* Follow is Accepted, and then it never shows a post unless the actor
|
|
64
|
+
* `Announce`s its members' posts. PeerTube rejects a `Group` outright unless
|
|
65
|
+
* it carries `attributedTo` naming a `Person`. An account that authors its
|
|
66
|
+
* own posts (a project, a channel) must therefore never claim it; a community
|
|
67
|
+
* is the one kind whose nature IS forwarding.
|
|
70
68
|
*/
|
|
71
|
-
export type LocalActorType = Extract<ApActorType, 'Person' | 'Organization' | 'Service'>;
|
|
69
|
+
export type LocalActorType = Extract<ApActorType, 'Person' | 'Organization' | 'Service' | 'Group'>;
|
|
72
70
|
|
|
73
71
|
/**
|
|
74
|
-
* Oxy account kind → the AS2 actor type the fediverse is told about it
|
|
72
|
+
* Oxy account kind → the AS2 actor type the fediverse is told about it — the
|
|
73
|
+
* ONE table both directions read: what Oxy announces for its own accounts here,
|
|
74
|
+
* and, through {@link accountKindForActor}, what it records a remote actor as.
|
|
75
|
+
* Changing how a kind federates is an edit to this table and nowhere else.
|
|
75
76
|
*
|
|
76
77
|
* `satisfies Record<AccountKind, LocalActorType>` is the load-bearing part: a
|
|
77
78
|
* kind added to `@oxy.so/contracts` fails THIS build rather than silently
|
|
@@ -103,6 +104,8 @@ export type LocalActorType = Extract<ApActorType, 'Person' | 'Organization' | 'S
|
|
|
103
104
|
* accepted by all four implementations' whitelists and compared in none of
|
|
104
105
|
* them — neither `bot?` nor `group?` in Mastodon, `bot_account = false` in
|
|
105
106
|
* Lemmy, `isBot` false in Misskey, an ordinary account in PeerTube.
|
|
107
|
+
* - **`community` → `Group`.** A forum its members post into and that
|
|
108
|
+
* re-broadcasts them: exactly what Lemmy, Mbin and Friendica mean by `Group`.
|
|
106
109
|
*
|
|
107
110
|
* What this does NOT do: it does not stop a remote instance offering a reply box
|
|
108
111
|
* under a channel's post. NO actor type gates that in any of the four — Mastodon
|
|
@@ -117,6 +120,7 @@ export const LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND = {
|
|
|
117
120
|
project: 'Organization',
|
|
118
121
|
bot: 'Service',
|
|
119
122
|
channel: 'Organization',
|
|
123
|
+
community: 'Group',
|
|
120
124
|
} as const satisfies Record<AccountKind, LocalActorType>;
|
|
121
125
|
|
|
122
126
|
/**
|
|
@@ -135,6 +139,58 @@ export function localActorTypeForAccountKind(kind: unknown): LocalActorType {
|
|
|
135
139
|
return isAccountKind(kind) ? LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND[kind] : 'Person';
|
|
136
140
|
}
|
|
137
141
|
|
|
142
|
+
/**
|
|
143
|
+
* The inverse of {@link LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND}, derived from it. Three
|
|
144
|
+
* kinds share `Organization`, so a type reads back as the FIRST kind the table
|
|
145
|
+
* lists for it (`Organization` → `organization`); nothing on the wire tells a
|
|
146
|
+
* remote project from a remote organization.
|
|
147
|
+
*/
|
|
148
|
+
const ACCOUNT_KIND_BY_LOCAL_ACTOR_TYPE: ReadonlyMap<string, AccountKind> = (() => {
|
|
149
|
+
const inverse = new Map<string, AccountKind>();
|
|
150
|
+
for (const [kind, type] of Object.entries(LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND)) {
|
|
151
|
+
if (!inverse.has(type)) inverse.set(type, kind as AccountKind);
|
|
152
|
+
}
|
|
153
|
+
return inverse;
|
|
154
|
+
})();
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* AS2 actor types Oxy never announces but receives, and the announced type each
|
|
158
|
+
* is read as. `Application` is a non-human software actor — what `Service`
|
|
159
|
+
* already says, and half of Mastodon's own `bot?`.
|
|
160
|
+
*/
|
|
161
|
+
const INBOUND_ACTOR_TYPE_ALIASES: ReadonlyMap<string, LocalActorType> = new Map([
|
|
162
|
+
['Application', 'Service'],
|
|
163
|
+
]);
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The kind a `Group` that only its moderators may post into is recorded as.
|
|
167
|
+
* Such a group forwards nobody: it PUBLISHES (a PeerTube video channel, a
|
|
168
|
+
* WordPress multi-author blog), which is what an Oxy channel is. The one
|
|
169
|
+
* inbound signal the table above has no column for.
|
|
170
|
+
*/
|
|
171
|
+
const MODERATED_GROUP_KIND: AccountKind = 'channel';
|
|
172
|
+
|
|
173
|
+
/** The fields of a remote actor document that decide its kind. */
|
|
174
|
+
export interface ActorKindSignals {
|
|
175
|
+
type?: unknown;
|
|
176
|
+
/** Lemmy's and PeerTube's flag: only moderators may post into this `Group`. */
|
|
177
|
+
postingRestrictedToMods?: unknown;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The Oxy account kind a REMOTE actor is recorded as, read from the same table
|
|
182
|
+
* the local announcement uses ({@link LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND}). A type
|
|
183
|
+
* the table does not know (absent, misspelt, an extension type) is `personal`,
|
|
184
|
+
* the same default {@link localActorTypeForAccountKind} announces.
|
|
185
|
+
*/
|
|
186
|
+
export function accountKindForActor(actor: ActorKindSignals): AccountKind {
|
|
187
|
+
if (typeof actor.type !== 'string') return 'personal';
|
|
188
|
+
const type = INBOUND_ACTOR_TYPE_ALIASES.get(actor.type) ?? actor.type;
|
|
189
|
+
if (type === LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND.community && actor.postingRestrictedToMods === true)
|
|
190
|
+
return MODERATED_GROUP_KIND;
|
|
191
|
+
return ACCOUNT_KIND_BY_LOCAL_ACTOR_TYPE.get(type) ?? 'personal';
|
|
192
|
+
}
|
|
193
|
+
|
|
138
194
|
/** Map common image extensions to a MIME type for an actor image `mediaType`. */
|
|
139
195
|
const IMAGE_MEDIA_TYPE_BY_EXT: Record<string, string> = {
|
|
140
196
|
png: 'image/png',
|
package/src/index.ts
CHANGED
|
@@ -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
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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>> {
|
|
@@ -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
|
+
}
|
package/src/node/delivery.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
284
|
-
let totalBytes = 0;
|
|
285
|
-
|
|
290
|
+
const iterator = response[Symbol.asyncIterator]();
|
|
286
291
|
try {
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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. */
|