@oxy.so/protocol 1.4.1 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/node/constants.js +9 -1
- package/dist/cjs/node/didWebResolver.js +2 -1
- package/dist/cjs/node/httpFetch.js +48 -13
- package/dist/cjs/node/index.js +4 -1
- package/dist/cjs/node/nodeClient.js +11 -8
- package/dist/esm/node/constants.js +8 -0
- package/dist/esm/node/didWebResolver.js +3 -2
- package/dist/esm/node/httpFetch.js +46 -12
- package/dist/esm/node/index.js +2 -2
- package/dist/esm/node/nodeClient.js +12 -9
- package/dist/types/node/constants.d.ts +8 -0
- package/dist/types/node/didWebResolver.d.ts +2 -0
- package/dist/types/node/httpFetch.d.ts +25 -8
- package/dist/types/node/index.d.ts +2 -2
- package/dist/types/node/nodeClient.d.ts +9 -2
- package/package.json +2 -2
- package/src/__tests__/httpFetch.test.ts +49 -0
- package/src/node/constants.ts +10 -0
- package/src/node/didWebResolver.ts +5 -1
- package/src/node/httpFetch.ts +53 -12
- package/src/node/index.ts +8 -1
- package/src/node/nodeClient.ts +19 -8
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* own shape constants live here.
|
|
13
13
|
*/
|
|
14
14
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
-
exports.DEFAULT_DID_DOC_MAX_BYTES = exports.DEFAULT_WRITE_RESPONSE_MAX_BYTES = exports.DEFAULT_LOG_MAX_BYTES = exports.DEFAULT_HEAD_MAX_BYTES = exports.DEFAULT_CLIENT_MAX_REDIRECTS = exports.DEFAULT_CLIENT_TIMEOUT_MS = exports.NODE_BLOBS_PATH = exports.NODE_SYNC_PUSH_PATH = exports.NODE_RECORDS_PATH = exports.NODE_LOG_PATH = exports.NODE_HEAD_PATH = exports.SHA256_HEX = exports.OWNER_ACTION_BLOB_PIN = exports.NODE_MODES = exports.OWNER_AUTH_MAX_AGE_MS = exports.OWNER_AUTH_HEADERS = exports.JSON_BODY_LIMIT = exports.MAX_SYNC_BATCH = exports.DEFAULT_MAX_BLOB_BYTES = exports.MAX_LOG_LIMIT = exports.DEFAULT_LOG_LIMIT = exports.DEFAULT_PORT = exports.DEFAULT_APP_NAMESPACE = exports.DEFAULT_SERVICE_TYPE = exports.DEFAULT_WELL_KNOWN_PATH = exports.PROTOCOL_VERSION = void 0;
|
|
15
|
+
exports.DEFAULT_DID_DOC_MAX_BYTES = exports.DEFAULT_WRITE_RESPONSE_MAX_BYTES = exports.DEFAULT_LOG_MAX_BYTES = exports.DEFAULT_HEAD_MAX_BYTES = exports.DEFAULT_CLIENT_MAX_REDIRECTS = exports.DEFAULT_BLOB_BODY_TIMEOUT_MS = exports.DEFAULT_CLIENT_BODY_TIMEOUT_MS = exports.DEFAULT_CLIENT_TIMEOUT_MS = exports.NODE_BLOBS_PATH = exports.NODE_SYNC_PUSH_PATH = exports.NODE_RECORDS_PATH = exports.NODE_LOG_PATH = exports.NODE_HEAD_PATH = exports.SHA256_HEX = exports.OWNER_ACTION_BLOB_PIN = exports.NODE_MODES = exports.OWNER_AUTH_MAX_AGE_MS = exports.OWNER_AUTH_HEADERS = exports.JSON_BODY_LIMIT = exports.MAX_SYNC_BATCH = exports.DEFAULT_MAX_BLOB_BYTES = exports.MAX_LOG_LIMIT = exports.DEFAULT_LOG_LIMIT = exports.DEFAULT_PORT = exports.DEFAULT_APP_NAMESPACE = exports.DEFAULT_SERVICE_TYPE = exports.DEFAULT_WELL_KNOWN_PATH = exports.PROTOCOL_VERSION = void 0;
|
|
16
16
|
/**
|
|
17
17
|
* The node-protocol version advertised at the well-known manifest. Bumped only
|
|
18
18
|
* on a breaking change to the wire shape of the log / head / record APIs. This
|
|
@@ -73,6 +73,14 @@ exports.NODE_SYNC_PUSH_PATH = '/sync/push';
|
|
|
73
73
|
exports.NODE_BLOBS_PATH = '/blobs';
|
|
74
74
|
/** Default time-to-first-byte deadline for a NodeClient request (ms). */
|
|
75
75
|
exports.DEFAULT_CLIENT_TIMEOUT_MS = 8000;
|
|
76
|
+
/**
|
|
77
|
+
* Default deadline for reading a whole JSON response body (head, log page,
|
|
78
|
+
* write result, `did.json`) once its headers have arrived (ms). The headers
|
|
79
|
+
* deadline above ends at the headers; this one stops a peer that then stalls.
|
|
80
|
+
*/
|
|
81
|
+
exports.DEFAULT_CLIENT_BODY_TIMEOUT_MS = 15000;
|
|
82
|
+
/** Default deadline for reading a whole fetched blob body (up to {@link DEFAULT_MAX_BLOB_BYTES}) (ms). */
|
|
83
|
+
exports.DEFAULT_BLOB_BODY_TIMEOUT_MS = 60000;
|
|
76
84
|
/** Default redirect budget for a NodeClient request (each re-validated upstream). */
|
|
77
85
|
exports.DEFAULT_CLIENT_MAX_REDIRECTS = 1;
|
|
78
86
|
/** Default bounded read for a `/oxy/head` response (tiny JSON). */
|
|
@@ -91,6 +91,7 @@ function createDidWebResolver(fetch, options = {}) {
|
|
|
91
91
|
const headersTimeoutMs = options.headersTimeoutMs ?? constants_1.DEFAULT_CLIENT_TIMEOUT_MS;
|
|
92
92
|
const maxRedirects = options.maxRedirects ?? constants_1.DEFAULT_CLIENT_MAX_REDIRECTS;
|
|
93
93
|
const maxBytes = options.maxBytes ?? constants_1.DEFAULT_DID_DOC_MAX_BYTES;
|
|
94
|
+
const bodyTimeoutMs = options.bodyTimeoutMs ?? constants_1.DEFAULT_CLIENT_BODY_TIMEOUT_MS;
|
|
94
95
|
return {
|
|
95
96
|
async resolve(subjectDid) {
|
|
96
97
|
const url = didWebToUrl(subjectDid);
|
|
@@ -103,7 +104,7 @@ function createDidWebResolver(fetch, options = {}) {
|
|
|
103
104
|
res.destroy();
|
|
104
105
|
return null;
|
|
105
106
|
}
|
|
106
|
-
const body = await (0, httpFetch_1.readBoundedJson)(res, maxBytes);
|
|
107
|
+
const body = await (0, httpFetch_1.readBoundedJson)(res, maxBytes, bodyTimeoutMs);
|
|
107
108
|
const parsed = contracts_1.didDocumentSchema.safeParse(body);
|
|
108
109
|
if (!parsed.success) {
|
|
109
110
|
return null;
|
|
@@ -10,17 +10,18 @@
|
|
|
10
10
|
* Oxy supplies an adapter over `safeFetch` (HTTPS-only, DNS-pinned, private-IP
|
|
11
11
|
* denylist, bounded redirects); a test supplies an in-process stub. Either way
|
|
12
12
|
* the SSRF/transport policy stays in the injected implementation, and the
|
|
13
|
-
* bounded body read (the cap that
|
|
14
|
-
* stays here, close to the parsing.
|
|
13
|
+
* bounded body read (the byte cap and the body deadline that stop a malicious
|
|
14
|
+
* node streaming forever or stalling mid-body) stays here, close to the parsing.
|
|
15
15
|
*
|
|
16
16
|
* A Node `IncomingMessage` (what `safeFetch` returns) satisfies
|
|
17
17
|
* {@link NodeFetchResponse.body} directly — it is an `AsyncIterable<Buffer>`,
|
|
18
18
|
* and `Buffer` is a `Uint8Array`.
|
|
19
19
|
*/
|
|
20
20
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
-
exports.ResponseTooLargeError = void 0;
|
|
21
|
+
exports.ResponseTimeoutError = exports.ResponseTooLargeError = void 0;
|
|
22
22
|
exports.readBoundedBytes = readBoundedBytes;
|
|
23
23
|
exports.readBoundedJson = readBoundedJson;
|
|
24
|
+
const constants_1 = require("./constants");
|
|
24
25
|
/** Thrown when a response body exceeds the caller's byte ceiling. */
|
|
25
26
|
class ResponseTooLargeError extends Error {
|
|
26
27
|
constructor(maxBytes) {
|
|
@@ -31,16 +32,47 @@ class ResponseTooLargeError extends Error {
|
|
|
31
32
|
}
|
|
32
33
|
exports.ResponseTooLargeError = ResponseTooLargeError;
|
|
33
34
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
35
|
+
* Thrown when a response body does not end within the caller's deadline — a
|
|
36
|
+
* peer that sent its headers and then stalled or trickled the body. Unlike
|
|
37
|
+
* {@link ResponseTooLargeError} this says nothing about the peer's data, only
|
|
38
|
+
* that this attempt ran out of time: a caller treats it as transient and retries.
|
|
37
39
|
*/
|
|
38
|
-
|
|
40
|
+
class ResponseTimeoutError extends Error {
|
|
41
|
+
constructor(timeoutMs) {
|
|
42
|
+
super(`response body did not end within ${timeoutMs} ms`);
|
|
43
|
+
this.timeoutMs = timeoutMs;
|
|
44
|
+
this.name = 'ResponseTimeoutError';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
exports.ResponseTimeoutError = ResponseTimeoutError;
|
|
48
|
+
/**
|
|
49
|
+
* Read a response body into a single buffer, at most `maxBytes` and within
|
|
50
|
+
* `timeoutMs` for the WHOLE body (from the call until the stream ends). Past
|
|
51
|
+
* the cap it throws {@link ResponseTooLargeError}, past the deadline
|
|
52
|
+
* {@link ResponseTimeoutError}; on either, or a stream error, the stream is
|
|
53
|
+
* destroyed before the throw, and the deadline timer is cleared on every path.
|
|
54
|
+
*
|
|
55
|
+
* The transport's `headersTimeoutMs` ends at the response headers; this
|
|
56
|
+
* deadline is what stops a node that sends headers and then stalls the body
|
|
57
|
+
* from holding the reader open. `timeoutMs` defaults to
|
|
58
|
+
* {@link DEFAULT_CLIENT_BODY_TIMEOUT_MS}.
|
|
59
|
+
*/
|
|
60
|
+
async function readBoundedBytes(res, maxBytes, timeoutMs = constants_1.DEFAULT_CLIENT_BODY_TIMEOUT_MS) {
|
|
39
61
|
const chunks = [];
|
|
40
62
|
let total = 0;
|
|
63
|
+
let timer;
|
|
64
|
+
const deadline = new Promise((_, reject) => {
|
|
65
|
+
timer = setTimeout(() => reject(new ResponseTimeoutError(timeoutMs)), timeoutMs);
|
|
66
|
+
});
|
|
67
|
+
const iterator = res.body[Symbol.asyncIterator]();
|
|
41
68
|
try {
|
|
42
|
-
for
|
|
43
|
-
|
|
69
|
+
for (;;) {
|
|
70
|
+
// `Promise.race` subscribes to both, so neither the deadline nor a read
|
|
71
|
+
// that rejects once the stream is destroyed goes unhandled.
|
|
72
|
+
const step = await Promise.race([iterator.next(), deadline]);
|
|
73
|
+
if (step.done)
|
|
74
|
+
break;
|
|
75
|
+
const buf = Buffer.isBuffer(step.value) ? step.value : Buffer.from(step.value);
|
|
44
76
|
total += buf.length;
|
|
45
77
|
if (total > maxBytes) {
|
|
46
78
|
throw new ResponseTooLargeError(maxBytes);
|
|
@@ -52,10 +84,13 @@ async function readBoundedBytes(res, maxBytes) {
|
|
|
52
84
|
res.destroy();
|
|
53
85
|
throw err;
|
|
54
86
|
}
|
|
55
|
-
|
|
87
|
+
finally {
|
|
88
|
+
clearTimeout(timer);
|
|
89
|
+
}
|
|
90
|
+
return Buffer.concat(chunks, total);
|
|
56
91
|
}
|
|
57
|
-
/** Read a bounded response body and parse it as JSON. */
|
|
58
|
-
async function readBoundedJson(res, maxBytes) {
|
|
59
|
-
const bytes = await readBoundedBytes(res, maxBytes);
|
|
92
|
+
/** Read a bounded, deadlined response body and parse it as JSON. */
|
|
93
|
+
async function readBoundedJson(res, maxBytes, timeoutMs = constants_1.DEFAULT_CLIENT_BODY_TIMEOUT_MS) {
|
|
94
|
+
const bytes = await readBoundedBytes(res, maxBytes, timeoutMs);
|
|
60
95
|
return JSON.parse(bytes.toString('utf8'));
|
|
61
96
|
}
|
package/dist/cjs/node/index.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* a node via `NodeClient`).
|
|
15
15
|
*/
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
-
exports.DEFAULT_DID_DOC_MAX_BYTES = exports.DEFAULT_WRITE_RESPONSE_MAX_BYTES = exports.DEFAULT_LOG_MAX_BYTES = exports.DEFAULT_HEAD_MAX_BYTES = exports.DEFAULT_CLIENT_MAX_REDIRECTS = exports.DEFAULT_CLIENT_TIMEOUT_MS = exports.NODE_BLOBS_PATH = exports.NODE_SYNC_PUSH_PATH = exports.NODE_RECORDS_PATH = exports.NODE_LOG_PATH = exports.NODE_HEAD_PATH = exports.SHA256_HEX = exports.OWNER_ACTION_BLOB_PIN = exports.NODE_MODES = exports.OWNER_AUTH_MAX_AGE_MS = exports.OWNER_AUTH_HEADERS = exports.JSON_BODY_LIMIT = exports.MAX_SYNC_BATCH = exports.DEFAULT_MAX_BLOB_BYTES = exports.MAX_LOG_LIMIT = exports.DEFAULT_LOG_LIMIT = exports.DEFAULT_PORT = exports.DEFAULT_APP_NAMESPACE = exports.DEFAULT_SERVICE_TYPE = exports.DEFAULT_WELL_KNOWN_PATH = exports.PROTOCOL_VERSION = exports.didWebToUrl = exports.createDidWebResolver = exports.ResponseTooLargeError = exports.readBoundedJson = exports.readBoundedBytes = exports.trimTrailingSlashes = exports.NodeClientError = exports.NodeClient = exports.verifyNodeRecordEnvelope = exports.DEFAULT_MAX_RATE_LIMIT_ENTRIES = exports.DEFAULT_WRITE_RATE_LIMIT = exports.createRateLimiter = exports.BlobHashMismatchError = exports.createNodeApp = void 0;
|
|
17
|
+
exports.DEFAULT_DID_DOC_MAX_BYTES = exports.DEFAULT_WRITE_RESPONSE_MAX_BYTES = exports.DEFAULT_LOG_MAX_BYTES = exports.DEFAULT_HEAD_MAX_BYTES = exports.DEFAULT_CLIENT_MAX_REDIRECTS = exports.DEFAULT_BLOB_BODY_TIMEOUT_MS = exports.DEFAULT_CLIENT_BODY_TIMEOUT_MS = exports.DEFAULT_CLIENT_TIMEOUT_MS = exports.NODE_BLOBS_PATH = exports.NODE_SYNC_PUSH_PATH = exports.NODE_RECORDS_PATH = exports.NODE_LOG_PATH = exports.NODE_HEAD_PATH = exports.SHA256_HEX = exports.OWNER_ACTION_BLOB_PIN = exports.NODE_MODES = exports.OWNER_AUTH_MAX_AGE_MS = exports.OWNER_AUTH_HEADERS = exports.JSON_BODY_LIMIT = exports.MAX_SYNC_BATCH = exports.DEFAULT_MAX_BLOB_BYTES = exports.MAX_LOG_LIMIT = exports.DEFAULT_LOG_LIMIT = exports.DEFAULT_PORT = exports.DEFAULT_APP_NAMESPACE = exports.DEFAULT_SERVICE_TYPE = exports.DEFAULT_WELL_KNOWN_PATH = exports.PROTOCOL_VERSION = exports.didWebToUrl = exports.createDidWebResolver = exports.ResponseTimeoutError = exports.ResponseTooLargeError = exports.readBoundedJson = exports.readBoundedBytes = exports.trimTrailingSlashes = exports.NodeClientError = exports.NodeClient = exports.verifyNodeRecordEnvelope = exports.DEFAULT_MAX_RATE_LIMIT_ENTRIES = exports.DEFAULT_WRITE_RATE_LIMIT = exports.createRateLimiter = exports.BlobHashMismatchError = exports.createNodeApp = void 0;
|
|
18
18
|
// ── Express app factory ───────────────────────────────────────────────────────
|
|
19
19
|
var nodeApp_1 = require("./nodeApp");
|
|
20
20
|
Object.defineProperty(exports, "createNodeApp", { enumerable: true, get: function () { return nodeApp_1.createNodeApp; } });
|
|
@@ -37,6 +37,7 @@ var httpFetch_1 = require("./httpFetch");
|
|
|
37
37
|
Object.defineProperty(exports, "readBoundedBytes", { enumerable: true, get: function () { return httpFetch_1.readBoundedBytes; } });
|
|
38
38
|
Object.defineProperty(exports, "readBoundedJson", { enumerable: true, get: function () { return httpFetch_1.readBoundedJson; } });
|
|
39
39
|
Object.defineProperty(exports, "ResponseTooLargeError", { enumerable: true, get: function () { return httpFetch_1.ResponseTooLargeError; } });
|
|
40
|
+
Object.defineProperty(exports, "ResponseTimeoutError", { enumerable: true, get: function () { return httpFetch_1.ResponseTimeoutError; } });
|
|
40
41
|
// ── did:web verification-method resolver ───────────────────────────────────────
|
|
41
42
|
var didWebResolver_1 = require("./didWebResolver");
|
|
42
43
|
Object.defineProperty(exports, "createDidWebResolver", { enumerable: true, get: function () { return didWebResolver_1.createDidWebResolver; } });
|
|
@@ -64,6 +65,8 @@ Object.defineProperty(exports, "NODE_RECORDS_PATH", { enumerable: true, get: fun
|
|
|
64
65
|
Object.defineProperty(exports, "NODE_SYNC_PUSH_PATH", { enumerable: true, get: function () { return constants_1.NODE_SYNC_PUSH_PATH; } });
|
|
65
66
|
Object.defineProperty(exports, "NODE_BLOBS_PATH", { enumerable: true, get: function () { return constants_1.NODE_BLOBS_PATH; } });
|
|
66
67
|
Object.defineProperty(exports, "DEFAULT_CLIENT_TIMEOUT_MS", { enumerable: true, get: function () { return constants_1.DEFAULT_CLIENT_TIMEOUT_MS; } });
|
|
68
|
+
Object.defineProperty(exports, "DEFAULT_CLIENT_BODY_TIMEOUT_MS", { enumerable: true, get: function () { return constants_1.DEFAULT_CLIENT_BODY_TIMEOUT_MS; } });
|
|
69
|
+
Object.defineProperty(exports, "DEFAULT_BLOB_BODY_TIMEOUT_MS", { enumerable: true, get: function () { return constants_1.DEFAULT_BLOB_BODY_TIMEOUT_MS; } });
|
|
67
70
|
Object.defineProperty(exports, "DEFAULT_CLIENT_MAX_REDIRECTS", { enumerable: true, get: function () { return constants_1.DEFAULT_CLIENT_MAX_REDIRECTS; } });
|
|
68
71
|
Object.defineProperty(exports, "DEFAULT_HEAD_MAX_BYTES", { enumerable: true, get: function () { return constants_1.DEFAULT_HEAD_MAX_BYTES; } });
|
|
69
72
|
Object.defineProperty(exports, "DEFAULT_LOG_MAX_BYTES", { enumerable: true, get: function () { return constants_1.DEFAULT_LOG_MAX_BYTES; } });
|
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
* the protocol package never depends on `@oxy.so/core`. Oxy supplies an adapter
|
|
10
10
|
* over `@oxy.so/core/server`'s `safeFetch` (HTTPS-only, DNS-pinned, private-IP
|
|
11
11
|
* denylist, bounded redirects); a test supplies an in-process stub. Every
|
|
12
|
-
* response body is read with a hard byte ceiling
|
|
13
|
-
* unbounded body into the caller
|
|
12
|
+
* response body is read with a hard byte ceiling AND a whole-body deadline, so
|
|
13
|
+
* a node can neither stream an unbounded body into the caller nor hold it by
|
|
14
|
+
* stalling after the headers (`ResponseTimeoutError`, which is transient).
|
|
14
15
|
*/
|
|
15
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
17
|
exports.NodeClient = exports.NodeClientError = void 0;
|
|
@@ -54,6 +55,8 @@ class NodeClient {
|
|
|
54
55
|
this.baseUrl = trimTrailingSlashes(options.baseUrl);
|
|
55
56
|
this.fetch = options.fetch;
|
|
56
57
|
this.headersTimeoutMs = options.headersTimeoutMs ?? constants_1.DEFAULT_CLIENT_TIMEOUT_MS;
|
|
58
|
+
this.bodyTimeoutMs = options.bodyTimeoutMs ?? constants_1.DEFAULT_CLIENT_BODY_TIMEOUT_MS;
|
|
59
|
+
this.blobBodyTimeoutMs = options.blobBodyTimeoutMs ?? constants_1.DEFAULT_BLOB_BODY_TIMEOUT_MS;
|
|
57
60
|
this.maxRedirects = options.maxRedirects ?? constants_1.DEFAULT_CLIENT_MAX_REDIRECTS;
|
|
58
61
|
this.headMaxBytes = options.headMaxBytes ?? constants_1.DEFAULT_HEAD_MAX_BYTES;
|
|
59
62
|
this.logMaxBytes = options.logMaxBytes ?? constants_1.DEFAULT_LOG_MAX_BYTES;
|
|
@@ -75,7 +78,7 @@ class NodeClient {
|
|
|
75
78
|
res.destroy();
|
|
76
79
|
throw new NodeClientError(`node ${constants_1.NODE_HEAD_PATH} responded HTTP ${res.status}`, res.status);
|
|
77
80
|
}
|
|
78
|
-
const body = await (0, httpFetch_1.readBoundedJson)(res, this.headMaxBytes);
|
|
81
|
+
const body = await (0, httpFetch_1.readBoundedJson)(res, this.headMaxBytes, this.bodyTimeoutMs);
|
|
79
82
|
const obj = (typeof body === 'object' && body !== null ? body : {});
|
|
80
83
|
return {
|
|
81
84
|
seq: typeof obj.seq === 'number' ? obj.seq : null,
|
|
@@ -100,7 +103,7 @@ class NodeClient {
|
|
|
100
103
|
res.destroy();
|
|
101
104
|
throw new NodeClientError(`node ${constants_1.NODE_LOG_PATH} responded HTTP ${res.status}`, res.status);
|
|
102
105
|
}
|
|
103
|
-
const body = await (0, httpFetch_1.readBoundedJson)(res, this.logMaxBytes);
|
|
106
|
+
const body = await (0, httpFetch_1.readBoundedJson)(res, this.logMaxBytes, this.bodyTimeoutMs);
|
|
104
107
|
const records = body.records;
|
|
105
108
|
if (!Array.isArray(records)) {
|
|
106
109
|
throw new NodeClientError(`node ${constants_1.NODE_LOG_PATH} returned no records array`, res.status);
|
|
@@ -129,7 +132,7 @@ class NodeClient {
|
|
|
129
132
|
headers: { 'Content-Type': 'application/json' },
|
|
130
133
|
body: Buffer.from(JSON.stringify(envelope), 'utf8'),
|
|
131
134
|
}));
|
|
132
|
-
const body = await (0, httpFetch_1.readBoundedJson)(res, this.writeResponseMaxBytes);
|
|
135
|
+
const body = await (0, httpFetch_1.readBoundedJson)(res, this.writeResponseMaxBytes, this.bodyTimeoutMs);
|
|
133
136
|
if (res.status < 200 || res.status >= 300) {
|
|
134
137
|
const reason = readError(body);
|
|
135
138
|
throw new NodeClientError(`node ${constants_1.NODE_RECORDS_PATH} responded HTTP ${res.status}${reason ? ` (${reason})` : ''}`, res.status, reason);
|
|
@@ -151,7 +154,7 @@ class NodeClient {
|
|
|
151
154
|
headers: { 'Content-Type': 'application/json' },
|
|
152
155
|
body: Buffer.from(JSON.stringify({ records: envelopes }), 'utf8'),
|
|
153
156
|
}));
|
|
154
|
-
const body = await (0, httpFetch_1.readBoundedJson)(res, this.writeResponseMaxBytes);
|
|
157
|
+
const body = await (0, httpFetch_1.readBoundedJson)(res, this.writeResponseMaxBytes, this.bodyTimeoutMs);
|
|
155
158
|
if (res.status < 200 || res.status >= 300) {
|
|
156
159
|
const reason = readError(body);
|
|
157
160
|
throw new NodeClientError(`node ${constants_1.NODE_SYNC_PUSH_PATH} responded HTTP ${res.status}${reason ? ` (${reason})` : ''}`, res.status, reason);
|
|
@@ -175,7 +178,7 @@ class NodeClient {
|
|
|
175
178
|
res.destroy();
|
|
176
179
|
throw new NodeClientError(`node ${constants_1.NODE_BLOBS_PATH}/:hash responded HTTP ${res.status}`, res.status);
|
|
177
180
|
}
|
|
178
|
-
return (0, httpFetch_1.readBoundedBytes)(res, this.blobMaxBytes);
|
|
181
|
+
return (0, httpFetch_1.readBoundedBytes)(res, this.blobMaxBytes, this.blobBodyTimeoutMs);
|
|
179
182
|
}
|
|
180
183
|
/**
|
|
181
184
|
* Pin a content-addressed blob with an owner-signed authorization
|
|
@@ -194,7 +197,7 @@ class NodeClient {
|
|
|
194
197
|
},
|
|
195
198
|
body: bytes,
|
|
196
199
|
}));
|
|
197
|
-
const body = await (0, httpFetch_1.readBoundedJson)(res, this.writeResponseMaxBytes);
|
|
200
|
+
const body = await (0, httpFetch_1.readBoundedJson)(res, this.writeResponseMaxBytes, this.bodyTimeoutMs);
|
|
198
201
|
if (res.status < 200 || res.status >= 300) {
|
|
199
202
|
const reason = readError(body);
|
|
200
203
|
throw new NodeClientError(`node ${constants_1.NODE_BLOBS_PATH}/:hash responded HTTP ${res.status}${reason ? ` (${reason})` : ''}`, res.status, reason);
|
|
@@ -70,6 +70,14 @@ export const NODE_SYNC_PUSH_PATH = '/sync/push';
|
|
|
70
70
|
export const NODE_BLOBS_PATH = '/blobs';
|
|
71
71
|
/** Default time-to-first-byte deadline for a NodeClient request (ms). */
|
|
72
72
|
export const DEFAULT_CLIENT_TIMEOUT_MS = 8000;
|
|
73
|
+
/**
|
|
74
|
+
* Default deadline for reading a whole JSON response body (head, log page,
|
|
75
|
+
* write result, `did.json`) once its headers have arrived (ms). The headers
|
|
76
|
+
* deadline above ends at the headers; this one stops a peer that then stalls.
|
|
77
|
+
*/
|
|
78
|
+
export const DEFAULT_CLIENT_BODY_TIMEOUT_MS = 15000;
|
|
79
|
+
/** Default deadline for reading a whole fetched blob body (up to {@link DEFAULT_MAX_BLOB_BYTES}) (ms). */
|
|
80
|
+
export const DEFAULT_BLOB_BODY_TIMEOUT_MS = 60000;
|
|
73
81
|
/** Default redirect budget for a NodeClient request (each re-validated upstream). */
|
|
74
82
|
export const DEFAULT_CLIENT_MAX_REDIRECTS = 1;
|
|
75
83
|
/** Default bounded read for a `/oxy/head` response (tiny JSON). */
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { didDocumentSchema, } from '@oxy.so/contracts';
|
|
20
20
|
import { readBoundedJson } from './httpFetch.js';
|
|
21
|
-
import { DEFAULT_CLIENT_MAX_REDIRECTS, DEFAULT_CLIENT_TIMEOUT_MS, DEFAULT_DID_DOC_MAX_BYTES, } from './constants.js';
|
|
21
|
+
import { DEFAULT_CLIENT_BODY_TIMEOUT_MS, DEFAULT_CLIENT_MAX_REDIRECTS, DEFAULT_CLIENT_TIMEOUT_MS, DEFAULT_DID_DOC_MAX_BYTES, } from './constants.js';
|
|
22
22
|
/**
|
|
23
23
|
* Map a `did:web` DID to its `did.json` URL, or `null` when `did` is not a
|
|
24
24
|
* well-formed `did:web` identifier.
|
|
@@ -87,6 +87,7 @@ export function createDidWebResolver(fetch, options = {}) {
|
|
|
87
87
|
const headersTimeoutMs = options.headersTimeoutMs ?? DEFAULT_CLIENT_TIMEOUT_MS;
|
|
88
88
|
const maxRedirects = options.maxRedirects ?? DEFAULT_CLIENT_MAX_REDIRECTS;
|
|
89
89
|
const maxBytes = options.maxBytes ?? DEFAULT_DID_DOC_MAX_BYTES;
|
|
90
|
+
const bodyTimeoutMs = options.bodyTimeoutMs ?? DEFAULT_CLIENT_BODY_TIMEOUT_MS;
|
|
90
91
|
return {
|
|
91
92
|
async resolve(subjectDid) {
|
|
92
93
|
const url = didWebToUrl(subjectDid);
|
|
@@ -99,7 +100,7 @@ export function createDidWebResolver(fetch, options = {}) {
|
|
|
99
100
|
res.destroy();
|
|
100
101
|
return null;
|
|
101
102
|
}
|
|
102
|
-
const body = await readBoundedJson(res, maxBytes);
|
|
103
|
+
const body = await readBoundedJson(res, maxBytes, bodyTimeoutMs);
|
|
103
104
|
const parsed = didDocumentSchema.safeParse(body);
|
|
104
105
|
if (!parsed.success) {
|
|
105
106
|
return null;
|
|
@@ -9,13 +9,14 @@
|
|
|
9
9
|
* Oxy supplies an adapter over `safeFetch` (HTTPS-only, DNS-pinned, private-IP
|
|
10
10
|
* denylist, bounded redirects); a test supplies an in-process stub. Either way
|
|
11
11
|
* the SSRF/transport policy stays in the injected implementation, and the
|
|
12
|
-
* bounded body read (the cap that
|
|
13
|
-
* stays here, close to the parsing.
|
|
12
|
+
* bounded body read (the byte cap and the body deadline that stop a malicious
|
|
13
|
+
* node streaming forever or stalling mid-body) stays here, close to the parsing.
|
|
14
14
|
*
|
|
15
15
|
* A Node `IncomingMessage` (what `safeFetch` returns) satisfies
|
|
16
16
|
* {@link NodeFetchResponse.body} directly — it is an `AsyncIterable<Buffer>`,
|
|
17
17
|
* and `Buffer` is a `Uint8Array`.
|
|
18
18
|
*/
|
|
19
|
+
import { DEFAULT_CLIENT_BODY_TIMEOUT_MS } from './constants.js';
|
|
19
20
|
/** Thrown when a response body exceeds the caller's byte ceiling. */
|
|
20
21
|
export class ResponseTooLargeError extends Error {
|
|
21
22
|
constructor(maxBytes) {
|
|
@@ -25,16 +26,46 @@ export class ResponseTooLargeError extends Error {
|
|
|
25
26
|
}
|
|
26
27
|
}
|
|
27
28
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
29
|
+
* Thrown when a response body does not end within the caller's deadline — a
|
|
30
|
+
* peer that sent its headers and then stalled or trickled the body. Unlike
|
|
31
|
+
* {@link ResponseTooLargeError} this says nothing about the peer's data, only
|
|
32
|
+
* that this attempt ran out of time: a caller treats it as transient and retries.
|
|
31
33
|
*/
|
|
32
|
-
export
|
|
34
|
+
export class ResponseTimeoutError extends Error {
|
|
35
|
+
constructor(timeoutMs) {
|
|
36
|
+
super(`response body did not end within ${timeoutMs} ms`);
|
|
37
|
+
this.timeoutMs = timeoutMs;
|
|
38
|
+
this.name = 'ResponseTimeoutError';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Read a response body into a single buffer, at most `maxBytes` and within
|
|
43
|
+
* `timeoutMs` for the WHOLE body (from the call until the stream ends). Past
|
|
44
|
+
* the cap it throws {@link ResponseTooLargeError}, past the deadline
|
|
45
|
+
* {@link ResponseTimeoutError}; on either, or a stream error, the stream is
|
|
46
|
+
* destroyed before the throw, and the deadline timer is cleared on every path.
|
|
47
|
+
*
|
|
48
|
+
* The transport's `headersTimeoutMs` ends at the response headers; this
|
|
49
|
+
* deadline is what stops a node that sends headers and then stalls the body
|
|
50
|
+
* from holding the reader open. `timeoutMs` defaults to
|
|
51
|
+
* {@link DEFAULT_CLIENT_BODY_TIMEOUT_MS}.
|
|
52
|
+
*/
|
|
53
|
+
export async function readBoundedBytes(res, maxBytes, timeoutMs = DEFAULT_CLIENT_BODY_TIMEOUT_MS) {
|
|
33
54
|
const chunks = [];
|
|
34
55
|
let total = 0;
|
|
56
|
+
let timer;
|
|
57
|
+
const deadline = new Promise((_, reject) => {
|
|
58
|
+
timer = setTimeout(() => reject(new ResponseTimeoutError(timeoutMs)), timeoutMs);
|
|
59
|
+
});
|
|
60
|
+
const iterator = res.body[Symbol.asyncIterator]();
|
|
35
61
|
try {
|
|
36
|
-
for
|
|
37
|
-
|
|
62
|
+
for (;;) {
|
|
63
|
+
// `Promise.race` subscribes to both, so neither the deadline nor a read
|
|
64
|
+
// that rejects once the stream is destroyed goes unhandled.
|
|
65
|
+
const step = await Promise.race([iterator.next(), deadline]);
|
|
66
|
+
if (step.done)
|
|
67
|
+
break;
|
|
68
|
+
const buf = Buffer.isBuffer(step.value) ? step.value : Buffer.from(step.value);
|
|
38
69
|
total += buf.length;
|
|
39
70
|
if (total > maxBytes) {
|
|
40
71
|
throw new ResponseTooLargeError(maxBytes);
|
|
@@ -46,10 +77,13 @@ export async function readBoundedBytes(res, maxBytes) {
|
|
|
46
77
|
res.destroy();
|
|
47
78
|
throw err;
|
|
48
79
|
}
|
|
49
|
-
|
|
80
|
+
finally {
|
|
81
|
+
clearTimeout(timer);
|
|
82
|
+
}
|
|
83
|
+
return Buffer.concat(chunks, total);
|
|
50
84
|
}
|
|
51
|
-
/** Read a bounded response body and parse it as JSON. */
|
|
52
|
-
export async function readBoundedJson(res, maxBytes) {
|
|
53
|
-
const bytes = await readBoundedBytes(res, maxBytes);
|
|
85
|
+
/** Read a bounded, deadlined response body and parse it as JSON. */
|
|
86
|
+
export async function readBoundedJson(res, maxBytes, timeoutMs = DEFAULT_CLIENT_BODY_TIMEOUT_MS) {
|
|
87
|
+
const bytes = await readBoundedBytes(res, maxBytes, timeoutMs);
|
|
54
88
|
return JSON.parse(bytes.toString('utf8'));
|
|
55
89
|
}
|
package/dist/esm/node/index.js
CHANGED
|
@@ -21,8 +21,8 @@ export { verifyNodeRecordEnvelope } from './verifyRecord.js';
|
|
|
21
21
|
// ── HTTP client ────────────────────────────────────────────────────────────────
|
|
22
22
|
export { NodeClient, NodeClientError, trimTrailingSlashes } from './nodeClient.js';
|
|
23
23
|
// ── Injected transport contract + bounded readers ──────────────────────────────
|
|
24
|
-
export { readBoundedBytes, readBoundedJson, ResponseTooLargeError } from './httpFetch.js';
|
|
24
|
+
export { readBoundedBytes, readBoundedJson, ResponseTooLargeError, ResponseTimeoutError, } from './httpFetch.js';
|
|
25
25
|
// ── did:web verification-method resolver ───────────────────────────────────────
|
|
26
26
|
export { createDidWebResolver, didWebToUrl } from './didWebResolver.js';
|
|
27
27
|
// ── Node-protocol shape constants ──────────────────────────────────────────────
|
|
28
|
-
export { PROTOCOL_VERSION, DEFAULT_WELL_KNOWN_PATH, DEFAULT_SERVICE_TYPE, DEFAULT_APP_NAMESPACE, DEFAULT_PORT, DEFAULT_LOG_LIMIT, MAX_LOG_LIMIT, DEFAULT_MAX_BLOB_BYTES, MAX_SYNC_BATCH, JSON_BODY_LIMIT, OWNER_AUTH_HEADERS, OWNER_AUTH_MAX_AGE_MS, NODE_MODES, OWNER_ACTION_BLOB_PIN, SHA256_HEX, NODE_HEAD_PATH, NODE_LOG_PATH, NODE_RECORDS_PATH, NODE_SYNC_PUSH_PATH, NODE_BLOBS_PATH, DEFAULT_CLIENT_TIMEOUT_MS, DEFAULT_CLIENT_MAX_REDIRECTS, DEFAULT_HEAD_MAX_BYTES, DEFAULT_LOG_MAX_BYTES, DEFAULT_WRITE_RESPONSE_MAX_BYTES, DEFAULT_DID_DOC_MAX_BYTES, } from './constants.js';
|
|
28
|
+
export { PROTOCOL_VERSION, DEFAULT_WELL_KNOWN_PATH, DEFAULT_SERVICE_TYPE, DEFAULT_APP_NAMESPACE, DEFAULT_PORT, DEFAULT_LOG_LIMIT, MAX_LOG_LIMIT, DEFAULT_MAX_BLOB_BYTES, MAX_SYNC_BATCH, JSON_BODY_LIMIT, OWNER_AUTH_HEADERS, OWNER_AUTH_MAX_AGE_MS, NODE_MODES, OWNER_ACTION_BLOB_PIN, SHA256_HEX, NODE_HEAD_PATH, NODE_LOG_PATH, NODE_RECORDS_PATH, NODE_SYNC_PUSH_PATH, NODE_BLOBS_PATH, DEFAULT_CLIENT_TIMEOUT_MS, DEFAULT_CLIENT_BODY_TIMEOUT_MS, DEFAULT_BLOB_BODY_TIMEOUT_MS, DEFAULT_CLIENT_MAX_REDIRECTS, DEFAULT_HEAD_MAX_BYTES, DEFAULT_LOG_MAX_BYTES, DEFAULT_WRITE_RESPONSE_MAX_BYTES, DEFAULT_DID_DOC_MAX_BYTES, } from './constants.js';
|
|
@@ -8,11 +8,12 @@
|
|
|
8
8
|
* the protocol package never depends on `@oxy.so/core`. Oxy supplies an adapter
|
|
9
9
|
* over `@oxy.so/core/server`'s `safeFetch` (HTTPS-only, DNS-pinned, private-IP
|
|
10
10
|
* denylist, bounded redirects); a test supplies an in-process stub. Every
|
|
11
|
-
* response body is read with a hard byte ceiling
|
|
12
|
-
* unbounded body into the caller
|
|
11
|
+
* response body is read with a hard byte ceiling AND a whole-body deadline, so
|
|
12
|
+
* a node can neither stream an unbounded body into the caller nor hold it by
|
|
13
|
+
* stalling after the headers (`ResponseTimeoutError`, which is transient).
|
|
13
14
|
*/
|
|
14
15
|
import { readBoundedBytes, readBoundedJson } from './httpFetch.js';
|
|
15
|
-
import { DEFAULT_CLIENT_MAX_REDIRECTS, DEFAULT_CLIENT_TIMEOUT_MS, DEFAULT_HEAD_MAX_BYTES, DEFAULT_LOG_MAX_BYTES, DEFAULT_MAX_BLOB_BYTES, DEFAULT_WRITE_RESPONSE_MAX_BYTES, NODE_BLOBS_PATH, NODE_HEAD_PATH, NODE_LOG_PATH, NODE_RECORDS_PATH, NODE_SYNC_PUSH_PATH, OWNER_AUTH_HEADERS, } from './constants.js';
|
|
16
|
+
import { DEFAULT_BLOB_BODY_TIMEOUT_MS, DEFAULT_CLIENT_BODY_TIMEOUT_MS, DEFAULT_CLIENT_MAX_REDIRECTS, DEFAULT_CLIENT_TIMEOUT_MS, DEFAULT_HEAD_MAX_BYTES, DEFAULT_LOG_MAX_BYTES, DEFAULT_MAX_BLOB_BYTES, DEFAULT_WRITE_RESPONSE_MAX_BYTES, NODE_BLOBS_PATH, NODE_HEAD_PATH, NODE_LOG_PATH, NODE_RECORDS_PATH, NODE_SYNC_PUSH_PATH, OWNER_AUTH_HEADERS, } from './constants.js';
|
|
16
17
|
/** A non-2xx node response (or a node that returned a malformed body). */
|
|
17
18
|
export class NodeClientError extends Error {
|
|
18
19
|
constructor(message, status, reason) {
|
|
@@ -49,6 +50,8 @@ export class NodeClient {
|
|
|
49
50
|
this.baseUrl = trimTrailingSlashes(options.baseUrl);
|
|
50
51
|
this.fetch = options.fetch;
|
|
51
52
|
this.headersTimeoutMs = options.headersTimeoutMs ?? DEFAULT_CLIENT_TIMEOUT_MS;
|
|
53
|
+
this.bodyTimeoutMs = options.bodyTimeoutMs ?? DEFAULT_CLIENT_BODY_TIMEOUT_MS;
|
|
54
|
+
this.blobBodyTimeoutMs = options.blobBodyTimeoutMs ?? DEFAULT_BLOB_BODY_TIMEOUT_MS;
|
|
52
55
|
this.maxRedirects = options.maxRedirects ?? DEFAULT_CLIENT_MAX_REDIRECTS;
|
|
53
56
|
this.headMaxBytes = options.headMaxBytes ?? DEFAULT_HEAD_MAX_BYTES;
|
|
54
57
|
this.logMaxBytes = options.logMaxBytes ?? DEFAULT_LOG_MAX_BYTES;
|
|
@@ -70,7 +73,7 @@ export class NodeClient {
|
|
|
70
73
|
res.destroy();
|
|
71
74
|
throw new NodeClientError(`node ${NODE_HEAD_PATH} responded HTTP ${res.status}`, res.status);
|
|
72
75
|
}
|
|
73
|
-
const body = await readBoundedJson(res, this.headMaxBytes);
|
|
76
|
+
const body = await readBoundedJson(res, this.headMaxBytes, this.bodyTimeoutMs);
|
|
74
77
|
const obj = (typeof body === 'object' && body !== null ? body : {});
|
|
75
78
|
return {
|
|
76
79
|
seq: typeof obj.seq === 'number' ? obj.seq : null,
|
|
@@ -95,7 +98,7 @@ export class NodeClient {
|
|
|
95
98
|
res.destroy();
|
|
96
99
|
throw new NodeClientError(`node ${NODE_LOG_PATH} responded HTTP ${res.status}`, res.status);
|
|
97
100
|
}
|
|
98
|
-
const body = await readBoundedJson(res, this.logMaxBytes);
|
|
101
|
+
const body = await readBoundedJson(res, this.logMaxBytes, this.bodyTimeoutMs);
|
|
99
102
|
const records = body.records;
|
|
100
103
|
if (!Array.isArray(records)) {
|
|
101
104
|
throw new NodeClientError(`node ${NODE_LOG_PATH} returned no records array`, res.status);
|
|
@@ -124,7 +127,7 @@ export class NodeClient {
|
|
|
124
127
|
headers: { 'Content-Type': 'application/json' },
|
|
125
128
|
body: Buffer.from(JSON.stringify(envelope), 'utf8'),
|
|
126
129
|
}));
|
|
127
|
-
const body = await readBoundedJson(res, this.writeResponseMaxBytes);
|
|
130
|
+
const body = await readBoundedJson(res, this.writeResponseMaxBytes, this.bodyTimeoutMs);
|
|
128
131
|
if (res.status < 200 || res.status >= 300) {
|
|
129
132
|
const reason = readError(body);
|
|
130
133
|
throw new NodeClientError(`node ${NODE_RECORDS_PATH} responded HTTP ${res.status}${reason ? ` (${reason})` : ''}`, res.status, reason);
|
|
@@ -146,7 +149,7 @@ export class NodeClient {
|
|
|
146
149
|
headers: { 'Content-Type': 'application/json' },
|
|
147
150
|
body: Buffer.from(JSON.stringify({ records: envelopes }), 'utf8'),
|
|
148
151
|
}));
|
|
149
|
-
const body = await readBoundedJson(res, this.writeResponseMaxBytes);
|
|
152
|
+
const body = await readBoundedJson(res, this.writeResponseMaxBytes, this.bodyTimeoutMs);
|
|
150
153
|
if (res.status < 200 || res.status >= 300) {
|
|
151
154
|
const reason = readError(body);
|
|
152
155
|
throw new NodeClientError(`node ${NODE_SYNC_PUSH_PATH} responded HTTP ${res.status}${reason ? ` (${reason})` : ''}`, res.status, reason);
|
|
@@ -170,7 +173,7 @@ export class NodeClient {
|
|
|
170
173
|
res.destroy();
|
|
171
174
|
throw new NodeClientError(`node ${NODE_BLOBS_PATH}/:hash responded HTTP ${res.status}`, res.status);
|
|
172
175
|
}
|
|
173
|
-
return readBoundedBytes(res, this.blobMaxBytes);
|
|
176
|
+
return readBoundedBytes(res, this.blobMaxBytes, this.blobBodyTimeoutMs);
|
|
174
177
|
}
|
|
175
178
|
/**
|
|
176
179
|
* Pin a content-addressed blob with an owner-signed authorization
|
|
@@ -189,7 +192,7 @@ export class NodeClient {
|
|
|
189
192
|
},
|
|
190
193
|
body: bytes,
|
|
191
194
|
}));
|
|
192
|
-
const body = await readBoundedJson(res, this.writeResponseMaxBytes);
|
|
195
|
+
const body = await readBoundedJson(res, this.writeResponseMaxBytes, this.bodyTimeoutMs);
|
|
193
196
|
if (res.status < 200 || res.status >= 300) {
|
|
194
197
|
const reason = readError(body);
|
|
195
198
|
throw new NodeClientError(`node ${NODE_BLOBS_PATH}/:hash responded HTTP ${res.status}${reason ? ` (${reason})` : ''}`, res.status, reason);
|
|
@@ -68,6 +68,14 @@ export declare const NODE_SYNC_PUSH_PATH = "/sync/push";
|
|
|
68
68
|
export declare const NODE_BLOBS_PATH = "/blobs";
|
|
69
69
|
/** Default time-to-first-byte deadline for a NodeClient request (ms). */
|
|
70
70
|
export declare const DEFAULT_CLIENT_TIMEOUT_MS = 8000;
|
|
71
|
+
/**
|
|
72
|
+
* Default deadline for reading a whole JSON response body (head, log page,
|
|
73
|
+
* write result, `did.json`) once its headers have arrived (ms). The headers
|
|
74
|
+
* deadline above ends at the headers; this one stops a peer that then stalls.
|
|
75
|
+
*/
|
|
76
|
+
export declare const DEFAULT_CLIENT_BODY_TIMEOUT_MS = 15000;
|
|
77
|
+
/** Default deadline for reading a whole fetched blob body (up to {@link DEFAULT_MAX_BLOB_BYTES}) (ms). */
|
|
78
|
+
export declare const DEFAULT_BLOB_BODY_TIMEOUT_MS = 60000;
|
|
71
79
|
/** Default redirect budget for a NodeClient request (each re-validated upstream). */
|
|
72
80
|
export declare const DEFAULT_CLIENT_MAX_REDIRECTS = 1;
|
|
73
81
|
/** Default bounded read for a `/oxy/head` response (tiny JSON). */
|
|
@@ -22,6 +22,8 @@ import { type NodeFetch } from './httpFetch';
|
|
|
22
22
|
export interface DidWebResolverOptions {
|
|
23
23
|
/** Time-to-first-byte deadline for the `did.json` fetch (ms). */
|
|
24
24
|
headersTimeoutMs?: number;
|
|
25
|
+
/** Whole-body read deadline for the `did.json`, once its headers arrived (ms). */
|
|
26
|
+
bodyTimeoutMs?: number;
|
|
25
27
|
/** Redirect budget for the `did.json` fetch (each re-validated by the transport). */
|
|
26
28
|
maxRedirects?: number;
|
|
27
29
|
/** Bounded read ceiling for the fetched `did.json`. */
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* Oxy supplies an adapter over `safeFetch` (HTTPS-only, DNS-pinned, private-IP
|
|
10
10
|
* denylist, bounded redirects); a test supplies an in-process stub. Either way
|
|
11
11
|
* the SSRF/transport policy stays in the injected implementation, and the
|
|
12
|
-
* bounded body read (the cap that
|
|
13
|
-
* stays here, close to the parsing.
|
|
12
|
+
* bounded body read (the byte cap and the body deadline that stop a malicious
|
|
13
|
+
* node streaming forever or stalling mid-body) stays here, close to the parsing.
|
|
14
14
|
*
|
|
15
15
|
* A Node `IncomingMessage` (what `safeFetch` returns) satisfies
|
|
16
16
|
* {@link NodeFetchResponse.body} directly — it is an `AsyncIterable<Buffer>`,
|
|
@@ -51,10 +51,27 @@ export declare class ResponseTooLargeError extends Error {
|
|
|
51
51
|
constructor(maxBytes: number);
|
|
52
52
|
}
|
|
53
53
|
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
54
|
+
* Thrown when a response body does not end within the caller's deadline — a
|
|
55
|
+
* peer that sent its headers and then stalled or trickled the body. Unlike
|
|
56
|
+
* {@link ResponseTooLargeError} this says nothing about the peer's data, only
|
|
57
|
+
* that this attempt ran out of time: a caller treats it as transient and retries.
|
|
57
58
|
*/
|
|
58
|
-
export declare
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
export declare class ResponseTimeoutError extends Error {
|
|
60
|
+
readonly timeoutMs: number;
|
|
61
|
+
constructor(timeoutMs: number);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Read a response body into a single buffer, at most `maxBytes` and within
|
|
65
|
+
* `timeoutMs` for the WHOLE body (from the call until the stream ends). Past
|
|
66
|
+
* the cap it throws {@link ResponseTooLargeError}, past the deadline
|
|
67
|
+
* {@link ResponseTimeoutError}; on either, or a stream error, the stream is
|
|
68
|
+
* destroyed before the throw, and the deadline timer is cleared on every path.
|
|
69
|
+
*
|
|
70
|
+
* The transport's `headersTimeoutMs` ends at the response headers; this
|
|
71
|
+
* deadline is what stops a node that sends headers and then stalls the body
|
|
72
|
+
* from holding the reader open. `timeoutMs` defaults to
|
|
73
|
+
* {@link DEFAULT_CLIENT_BODY_TIMEOUT_MS}.
|
|
74
|
+
*/
|
|
75
|
+
export declare function readBoundedBytes(res: NodeFetchResponse, maxBytes: number, timeoutMs?: number): Promise<Buffer>;
|
|
76
|
+
/** Read a bounded, deadlined response body and parse it as JSON. */
|
|
77
|
+
export declare function readBoundedJson(res: NodeFetchResponse, maxBytes: number, timeoutMs?: number): Promise<unknown>;
|
|
@@ -20,9 +20,9 @@ export { verifyNodeRecordEnvelope } from './verifyRecord';
|
|
|
20
20
|
export type { NodeVerifyResult, NodeVerifyRejectionReason } from './verifyRecord';
|
|
21
21
|
export { NodeClient, NodeClientError, trimTrailingSlashes } from './nodeClient';
|
|
22
22
|
export type { NodeClientOptions, NodeHead, NodeLogPage, NodeWriteResult, NodeBlobPutResult, NodeBlobPinAuth, } from './nodeClient';
|
|
23
|
-
export { readBoundedBytes, readBoundedJson, ResponseTooLargeError } from './httpFetch';
|
|
23
|
+
export { readBoundedBytes, readBoundedJson, ResponseTooLargeError, ResponseTimeoutError, } from './httpFetch';
|
|
24
24
|
export type { NodeFetch, NodeFetchInit, NodeFetchResponse } from './httpFetch';
|
|
25
25
|
export { createDidWebResolver, didWebToUrl } from './didWebResolver';
|
|
26
26
|
export type { DidWebResolverOptions } from './didWebResolver';
|
|
27
|
-
export { PROTOCOL_VERSION, DEFAULT_WELL_KNOWN_PATH, DEFAULT_SERVICE_TYPE, DEFAULT_APP_NAMESPACE, DEFAULT_PORT, DEFAULT_LOG_LIMIT, MAX_LOG_LIMIT, DEFAULT_MAX_BLOB_BYTES, MAX_SYNC_BATCH, JSON_BODY_LIMIT, OWNER_AUTH_HEADERS, OWNER_AUTH_MAX_AGE_MS, NODE_MODES, OWNER_ACTION_BLOB_PIN, SHA256_HEX, NODE_HEAD_PATH, NODE_LOG_PATH, NODE_RECORDS_PATH, NODE_SYNC_PUSH_PATH, NODE_BLOBS_PATH, DEFAULT_CLIENT_TIMEOUT_MS, DEFAULT_CLIENT_MAX_REDIRECTS, DEFAULT_HEAD_MAX_BYTES, DEFAULT_LOG_MAX_BYTES, DEFAULT_WRITE_RESPONSE_MAX_BYTES, DEFAULT_DID_DOC_MAX_BYTES, } from './constants';
|
|
27
|
+
export { PROTOCOL_VERSION, DEFAULT_WELL_KNOWN_PATH, DEFAULT_SERVICE_TYPE, DEFAULT_APP_NAMESPACE, DEFAULT_PORT, DEFAULT_LOG_LIMIT, MAX_LOG_LIMIT, DEFAULT_MAX_BLOB_BYTES, MAX_SYNC_BATCH, JSON_BODY_LIMIT, OWNER_AUTH_HEADERS, OWNER_AUTH_MAX_AGE_MS, NODE_MODES, OWNER_ACTION_BLOB_PIN, SHA256_HEX, NODE_HEAD_PATH, NODE_LOG_PATH, NODE_RECORDS_PATH, NODE_SYNC_PUSH_PATH, NODE_BLOBS_PATH, DEFAULT_CLIENT_TIMEOUT_MS, DEFAULT_CLIENT_BODY_TIMEOUT_MS, DEFAULT_BLOB_BODY_TIMEOUT_MS, DEFAULT_CLIENT_MAX_REDIRECTS, DEFAULT_HEAD_MAX_BYTES, DEFAULT_LOG_MAX_BYTES, DEFAULT_WRITE_RESPONSE_MAX_BYTES, DEFAULT_DID_DOC_MAX_BYTES, } from './constants';
|
|
28
28
|
export type { NodeMode } from './constants';
|
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
* the protocol package never depends on `@oxy.so/core`. Oxy supplies an adapter
|
|
9
9
|
* over `@oxy.so/core/server`'s `safeFetch` (HTTPS-only, DNS-pinned, private-IP
|
|
10
10
|
* denylist, bounded redirects); a test supplies an in-process stub. Every
|
|
11
|
-
* response body is read with a hard byte ceiling
|
|
12
|
-
* unbounded body into the caller
|
|
11
|
+
* response body is read with a hard byte ceiling AND a whole-body deadline, so
|
|
12
|
+
* a node can neither stream an unbounded body into the caller nor hold it by
|
|
13
|
+
* stalling after the headers (`ResponseTimeoutError`, which is transient).
|
|
13
14
|
*/
|
|
14
15
|
import type { SignedRecordEnvelope } from '@oxy.so/contracts';
|
|
15
16
|
import { type NodeFetch } from './httpFetch';
|
|
@@ -63,6 +64,10 @@ export interface NodeClientOptions {
|
|
|
63
64
|
fetch: NodeFetch;
|
|
64
65
|
/** Time-to-first-byte deadline per request (ms). */
|
|
65
66
|
headersTimeoutMs?: number;
|
|
67
|
+
/** Whole-body read deadline for a JSON response, once its headers arrived (ms). */
|
|
68
|
+
bodyTimeoutMs?: number;
|
|
69
|
+
/** Whole-body read deadline for a fetched blob (ms). */
|
|
70
|
+
blobBodyTimeoutMs?: number;
|
|
66
71
|
/** Redirect budget per request (each re-validated by the transport). */
|
|
67
72
|
maxRedirects?: number;
|
|
68
73
|
/** Bounded read ceiling for a `/oxy/head` response. */
|
|
@@ -86,6 +91,8 @@ export declare class NodeClient {
|
|
|
86
91
|
private readonly baseUrl;
|
|
87
92
|
private readonly fetch;
|
|
88
93
|
private readonly headersTimeoutMs;
|
|
94
|
+
private readonly bodyTimeoutMs;
|
|
95
|
+
private readonly blobBodyTimeoutMs;
|
|
89
96
|
private readonly maxRedirects;
|
|
90
97
|
private readonly headMaxBytes;
|
|
91
98
|
private readonly logMaxBytes;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxy.so/protocol",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "Oxy Protocol — the app-agnostic base substrate: signed-record envelope, canonical JSON, signature/verification, and platform crypto. Reused by any Oxy app to decentralize its own content.",
|
|
5
5
|
"main": "dist/cjs/index.js",
|
|
6
6
|
"module": "dist/esm/index.js",
|
|
@@ -132,7 +132,7 @@
|
|
|
132
132
|
},
|
|
133
133
|
"dependencies": {
|
|
134
134
|
"@noble/curves": "^1.9.7",
|
|
135
|
-
"@oxy.so/contracts": "^5.
|
|
135
|
+
"@oxy.so/contracts": "^5.4.0",
|
|
136
136
|
"zod": "^3.25.64"
|
|
137
137
|
},
|
|
138
138
|
"peerDependencies": {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { PassThrough } from 'node:stream';
|
|
2
|
+
import {
|
|
3
|
+
type NodeFetchResponse,
|
|
4
|
+
readBoundedBytes,
|
|
5
|
+
ResponseTimeoutError,
|
|
6
|
+
ResponseTooLargeError,
|
|
7
|
+
} from '../node/httpFetch';
|
|
8
|
+
|
|
9
|
+
function streamResponse(): { stream: PassThrough; res: NodeFetchResponse } {
|
|
10
|
+
const stream = new PassThrough();
|
|
11
|
+
return {
|
|
12
|
+
stream,
|
|
13
|
+
res: { status: 200, headers: {}, body: stream, destroy: () => stream.destroy() },
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('readBoundedBytes', () => {
|
|
18
|
+
it('reads a body that ends within its limits', async () => {
|
|
19
|
+
const { stream, res } = streamResponse();
|
|
20
|
+
const pending = readBoundedBytes(res, 8, 1_000);
|
|
21
|
+
stream.write('ab');
|
|
22
|
+
stream.end('cd');
|
|
23
|
+
expect((await pending).toString()).toBe('abcd');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('throws ResponseTooLargeError one byte past the cap and destroys the stream', async () => {
|
|
27
|
+
const { stream, res } = streamResponse();
|
|
28
|
+
const pending = readBoundedBytes(res, 3, 1_000);
|
|
29
|
+
stream.write('abcd');
|
|
30
|
+
await expect(pending).rejects.toBeInstanceOf(ResponseTooLargeError);
|
|
31
|
+
expect(stream.destroyed).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('throws ResponseTimeoutError for a body that stalls and destroys the stream', async () => {
|
|
35
|
+
jest.useFakeTimers();
|
|
36
|
+
try {
|
|
37
|
+
const { stream, res } = streamResponse();
|
|
38
|
+
const pending = readBoundedBytes(res, 1_024, 5_000);
|
|
39
|
+
stream.write('{"partial":');
|
|
40
|
+
const settled = expect(pending).rejects.toBeInstanceOf(ResponseTimeoutError);
|
|
41
|
+
await jest.advanceTimersByTimeAsync(5_000);
|
|
42
|
+
await settled;
|
|
43
|
+
expect(stream.destroyed).toBe(true);
|
|
44
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
45
|
+
} finally {
|
|
46
|
+
jest.useRealTimers();
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
});
|
package/src/node/constants.ts
CHANGED
|
@@ -89,6 +89,16 @@ export const NODE_BLOBS_PATH = '/blobs';
|
|
|
89
89
|
/** Default time-to-first-byte deadline for a NodeClient request (ms). */
|
|
90
90
|
export const DEFAULT_CLIENT_TIMEOUT_MS = 8_000;
|
|
91
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Default deadline for reading a whole JSON response body (head, log page,
|
|
94
|
+
* write result, `did.json`) once its headers have arrived (ms). The headers
|
|
95
|
+
* deadline above ends at the headers; this one stops a peer that then stalls.
|
|
96
|
+
*/
|
|
97
|
+
export const DEFAULT_CLIENT_BODY_TIMEOUT_MS = 15_000;
|
|
98
|
+
|
|
99
|
+
/** Default deadline for reading a whole fetched blob body (up to {@link DEFAULT_MAX_BLOB_BYTES}) (ms). */
|
|
100
|
+
export const DEFAULT_BLOB_BODY_TIMEOUT_MS = 60_000;
|
|
101
|
+
|
|
92
102
|
/** Default redirect budget for a NodeClient request (each re-validated upstream). */
|
|
93
103
|
export const DEFAULT_CLIENT_MAX_REDIRECTS = 1;
|
|
94
104
|
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
import type { ResolvedVerificationMethods, VerificationMethodResolver } from '../identity/resolver';
|
|
26
26
|
import { type NodeFetch, readBoundedJson } from './httpFetch';
|
|
27
27
|
import {
|
|
28
|
+
DEFAULT_CLIENT_BODY_TIMEOUT_MS,
|
|
28
29
|
DEFAULT_CLIENT_MAX_REDIRECTS,
|
|
29
30
|
DEFAULT_CLIENT_TIMEOUT_MS,
|
|
30
31
|
DEFAULT_DID_DOC_MAX_BYTES,
|
|
@@ -34,6 +35,8 @@ import {
|
|
|
34
35
|
export interface DidWebResolverOptions {
|
|
35
36
|
/** Time-to-first-byte deadline for the `did.json` fetch (ms). */
|
|
36
37
|
headersTimeoutMs?: number;
|
|
38
|
+
/** Whole-body read deadline for the `did.json`, once its headers arrived (ms). */
|
|
39
|
+
bodyTimeoutMs?: number;
|
|
37
40
|
/** Redirect budget for the `did.json` fetch (each re-validated by the transport). */
|
|
38
41
|
maxRedirects?: number;
|
|
39
42
|
/** Bounded read ceiling for the fetched `did.json`. */
|
|
@@ -124,6 +127,7 @@ export function createDidWebResolver(
|
|
|
124
127
|
const headersTimeoutMs = options.headersTimeoutMs ?? DEFAULT_CLIENT_TIMEOUT_MS;
|
|
125
128
|
const maxRedirects = options.maxRedirects ?? DEFAULT_CLIENT_MAX_REDIRECTS;
|
|
126
129
|
const maxBytes = options.maxBytes ?? DEFAULT_DID_DOC_MAX_BYTES;
|
|
130
|
+
const bodyTimeoutMs = options.bodyTimeoutMs ?? DEFAULT_CLIENT_BODY_TIMEOUT_MS;
|
|
127
131
|
|
|
128
132
|
return {
|
|
129
133
|
async resolve(subjectDid: string): Promise<ResolvedVerificationMethods | null> {
|
|
@@ -137,7 +141,7 @@ export function createDidWebResolver(
|
|
|
137
141
|
res.destroy();
|
|
138
142
|
return null;
|
|
139
143
|
}
|
|
140
|
-
const body = await readBoundedJson(res, maxBytes);
|
|
144
|
+
const body = await readBoundedJson(res, maxBytes, bodyTimeoutMs);
|
|
141
145
|
const parsed = didDocumentSchema.safeParse(body);
|
|
142
146
|
if (!parsed.success) {
|
|
143
147
|
return null;
|
package/src/node/httpFetch.ts
CHANGED
|
@@ -9,14 +9,16 @@
|
|
|
9
9
|
* Oxy supplies an adapter over `safeFetch` (HTTPS-only, DNS-pinned, private-IP
|
|
10
10
|
* denylist, bounded redirects); a test supplies an in-process stub. Either way
|
|
11
11
|
* the SSRF/transport policy stays in the injected implementation, and the
|
|
12
|
-
* bounded body read (the cap that
|
|
13
|
-
* stays here, close to the parsing.
|
|
12
|
+
* bounded body read (the byte cap and the body deadline that stop a malicious
|
|
13
|
+
* node streaming forever or stalling mid-body) stays here, close to the parsing.
|
|
14
14
|
*
|
|
15
15
|
* A Node `IncomingMessage` (what `safeFetch` returns) satisfies
|
|
16
16
|
* {@link NodeFetchResponse.body} directly — it is an `AsyncIterable<Buffer>`,
|
|
17
17
|
* and `Buffer` is a `Uint8Array`.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
import { DEFAULT_CLIENT_BODY_TIMEOUT_MS } from './constants';
|
|
21
|
+
|
|
20
22
|
/** Per-request options the client passes to the injected transport. */
|
|
21
23
|
export interface NodeFetchInit {
|
|
22
24
|
/** HTTP method (`GET` / `POST` / `PUT`). */
|
|
@@ -58,16 +60,49 @@ export class ResponseTooLargeError extends Error {
|
|
|
58
60
|
}
|
|
59
61
|
|
|
60
62
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
63
|
+
* Thrown when a response body does not end within the caller's deadline — a
|
|
64
|
+
* peer that sent its headers and then stalled or trickled the body. Unlike
|
|
65
|
+
* {@link ResponseTooLargeError} this says nothing about the peer's data, only
|
|
66
|
+
* that this attempt ran out of time: a caller treats it as transient and retries.
|
|
67
|
+
*/
|
|
68
|
+
export class ResponseTimeoutError extends Error {
|
|
69
|
+
constructor(public readonly timeoutMs: number) {
|
|
70
|
+
super(`response body did not end within ${timeoutMs} ms`);
|
|
71
|
+
this.name = 'ResponseTimeoutError';
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Read a response body into a single buffer, at most `maxBytes` and within
|
|
77
|
+
* `timeoutMs` for the WHOLE body (from the call until the stream ends). Past
|
|
78
|
+
* the cap it throws {@link ResponseTooLargeError}, past the deadline
|
|
79
|
+
* {@link ResponseTimeoutError}; on either, or a stream error, the stream is
|
|
80
|
+
* destroyed before the throw, and the deadline timer is cleared on every path.
|
|
81
|
+
*
|
|
82
|
+
* The transport's `headersTimeoutMs` ends at the response headers; this
|
|
83
|
+
* deadline is what stops a node that sends headers and then stalls the body
|
|
84
|
+
* from holding the reader open. `timeoutMs` defaults to
|
|
85
|
+
* {@link DEFAULT_CLIENT_BODY_TIMEOUT_MS}.
|
|
64
86
|
*/
|
|
65
|
-
export async function readBoundedBytes(
|
|
87
|
+
export async function readBoundedBytes(
|
|
88
|
+
res: NodeFetchResponse,
|
|
89
|
+
maxBytes: number,
|
|
90
|
+
timeoutMs: number = DEFAULT_CLIENT_BODY_TIMEOUT_MS,
|
|
91
|
+
): Promise<Buffer> {
|
|
66
92
|
const chunks: Buffer[] = [];
|
|
67
93
|
let total = 0;
|
|
94
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
95
|
+
const deadline = new Promise<never>((_, reject) => {
|
|
96
|
+
timer = setTimeout(() => reject(new ResponseTimeoutError(timeoutMs)), timeoutMs);
|
|
97
|
+
});
|
|
98
|
+
const iterator = res.body[Symbol.asyncIterator]();
|
|
68
99
|
try {
|
|
69
|
-
for
|
|
70
|
-
|
|
100
|
+
for (;;) {
|
|
101
|
+
// `Promise.race` subscribes to both, so neither the deadline nor a read
|
|
102
|
+
// that rejects once the stream is destroyed goes unhandled.
|
|
103
|
+
const step = await Promise.race([iterator.next(), deadline]);
|
|
104
|
+
if (step.done) break;
|
|
105
|
+
const buf = Buffer.isBuffer(step.value) ? step.value : Buffer.from(step.value);
|
|
71
106
|
total += buf.length;
|
|
72
107
|
if (total > maxBytes) {
|
|
73
108
|
throw new ResponseTooLargeError(maxBytes);
|
|
@@ -77,12 +112,18 @@ export async function readBoundedBytes(res: NodeFetchResponse, maxBytes: number)
|
|
|
77
112
|
} catch (err) {
|
|
78
113
|
res.destroy();
|
|
79
114
|
throw err;
|
|
115
|
+
} finally {
|
|
116
|
+
clearTimeout(timer);
|
|
80
117
|
}
|
|
81
|
-
return Buffer.concat(chunks);
|
|
118
|
+
return Buffer.concat(chunks, total);
|
|
82
119
|
}
|
|
83
120
|
|
|
84
|
-
/** Read a bounded response body and parse it as JSON. */
|
|
85
|
-
export async function readBoundedJson(
|
|
86
|
-
|
|
121
|
+
/** Read a bounded, deadlined response body and parse it as JSON. */
|
|
122
|
+
export async function readBoundedJson(
|
|
123
|
+
res: NodeFetchResponse,
|
|
124
|
+
maxBytes: number,
|
|
125
|
+
timeoutMs: number = DEFAULT_CLIENT_BODY_TIMEOUT_MS,
|
|
126
|
+
): Promise<unknown> {
|
|
127
|
+
const bytes = await readBoundedBytes(res, maxBytes, timeoutMs);
|
|
87
128
|
return JSON.parse(bytes.toString('utf8'));
|
|
88
129
|
}
|
package/src/node/index.ts
CHANGED
|
@@ -48,7 +48,12 @@ export type {
|
|
|
48
48
|
} from './nodeClient';
|
|
49
49
|
|
|
50
50
|
// ── Injected transport contract + bounded readers ──────────────────────────────
|
|
51
|
-
export {
|
|
51
|
+
export {
|
|
52
|
+
readBoundedBytes,
|
|
53
|
+
readBoundedJson,
|
|
54
|
+
ResponseTooLargeError,
|
|
55
|
+
ResponseTimeoutError,
|
|
56
|
+
} from './httpFetch';
|
|
52
57
|
export type { NodeFetch, NodeFetchInit, NodeFetchResponse } from './httpFetch';
|
|
53
58
|
|
|
54
59
|
// ── did:web verification-method resolver ───────────────────────────────────────
|
|
@@ -78,6 +83,8 @@ export {
|
|
|
78
83
|
NODE_SYNC_PUSH_PATH,
|
|
79
84
|
NODE_BLOBS_PATH,
|
|
80
85
|
DEFAULT_CLIENT_TIMEOUT_MS,
|
|
86
|
+
DEFAULT_CLIENT_BODY_TIMEOUT_MS,
|
|
87
|
+
DEFAULT_BLOB_BODY_TIMEOUT_MS,
|
|
81
88
|
DEFAULT_CLIENT_MAX_REDIRECTS,
|
|
82
89
|
DEFAULT_HEAD_MAX_BYTES,
|
|
83
90
|
DEFAULT_LOG_MAX_BYTES,
|
package/src/node/nodeClient.ts
CHANGED
|
@@ -8,13 +8,16 @@
|
|
|
8
8
|
* the protocol package never depends on `@oxy.so/core`. Oxy supplies an adapter
|
|
9
9
|
* over `@oxy.so/core/server`'s `safeFetch` (HTTPS-only, DNS-pinned, private-IP
|
|
10
10
|
* denylist, bounded redirects); a test supplies an in-process stub. Every
|
|
11
|
-
* response body is read with a hard byte ceiling
|
|
12
|
-
* unbounded body into the caller
|
|
11
|
+
* response body is read with a hard byte ceiling AND a whole-body deadline, so
|
|
12
|
+
* a node can neither stream an unbounded body into the caller nor hold it by
|
|
13
|
+
* stalling after the headers (`ResponseTimeoutError`, which is transient).
|
|
13
14
|
*/
|
|
14
15
|
|
|
15
16
|
import type { SignedRecordEnvelope } from '@oxy.so/contracts';
|
|
16
17
|
import { type NodeFetch, type NodeFetchInit, readBoundedBytes, readBoundedJson } from './httpFetch';
|
|
17
18
|
import {
|
|
19
|
+
DEFAULT_BLOB_BODY_TIMEOUT_MS,
|
|
20
|
+
DEFAULT_CLIENT_BODY_TIMEOUT_MS,
|
|
18
21
|
DEFAULT_CLIENT_MAX_REDIRECTS,
|
|
19
22
|
DEFAULT_CLIENT_TIMEOUT_MS,
|
|
20
23
|
DEFAULT_HEAD_MAX_BYTES,
|
|
@@ -87,6 +90,10 @@ export interface NodeClientOptions {
|
|
|
87
90
|
fetch: NodeFetch;
|
|
88
91
|
/** Time-to-first-byte deadline per request (ms). */
|
|
89
92
|
headersTimeoutMs?: number;
|
|
93
|
+
/** Whole-body read deadline for a JSON response, once its headers arrived (ms). */
|
|
94
|
+
bodyTimeoutMs?: number;
|
|
95
|
+
/** Whole-body read deadline for a fetched blob (ms). */
|
|
96
|
+
blobBodyTimeoutMs?: number;
|
|
90
97
|
/** Redirect budget per request (each re-validated by the transport). */
|
|
91
98
|
maxRedirects?: number;
|
|
92
99
|
/** Bounded read ceiling for a `/oxy/head` response. */
|
|
@@ -129,6 +136,8 @@ export class NodeClient {
|
|
|
129
136
|
private readonly baseUrl: string;
|
|
130
137
|
private readonly fetch: NodeFetch;
|
|
131
138
|
private readonly headersTimeoutMs: number;
|
|
139
|
+
private readonly bodyTimeoutMs: number;
|
|
140
|
+
private readonly blobBodyTimeoutMs: number;
|
|
132
141
|
private readonly maxRedirects: number;
|
|
133
142
|
private readonly headMaxBytes: number;
|
|
134
143
|
private readonly logMaxBytes: number;
|
|
@@ -139,6 +148,8 @@ export class NodeClient {
|
|
|
139
148
|
this.baseUrl = trimTrailingSlashes(options.baseUrl);
|
|
140
149
|
this.fetch = options.fetch;
|
|
141
150
|
this.headersTimeoutMs = options.headersTimeoutMs ?? DEFAULT_CLIENT_TIMEOUT_MS;
|
|
151
|
+
this.bodyTimeoutMs = options.bodyTimeoutMs ?? DEFAULT_CLIENT_BODY_TIMEOUT_MS;
|
|
152
|
+
this.blobBodyTimeoutMs = options.blobBodyTimeoutMs ?? DEFAULT_BLOB_BODY_TIMEOUT_MS;
|
|
142
153
|
this.maxRedirects = options.maxRedirects ?? DEFAULT_CLIENT_MAX_REDIRECTS;
|
|
143
154
|
this.headMaxBytes = options.headMaxBytes ?? DEFAULT_HEAD_MAX_BYTES;
|
|
144
155
|
this.logMaxBytes = options.logMaxBytes ?? DEFAULT_LOG_MAX_BYTES;
|
|
@@ -162,7 +173,7 @@ export class NodeClient {
|
|
|
162
173
|
res.destroy();
|
|
163
174
|
throw new NodeClientError(`node ${NODE_HEAD_PATH} responded HTTP ${res.status}`, res.status);
|
|
164
175
|
}
|
|
165
|
-
const body = await readBoundedJson(res, this.headMaxBytes);
|
|
176
|
+
const body = await readBoundedJson(res, this.headMaxBytes, this.bodyTimeoutMs);
|
|
166
177
|
const obj = (typeof body === 'object' && body !== null ? body : {}) as Record<string, unknown>;
|
|
167
178
|
return {
|
|
168
179
|
seq: typeof obj.seq === 'number' ? obj.seq : null,
|
|
@@ -188,7 +199,7 @@ export class NodeClient {
|
|
|
188
199
|
res.destroy();
|
|
189
200
|
throw new NodeClientError(`node ${NODE_LOG_PATH} responded HTTP ${res.status}`, res.status);
|
|
190
201
|
}
|
|
191
|
-
const body = await readBoundedJson(res, this.logMaxBytes);
|
|
202
|
+
const body = await readBoundedJson(res, this.logMaxBytes, this.bodyTimeoutMs);
|
|
192
203
|
const records = (body as { records?: unknown }).records;
|
|
193
204
|
if (!Array.isArray(records)) {
|
|
194
205
|
throw new NodeClientError(`node ${NODE_LOG_PATH} returned no records array`, res.status);
|
|
@@ -222,7 +233,7 @@ export class NodeClient {
|
|
|
222
233
|
body: Buffer.from(JSON.stringify(envelope), 'utf8'),
|
|
223
234
|
}),
|
|
224
235
|
);
|
|
225
|
-
const body = await readBoundedJson(res, this.writeResponseMaxBytes);
|
|
236
|
+
const body = await readBoundedJson(res, this.writeResponseMaxBytes, this.bodyTimeoutMs);
|
|
226
237
|
if (res.status < 200 || res.status >= 300) {
|
|
227
238
|
const reason = readError(body);
|
|
228
239
|
throw new NodeClientError(
|
|
@@ -258,7 +269,7 @@ export class NodeClient {
|
|
|
258
269
|
body: Buffer.from(JSON.stringify({ records: envelopes }), 'utf8'),
|
|
259
270
|
}),
|
|
260
271
|
);
|
|
261
|
-
const body = await readBoundedJson(res, this.writeResponseMaxBytes);
|
|
272
|
+
const body = await readBoundedJson(res, this.writeResponseMaxBytes, this.bodyTimeoutMs);
|
|
262
273
|
if (res.status < 200 || res.status >= 300) {
|
|
263
274
|
const reason = readError(body);
|
|
264
275
|
throw new NodeClientError(
|
|
@@ -293,7 +304,7 @@ export class NodeClient {
|
|
|
293
304
|
res.status,
|
|
294
305
|
);
|
|
295
306
|
}
|
|
296
|
-
return readBoundedBytes(res, this.blobMaxBytes);
|
|
307
|
+
return readBoundedBytes(res, this.blobMaxBytes, this.blobBodyTimeoutMs);
|
|
297
308
|
}
|
|
298
309
|
|
|
299
310
|
/**
|
|
@@ -320,7 +331,7 @@ export class NodeClient {
|
|
|
320
331
|
body: bytes,
|
|
321
332
|
}),
|
|
322
333
|
);
|
|
323
|
-
const body = await readBoundedJson(res, this.writeResponseMaxBytes);
|
|
334
|
+
const body = await readBoundedJson(res, this.writeResponseMaxBytes, this.bodyTimeoutMs);
|
|
324
335
|
if (res.status < 200 || res.status >= 300) {
|
|
325
336
|
const reason = readError(body);
|
|
326
337
|
throw new NodeClientError(
|