@blamejs/core 0.8.43 → 0.8.49

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 (222) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/README.md +10 -10
  3. package/index.js +52 -0
  4. package/lib/a2a.js +159 -34
  5. package/lib/acme.js +762 -0
  6. package/lib/ai-pref.js +166 -43
  7. package/lib/api-key.js +108 -47
  8. package/lib/api-snapshot.js +157 -40
  9. package/lib/app-shutdown.js +113 -77
  10. package/lib/archive.js +337 -40
  11. package/lib/arg-parser.js +697 -0
  12. package/lib/asyncapi.js +99 -55
  13. package/lib/atomic-file.js +465 -104
  14. package/lib/audit-chain.js +123 -34
  15. package/lib/audit-daily-review.js +389 -0
  16. package/lib/audit-sign.js +302 -56
  17. package/lib/audit-tools.js +412 -63
  18. package/lib/audit.js +656 -35
  19. package/lib/auth/jwt-external.js +17 -0
  20. package/lib/auth/oauth.js +7 -0
  21. package/lib/auth-bot-challenge.js +505 -0
  22. package/lib/auth-header.js +92 -25
  23. package/lib/backup/bundle.js +26 -0
  24. package/lib/backup/index.js +512 -89
  25. package/lib/backup/manifest.js +168 -7
  26. package/lib/break-glass.js +415 -39
  27. package/lib/budr.js +103 -30
  28. package/lib/bundler.js +86 -66
  29. package/lib/cache.js +192 -72
  30. package/lib/chain-writer.js +65 -40
  31. package/lib/circuit-breaker.js +56 -33
  32. package/lib/cli-helpers.js +106 -75
  33. package/lib/cli.js +6 -30
  34. package/lib/cloud-events.js +99 -32
  35. package/lib/cluster-storage.js +162 -37
  36. package/lib/cluster.js +340 -49
  37. package/lib/codepoint-class.js +66 -0
  38. package/lib/compliance.js +424 -24
  39. package/lib/config-drift.js +111 -46
  40. package/lib/config.js +94 -40
  41. package/lib/consent.js +165 -18
  42. package/lib/constants.js +1 -0
  43. package/lib/content-credentials.js +153 -48
  44. package/lib/cookies.js +154 -62
  45. package/lib/credential-hash.js +133 -61
  46. package/lib/crypto-field.js +702 -18
  47. package/lib/crypto-hpke.js +256 -0
  48. package/lib/crypto.js +744 -22
  49. package/lib/csv.js +178 -35
  50. package/lib/daemon.js +456 -0
  51. package/lib/dark-patterns.js +186 -55
  52. package/lib/db-query.js +79 -2
  53. package/lib/db.js +1431 -60
  54. package/lib/ddl-change-control.js +523 -0
  55. package/lib/deprecate.js +195 -40
  56. package/lib/dev.js +82 -39
  57. package/lib/dora.js +67 -48
  58. package/lib/dr-runbook.js +368 -0
  59. package/lib/dsr.js +142 -11
  60. package/lib/dual-control.js +91 -56
  61. package/lib/events.js +120 -41
  62. package/lib/external-db-migrate.js +192 -2
  63. package/lib/external-db.js +795 -50
  64. package/lib/fapi2.js +122 -1
  65. package/lib/fda-21cfr11.js +395 -0
  66. package/lib/fdx.js +132 -2
  67. package/lib/file-type.js +87 -0
  68. package/lib/file-upload.js +93 -0
  69. package/lib/flag.js +82 -20
  70. package/lib/forms.js +132 -29
  71. package/lib/framework-error.js +169 -0
  72. package/lib/framework-schema.js +163 -35
  73. package/lib/gate-contract.js +849 -175
  74. package/lib/graphql-federation.js +68 -7
  75. package/lib/guard-all.js +172 -55
  76. package/lib/guard-archive.js +286 -124
  77. package/lib/guard-auth.js +194 -21
  78. package/lib/guard-cidr.js +190 -28
  79. package/lib/guard-csv.js +397 -51
  80. package/lib/guard-domain.js +213 -91
  81. package/lib/guard-email.js +236 -29
  82. package/lib/guard-filename.js +307 -75
  83. package/lib/guard-graphql.js +263 -30
  84. package/lib/guard-html.js +310 -116
  85. package/lib/guard-image.js +243 -30
  86. package/lib/guard-json.js +260 -54
  87. package/lib/guard-jsonpath.js +235 -23
  88. package/lib/guard-jwt.js +284 -30
  89. package/lib/guard-markdown.js +204 -22
  90. package/lib/guard-mime.js +190 -26
  91. package/lib/guard-oauth.js +277 -28
  92. package/lib/guard-pdf.js +251 -27
  93. package/lib/guard-regex.js +226 -18
  94. package/lib/guard-shell.js +229 -26
  95. package/lib/guard-svg.js +177 -10
  96. package/lib/guard-template.js +232 -21
  97. package/lib/guard-time.js +195 -29
  98. package/lib/guard-uuid.js +189 -30
  99. package/lib/guard-xml.js +259 -36
  100. package/lib/guard-yaml.js +241 -44
  101. package/lib/honeytoken.js +63 -27
  102. package/lib/html-balance.js +83 -0
  103. package/lib/http-client.js +486 -59
  104. package/lib/http-message-signature.js +582 -0
  105. package/lib/i18n.js +102 -49
  106. package/lib/iab-mspa.js +112 -32
  107. package/lib/iab-tcf.js +107 -2
  108. package/lib/inbox.js +90 -52
  109. package/lib/keychain.js +865 -0
  110. package/lib/legal-hold.js +374 -0
  111. package/lib/local-db-thin.js +320 -0
  112. package/lib/log-stream.js +281 -51
  113. package/lib/log.js +184 -86
  114. package/lib/mail-bounce.js +107 -62
  115. package/lib/mail.js +295 -58
  116. package/lib/mcp.js +108 -27
  117. package/lib/metrics.js +98 -89
  118. package/lib/middleware/age-gate.js +36 -0
  119. package/lib/middleware/ai-act-disclosure.js +37 -0
  120. package/lib/middleware/api-encrypt.js +45 -0
  121. package/lib/middleware/assetlinks.js +40 -0
  122. package/lib/middleware/asyncapi-serve.js +35 -0
  123. package/lib/middleware/attach-user.js +40 -0
  124. package/lib/middleware/bearer-auth.js +40 -0
  125. package/lib/middleware/body-parser.js +230 -0
  126. package/lib/middleware/bot-disclose.js +34 -0
  127. package/lib/middleware/bot-guard.js +39 -0
  128. package/lib/middleware/compression.js +37 -0
  129. package/lib/middleware/cookies.js +32 -0
  130. package/lib/middleware/cors.js +40 -0
  131. package/lib/middleware/csp-nonce.js +40 -0
  132. package/lib/middleware/csp-report.js +34 -0
  133. package/lib/middleware/csrf-protect.js +43 -0
  134. package/lib/middleware/daily-byte-quota.js +53 -85
  135. package/lib/middleware/db-role-for.js +40 -0
  136. package/lib/middleware/dpop.js +40 -0
  137. package/lib/middleware/error-handler.js +37 -14
  138. package/lib/middleware/fetch-metadata.js +39 -0
  139. package/lib/middleware/flag-context.js +34 -0
  140. package/lib/middleware/gpc.js +33 -0
  141. package/lib/middleware/headers.js +35 -0
  142. package/lib/middleware/health.js +46 -0
  143. package/lib/middleware/host-allowlist.js +30 -0
  144. package/lib/middleware/network-allowlist.js +38 -0
  145. package/lib/middleware/openapi-serve.js +34 -0
  146. package/lib/middleware/rate-limit.js +160 -18
  147. package/lib/middleware/request-id.js +36 -18
  148. package/lib/middleware/request-log.js +37 -0
  149. package/lib/middleware/require-aal.js +29 -0
  150. package/lib/middleware/require-auth.js +32 -0
  151. package/lib/middleware/require-bound-key.js +41 -0
  152. package/lib/middleware/require-content-type.js +32 -0
  153. package/lib/middleware/require-methods.js +27 -0
  154. package/lib/middleware/require-mtls.js +33 -0
  155. package/lib/middleware/require-step-up.js +37 -0
  156. package/lib/middleware/security-headers.js +44 -0
  157. package/lib/middleware/security-txt.js +38 -0
  158. package/lib/middleware/span-http-server.js +37 -0
  159. package/lib/middleware/sse.js +36 -0
  160. package/lib/middleware/trace-log-correlation.js +33 -0
  161. package/lib/middleware/trace-propagate.js +32 -0
  162. package/lib/middleware/tus-upload.js +90 -0
  163. package/lib/middleware/web-app-manifest.js +53 -0
  164. package/lib/mtls-ca.js +100 -70
  165. package/lib/network-byte-quota.js +308 -0
  166. package/lib/network-heartbeat.js +135 -0
  167. package/lib/network-tls.js +534 -4
  168. package/lib/network.js +103 -0
  169. package/lib/notify.js +114 -43
  170. package/lib/ntp-check.js +192 -51
  171. package/lib/observability.js +145 -47
  172. package/lib/openapi.js +90 -44
  173. package/lib/outbox.js +99 -1
  174. package/lib/pagination.js +168 -86
  175. package/lib/parsers/index.js +16 -5
  176. package/lib/permissions.js +93 -40
  177. package/lib/pqc-agent.js +84 -8
  178. package/lib/pqc-software.js +94 -60
  179. package/lib/process-spawn.js +95 -21
  180. package/lib/pubsub.js +96 -66
  181. package/lib/queue.js +375 -54
  182. package/lib/redact.js +793 -21
  183. package/lib/render.js +139 -47
  184. package/lib/request-helpers.js +485 -121
  185. package/lib/restore-bundle.js +142 -39
  186. package/lib/restore-rollback.js +136 -45
  187. package/lib/retention.js +178 -50
  188. package/lib/retry.js +116 -33
  189. package/lib/router.js +475 -23
  190. package/lib/safe-async.js +543 -94
  191. package/lib/safe-buffer.js +337 -41
  192. package/lib/safe-json.js +467 -62
  193. package/lib/safe-jsonpath.js +285 -0
  194. package/lib/safe-schema.js +631 -87
  195. package/lib/safe-sql.js +221 -59
  196. package/lib/safe-url.js +278 -46
  197. package/lib/sandbox-worker.js +135 -0
  198. package/lib/sandbox.js +358 -0
  199. package/lib/scheduler.js +135 -70
  200. package/lib/self-update.js +647 -0
  201. package/lib/session-device-binding.js +431 -0
  202. package/lib/session.js +259 -49
  203. package/lib/slug.js +138 -26
  204. package/lib/ssrf-guard.js +316 -56
  205. package/lib/storage.js +433 -70
  206. package/lib/subject.js +405 -23
  207. package/lib/template.js +148 -8
  208. package/lib/tenant-quota.js +545 -0
  209. package/lib/testing.js +440 -53
  210. package/lib/time.js +291 -23
  211. package/lib/tls-exporter.js +239 -0
  212. package/lib/tracing.js +90 -74
  213. package/lib/uuid.js +97 -22
  214. package/lib/vault/index.js +284 -22
  215. package/lib/vault/seal-pem-file.js +66 -0
  216. package/lib/watcher.js +368 -0
  217. package/lib/webhook.js +196 -63
  218. package/lib/websocket.js +393 -68
  219. package/lib/wiki-concepts.js +338 -0
  220. package/lib/worker-pool.js +464 -0
  221. package/package.json +3 -3
  222. package/sbom.cyclonedx.json +7 -7
@@ -1,75 +1,50 @@
1
1
  "use strict";
2
2
  /**
3
- * HTTP client primitive — Promise-returning, AbortSignal-aware,
4
- * connection-pooled, streaming-capable, HTTP/2-capable.
3
+ * @module b.httpClient
4
+ * @nav HTTP
5
+ * @title Http Client
5
6
  *
6
- * Built on node:http, node:https, and node:http2. Zero npm runtime
7
- * dependency. Same caller surface for h1 and h2; the protocol version
8
- * is negotiated per-origin via ALPN (h2 preferred, h1 fallback).
7
+ * @intro
8
+ * Outbound HTTP client with SSRF gate, retry, circuit breaker,
9
+ * wall-clock + idle timeouts, AbortSignal propagation, connection
10
+ * pooling, streaming, and ALPN-negotiated HTTP/2. Built on node:http,
11
+ * node:https, and node:http2 with zero npm runtime dependency.
9
12
  *
10
- * Single entry point:
13
+ * Every outbound request flows through `b.ssrfGuard` out of the box:
14
+ * hostname → DNS lookup is pinned to vetted IP literals, RFC 1918 /
15
+ * loopback / link-local / IPv6 ULA destinations are refused, and the
16
+ * redirect chain is re-validated at every hop so a 302 to
17
+ * `http://169.254.169.254/` (cloud metadata) can't smuggle past the
18
+ * first-hop gate. The same DNS pinning applies to retries — there's
19
+ * no retry path that bypasses the guard.
11
20
  *
12
- * await httpClient.request({
13
- * method, // string, default GET
14
- * url, // string or URL
15
- * headers, // object, default {}
16
- * body, // Buffer | string | Readable | undefined
17
- * timeoutMs, // wall-clock cap (caller-chosen, no default)
18
- * idleTimeoutMs, // zero-progress idle cap (default 30s)
19
- * responseMode, // "buffer" (default) | "stream" | "always-resolve"
20
- * maxResponseBytes, // for buffer mode (default 16 MiB control,
21
- * // 1 GiB GET — operators with > 1 GiB
22
- * // stored objects must use stream mode)
23
- * onChunk, // (chunk: Buffer) => void — fires for each
24
- * // response chunk in BOTH buffer and stream
25
- * // modes. Use to hash bytes during pipe-to-disk
26
- * // without an extra Transform pass.
27
- * signal, // AbortSignal — propagated to req/stream
28
- * errorClass, // FrameworkError subclass
29
- * observer, // optional (stage, info) => void hook
30
- * agent, // override per-origin pool (h1 only)
31
- * preferH2, // bool — for cleartext h2 (h2c). HTTPS origins
32
- * // already attempt h2 via ALPN; this flag is
33
- * // for HTTP origins (internal services, tests)
34
- * // that explicitly speak h2c.
35
- * })
36
- * → { statusCode, headers, body }
21
+ * Protocol selection is automatic. HTTPS origins handshake with
22
+ * ALPN `['h2', 'http/1.1']` and cache the resulting transport per
23
+ * `<protocol>//<hostname>:<port>`. While a transport is mid-negotiate
24
+ * the cache holds the in-flight Promise so concurrent calls to a new
25
+ * origin coalesce onto a single connection. h2 GOAWAY or session
26
+ * error evicts the entry; the next request reconnects.
37
27
  *
38
- * Protocol selection:
28
+ * Resiliency defaults: TLS 1.3 minimum, PQC-preferred `ecdhCurve`
29
+ * group order, split wall-clock vs zero-progress idle timeouts,
30
+ * request-body stream errors propagated to the returned Promise,
31
+ * and h2 stream cancellation via NGHTTP2_CANCEL (clean, not
32
+ * `stream.destroy`) when the AbortSignal fires.
39
33
  *
40
- * - HTTPS origin: TLS handshake with ALPN ['h2', 'http/1.1']. If
41
- * server picks 'h2', subsequent requests to that origin multiplex
42
- * over the same h2 session. If server picks 'h1', the cached
43
- * transport is an https.Agent with keepAlive.
44
- *
45
- * - HTTP origin without preferH2: h1 only.
46
- * - HTTP origin with preferH2: h2c (cleartext h2). No ALPN — caller
47
- * attests the server speaks h2c. Used by internal services and
48
- * test fixtures (mock h2 server).
49
- *
50
- * Per-origin transport cache:
51
- *
52
- * key = "<protocol>//<hostname>:<port>"
53
- * value = { kind: 'h1', lib, agent } | { kind: 'h2', session }
54
- *
55
- * While a transport is being negotiated (TLS handshake / h2 connect)
56
- * the cache holds the in-flight Promise so concurrent calls to a
57
- * new origin coalesce onto the same connection.
58
- *
59
- * Resiliency:
60
- * - Wall-clock + idle timeouts (split — slow-progress vs zero-progress)
61
- * - AbortSignal propagated to req.destroy / stream.close
62
- * - TLS 1.3 minimum + PQC ecdhCurve preference
63
- * - h2 session GOAWAY / error → cache eviction; next request reconnects
64
- * - h2 stream cancellation via NGHTTP2_CANCEL on abort (clean, not destroy)
65
- * - Request-body stream errors propagated to Promise rejection
34
+ * @card
35
+ * Outbound HTTP client with SSRF gate, retry, circuit breaker, wall-clock + idle timeouts, AbortSignal propagation, connection pooling, streaming, and ALPN-negotiated HTTP/2.
66
36
  */
67
37
 
38
+ var fs = require("fs");
68
39
  var http = require("http");
69
40
  var https = require("https");
70
41
  var http2 = require("http2");
42
+ var nodeCrypto = require("crypto");
43
+ var nodePath = require("path");
71
44
  var nodeStream = require("node:stream");
45
+ var streamPromises = require("node:stream/promises");
72
46
  var { URL } = require("url");
47
+ var atomicFile = require("./atomic-file");
73
48
  var C = require("./constants");
74
49
  var crypto = require("./crypto");
75
50
  var pqcAgent = require("./pqc-agent");
@@ -78,7 +53,8 @@ var safeBuffer = require("./safe-buffer");
78
53
  var safeUrl = require("./safe-url");
79
54
  var ssrfGuard = require("./ssrf-guard");
80
55
  var networkProxy = require("./network-proxy");
81
- var { FrameworkError } = require("./framework-error");
56
+ var validateOpts = require("./validate-opts");
57
+ var { FrameworkError, HttpClientError } = require("./framework-error");
82
58
 
83
59
  // Per-origin transport cache. Entry is either the resolved transport
84
60
  // object or a pending Promise that resolves to one. The Promise form
@@ -121,6 +97,15 @@ var _transports = new Map();
121
97
  // built into the protocol, with replay protection at the QUIC
122
98
  // layer. The framework's `b.httpClient` is HTTP/1.1 + HTTP/2 only;
123
99
  // operators wanting h3 wire their own client.
100
+ //
101
+ // QUIC retry / address-validation (RFC 9000 §8 + RFC 9001 §6) is
102
+ // deferred-with-condition: outbound h3 negotiation re-opens when
103
+ // Node's `--experimental-quic` graduates to stable and ships a
104
+ // `node:http3` module. The escape hatch today is `opts.agent` —
105
+ // operators on internal-mesh deployments that already terminate h3
106
+ // pass their own h3 agent rather than the framework rolling its
107
+ // own implementation under an experimental Node flag. SECURITY.md
108
+ // "Watch list" tracks the re-open trigger.
124
109
 
125
110
  // Pool tuning for the HTTP-client transport cache. Keep-alive is
126
111
  // shorter than the standalone pqc-agent default (1s vs 30s) because
@@ -143,6 +128,31 @@ var DEFAULT_AGENT_OPTS = Object.freeze({
143
128
 
144
129
  var HTTP_CLIENT_AGENT_OPTS = Object.assign({}, DEFAULT_AGENT_OPTS);
145
130
 
131
+ /**
132
+ * @primitive b.httpClient.configurePool
133
+ * @signature b.httpClient.configurePool(opts)
134
+ * @since 0.1.0
135
+ * @status stable
136
+ * @related b.httpClient.request
137
+ *
138
+ * Updates the keepAlive Agent options used for new h1 transports and
139
+ * tears down the per-origin transport cache so subsequent requests
140
+ * pick up the fresh values. Existing in-flight responses keep their
141
+ * old transport. Throws on unknown keys, non-positive integers, or a
142
+ * non-boolean `keepAlive`. Use at boot when the default 16/8 socket
143
+ * caps don't match the operator's downstream concurrency budget.
144
+ *
145
+ * @opts
146
+ * keepAlive: true, // boolean — whether to reuse sockets
147
+ * keepAliveMsecs: 1000, // positive integer ms between keep-alive probes
148
+ * maxSockets: 16, // positive integer — concurrent sockets per origin
149
+ * maxFreeSockets: 8, // positive integer — idle sockets retained per origin
150
+ * scheduling: "lifo", // "lifo" | "fifo"
151
+ *
152
+ * @example
153
+ * b.httpClient.configurePool({ maxSockets: 64, maxFreeSockets: 32 });
154
+ * // → undefined (cache cleared; next request builds a 64-socket pool)
155
+ */
146
156
  function configurePool(opts) {
147
157
  if (!opts || typeof opts !== "object") {
148
158
  throw new Error("httpClient.configurePool: opts must be an object");
@@ -639,6 +649,49 @@ function _stripCrossOriginAuth(headers) {
639
649
  return out;
640
650
  }
641
651
 
652
+ /**
653
+ * @primitive b.httpClient.request
654
+ * @signature b.httpClient.request(opts)
655
+ * @since 0.1.0
656
+ * @status stable
657
+ * @related b.httpClient.downloadStream, b.httpClient.uploadMultipartStream, b.ssrfGuard
658
+ *
659
+ * Promise-returning, AbortSignal-aware HTTP request. Negotiates h2 /
660
+ * h1 per-origin via ALPN, reuses transports from the cache, runs every
661
+ * destination through `b.ssrfGuard` before connecting, and re-validates
662
+ * each redirect hop. Returns `{ statusCode, headers, body }` for the
663
+ * default `"buffer"` mode; `"stream"` returns a Readable for the body.
664
+ * Sensitive headers (Authorization / Cookie / Proxy-Authorization) are
665
+ * stripped on cross-origin redirect. Body-stream errors propagate to
666
+ * the rejected Promise.
667
+ *
668
+ * @opts
669
+ * method: "GET", // HTTP method
670
+ * url: <required>, // string or URL — destination
671
+ * headers: {}, // request headers
672
+ * body: undefined, // Buffer | string | Readable | undefined
673
+ * timeoutMs: undefined, // wall-clock cap; no default — operator chooses
674
+ * idleTimeoutMs: 30000, // zero-progress cap
675
+ * responseMode: "buffer", // "buffer" | "stream" | "always-resolve"
676
+ * maxResponseBytes: undefined, // 16 MiB control / 1 GiB GET defaults; ignored in "stream"
677
+ * onChunk: undefined, // (chunk: Buffer) => void — fires per response chunk
678
+ * signal: undefined, // AbortSignal — propagated to req / stream
679
+ * errorClass: HttpClientError, // FrameworkError subclass for thrown errors
680
+ * observer: undefined, // (stage, info) => void — lifecycle hook
681
+ * agent: undefined, // override per-origin Agent (h1 only)
682
+ * preferH2: false, // attempt h2c against an HTTP origin (no ALPN)
683
+ * before: undefined, // array of (opts) => opts | Promise — request mutators
684
+ * after: undefined, // array of (response) => response | Promise — response mutators
685
+ * onUploadProgress: undefined, // (bytesSent, totalBytes?) => void
686
+ *
687
+ * @example
688
+ * var res = await b.httpClient.request({
689
+ * method: "GET",
690
+ * url: "https://example.com/health",
691
+ * timeoutMs: 5000,
692
+ * });
693
+ * // → { statusCode: 200, headers: { "content-type": "application/json", ... }, body: <Buffer> }
694
+ */
642
695
  function request(opts) {
643
696
  if (!opts || !opts.url) {
644
697
  return Promise.reject(_makeError(opts && opts.errorClass, "BAD_ARG", "url is required", true));
@@ -1395,6 +1448,377 @@ function _requestH2(transport, u, opts) {
1395
1448
  });
1396
1449
  }
1397
1450
 
1451
+ // ---- Streaming primitives ----
1452
+ //
1453
+ // downloadStream — pipe a response body to a tmp file, hash-while-piping,
1454
+ // atomic-rename on hash match. Operators receive `{ statusCode,
1455
+ // bytesWritten, hash }`; on hash mismatch the tmp file is deleted and an
1456
+ // HttpClientError with code "httpclient/hash-mismatch" is thrown.
1457
+ //
1458
+ // uploadMultipartStream — POST a file body via multipart/form-data
1459
+ // without buffering. Streams from disk through the request body using
1460
+ // `fs.createReadStream` + `node:stream/promises` pipeline.
1461
+ //
1462
+ // Both compose through `request()` (responseMode: "stream") so safeUrl,
1463
+ // ssrfGuard, allowedHosts, network-proxy, audit-on-host-deny, and the
1464
+ // per-origin transport cache apply unchanged.
1465
+
1466
+ // Algorithms exposed to operators. Defaults to PQC-first sha3-512;
1467
+ // callers needing legacy-peer interop with sha-256 (S3 ETag class) opt
1468
+ // in explicitly.
1469
+ var ALLOWED_DOWNLOAD_HASH_ALGS = ["sha3-512", "sha-256", "sha-512", "shake256"];
1470
+ var DEFAULT_DOWNLOAD_HASH_ALG = "sha3-512";
1471
+ var DEFAULT_DOWNLOAD_FILE_MODE = 0o600;
1472
+
1473
+ function _hcErr(code, message, statusCode) {
1474
+ return new HttpClientError(code, message, true, statusCode);
1475
+ }
1476
+
1477
+ // Throw at config-time if opts shape is malformed — operator catches the
1478
+ // typo here, not inside the request loop.
1479
+ function _validateDownloadOpts(opts) {
1480
+ if (!opts || typeof opts !== "object") {
1481
+ throw _hcErr("httpclient/bad-opts", "downloadStream: opts must be an object");
1482
+ }
1483
+ validateOpts.requireNonEmptyString(opts.url, "downloadStream: url",
1484
+ HttpClientError, "httpclient/bad-opts");
1485
+ validateOpts.requireNonEmptyString(opts.dest, "downloadStream: dest",
1486
+ HttpClientError, "httpclient/bad-opts");
1487
+ validateOpts.optionalNonEmptyString(opts.hash, "downloadStream: hash",
1488
+ HttpClientError, "httpclient/bad-opts");
1489
+ if (opts.hash !== undefined && ALLOWED_DOWNLOAD_HASH_ALGS.indexOf(opts.hash) === -1) {
1490
+ throw _hcErr("httpclient/bad-opts",
1491
+ "downloadStream: hash must be one of " + ALLOWED_DOWNLOAD_HASH_ALGS.join(", ") +
1492
+ "; got " + JSON.stringify(opts.hash));
1493
+ }
1494
+ if (opts.expected !== undefined) {
1495
+ validateOpts.requireNonEmptyString(opts.expected, "downloadStream: expected",
1496
+ HttpClientError, "httpclient/bad-opts");
1497
+ if (!safeBuffer.isHex(opts.expected)) {
1498
+ throw _hcErr("httpclient/bad-opts",
1499
+ "downloadStream: expected must be a non-empty hex digest");
1500
+ }
1501
+ }
1502
+ validateOpts.optionalPositiveFinite(opts.timeoutMs, "downloadStream: timeoutMs",
1503
+ HttpClientError, "httpclient/bad-opts");
1504
+ if (opts.maxBytes !== undefined &&
1505
+ (typeof opts.maxBytes !== "number" || !isFinite(opts.maxBytes) || opts.maxBytes <= 0 ||
1506
+ Math.floor(opts.maxBytes) !== opts.maxBytes)) {
1507
+ throw _hcErr("httpclient/bad-opts",
1508
+ "downloadStream: maxBytes must be a positive finite integer");
1509
+ }
1510
+ }
1511
+
1512
+ function _emitAudit(opts, action, outcome, metadata) {
1513
+ if (!opts || !opts.audit || typeof opts.audit.safeEmit !== "function") return;
1514
+ try {
1515
+ opts.audit.safeEmit({
1516
+ action: action,
1517
+ outcome: outcome,
1518
+ resource: { kind: "outbound.http", id: String(opts.url || "") },
1519
+ metadata: metadata || {},
1520
+ });
1521
+ } catch (_e) { /* audit best-effort */ }
1522
+ }
1523
+
1524
+ /**
1525
+ * @primitive b.httpClient.downloadStream
1526
+ * @signature b.httpClient.downloadStream(opts)
1527
+ * @since 0.1.0
1528
+ * @status stable
1529
+ * @related b.httpClient.request, b.httpClient.uploadMultipartStream, b.atomicFile.ensureDir
1530
+ *
1531
+ * Streams a remote resource to disk while hashing the bytes in flight,
1532
+ * then atomically renames the tmp file to `opts.dest` only after the
1533
+ * hash matches `opts.expected` (when supplied). Hash mismatch deletes
1534
+ * the tmp file and throws `httpclient/hash-mismatch`. Composes through
1535
+ * `request({ responseMode: "stream" })` so the SSRF gate, allowedHosts
1536
+ * filter, network proxy, and per-origin transport cache all apply.
1537
+ *
1538
+ * @opts
1539
+ * url: <required>, // string — source
1540
+ * dest: <required>, // absolute filesystem path — final landing
1541
+ * hash: "sha3-512", // "sha3-512" | "sha-256" | "sha-512" | "shake256"
1542
+ * expected: undefined, // hex digest; when set, verified before rename
1543
+ * timeoutMs: undefined, // wall-clock cap
1544
+ * maxBytes: undefined, // positive integer — abort past this size
1545
+ * audit: undefined, // audit sink with safeEmit({...})
1546
+ *
1547
+ * @example
1548
+ * var result = await b.httpClient.downloadStream({
1549
+ * url: "https://example.com/release.tar.gz",
1550
+ * dest: "/var/lib/blamejs/release.tar.gz",
1551
+ * hash: "sha3-512",
1552
+ * expected: "9f86d081884c7d65...d4e5",
1553
+ * });
1554
+ * // → { statusCode: 200, bytesWritten: 1048576, hash: "9f86d081884c7d65...d4e5" }
1555
+ */
1556
+ async function downloadStream(opts) {
1557
+ _validateDownloadOpts(opts);
1558
+ var alg = opts.hash || DEFAULT_DOWNLOAD_HASH_ALG;
1559
+ var dest = opts.dest;
1560
+ var tmpPath = dest + ".tmp-" + crypto.generateToken(C.BYTES.bytes(8));
1561
+ var dir = nodePath.dirname(dest);
1562
+
1563
+ atomicFile.ensureDir(dir);
1564
+
1565
+ // Stream-mode request — body is a Readable that emits the response
1566
+ // chunks. The framework's onChunk path is intentionally NOT used here
1567
+ // because we own the destination tmp file and need precise error
1568
+ // ordering between hash + write-fsync + rename.
1569
+ var res;
1570
+ try {
1571
+ res = await request({
1572
+ method: "GET",
1573
+ url: opts.url,
1574
+ headers: opts.headers || {},
1575
+ responseMode: "stream",
1576
+ timeoutMs: opts.timeoutMs,
1577
+ idleTimeoutMs: opts.idleTimeoutMs,
1578
+ signal: opts.signal,
1579
+ agent: opts.agent,
1580
+ allowedProtocols: opts.allowedProtocols,
1581
+ allowedHosts: opts.allowedHosts,
1582
+ allowInternal: opts.allowInternal,
1583
+ audit: opts.audit,
1584
+ errorClass: HttpClientError,
1585
+ });
1586
+ } catch (e) {
1587
+ _emitAudit(opts, "system.httpclient.download_stream.refused", "denied", {
1588
+ reason: "request-failed", message: e.message, code: e.code,
1589
+ });
1590
+ throw e;
1591
+ }
1592
+
1593
+ if (res.statusCode < 200 || res.statusCode >= 300) {
1594
+ // Stream mode of request() already rejected on >=400 above, so this
1595
+ // branch covers 1xx/3xx surfaces that slipped through. Drain + refuse.
1596
+ if (res.body && typeof res.body.resume === "function") res.body.resume();
1597
+ _emitAudit(opts, "system.httpclient.download_stream.refused", "denied", {
1598
+ reason: "non-2xx", statusCode: res.statusCode,
1599
+ });
1600
+ throw _hcErr("httpclient/http-error",
1601
+ "downloadStream: upstream returned HTTP " + res.statusCode, res.statusCode);
1602
+ }
1603
+
1604
+ var hasher = nodeCrypto.createHash(alg);
1605
+ var counter = new nodeStream.Transform({
1606
+ transform: function (chunk, _enc, cb) {
1607
+ hasher.update(chunk);
1608
+ counter.bytesWritten += chunk.length;
1609
+ if (typeof opts.maxBytes === "number" && counter.bytesWritten > opts.maxBytes) {
1610
+ return cb(_hcErr("httpclient/response-too-large",
1611
+ "downloadStream: response body exceeds maxBytes " + opts.maxBytes, res.statusCode));
1612
+ }
1613
+ cb(null, chunk);
1614
+ },
1615
+ });
1616
+ counter.bytesWritten = 0;
1617
+
1618
+ var fileStream = fs.createWriteStream(tmpPath, { mode: DEFAULT_DOWNLOAD_FILE_MODE, flags: "w" });
1619
+
1620
+ try {
1621
+ await streamPromises.pipeline(res.body, counter, fileStream);
1622
+ } catch (e) {
1623
+ // Pipeline failure → tmp may be partially written. Remove + audit.
1624
+ try { fs.unlinkSync(tmpPath); } catch (_u) { /* best-effort cleanup */ }
1625
+ _emitAudit(opts, "system.httpclient.download_stream.refused", "denied", {
1626
+ reason: "pipeline-failed", message: e.message, code: e.code,
1627
+ });
1628
+ if (e && e.isHttpClientError) throw e;
1629
+ throw _hcErr(e.code || "httpclient/pipeline-failed",
1630
+ "downloadStream: pipeline failed: " + (e.message || String(e)), res.statusCode);
1631
+ }
1632
+
1633
+ // fsync the file's data + close. atomicFile.fsync is best-effort
1634
+ // across platforms but matches the discipline of the rest of the
1635
+ // framework's atomic-write paths.
1636
+ try {
1637
+ var fd = fs.openSync(tmpPath, "r+");
1638
+ try { atomicFile.fsync(fd); } finally { try { fs.closeSync(fd); } catch (_c) { /* best-effort fd close */ } }
1639
+ } catch (_fe) { /* fsync best-effort */ }
1640
+
1641
+ var actualHex = hasher.digest("hex");
1642
+ if (typeof opts.expected === "string" && opts.expected.length > 0) {
1643
+ var expected = opts.expected.toLowerCase();
1644
+ if (actualHex.toLowerCase() !== expected) {
1645
+ try { fs.unlinkSync(tmpPath); } catch (_u) { /* best-effort cleanup */ }
1646
+ _emitAudit(opts, "system.httpclient.download_stream.refused", "denied", {
1647
+ reason: "hash-mismatch", alg: alg, expected: expected, actual: actualHex,
1648
+ statusCode: res.statusCode, bytesWritten: counter.bytesWritten,
1649
+ });
1650
+ throw _hcErr("httpclient/hash-mismatch",
1651
+ "downloadStream: hash mismatch (alg=" + alg + ", expected=" + expected +
1652
+ ", actual=" + actualHex + ")", res.statusCode);
1653
+ }
1654
+ }
1655
+
1656
+ // Atomic rename + dir fsync.
1657
+ try {
1658
+ fs.renameSync(tmpPath, dest);
1659
+ atomicFile.fsyncDir(dir);
1660
+ } catch (e) {
1661
+ try { fs.unlinkSync(tmpPath); } catch (_u) { /* best-effort cleanup */ }
1662
+ _emitAudit(opts, "system.httpclient.download_stream.refused", "denied", {
1663
+ reason: "rename-failed", message: e.message,
1664
+ });
1665
+ throw _hcErr("httpclient/rename-failed",
1666
+ "downloadStream: rename to " + dest + " failed: " + e.message, res.statusCode);
1667
+ }
1668
+
1669
+ _emitAudit(opts, "system.httpclient.download_stream.completed", "allowed", {
1670
+ statusCode: res.statusCode,
1671
+ bytesWritten: counter.bytesWritten,
1672
+ alg: alg,
1673
+ hashVerified: typeof opts.expected === "string" && opts.expected.length > 0,
1674
+ });
1675
+
1676
+ return {
1677
+ statusCode: res.statusCode,
1678
+ bytesWritten: counter.bytesWritten,
1679
+ hash: actualHex,
1680
+ };
1681
+ }
1682
+
1683
+ // ---- uploadMultipartStream ----
1684
+
1685
+ function _validateUploadOpts(opts) {
1686
+ if (!opts || typeof opts !== "object") {
1687
+ throw _hcErr("httpclient/bad-opts", "uploadMultipartStream: opts must be an object");
1688
+ }
1689
+ validateOpts.requireNonEmptyString(opts.url, "uploadMultipartStream: url",
1690
+ HttpClientError, "httpclient/bad-opts");
1691
+ if (!opts.file || typeof opts.file !== "object") {
1692
+ throw _hcErr("httpclient/bad-opts", "uploadMultipartStream: file must be an object");
1693
+ }
1694
+ validateOpts.requireNonEmptyString(opts.file.path, "uploadMultipartStream: file.path",
1695
+ HttpClientError, "httpclient/bad-opts");
1696
+ validateOpts.requireNonEmptyString(opts.file.fieldName, "uploadMultipartStream: file.fieldName",
1697
+ HttpClientError, "httpclient/bad-opts");
1698
+ if (opts.fields !== undefined && (typeof opts.fields !== "object" || opts.fields === null || Array.isArray(opts.fields))) {
1699
+ throw _hcErr("httpclient/bad-opts", "uploadMultipartStream: fields must be an object");
1700
+ }
1701
+ validateOpts.optionalPositiveFinite(opts.timeoutMs, "uploadMultipartStream: timeoutMs",
1702
+ HttpClientError, "httpclient/bad-opts");
1703
+ if (opts.maxBytes !== undefined &&
1704
+ (typeof opts.maxBytes !== "number" || !isFinite(opts.maxBytes) || opts.maxBytes <= 0 ||
1705
+ Math.floor(opts.maxBytes) !== opts.maxBytes)) {
1706
+ throw _hcErr("httpclient/bad-opts",
1707
+ "uploadMultipartStream: maxBytes must be a positive finite integer");
1708
+ }
1709
+ }
1710
+
1711
+ /**
1712
+ * @primitive b.httpClient.uploadMultipartStream
1713
+ * @signature b.httpClient.uploadMultipartStream(opts)
1714
+ * @since 0.1.0
1715
+ * @status stable
1716
+ * @related b.httpClient.request, b.httpClient.downloadStream
1717
+ *
1718
+ * POSTs a file body via `multipart/form-data` without buffering the
1719
+ * file in memory. Streams from disk through the request body using
1720
+ * `fs.createReadStream` + `node:stream/promises` pipeline. Throws
1721
+ * `httpclient/missing-file` when `opts.file.path` doesn't exist or
1722
+ * isn't a regular file. Composes through `request()` so SSRF gating,
1723
+ * proxy routing, and the per-origin transport cache apply unchanged.
1724
+ *
1725
+ * @opts
1726
+ * url: <required>, // string — destination
1727
+ * file: <required>, // { path, fieldName, filename?, contentType? }
1728
+ * fields: undefined, // object — extra form fields { name: value, ... }
1729
+ * timeoutMs: undefined, // wall-clock cap
1730
+ * maxBytes: undefined, // positive integer — refuse files larger than this
1731
+ * audit: undefined, // audit sink with safeEmit({...})
1732
+ *
1733
+ * @example
1734
+ * var res = await b.httpClient.uploadMultipartStream({
1735
+ * url: "https://example.com/upload",
1736
+ * file: {
1737
+ * path: "/var/lib/blamejs/release.tar.gz",
1738
+ * fieldName: "artifact",
1739
+ * contentType: "application/gzip",
1740
+ * },
1741
+ * fields: { releaseTag: "v1.2.3" },
1742
+ * });
1743
+ * // → { statusCode: 200, headers: { ... }, body: <Buffer> }
1744
+ */
1745
+ async function uploadMultipartStream(opts) {
1746
+ _validateUploadOpts(opts);
1747
+
1748
+ var filePath = opts.file.path;
1749
+ var st;
1750
+ try { st = fs.statSync(filePath); }
1751
+ catch (e) {
1752
+ _emitAudit(opts, "system.httpclient.upload_stream.refused", "denied", {
1753
+ reason: "missing-file", path: filePath, message: e.message,
1754
+ });
1755
+ throw _hcErr("httpclient/missing-file",
1756
+ "uploadMultipartStream: file.path not readable: " + e.message);
1757
+ }
1758
+ if (!st.isFile()) {
1759
+ _emitAudit(opts, "system.httpclient.upload_stream.refused", "denied", {
1760
+ reason: "not-a-regular-file", path: filePath,
1761
+ });
1762
+ throw _hcErr("httpclient/missing-file",
1763
+ "uploadMultipartStream: file.path is not a regular file");
1764
+ }
1765
+
1766
+ var filename = (typeof opts.file.filename === "string" && opts.file.filename.length > 0)
1767
+ ? opts.file.filename
1768
+ : nodePath.basename(filePath);
1769
+ var contentType = (typeof opts.file.contentType === "string" && opts.file.contentType.length > 0)
1770
+ ? opts.file.contentType
1771
+ : "application/octet-stream";
1772
+
1773
+ // Reuse the existing multipart shorthand by passing { filePath } —
1774
+ // it produces a Readable body + sets Content-Length when sizes resolve.
1775
+ // _buildMultipartBody is internal; we route through request()'s
1776
+ // multipart shorthand so the same wire path applies.
1777
+ var fileSpec = {
1778
+ field: opts.file.fieldName,
1779
+ filePath: filePath,
1780
+ filename: filename,
1781
+ contentType: contentType,
1782
+ };
1783
+
1784
+ var res;
1785
+ try {
1786
+ res = await request({
1787
+ method: "POST",
1788
+ url: opts.url,
1789
+ headers: opts.headers || {},
1790
+ multipart: { fields: opts.fields || {}, files: [fileSpec], streaming: true },
1791
+ timeoutMs: opts.timeoutMs,
1792
+ idleTimeoutMs: opts.idleTimeoutMs,
1793
+ signal: opts.signal,
1794
+ agent: opts.agent,
1795
+ allowedProtocols: opts.allowedProtocols,
1796
+ allowedHosts: opts.allowedHosts,
1797
+ allowInternal: opts.allowInternal,
1798
+ maxResponseBytes: opts.maxResponseBytes,
1799
+ audit: opts.audit,
1800
+ errorClass: HttpClientError,
1801
+ });
1802
+ } catch (e) {
1803
+ _emitAudit(opts, "system.httpclient.upload_stream.refused", "denied", {
1804
+ reason: "request-failed", message: e.message, code: e.code,
1805
+ });
1806
+ throw e;
1807
+ }
1808
+
1809
+ _emitAudit(opts, "system.httpclient.upload_stream.completed", "allowed", {
1810
+ statusCode: res.statusCode,
1811
+ fileBytes: st.size,
1812
+ fieldName: opts.file.fieldName,
1813
+ filename: filename,
1814
+ });
1815
+
1816
+ return {
1817
+ statusCode: res.statusCode,
1818
+ response: res,
1819
+ };
1820
+ }
1821
+
1398
1822
  // ---- Test helpers ----
1399
1823
 
1400
1824
  function _resetForTest() {
@@ -1424,10 +1848,13 @@ function _getCachedTransportKind(url) {
1424
1848
 
1425
1849
  module.exports = {
1426
1850
  request: request,
1851
+ downloadStream: downloadStream,
1852
+ uploadMultipartStream: uploadMultipartStream,
1427
1853
  configurePool: configurePool,
1428
1854
  DEFAULT_CONTROL_PLANE_CAP: DEFAULT_CONTROL_PLANE_CAP,
1429
1855
  DEFAULT_GET_CAP: DEFAULT_GET_CAP,
1430
1856
  DEFAULT_AGENT_OPTS: DEFAULT_AGENT_OPTS,
1857
+ ALLOWED_DOWNLOAD_HASH_ALGS: ALLOWED_DOWNLOAD_HASH_ALGS,
1431
1858
  _resetForTest: _resetForTest,
1432
1859
  _getCachedTransportCount: _getCachedTransportCount,
1433
1860
  _getCachedTransportKind: _getCachedTransportKind,