@slicedearth/whoisleuth-cli 1.43.0 → 1.45.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.
Files changed (42) hide show
  1. package/README.md +8 -4
  2. package/bin/whoisleuth.mjs +20 -9
  3. package/cli/bulk-checkpoint.mjs +65 -28
  4. package/cli/bulk-command-runner.mjs +15 -2
  5. package/cli/bulk.mjs +18 -7
  6. package/cli/completion.mjs +34 -3
  7. package/cli/config-profile.mjs +15 -12
  8. package/cli/discovery-observation-snapshot.mjs +8 -14
  9. package/cli/discovery-scan-command-runner.mjs +14 -1
  10. package/cli/discovery-snapshot.mjs +8 -15
  11. package/cli/errors.mjs +7 -1
  12. package/cli/formatters/json.mjs +12 -4
  13. package/cli/formatters/terminal.mjs +1 -1
  14. package/cli/http.mjs +2 -1
  15. package/cli/lookup-reconcile.mjs +1 -1
  16. package/cli/mail-review.mjs +5 -5
  17. package/cli/manual.mjs +1 -1
  18. package/cli/registry-fixture-scaffold.mjs +4 -3
  19. package/cli/runner.mjs +20 -9
  20. package/docs/cli-reference.md +26 -7
  21. package/docs/cli.md +1 -0
  22. package/frontend/src/lib/analysis/acquisition-due-diligence.js +1 -1
  23. package/frontend/src/lib/analysis/brand-profile-model.js +4 -2
  24. package/frontend/src/lib/analysis/case-evidence-model.js +1 -1
  25. package/frontend/src/lib/analysis/case-record-core.js +1 -1
  26. package/frontend/src/lib/analysis/case-record-operations.js +1 -1
  27. package/frontend/src/lib/analysis/lookup-asset-graph.js +8 -8
  28. package/frontend/src/lib/analysis/lookup-decision-support.js +2 -2
  29. package/frontend/src/lib/analysis/lookup-presentation.js +9 -15
  30. package/frontend/src/lib/analysis/lookup-source-refresh.js +36 -63
  31. package/frontend/src/lib/analysis/utils.js +1 -1
  32. package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -4
  33. package/frontend/src/lib/analysis/workspace-archive.js +3 -7
  34. package/lib/bounded-file.mjs +69 -0
  35. package/lib/bounded-json-response.mjs +157 -0
  36. package/lib/browser-library-profile.mjs +167 -38
  37. package/lib/lookup-cache.mjs +5 -1
  38. package/lib/rdap-transport.mjs +6 -6
  39. package/lib/safe-fetch.mjs +37 -32
  40. package/lib/threat-intelligence-runtime.mjs +1 -2
  41. package/lib/website-technology.mjs +2 -6
  42. package/package.json +1 -1
@@ -1,3 +1,4 @@
1
+ import { BoundedJsonResponseError, requestJsonCapped, } from "../bounded-json-response.js";
1
2
  export const LOOKUP_SOURCE_REFRESH_VERSION = 1;
2
3
  export const LOOKUP_SOURCE_STALE_AFTER_DAYS = 7;
3
4
  export const LOOKUP_FRESHNESS_POLICY_VERSION = 1;
@@ -141,47 +142,6 @@ function record(value) {
141
142
  ? value
142
143
  : {};
143
144
  }
144
- async function boundedJson(response) {
145
- const declaredLength = Number(response.headers.get('content-length'));
146
- if (Number.isFinite(declaredLength) && declaredLength > MAX_LOOKUP_SOURCE_REFRESH_BYTES) {
147
- throw new Error('oversized');
148
- }
149
- if (!response.body)
150
- return {};
151
- const reader = response.body.getReader();
152
- const chunks = [];
153
- let total = 0;
154
- try {
155
- while (true) {
156
- const { done, value } = await reader.read();
157
- if (done)
158
- break;
159
- if (!value)
160
- continue;
161
- total += value.byteLength;
162
- if (total > MAX_LOOKUP_SOURCE_REFRESH_BYTES)
163
- throw new Error('oversized');
164
- chunks.push(value);
165
- }
166
- }
167
- finally {
168
- if (total > MAX_LOOKUP_SOURCE_REFRESH_BYTES)
169
- await reader.cancel().catch(() => { });
170
- reader.releaseLock();
171
- }
172
- const bytes = new Uint8Array(total);
173
- let offset = 0;
174
- for (const chunk of chunks) {
175
- bytes.set(chunk, offset);
176
- offset += chunk.byteLength;
177
- }
178
- try {
179
- return JSON.parse(new TextDecoder().decode(bytes));
180
- }
181
- catch {
182
- return {};
183
- }
184
- }
185
145
  function text(value, maximum = 180) {
186
146
  return String(value ?? '')
187
147
  .replace(/[\u0000-\u001f\u007f]+/gu, ' ')
@@ -194,6 +154,7 @@ function summarizeSource(plan, body, observedAt, depth, supersedesObservedAt) {
194
154
  version: LOOKUP_SOURCE_REFRESH_VERSION,
195
155
  id: plan.id,
196
156
  observedAt,
157
+ attemptedAt: observedAt,
197
158
  reason: plan.reason,
198
159
  evidenceIds: plan.evidenceIds.slice(0, 12),
199
160
  supersedesObservedAt,
@@ -208,7 +169,6 @@ function summarizeSource(plan, body, observedAt, depth, supersedesObservedAt) {
208
169
  detail: complete
209
170
  ? `Registry RDAP returned a validated ${upstreamStatus} response.`
210
171
  : `Registry RDAP returned ${Number.isFinite(upstreamStatus) ? `HTTP ${upstreamStatus}` : 'no complete structured record'}.`,
211
- observedAt,
212
172
  };
213
173
  }
214
174
  if (plan.id === 'whois') {
@@ -221,7 +181,6 @@ function summarizeSource(plan, body, observedAt, depth, supersedesObservedAt) {
221
181
  detail: complete
222
182
  ? `WHOIS returned a complete ${chain.length}-hop referral chain.`
223
183
  : `WHOIS returned ${chain.length} hop${chain.length === 1 ? '' : 's'} with ${chainStatus || 'unknown'} chain status.`,
224
- observedAt,
225
184
  };
226
185
  }
227
186
  const state = text(body.state, 40) || 'unknown';
@@ -234,7 +193,19 @@ function summarizeSource(plan, body, observedAt, depth, supersedesObservedAt) {
234
193
  ...base,
235
194
  state: complete ? 'complete' : 'limited',
236
195
  detail: `Domain evidence returned ${state}${sourceStates.length ? `; DNS, HTTP, and TLS states: ${sourceStates.join(', ')}` : ''}.`,
237
- observedAt,
196
+ };
197
+ }
198
+ function unavailableRefreshResult(plan, attemptedAt, supersedesObservedAt, detail) {
199
+ return {
200
+ version: LOOKUP_SOURCE_REFRESH_VERSION,
201
+ id: plan.id,
202
+ state: 'unavailable',
203
+ detail,
204
+ observedAt: null,
205
+ attemptedAt,
206
+ reason: plan.reason,
207
+ evidenceIds: plan.evidenceIds.slice(0, 12),
208
+ supersedesObservedAt,
238
209
  };
239
210
  }
240
211
  export async function requestLookupSourceRefresh(plan, query, depth, options = {}) {
@@ -244,19 +215,24 @@ export async function requestLookupSourceRefresh(plan, query, depth, options = {
244
215
  const timeoutMs = Number.isFinite(options.timeoutMs)
245
216
  ? Math.max(1, Math.min(LOOKUP_SOURCE_REFRESH_TIMEOUT_MS, Math.round(Number(options.timeoutMs))))
246
217
  : LOOKUP_SOURCE_REFRESH_TIMEOUT_MS;
247
- const controller = new AbortController();
248
- const timeout = setTimeout(() => controller.abort('timeout'), timeoutMs);
218
+ const attemptedAt = options.now?.() ?? new Date().toISOString();
219
+ const supersedesObservedAt = options.supersedesObservedAt === undefined
220
+ ? plan.supersedesObservedAt
221
+ : options.supersedesObservedAt;
249
222
  try {
250
223
  const url = `${plan.endpoint}?q=${encodeURIComponent(normalizedQuery)}${plan.id === 'availability' && depth === 'fast' ? '&fast=true' : ''}`;
251
- const response = await (options.fetchImpl ?? fetch)(url, {
224
+ const { response, body: rawBody } = await requestJsonCapped(url, {
252
225
  credentials: 'same-origin',
253
- signal: controller.signal,
226
+ }, {
227
+ ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
228
+ maximumBytes: MAX_LOOKUP_SOURCE_REFRESH_BYTES,
229
+ timeoutMs,
254
230
  });
255
- const body = record(await boundedJson(response));
231
+ const body = record(rawBody);
256
232
  if (!response.ok) {
257
233
  return {
258
- ok: false,
259
- message: text(body.error, 240) || `Source refresh failed with HTTP ${response.status}.`,
234
+ ok: true,
235
+ value: unavailableRefreshResult(plan, attemptedAt, supersedesObservedAt, text(body.error, 240) || `The source returned HTTP ${response.status}; no new observation was recorded.`),
260
236
  };
261
237
  }
262
238
  if (Object.keys(body).length > MAX_LOOKUP_SOURCE_REFRESH_KEYS) {
@@ -264,23 +240,20 @@ export async function requestLookupSourceRefresh(plan, query, depth, options = {
264
240
  }
265
241
  return {
266
242
  ok: true,
267
- value: summarizeSource(plan, body, options.now?.() ?? new Date().toISOString(), depth, options.supersedesObservedAt === undefined
268
- ? plan.supersedesObservedAt
269
- : options.supersedesObservedAt),
243
+ value: summarizeSource(plan, body, attemptedAt, depth, supersedesObservedAt),
270
244
  };
271
245
  }
272
246
  catch (cause) {
273
- if (cause instanceof Error && cause.message === 'oversized') {
274
- return { ok: false, message: 'Source refresh returned an oversized response.' };
247
+ if (cause instanceof BoundedJsonResponseError && cause.code === 'response_too_large') {
248
+ return { ok: true, value: unavailableRefreshResult(plan, attemptedAt, supersedesObservedAt, 'The source response exceeded the local limit; no new observation was recorded.') };
275
249
  }
276
250
  return {
277
- ok: false,
278
- message: controller.signal.aborted ? 'Source refresh timed out.' : 'Source refresh could not be completed.',
251
+ ok: true,
252
+ value: unavailableRefreshResult(plan, attemptedAt, supersedesObservedAt, cause instanceof BoundedJsonResponseError && cause.code === 'timeout'
253
+ ? 'The source refresh timed out; no new observation was recorded.'
254
+ : 'The source refresh could not be completed; no new observation was recorded.'),
279
255
  };
280
256
  }
281
- finally {
282
- clearTimeout(timeout);
283
- }
284
257
  }
285
258
  export function mergeLookupSourceRefreshLedger(current, result) {
286
259
  const candidates = [
@@ -289,10 +262,10 @@ export function mergeLookupSourceRefreshLedger(current, result) {
289
262
  ];
290
263
  const byObservation = new Map();
291
264
  for (const entry of candidates.slice(-MAX_LOOKUP_SOURCE_REFRESH_HISTORY * 4)) {
292
- byObservation.set(`${entry.id}:${entry.observedAt}`, entry);
265
+ byObservation.set(`${entry.id}:${entry.attemptedAt}`, entry);
293
266
  }
294
267
  const ordered = [...byObservation.values()]
295
- .sort((left, right) => Date.parse(left.observedAt) - Date.parse(right.observedAt));
268
+ .sort((left, right) => Date.parse(left.attemptedAt) - Date.parse(right.attemptedAt));
296
269
  const truncated = Boolean(current?.truncated)
297
270
  || ordered.length > MAX_LOOKUP_SOURCE_REFRESH_HISTORY
298
271
  || candidates.length > MAX_LOOKUP_SOURCE_REFRESH_HISTORY;
@@ -5,7 +5,7 @@ import { hammingDistanceHex, isInformativePerceptualHash, isPerceptualHash, } fr
5
5
  // this only gates whether a WHOIS/RDAP-sourced string is safe to drop into a
6
6
  // mailto: URI as the recipient, not a general email validator. mailto:
7
7
  // treats a comma as an additional-recipient separator (RFC 6068), so a
8
- // registrant/abuse-contact field containing e.g. "victim@x.com,cc@evil.com"
8
+ // registrant/abuse-contact field containing e.g. "person@example.test,cc@hostile.example"
9
9
  // would silently add a second recipient to the outreach/abuse draft the user
10
10
  // opens - rejecting anything outside a single plain address closes that off.
11
11
  const SIMPLE_EMAIL_RE = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/;
@@ -1,7 +1,7 @@
1
1
  // Browser-local authenticated encryption for portable workspace archives.
2
2
  // This protects a downloaded file while it is locked. It does not encrypt the
3
3
  // active IndexedDB workspace or defend an unlocked page from same-origin code.
4
- import { MAX_WORKSPACE_ARCHIVE_BYTES, WORKSPACE_ARCHIVE_SCHEMA, WORKSPACE_ARCHIVE_VERSION, isSupportedWorkspaceArchiveVersion, readWorkspaceArchive, } from "./workspace-archive.js";
4
+ import { MAX_WORKSPACE_ARCHIVE_BYTES, WORKSPACE_ARCHIVE_SCHEMA, isSupportedWorkspaceArchiveVersion, readWorkspaceArchive, } from "./workspace-archive.js";
5
5
  export const ENCRYPTED_WORKSPACE_ARCHIVE_SCHEMA = 'whoisleuth.encrypted-workspace-archive';
6
6
  export const ENCRYPTED_WORKSPACE_ARCHIVE_VERSION = 1;
7
7
  export const WORKSPACE_ARCHIVE_PBKDF2_ITERATIONS = 600_000;
@@ -26,9 +26,6 @@ function hasExactKeys(value, expected) {
26
26
  const keys = Object.keys(value);
27
27
  return keys.length === expected.length && keys.every((key) => expected.includes(key));
28
28
  }
29
- function byteLength(value) {
30
- return encoder.encode(value).byteLength;
31
- }
32
29
  function arrayBuffer(bytes) {
33
30
  return bytes.slice().buffer;
34
31
  }
@@ -2,7 +2,7 @@
2
2
  // authority for normalization and merge semantics; this module only packages
3
3
  // their portable contracts, verifies integrity, and previews conflicts.
4
4
  import { buildCaseExport, CASE_SCHEMA_VERSION, enforceStoreBudget, mergeCases, } from "./case-model.js";
5
- import { assertBrandProfileStoreBudget, BRAND_PROFILE_SCHEMA, BRAND_PROFILE_SCHEMA_VERSION, buildBrandProfileExport, mergeBrandProfiles, } from "./brand-profile-model.js";
5
+ import { assertBrandProfileStoreBudget, BRAND_PROFILE_SCHEMA, BRAND_PROFILE_SCHEMA_VERSION, buildBrandProfileExport, mergeBrandProfiles, normalizeBrandProfileId, } from "./brand-profile-model.js";
6
6
  import { assertCampaignStoreBudget, buildCampaignExport, CAMPAIGN_SCHEMA, CAMPAIGN_SCHEMA_VERSION, mergeCampaigns, } from "./campaign-model.js";
7
7
  import { assertWatchlistStoreBudget, buildWatchlistExport, mergeWatchlistStores, WATCHLIST_SCHEMA, WATCHLIST_SCHEMA_VERSION, } from "./watchlist-store.js";
8
8
  import { assertShortlistStoreBudget, buildShortlistExport, mergeShortlistStores, SHORTLIST_SCHEMA, SHORTLIST_SCHEMA_VERSION, } from "./shortlist-model.js";
@@ -40,7 +40,6 @@ export function isSupportedWorkspaceArchiveVersion(value) {
40
40
  && SUPPORTED_WORKSPACE_ARCHIVE_VERSIONS.some((version) => version === value);
41
41
  }
42
42
  const CONTROL_RE = /[\x00-\x1f\x7f]/;
43
- const SAFE_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
44
43
  const CHECKSUM_RE = /^sha256:[a-f0-9]{64}$/;
45
44
  function record(value) {
46
45
  return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
@@ -101,9 +100,7 @@ async function checksum(value, cryptoProvider = globalThis.crypto) {
101
100
  }
102
101
  function settingsDocument(input) {
103
102
  const profiles = Array.isArray(input.brandProfiles) ? input.brandProfiles : [];
104
- const requestedProfileId = typeof input.settings?.activeProfileId === 'string' && SAFE_ID_RE.test(input.settings.activeProfileId)
105
- ? input.settings.activeProfileId
106
- : '';
103
+ const requestedProfileId = normalizeBrandProfileId(input.settings?.activeProfileId) || '';
107
104
  const activeProfileId = profiles.some((profile) => profile?.id === requestedProfileId)
108
105
  ? requestedProfileId
109
106
  : '';
@@ -406,8 +403,7 @@ export async function readWorkspaceArchive(raw, options = {}) {
406
403
  }
407
404
  function settingsPreview(data, local, mergedProfiles) {
408
405
  const value = record(data) || {};
409
- const requestedProfileId = typeof value.activeProfileId === 'string' ? value.activeProfileId : '';
410
- const activeProfileId = SAFE_ID_RE.test(requestedProfileId) ? requestedProfileId : '';
406
+ const activeProfileId = normalizeBrandProfileId(value.activeProfileId) || '';
411
407
  const theme = normalizeTheme(value.theme);
412
408
  let skipped = 0;
413
409
  if (activeProfileId && !mergedProfiles.some((profile) => profile?.id === activeProfileId))
@@ -0,0 +1,69 @@
1
+ import { constants } from 'node:fs';
2
+ import { open } from 'node:fs/promises';
3
+ const MAX_BOUNDED_FILE_BYTES = 64 * 1024 * 1024;
4
+ function boundedSize(value, fallback) {
5
+ return Number.isSafeInteger(value) && Number(value) >= 0 && Number(value) <= MAX_BOUNDED_FILE_BYTES
6
+ ? Number(value)
7
+ : fallback;
8
+ }
9
+ export async function readBoundedRegularFile(filePath, options, dependencies = {}) {
10
+ const label = options.label?.trim().slice(0, 120) || 'Input file';
11
+ const maximumBytes = boundedSize(options.maximumBytes, 0);
12
+ const minimumBytes = boundedSize(options.minimumBytes, 0);
13
+ if (maximumBytes < 1 || minimumBytes > maximumBytes) {
14
+ throw new TypeError(`${label} has an invalid byte boundary.`);
15
+ }
16
+ const expectedBytes = options.expectedBytes === null || options.expectedBytes === undefined
17
+ ? null
18
+ : boundedSize(options.expectedBytes, -1);
19
+ if (expectedBytes !== null && (expectedBytes < minimumBytes || expectedBytes > maximumBytes)) {
20
+ throw new TypeError(`${label} expected size is outside its byte boundary.`);
21
+ }
22
+ // Non-blocking open prevents a named pipe from stalling the process before
23
+ // the descriptor can be classified. Final-component symlinks remain denied
24
+ // unless an analyst-facing input explicitly opts in; artefacts resolved
25
+ // inside a verified directory must retain the default confinement boundary.
26
+ const flags = constants.O_RDONLY
27
+ | constants.O_NONBLOCK
28
+ | (options.allowSymbolicLink ? 0 : constants.O_NOFOLLOW);
29
+ const handle = await (dependencies.openFile ?? open)(filePath, flags);
30
+ try {
31
+ const before = await handle.stat();
32
+ if (!before.isFile())
33
+ throw new TypeError(`${label} must be a regular file.`);
34
+ if (before.size < minimumBytes)
35
+ throw new TypeError(`${label} is smaller than its ${minimumBytes}-byte minimum.`);
36
+ if (before.size > maximumBytes)
37
+ throw new TypeError(`${label} exceeds its ${maximumBytes}-byte maximum.`);
38
+ if (expectedBytes !== null && before.size !== expectedBytes) {
39
+ throw new TypeError(`${label} size does not match its declared size.`);
40
+ }
41
+ // Read from the already-opened descriptor and reserve only one sentinel
42
+ // byte beyond the admitted size. A concurrent append is detected without
43
+ // allocating from an attacker-controlled post-stat file length.
44
+ const storage = Buffer.allocUnsafe(Math.min(maximumBytes + 1, before.size + 1));
45
+ let offset = 0;
46
+ while (offset < storage.length) {
47
+ const { bytesRead } = await handle.read(storage, offset, storage.length - offset, null);
48
+ if (bytesRead === 0)
49
+ break;
50
+ offset += bytesRead;
51
+ }
52
+ const after = await handle.stat();
53
+ if (offset > maximumBytes
54
+ || after.size !== before.size
55
+ || offset !== after.size
56
+ || after.mtimeMs !== before.mtimeMs
57
+ || after.ctimeMs !== before.ctimeMs) {
58
+ throw new TypeError(`${label} changed while it was being read.`);
59
+ }
60
+ return Buffer.from(storage.subarray(0, offset));
61
+ }
62
+ finally {
63
+ await handle.close();
64
+ }
65
+ }
66
+ export async function readBoundedRegularTextFile(filePath, options, dependencies = {}) {
67
+ return (await readBoundedRegularFile(filePath, options, dependencies)).toString('utf8');
68
+ }
69
+ export { MAX_BOUNDED_FILE_BYTES };
@@ -0,0 +1,157 @@
1
+ // Environment-neutral browser/server JSON response boundary. It enforces a
2
+ // declared and streamed byte ceiling before parsing and can apply one deadline
3
+ // across both the fetch and body-read phases.
4
+ export const SMALL_JSON_RESPONSE_BYTES = 64 * 1024;
5
+ export const STANDARD_JSON_RESPONSE_BYTES = 2 * 1024 * 1024;
6
+ export const LARGE_JSON_RESPONSE_BYTES = 8 * 1024 * 1024;
7
+ export const DEFAULT_JSON_RESPONSE_TIMEOUT_MS = 40_000;
8
+ const MAX_JSON_RESPONSE_TIMEOUT_MS = 120_000;
9
+ export class BoundedJsonResponseError extends Error {
10
+ code;
11
+ constructor(code, message) {
12
+ super(message);
13
+ this.name = 'BoundedJsonResponseError';
14
+ this.code = code;
15
+ }
16
+ }
17
+ async function raceWithAbort(promise, signal, onLateResolution) {
18
+ if (signal.aborted)
19
+ throw signal.reason ?? new Error('Request aborted.');
20
+ return new Promise((resolve, reject) => {
21
+ let aborted = false;
22
+ const onAbort = () => {
23
+ aborted = true;
24
+ reject(signal.reason ?? new Error('Request aborted.'));
25
+ };
26
+ signal.addEventListener('abort', onAbort, { once: true });
27
+ void promise.then((value) => {
28
+ signal.removeEventListener('abort', onAbort);
29
+ if (aborted) {
30
+ void Promise.resolve(onLateResolution?.(value)).catch(() => { });
31
+ return;
32
+ }
33
+ resolve(value);
34
+ }, (cause) => {
35
+ signal.removeEventListener('abort', onAbort);
36
+ reject(cause);
37
+ });
38
+ });
39
+ }
40
+ function boundedPositiveInteger(value, fallback, maximum) {
41
+ return typeof value === 'number' && Number.isFinite(value)
42
+ ? Math.max(1, Math.min(maximum, Math.round(value)))
43
+ : fallback;
44
+ }
45
+ export async function readJsonResponseCapped(response, maximumBytes = STANDARD_JSON_RESPONSE_BYTES, signal) {
46
+ const maxBytes = boundedPositiveInteger(maximumBytes, STANDARD_JSON_RESPONSE_BYTES, LARGE_JSON_RESPONSE_BYTES);
47
+ const declared = response.headers.get('content-length');
48
+ if (declared && /^\d+$/u.test(declared)) {
49
+ const bytes = Number(declared);
50
+ if (!Number.isSafeInteger(bytes) || bytes > maxBytes) {
51
+ await response.body?.cancel().catch(() => { });
52
+ throw new BoundedJsonResponseError('response_too_large', `JSON response exceeded ${maxBytes} bytes.`);
53
+ }
54
+ }
55
+ if (!response.body)
56
+ return null;
57
+ const reader = response.body.getReader();
58
+ const cancelOnAbort = () => { void reader.cancel(signal?.reason).catch(() => { }); };
59
+ signal?.addEventListener('abort', cancelOnAbort, { once: true });
60
+ const chunks = [];
61
+ let total = 0;
62
+ try {
63
+ while (true) {
64
+ const read = reader.read();
65
+ const { done, value } = signal ? await raceWithAbort(read, signal) : await read;
66
+ if (done)
67
+ break;
68
+ if (!value)
69
+ continue;
70
+ total += value.byteLength;
71
+ if (total > maxBytes) {
72
+ await reader.cancel().catch(() => { });
73
+ throw new BoundedJsonResponseError('response_too_large', `JSON response exceeded ${maxBytes} bytes.`);
74
+ }
75
+ chunks.push(value);
76
+ }
77
+ }
78
+ finally {
79
+ signal?.removeEventListener('abort', cancelOnAbort);
80
+ reader.releaseLock();
81
+ }
82
+ const bytes = new Uint8Array(total);
83
+ let offset = 0;
84
+ for (const chunk of chunks) {
85
+ bytes.set(chunk, offset);
86
+ offset += chunk.byteLength;
87
+ }
88
+ try {
89
+ return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
90
+ }
91
+ catch {
92
+ throw new BoundedJsonResponseError('invalid_json', 'Response body was not valid UTF-8 JSON.');
93
+ }
94
+ }
95
+ export async function requestJsonCapped(input, init = {}, options = {}) {
96
+ const timeoutMs = boundedPositiveInteger(options.timeoutMs, DEFAULT_JSON_RESPONSE_TIMEOUT_MS, MAX_JSON_RESPONSE_TIMEOUT_MS);
97
+ const controller = new AbortController();
98
+ const upstreamSignal = init.signal;
99
+ const forwardAbort = () => controller.abort(upstreamSignal?.reason);
100
+ if (upstreamSignal?.aborted)
101
+ forwardAbort();
102
+ else
103
+ upstreamSignal?.addEventListener('abort', forwardAbort, { once: true });
104
+ const timeoutReason = Object.freeze({ type: 'bounded-json-timeout' });
105
+ const timeout = setTimeout(() => controller.abort(timeoutReason), timeoutMs);
106
+ try {
107
+ if (controller.signal.aborted) {
108
+ throw new BoundedJsonResponseError('aborted', 'Request was cancelled.');
109
+ }
110
+ const fetchRequest = (options.fetchImpl ?? fetch)(input, {
111
+ ...init,
112
+ signal: controller.signal,
113
+ });
114
+ const response = await raceWithAbort(fetchRequest, controller.signal, async (lateResponse) => lateResponse.body?.cancel().catch(() => { }));
115
+ const contentType = response.headers.get('content-type')?.toLowerCase() ?? '';
116
+ const plausibleJson = !contentType
117
+ || contentType.includes('application/json')
118
+ || contentType.includes('+json');
119
+ if (options.allowNonJsonErrorResponse !== false && !response.ok && !plausibleJson) {
120
+ await response.body?.cancel().catch(() => { });
121
+ return { response, body: null };
122
+ }
123
+ let body;
124
+ try {
125
+ body = await readJsonResponseCapped(response, options.maximumBytes, controller.signal);
126
+ }
127
+ catch (cause) {
128
+ // Reverse proxies and hosting adapters can replace an upstream JSON
129
+ // error with a bounded HTML or plain-text response. Preserve the HTTP
130
+ // status so callers can apply retry and generic error handling, while
131
+ // continuing to require valid JSON for successful responses.
132
+ if (options.allowNonJsonErrorResponse !== false
133
+ && !response.ok
134
+ && cause instanceof BoundedJsonResponseError
135
+ && cause.code === 'invalid_json') {
136
+ body = null;
137
+ }
138
+ else {
139
+ throw cause;
140
+ }
141
+ }
142
+ return { response, body };
143
+ }
144
+ catch (cause) {
145
+ if (controller.signal.aborted) {
146
+ const timedOut = controller.signal.reason === timeoutReason;
147
+ throw new BoundedJsonResponseError(timedOut ? 'timeout' : 'aborted', timedOut ? `Request timed out after ${timeoutMs} ms.` : 'Request was cancelled.');
148
+ }
149
+ if (cause instanceof BoundedJsonResponseError)
150
+ throw cause;
151
+ throw cause;
152
+ }
153
+ finally {
154
+ clearTimeout(timeout);
155
+ upstreamSignal?.removeEventListener('abort', forwardAbort);
156
+ }
157
+ }