@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.
@@ -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 stops a malicious node streaming forever)
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
- * Read a response body into a single buffer, aborting (and destroying the
35
- * stream) the moment it exceeds `maxBytes`. The bound is the defence against a
36
- * node that streams an unbounded body.
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
- async function readBoundedBytes(res, maxBytes) {
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 await (const chunk of res.body) {
43
- const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
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
- return Buffer.concat(chunks);
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
  }
@@ -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, so a node cannot stream an
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 stops a malicious node streaming forever)
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
- * Read a response body into a single buffer, aborting (and destroying the
29
- * stream) the moment it exceeds `maxBytes`. The bound is the defence against a
30
- * node that streams an unbounded body.
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 async function readBoundedBytes(res, maxBytes) {
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 await (const chunk of res.body) {
37
- const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
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
- return Buffer.concat(chunks);
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
  }
@@ -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, so a node cannot stream an
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 stops a malicious node streaming forever)
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
- * Read a response body into a single buffer, aborting (and destroying the
55
- * stream) the moment it exceeds `maxBytes`. The bound is the defence against a
56
- * node that streams an unbounded body.
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 function readBoundedBytes(res: NodeFetchResponse, maxBytes: number): Promise<Buffer>;
59
- /** Read a bounded response body and parse it as JSON. */
60
- export declare function readBoundedJson(res: NodeFetchResponse, maxBytes: number): Promise<unknown>;
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, so a node cannot stream an
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.4.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.0.0",
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
+ });
@@ -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;
@@ -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 stops a malicious node streaming forever)
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
- * Read a response body into a single buffer, aborting (and destroying the
62
- * stream) the moment it exceeds `maxBytes`. The bound is the defence against a
63
- * node that streams an unbounded body.
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(res: NodeFetchResponse, maxBytes: number): Promise<Buffer> {
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 await (const chunk of res.body) {
70
- const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
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(res: NodeFetchResponse, maxBytes: number): Promise<unknown> {
86
- const bytes = await readBoundedBytes(res, maxBytes);
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 { readBoundedBytes, readBoundedJson, ResponseTooLargeError } from './httpFetch';
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,
@@ -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, so a node cannot stream an
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(