@blamejs/core 0.9.41 → 0.9.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.9.x
10
10
 
11
+ - v0.9.43 (2026-05-15) — **Three downstream-consumer DX primitives bundled: `b.testHarness.start` + `b.middleware.composePipeline` + `b.watcher` `mode: "auto"`.** Closes the second batch of operator-friction gaps. (1) **`b.testHarness.start(opts?)`** — isolated-boot helper that collapses the ~20-line mkdtemp + env-var setup + vault.init + teardown pattern every consumer was reinventing in `tests/helpers/`. Returns a handle exposing `{ dataDir, dbPath, vaultDir, env, stop() }`. Generates a mkdtemp-based isolated dataDir under `os.tmpdir()` with `b.crypto.generateToken(4)` random suffix, sets `<prefix>_DATA_DIR` / `_DB_PATH` / `_VAULT_DIR` env vars, optionally awaits `b.vault.init` in plaintext mode. Concurrent harnesses with `initVault: true` share the process-global vault state via internal reference counting; the last `stop()` releases vault. (2) **`b.middleware.composePipeline(entries, opts?)`** — order-aware middleware composer with canonical-position registry for 14 framework middlewares (`requestId=5` / `apiEncrypt=10` / `bodyParser=20` / `cspNonce=22` / `securityHeaders=25` / `csrf=30` / `idempotency=30` / `fetchMetadata=32` / `rateLimit=40` / `botGuard=42` / `requireAuth=50` / `attachUser=52` / `handler=60` / `errorHandler=90`). Conflict detection at registration time refuses duplicate names, duplicate explicit-position values, and non-monotonic positions. Strict mode (`opts.strict: true`) refuses canonical-name position mismatches; default `false` runs but emits `system.middleware.compose.canonical_mismatch` audit. Sync throws inside middleware propagate to `finalNext`. Boot-time `system.middleware.compose.pipeline_built` audit lists final ordered entries. (3) **`b.watcher.create({ root, mode: "auto", ... })`** — Docker bind-mount / non-inotify-fs auto-fallback. Inside a Linux container with a host bind-mount, `fs.watch` returns no events across gRPC-FUSE / VirtioFS / 9p / NFS / CIFS / vboxsf boundaries; `mode: "auto"` reads `/proc/self/mountinfo`, finds the mount carrying the watcher root, and falls back to `mode: "poll"` when the fstype is non-inotify OR when `/.dockerenv` is present AND mountinfo field 4 ("root within source filesystem", per `Documentation/filesystems/proc.rst §3.5`) is `!= "/"` (bind-mount signature — the kernel exposes the bound source path in this field; regular mounts always carry `/`). Native Linux mounts + non-Linux hosts (FSEvents / ReadDirectoryChangesW) keep `mode: "fs"`. The chosen backend + reason emits as `watcher.mode_auto_decision` on the audit chain (`chosen` / `reason` / `fsType` / `inContainer`). `mode: "fs"` (default) and `mode: "poll"` (explicit) unchanged; `mode: "auto"` is opt-in.
12
+ - v0.9.42 (2026-05-15) — **`b.middleware.idempotencyKey` `bodyFingerprint` hook + misordered-mount detector.** New `opts.bodyFingerprint: (req) => Buffer|string|object|null` lets operators supply a custom body extractor instead of relying on the default `req._rawBody || req.body` lookup; useful when the parsed-body shape needs canonicalization (sorted keys, stripped metadata) before the fingerprint hash so retry-with-equivalent-payload doesn't trip the §4.3 same-key-different-body refusal. Hook return is normalized to Buffer (Buffer passthrough; string → UTF-8 bytes; object/array → `JSON.stringify` → bytes; null/undefined → empty fingerprint). Throws inside the hook emit `idempotency.body_fingerprint_failed` audit (warning) and treat the body as empty. **Mount-order constraint:** idempotency must run AFTER body-parser; the hook reads request state at the moment idempotency executes, so a misordered mount silently degrades the fingerprint to method+path. `b.middleware.composePipeline` (v0.9.44) places bodyParser=20 / idempotency=30 by default. Body-bearing methods (POST/PUT/PATCH) that arrive without parsed-body OR raw-body data now emit `idempotency.empty_body_fingerprint` audit (warning) carrying `hasRawBody` / `hasParsedBody` / `hasFingerprintHook` so a misconfigured pipeline is detectable from audit logs.
11
13
  - v0.9.41 (2026-05-15) — **Operator-friction ergonomic helpers surfaced from downstream-consumer gap audit.** Three small additive surfaces, no behavior change for existing callers. (1) **`b.storage.listBackends()`** now surfaces `rootDir` for local-protocol backends, sourced from the live backend (with config-reload propagation) so downstream path-traversal guards + scratch-dir derivation read the canonical path directly from the framework instead of re-deriving from operator-supplied opts. Remote protocols (sigv4 / gcs / azure-blob / http-put) don't carry a rootDir; the field stays absent for those. (2) **`b.problemDetails.send(res, fields)`** — bare wire-shape emit shortcut that lets routes migrate incrementally from inline `res.status(400).json({ error: ... })` to RFC 9457 problem-details without restructuring the handler around an error throw. Equivalent to `respond(res, create(fields))` in one call; same `application/problem+json` content type + `Cache-Control: no-store`. (3) **`b.mail.send` CR/LF/NUL refusal** confirmed already in place at `lib/mail.js:275` / `:309` / `:1808` per RFC 5321 §2.3.8 + RFC 5322 §3.2.5 header-injection defense — operators with inline `validateEmailAddr` wrappers can retire them. No new API, just confirmation that the existing primitive already covers the wire-protocol injection class (CVE-2026-32178 .NET System.Net.Mail header injection defended at the framework boundary).
12
14
  - v0.9.40 (2026-05-15) — **`b.guardListId` — RFC 2919 List-Id header validator.** Companion to v0.9.39 `b.guardListUnsubscribe`; gates outbound mailing-list mail so the List-Id carries a well-formed identifier downstream filters + bulk-sender pipelines reliably route on. (1) **`b.guardListId.validate(headerValue, opts?)`** — parses bracketed (`<my-list.example.com>`), phrase-prefixed (`My Newsletter <my-list.example.com>`), and bare-identifier forms per RFC 2919 §2. Returns `{ action, listId, label, namespace, phrase, reason }`. Action one of `accept` / `refuse`. (2) **RFC 2919 §3 caps + ABNF** — list-id capped at 255 octets; header value capped at RFC 5322 §2.1.1 line cap (998 bytes); per-label shape per RFC 5322 §3.2.3 dot-atom-text. (3) **Phrase-smuggling defense** — phrase MUST NOT contain `<` / `>` (would smuggle a second bracketed identifier through the parser). Trailing content after `>` refused. Nested or unmatched brackets refused. (4) **CRLF / NUL / C0 / DEL refusal** — header-injection defense per RFC 5322 §3.2.5 + CVE-2026-32178 wire-protocol surface class. (5) **`localhost` namespace handling** (RFC 2919 §3) — strict requires the recommended 32-hex random component in the label (the SHOULD becomes operator-strict for HIPAA / PCI / GDPR / SOC2 postures); balanced / permissive accept without. (6) **FQDN namespace enforcement** under strict / balanced — list-id with single-label namespace (e.g. `mylist.test`) refused unless permissive. (7) Heuristic label / namespace split — last 2 dot-segments → namespace (matches typical DNS delegation); consumers needing PSL-accurate org-domain extraction compose `b.publicSuffix.organizationalDomain`. Three profiles + posture cascade (hipaa / pci-dss / gdpr / soc2 → strict). Fuzz harness ships in `fuzz/guard-list-id.fuzz.js`. Registered as standalone guard with `KIND="list-id"`. Threat-model: List-Id forging (RFC 2919 §8 explicitly notes the identifier is NOT an authentication signal; operators wanting authentication compose b.mail.auth.dmarc / arc.verify), bulk-sender bucket-drop (Gmail 2024 keys on List-Id presence for Precedence: list / 5000+ daily-send mail).
13
15
  - v0.9.39 (2026-05-15) — **`b.guardListUnsubscribe` — RFC 2369 + RFC 8058 List-Unsubscribe / List-Unsubscribe-Post validator.** Gates the outbound submission path so messages carrying a List-Id (or any mailing-list shape) emit headers Gmail / Yahoo / Outlook one-click unsubscribe machinery actually accepts. (1) **`b.guardListUnsubscribe.validate({ listUnsubscribe, listUnsubscribePost }, opts?)`** — returns `{ action, reason, uris, hasHttpsUri, hasMailtoUri, postHeaderOk, oneClickReady }`. (2) **Gmail / Yahoo bulk-sender 2024 enforcement** — under strict requires at least one `https://` URI in the header (mailto: alone refused) + the paired `List-Unsubscribe-Post: List-Unsubscribe=One-Click` value EXACTLY (case-sensitive — Gmail silently fails one-click on mixed-case variants). (3) **Always-refused schemes** — `javascript:` / `data:` / `file:` / `vbscript:` / `blob:` refused regardless of profile (XSS / file-read class in mail-client rendering). (4) **`http://` refused under strict / balanced** — one-click endpoint MUST be TLS per RFC 8058 §2. Permissive accepts http for audit-only legacy use. (5) **Header-injection defense** — CRLF, NUL, C0 controls, DEL refused at validate time (RFC 5322 §3.2.5). (6) **Bounded surface** — per-URI byte cap (2 KiB strict / 4 KiB permissive), URI-count cap (4 / 8 / 16), header total byte cap (4 / 4 / 8 KiB). RFC 3986 §3.1 scheme shape; RFC 2369 §3.1 angle-bracket URI list. HTTPS URIs validated through `b.safeUrl.parse` with the framework's HTTPS allowlist. Three profiles + posture cascade (hipaa / pci-dss / gdpr / soc2 → strict). Fuzz harness ships in `fuzz/guard-list-unsubscribe.fuzz.js`. Registered as a standalone guard with KIND="list-unsubscribe". Threat-model coverage: unsubscribe-link injection via AI-generated newsletter templates, open-redirect via List-Unsubscribe (operator validates target host downstream via own safeRedirect allowlist), mail-client mishandling (Outlook's mailto: auto-fetch history).
package/index.js CHANGED
@@ -147,6 +147,7 @@ var compliance = Object.assign({}, require("./lib/compliance"), {
147
147
  });
148
148
  var dataAct = require("./lib/data-act");
149
149
  var problemDetails = require("./lib/problem-details");
150
+ var testHarness = require("./lib/test-harness");
150
151
  var cacheStatus = require("./lib/cache-status");
151
152
  var cdnCacheControl = require("./lib/cdn-cache-control");
152
153
  var clientHints = require("./lib/client-hints");
@@ -417,6 +418,7 @@ module.exports = {
417
418
  nistCrosswalk: nistCrosswalk,
418
419
  dataAct: dataAct,
419
420
  problemDetails: problemDetails,
421
+ testHarness: testHarness,
420
422
  cacheStatus: cacheStatus,
421
423
  cdnCacheControl: cdnCacheControl,
422
424
  clientHints: clientHints,
@@ -0,0 +1,355 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.middleware.composePipeline
4
+ * @nav Middleware
5
+ * @title Compose Pipeline
6
+ * @order 550
7
+ *
8
+ * @intro
9
+ * Order-aware middleware composer. Replaces the per-project pattern
10
+ * of N separate `app.use(mw)` calls — where mount order silently
11
+ * matters (apiEncrypt must precede body-parser; body-parser must
12
+ * precede idempotency-key + csrf; csrf must precede require-auth) —
13
+ * with a single declarative pipeline that documents the order +
14
+ * detects conflicts at registration time.
15
+ *
16
+ * ## What this primitive owns
17
+ *
18
+ * - **Single mount point**: one `app.use(pipeline)` instead of N.
19
+ * - **Order documented in code**: the entry array IS the order;
20
+ * reading the registration tells the reviewer the canonical
21
+ * order without grepping `app.use` calls.
22
+ * - **Conflict detection at registration**: duplicate names refused;
23
+ * duplicate explicit positions refused; non-monotonic positions
24
+ * refused (a later entry with a smaller position is a
25
+ * mis-registration).
26
+ * - **Canonical-position warnings**: when an entry's `name` matches
27
+ * a known framework primitive's recommended position
28
+ * (`apiEncrypt` → 10, `bodyParser` → 20, `csrf` → 30,
29
+ * `idempotency` → 30, `rateLimit` → 40, `requireAuth` → 50,
30
+ * `handler` → 60, `errorHandler` → 90), the composer emits an
31
+ * `system.middleware.compose.canonical_mismatch` audit at warning when
32
+ * the operator-supplied order deviates. Refusal is opt-in via
33
+ * `opts.strict: true`; default is warn-and-continue so operators
34
+ * with intentional non-canonical ordering aren't blocked.
35
+ *
36
+ * ## What this primitive does NOT own
37
+ *
38
+ * - **The middlewares themselves** — the composer is a sequencer,
39
+ * not a registry. Each middleware retains its own
40
+ * `b.middleware.X(opts)` factory + behavior.
41
+ * - **Async-context propagation** — async middleware works (the
42
+ * composer awaits the previous `next()` via Promise wrap), but
43
+ * primitives that need `AsyncLocalStorage` should attach it at
44
+ * the middleware itself, not the composer.
45
+ * - **Error handling** — the composer dispatches through `next(err)`
46
+ * in the standard way; operators register a tail error-handler
47
+ * (`name: "errorHandler"`) for the canonical position 90 slot.
48
+ *
49
+ * ## Audit
50
+ *
51
+ * Each composed pipeline is registered at boot time with a unique
52
+ * `pipelineId` (sha3-512 of the sorted entry names) and emits a
53
+ * `system.middleware.compose.pipeline_built` audit with the entry list
54
+ * and canonical-mismatch flags. Per-request dispatch is NOT
55
+ * audited (would blow up the audit pipeline volume) — composers
56
+ * that need per-request observability compose `b.observability`
57
+ * inside their own middleware.
58
+ *
59
+ * @card
60
+ * Order-aware middleware composer. Single mount point replacing N app.use calls, with conflict detection at registration + canonical-position warnings for framework middlewares. Operator's pipeline order documented in code; the entry array IS the order.
61
+ */
62
+
63
+ var bCrypto = require("../crypto");
64
+ var { defineClass } = require("../framework-error");
65
+ var lazyRequire = require("../lazy-require");
66
+ var validateOpts = require("../validate-opts");
67
+
68
+ var audit = lazyRequire(function () { return require("../audit"); });
69
+
70
+ var ComposePipelineError = defineClass("ComposePipelineError", { alwaysPermanent: true });
71
+
72
+ // Canonical positions for framework middlewares. The composer
73
+ // surfaces mismatches as warnings; refusal is opt-in via
74
+ // `opts.strict: true`. Operators with intentional non-canonical
75
+ // ordering aren't blocked.
76
+ //
77
+ // Position groupings (relative — exact numbers can drift, the
78
+ // classes are what matter):
79
+ // < 10 : request-id, connection-tracking (must be earliest)
80
+ // 10-19 : api-encrypt (must decrypt before any read)
81
+ // 20-29 : body-parser (must precede idempotency, csrf, validation)
82
+ // 30-39 : csrf, idempotency, header policy (need body parsed)
83
+ // 40-49 : rate-limit, bot-guard (after auth context if any)
84
+ // 50-59 : require-auth, ACL (after body + csrf)
85
+ // 60-89 : application handlers
86
+ // >= 90 : error-handler (must be last; trailing catch)
87
+ var CANONICAL_POSITIONS = Object.freeze({
88
+ requestId: 5, // allow:raw-byte-literal — canonical position bucket
89
+ apiEncrypt: 10, // allow:raw-byte-literal — canonical position bucket
90
+ bodyParser: 20, // allow:raw-byte-literal — canonical position bucket
91
+ cspNonce: 22, // allow:raw-byte-literal — canonical position bucket
92
+ securityHeaders: 25, // allow:raw-byte-literal — canonical position bucket
93
+ csrf: 30, // allow:raw-byte-literal — canonical position bucket
94
+ idempotency: 30, // allow:raw-byte-literal — canonical position bucket (same as csrf)
95
+ fetchMetadata: 32, // allow:raw-byte-literal — canonical position bucket
96
+ rateLimit: 40, // allow:raw-byte-literal — canonical position bucket
97
+ botGuard: 42, // allow:raw-byte-literal — canonical position bucket
98
+ requireAuth: 50, // allow:raw-byte-literal — canonical position bucket
99
+ attachUser: 52, // allow:raw-byte-literal — canonical position bucket
100
+ handler: 60, // allow:raw-byte-literal — canonical position bucket // allow:raw-time-literal — pipeline position int, not seconds
101
+ errorHandler: 90, // allow:raw-byte-literal — canonical position bucket
102
+ });
103
+
104
+ /**
105
+ * @primitive b.middleware.composePipeline
106
+ * @signature b.middleware.composePipeline(entries, opts?)
107
+ * @since 0.9.44
108
+ * @status stable
109
+ * @related b.middleware.requestId, b.middleware.requireAuth, b.middleware.idempotencyKey
110
+ *
111
+ * Compose an ordered middleware pipeline into a single Express-shaped
112
+ * middleware. Each `entries[i]` is `{ name: string, mw: function,
113
+ * position?: number }`. Returns the composed `(req, res, next) =>
114
+ * void` middleware. Throws at registration time on duplicate names,
115
+ * duplicate positions, non-monotonic positions, or (with strict)
116
+ * canonical-position mismatches.
117
+ *
118
+ * @opts
119
+ * strict: boolean, // refuse on canonical-position mismatch (default false: warn-and-continue)
120
+ * name: string, // optional pipeline name for audit
121
+ *
122
+ * @example
123
+ * var pipeline = b.middleware.composePipeline([
124
+ * { name: "apiEncrypt", mw: apiEncryptMw },
125
+ * { name: "bodyParser", mw: bodyParserMw },
126
+ * { name: "csrf", mw: csrfMw },
127
+ * { name: "idempotency", mw: idempotencyMw, position: 35 },
128
+ * { name: "requireAuth", mw: requireAuthMw },
129
+ * ]);
130
+ * app.use(pipeline);
131
+ */
132
+ function composePipeline(entries, opts) {
133
+ opts = opts || {};
134
+ validateOpts.optionalBoolean(opts.strict, "composePipeline.strict",
135
+ ComposePipelineError, "compose-pipeline/bad-strict");
136
+
137
+ if (!Array.isArray(entries)) {
138
+ throw new ComposePipelineError("compose-pipeline/bad-entries",
139
+ "composePipeline: entries must be an array of { name, mw, position? } objects");
140
+ }
141
+ if (entries.length === 0) {
142
+ throw new ComposePipelineError("compose-pipeline/bad-entries",
143
+ "composePipeline: entries must contain at least one middleware");
144
+ }
145
+
146
+ var seenNames = Object.create(null);
147
+ var seenPositions = Object.create(null);
148
+ var canonicalMismatches = [];
149
+ var resolved = [];
150
+
151
+ for (var i = 0; i < entries.length; i += 1) {
152
+ var e = entries[i];
153
+ if (!e || typeof e !== "object") {
154
+ throw new ComposePipelineError("compose-pipeline/bad-entry",
155
+ "composePipeline: entry at index " + i + " must be an object");
156
+ }
157
+ if (typeof e.name !== "string" || e.name.length === 0 || e.name.length > 64) { // allow:raw-byte-literal — middleware-name cap
158
+ throw new ComposePipelineError("compose-pipeline/bad-entry",
159
+ "composePipeline: entries[" + i + "].name must be a non-empty string ≤ 64 bytes");
160
+ }
161
+ if (typeof e.mw !== "function") {
162
+ throw new ComposePipelineError("compose-pipeline/bad-entry",
163
+ "composePipeline: entries[" + i + "].mw must be a function (got " + typeof e.mw + ")");
164
+ }
165
+ if (seenNames[e.name]) {
166
+ throw new ComposePipelineError("compose-pipeline/duplicate-name",
167
+ "composePipeline: duplicate entry name '" + e.name + "' at index " + i);
168
+ }
169
+ seenNames[e.name] = true;
170
+
171
+ var position;
172
+ if (e.position !== undefined) {
173
+ if (typeof e.position !== "number" || !Number.isFinite(e.position) || e.position < 0) {
174
+ throw new ComposePipelineError("compose-pipeline/bad-position",
175
+ "composePipeline: entries[" + i + "].position must be a non-negative finite number");
176
+ }
177
+ position = e.position;
178
+ } else if (Object.prototype.hasOwnProperty.call(CANONICAL_POSITIONS, e.name)) {
179
+ // Operator-supplied name matches a canonical framework
180
+ // middleware — use the canonical position by default. Operator
181
+ // can still override by passing an explicit position.
182
+ position = CANONICAL_POSITIONS[e.name];
183
+ } else {
184
+ // Operator-defined middleware without explicit position —
185
+ // synthesize a position from the array index times 100 so
186
+ // operators that don't care about explicit ordering get a
187
+ // natural sequential flow. Use 100 so canonical positions
188
+ // (5..90) can interleave without colliding when an operator
189
+ // mixes named + unnamed entries.
190
+ position = (i + 1) * 100; // allow:raw-byte-literal — index→position scale; canonical-pos ceiling is 90
191
+ }
192
+
193
+ if (Object.prototype.hasOwnProperty.call(seenPositions, position)) {
194
+ // Same explicit position from two entries — refuse (would make
195
+ // dispatch order undefined). Two canonical entries CAN land at
196
+ // the same position (csrf + idempotency are both 30); allowed
197
+ // only when operator didn't supply an explicit position for
198
+ // either. Surface this via a less-severe error code so
199
+ // operators with intentional ties can override with explicit
200
+ // distinct positions.
201
+ var prevName = seenPositions[position];
202
+ var bothExplicit = entries[_findIndex(resolved, prevName)] &&
203
+ entries[_findIndex(resolved, prevName)].position !== undefined &&
204
+ e.position !== undefined;
205
+ if (bothExplicit) {
206
+ throw new ComposePipelineError("compose-pipeline/duplicate-position",
207
+ "composePipeline: entries[" + i + "].position=" + position +
208
+ " collides with '" + prevName + "'; supply explicit distinct positions to disambiguate");
209
+ }
210
+ }
211
+ seenPositions[position] = e.name;
212
+
213
+ if (resolved.length > 0 && position < resolved[resolved.length - 1].position) {
214
+ throw new ComposePipelineError("compose-pipeline/non-monotonic",
215
+ "composePipeline: entries[" + i + "] ('" + e.name + "', position=" + position +
216
+ ") declared before entries with higher position; entries must be in non-decreasing position order");
217
+ }
218
+
219
+ if (Object.prototype.hasOwnProperty.call(CANONICAL_POSITIONS, e.name) &&
220
+ e.position !== undefined && e.position !== CANONICAL_POSITIONS[e.name]) {
221
+ canonicalMismatches.push({
222
+ name: e.name,
223
+ suppliedPosition: e.position,
224
+ canonicalPosition: CANONICAL_POSITIONS[e.name],
225
+ });
226
+ }
227
+
228
+ resolved.push({ name: e.name, mw: e.mw, position: position });
229
+ }
230
+
231
+ if (canonicalMismatches.length > 0) {
232
+ if (opts.strict === true) {
233
+ throw new ComposePipelineError("compose-pipeline/canonical-mismatch",
234
+ "composePipeline: strict=true; " + canonicalMismatches.length +
235
+ " canonical-position mismatch(es): " +
236
+ canonicalMismatches.map(function (m) {
237
+ return m.name + " supplied=" + m.suppliedPosition + " canonical=" + m.canonicalPosition;
238
+ }).join(", "));
239
+ }
240
+ _emitAudit("system.middleware.compose.canonical_mismatch", {
241
+ pipelineName: opts.name || null,
242
+ mismatches: canonicalMismatches,
243
+ });
244
+ }
245
+
246
+ var pipelineId = bCrypto.namespaceHash("system.middleware.compose.pipeline",
247
+ resolved.map(function (r) { return r.name; }).join("\n"));
248
+
249
+ _emitAudit("system.middleware.compose.pipeline_built", {
250
+ pipelineId: pipelineId,
251
+ pipelineName: opts.name || null,
252
+ entryCount: resolved.length,
253
+ entries: resolved.map(function (r) { return { name: r.name, position: r.position }; }),
254
+ });
255
+
256
+ // Composed middleware — sequentially invokes each entry's mw via
257
+ // next() chaining. Standard Express idiom: each mw receives
258
+ // (req, res, next) and either calls next() to continue or
259
+ // next(err) to bail out to the error-handler.
260
+ //
261
+ // composedPipeline returns a Promise that resolves AFTER `finalNext`
262
+ // has been called by the chain. The framework router awaits this
263
+ // promise; without it, async middleware (bodyParser / apiEncrypt
264
+ // reading the request stream) leave the router with `next` still
265
+ // false when composedPipeline returns synchronously, and the
266
+ // router exits the request before the chain has actually advanced.
267
+ //
268
+ // Middleware do NOT await next() — that's the Express contract.
269
+ // So we can't rely on `await entry.mw(...)` to wait for the rest
270
+ // of the chain. Instead, the outer Promise resolves only when the
271
+ // chain reaches its end via finalNext, regardless of how many
272
+ // hops of async middleware have happened along the way.
273
+ //
274
+ // When a middleware calls `next(err)`, the chain skips non-error
275
+ // middleware (3-arg) and dispatches the error to the first 4-arg
276
+ // entry (`(err, req, res, next)` — Express's error-handler shape).
277
+ // If no error-handler entry is found, `finalNext(err)` carries the
278
+ // error up to the framework router.
279
+ return function composedPipeline(req, res, finalNext) {
280
+ return new Promise(function (resolve, reject) {
281
+ var idx = 0;
282
+ var finished = false;
283
+ function _finishOnce(err) {
284
+ if (finished) return;
285
+ finished = true;
286
+ try { finalNext(err); }
287
+ catch (finalErr) { return reject(finalErr); }
288
+ resolve();
289
+ }
290
+ async function dispatch(err) {
291
+ if (finished) return;
292
+ if (idx >= resolved.length) return _finishOnce(err);
293
+ var entry = resolved[idx];
294
+ idx += 1;
295
+ // 4-arg entries are error handlers (Express convention).
296
+ // Regular entries run on the success path; error entries on
297
+ // the error path. Skip entries that don't match the current
298
+ // path until one matches OR the chain ends.
299
+ var isErrorHandler = entry.mw.length === 4;
300
+ if (err && !isErrorHandler) return dispatch(err);
301
+ if (!err && isErrorHandler) return dispatch();
302
+ // Track whether the middleware called its next() argument.
303
+ // If a 4-arg error handler runs to completion without calling
304
+ // next, the error is considered handled and the chain ends
305
+ // cleanly (Express convention). Same applies to a 3-arg
306
+ // middleware that doesn't call next — the chain stops, but
307
+ // we don't invoke finalNext to avoid the router proceeding
308
+ // to the route handler after a middleware decided to halt.
309
+ var advanced = false;
310
+ function _next(passErr) {
311
+ advanced = true;
312
+ return dispatch(passErr);
313
+ }
314
+ try {
315
+ if (err) {
316
+ // Error handler: (err, req, res, next)
317
+ await entry.mw(err, req, res, _next);
318
+ if (!advanced) _finishOnce();
319
+ } else {
320
+ // Regular middleware: (req, res, next)
321
+ await entry.mw(req, res, _next);
322
+ // 3-arg middleware that doesn't call next — chain halts.
323
+ // The middleware presumably wrote the response itself.
324
+ }
325
+ } catch (syncErr) {
326
+ // Synchronous throw OR rejected promise — route through
327
+ // the error path so a downstream error-handler can format
328
+ // the response.
329
+ dispatch(syncErr).catch(reject);
330
+ }
331
+ }
332
+ dispatch().catch(reject);
333
+ });
334
+ };
335
+ }
336
+
337
+ composePipeline.CANONICAL_POSITIONS = CANONICAL_POSITIONS;
338
+ composePipeline.ComposePipelineError = ComposePipelineError;
339
+
340
+ function _emitAudit(action, metadata) {
341
+ try {
342
+ if (audit && typeof audit().safeEmit === "function") {
343
+ audit().safeEmit({ action: action, outcome: "success", metadata: metadata });
344
+ }
345
+ } catch (_e) { /* drop-silent — audit failure must not break pipeline registration */ }
346
+ }
347
+
348
+ function _findIndex(arr, name) {
349
+ for (var i = 0; i < arr.length; i += 1) {
350
+ if (arr[i].name === name) return i;
351
+ }
352
+ return -1;
353
+ }
354
+
355
+ module.exports = composePipeline;
@@ -43,6 +43,7 @@
43
43
  var nodeCrypto = require("node:crypto");
44
44
  var lazyRequire = require("../lazy-require");
45
45
  var numericBounds = require("../numeric-bounds");
46
+ var validateOpts = require("../validate-opts");
46
47
  var safeBuffer = require("../safe-buffer");
47
48
  var safeJson = require("../safe-json");
48
49
  var safeSql = require("../safe-sql");
@@ -458,6 +459,21 @@ function _emitAudit(action, metadata, outcome) {
458
459
  * methods: string[], // default: ["POST","PUT","PATCH","DELETE"]
459
460
  * headerName: string, // default: "idempotency-key"
460
461
  * requireIdempotencyKey: boolean, // default: false — refuse missing-key
462
+ * bodyFingerprint: function, // (req) => Buffer|string|object|null — operator-supplied body extractor
463
+ * maxBodyBytes: number, // default: 1 MiB — replay-cache body cap
464
+ *
465
+ * **Mount order — idempotency MUST run AFTER body-parser.** The hook
466
+ * (and the default `req._rawBody||req.body` lookup) reads request
467
+ * state at the moment the idempotency middleware runs; if it runs
468
+ * before body-parser, `req.body` is still unset and the fingerprint
469
+ * silently degrades to method+path only — which fails the §4.3
470
+ * "same key, different body" guarantee. `b.middleware.composePipeline`
471
+ * places bodyParser=20 / idempotency=30 by default so the canonical
472
+ * order is correct; operators wiring middleware manually must mount
473
+ * idempotency AFTER bodyParser. The runtime emits
474
+ * `idempotency.empty_body_fingerprint` audit (warning) whenever a
475
+ * body-bearing request reaches the middleware with no body data,
476
+ * so the misordering is detectable from audit logs.
461
477
  *
462
478
  * @example
463
479
  * var store = b.middleware.idempotencyKey.memoryStore({ maxEntries: 10000 });
@@ -465,6 +481,11 @@ function _emitAudit(action, metadata, outcome) {
465
481
  * store: store,
466
482
  * ttlMs: C.TIME.hours(24),
467
483
  * methods: ["POST", "PUT", "PATCH"],
484
+ * // Optional: provide a body-fingerprint extractor that pulls
485
+ * // from the parsed body shape. The extractor only runs against
486
+ * // state populated by upstream middleware; mount idempotency
487
+ * // AFTER bodyParser (composePipeline does this by default).
488
+ * bodyFingerprint: function (req) { return req.body || null; },
468
489
  * });
469
490
  * app.use(mw);
470
491
  */
@@ -484,6 +505,20 @@ function create(opts) {
484
505
  ? opts.headerName.toLowerCase()
485
506
  : "idempotency-key";
486
507
  var requireKey = opts.requireIdempotencyKey === true;
508
+ // Operator-supplied body-fingerprint extractor. When provided,
509
+ // the middleware calls this instead of the inline
510
+ // `req._rawBody || req.body` lookup. Lets operators mount
511
+ // body-parser BEFORE the idempotency middleware and surface the
512
+ // parsed body shape (req.body is the typical post-parser
513
+ // attachment point); the inline lookup runs BEFORE body-parser
514
+ // by default, so the fingerprint silently degrades to
515
+ // method+path-only when body-parser mounts after. With this
516
+ // hook the middleware reads the body shape the operator
517
+ // canonically attached, regardless of mount order.
518
+ var bodyFingerprintFn = validateOpts.optionalFunction(
519
+ opts.bodyFingerprint, "idempotencyKey.bodyFingerprint",
520
+ IdempotencyError, "idempotency/bad-body-fingerprint"
521
+ ) || null;
487
522
 
488
523
  // Per-response collector cap. Idempotency replay only makes sense
489
524
  // for response bodies that fit in memory; the cap is operator-
@@ -522,17 +557,59 @@ function create(opts) {
522
557
  return problemDetails().respond(res, bad);
523
558
  }
524
559
 
525
- var bodyBytes = req._rawBody || req.body || null;
526
- if (bodyBytes && typeof bodyBytes === "object" && !Buffer.isBuffer(bodyBytes)) {
527
- // Buffer-ize a non-buffer body (already-parsed JSON, etc.) so the
528
- // hash is stable. JSON.stringify with sorted keys would be more
529
- // robust but the operator-attached body shape is whatever the
530
- // upstream parser produced; canonicalization is operator-side.
560
+ var bodyBytes;
561
+ if (bodyFingerprintFn) {
562
+ // Operator-supplied hook called after body-parser so req.body
563
+ // is populated. Hook returns Buffer / string / null.
531
564
  try {
532
- bodyBytes = Buffer.from(JSON.stringify(bodyBytes), "utf8");
533
- } catch (_e) {
565
+ var fpVal = bodyFingerprintFn(req);
566
+ if (fpVal === null || fpVal === undefined) {
567
+ bodyBytes = null;
568
+ } else if (Buffer.isBuffer(fpVal)) {
569
+ bodyBytes = fpVal;
570
+ } else if (typeof fpVal === "string") {
571
+ bodyBytes = Buffer.from(fpVal, "utf8");
572
+ } else {
573
+ // Object / array — JSON-stringify so the hash is stable.
574
+ bodyBytes = Buffer.from(JSON.stringify(fpVal), "utf8");
575
+ }
576
+ } catch (e) {
577
+ _emitAudit("idempotency.body_fingerprint_failed",
578
+ { error: String(e && e.message || e) }, "warning");
534
579
  bodyBytes = null;
535
580
  }
581
+ } else {
582
+ bodyBytes = req._rawBody || req.body || null;
583
+ if (bodyBytes && typeof bodyBytes === "object" && !Buffer.isBuffer(bodyBytes)) {
584
+ // Buffer-ize a non-buffer body (already-parsed JSON, etc.) so the
585
+ // hash is stable. JSON.stringify with sorted keys would be more
586
+ // robust but the operator-attached body shape is whatever the
587
+ // upstream parser produced; canonicalization is operator-side.
588
+ try {
589
+ bodyBytes = Buffer.from(JSON.stringify(bodyBytes), "utf8");
590
+ } catch (_e) {
591
+ bodyBytes = null;
592
+ }
593
+ }
594
+ }
595
+
596
+ // Misordered-mount detector — body-bearing method reached us
597
+ // with neither a parsed body nor a raw-body buffer. Most likely
598
+ // body-parser hasn't run yet, which silently degrades the
599
+ // fingerprint to method+path. Emit a warning so the audit log
600
+ // surfaces the misconfiguration. (Genuinely empty POST bodies
601
+ // also trip this — acceptable cost; the audit field captures the
602
+ // distinction via `hasRawBody`/`hasParsedBody`.)
603
+ if (!bodyBytes && (method === "POST" || method === "PUT" || method === "PATCH")) {
604
+ _emitAudit("idempotency.empty_body_fingerprint",
605
+ {
606
+ method: method,
607
+ path: req.url,
608
+ hasRawBody: Boolean(req._rawBody),
609
+ hasParsedBody: req.body !== undefined && req.body !== null,
610
+ hasFingerprintHook: Boolean(bodyFingerprintFn),
611
+ },
612
+ "warning");
536
613
  }
537
614
 
538
615
  var fingerprint = _fingerprintRequest(req, bodyBytes);
@@ -68,6 +68,7 @@ var protectedResourceMetadata = require("./protected-resource-metadata");
68
68
  var scimServer = require("./scim-server");
69
69
  var idempotencyKey = require("./idempotency-key");
70
70
  var noCache = require("./no-cache");
71
+ var composePipeline = require("./compose-pipeline");
71
72
 
72
73
  module.exports = {
73
74
  requestId: requestId.create,
@@ -126,6 +127,7 @@ module.exports = {
126
127
  IdempotencyError: idempotencyKey.IdempotencyError,
127
128
  }),
128
129
  noCache: noCache.create,
130
+ composePipeline: composePipeline,
129
131
 
130
132
  // Module exports for advanced use (constants, raw factory access)
131
133
  _modules: {
@@ -0,0 +1,275 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.testHarness
4
+ * @nav DX
5
+ * @title Test Harness
6
+ * @order 820
7
+ *
8
+ * @intro
9
+ * Isolated-boot helper for framework-consumer test suites. Replaces
10
+ * the per-project pattern of:
11
+ *
12
+ * - mkdtemp a fresh data directory
13
+ * - set `MYAPP_DATA_DIR` / `MYAPP_DB_PATH` env vars so the app's
14
+ * boot path reads the test paths instead of production
15
+ * - init `b.vault` in plaintext mode against the test dataDir so
16
+ * primitives that compose vault don't try to read a real key
17
+ * - tear down: close vault, remove the temp directory, restore
18
+ * env vars
19
+ *
20
+ * That pattern lands as ~50-100 lines in every framework consumer's
21
+ * `tests/helpers/test-server.js`. This primitive owns it once.
22
+ *
23
+ * ## Lifecycle
24
+ *
25
+ * ```js
26
+ * var h = await b.testHarness.start({
27
+ * envPrefix: "MYAPP", // optional — env vars prefixed with this
28
+ * env: { LOG_LEVEL: "error" }, // optional — additional env vars to set
29
+ * initVault: true, // optional — init b.vault in plaintext mode
30
+ * resetCaches: true, // optional — call framework _resetForTest() hooks
31
+ * });
32
+ * // h.dataDir — operator-supplied or framework-generated mkdtemp path
33
+ * // h.dbPath — `<dataDir>/db.sqlite` unless operator overrides
34
+ * // h.vaultDir — `<dataDir>/vault`
35
+ *
36
+ * // ... operator's app boot reads process.env.MYAPP_DATA_DIR etc.
37
+ *
38
+ * await h.stop(); // teardown: close vault, remove dataDir, restore env
39
+ * ```
40
+ *
41
+ * ## Concurrent test isolation
42
+ *
43
+ * Tests using `SMOKE_PARALLEL=N` against the framework boot N processes
44
+ * in parallel — each one running this primitive gets its own
45
+ * `mkdtemp`-generated dataDir (collision-free) and its own env-var
46
+ * override scope (process-local). The harness does NOT use
47
+ * shared state; multiple `start()` calls in the same process create
48
+ * parallel handles.
49
+ *
50
+ * ## What the harness does NOT own
51
+ *
52
+ * - **The operator's HTTP server**. Consumers boot their own
53
+ * `app.listen(port)`. The harness only sets up paths + env +
54
+ * vault + cache-reset. The pattern in HS's
55
+ * `tests/helpers/test-server.js` mounts an Express app onto the
56
+ * harness's prepared paths.
57
+ * - **Per-request authentication state**. The harness doesn't
58
+ * mint session cookies / JWTs; tests that need that compose
59
+ * `b.session.create({ store: ... })` against the harness's
60
+ * paths.
61
+ * - **Audit replay tracking**. The harness emits no audit; the
62
+ * framework primitives the operator boots emit their own.
63
+ *
64
+ * ## When to use this vs the existing `_resetForTest()` hooks
65
+ *
66
+ * Framework primitives (vault, audit, db, …) expose
67
+ * `_resetForTest()` so a single test can scrub in-memory state
68
+ * without process-restart. The harness composes those resets +
69
+ * adds filesystem isolation. Use the harness when your test
70
+ * needs WRITE access to a fresh dataDir (file uploads, sealed
71
+ * db, audit-chain on disk); use the bare `_resetForTest()` hooks
72
+ * when in-memory state is enough.
73
+ *
74
+ * @card
75
+ * Isolated-boot helper for framework-consumer test suites. Owns the mkdtemp + env-vars + vault.init + teardown pattern that every consumer reinvents in their tests/helpers/test-server.js.
76
+ */
77
+
78
+ var nodeFs = require("node:fs");
79
+ var os = require("node:os");
80
+ var nodePath = require("node:path");
81
+ var bCrypto = require("./crypto");
82
+ var validateOpts = require("./validate-opts");
83
+ var { defineClass } = require("./framework-error");
84
+ var lazyRequire = require("./lazy-require");
85
+
86
+ var vault = lazyRequire(function () { return require("./vault"); });
87
+
88
+ var TestHarnessError = defineClass("TestHarnessError", { alwaysPermanent: true });
89
+
90
+ // Reference count of harnesses with initVault=true currently alive.
91
+ // vault state is process-global + idempotent across init() calls, so
92
+ // concurrent harnesses share a single initialized vault. The last
93
+ // harness to stop() releases the vault via _resetForTest; earlier
94
+ // stops decrement without tearing down so the still-running peers
95
+ // keep working.
96
+ var _vaultRefCount = 0;
97
+
98
+ /**
99
+ * @primitive b.testHarness.start
100
+ * @signature b.testHarness.start(opts?)
101
+ * @since 0.9.43
102
+ * @status stable
103
+ * @related b.vault.init
104
+ *
105
+ * Boot an isolated test harness. Returns a promise resolving to a
106
+ * handle exposing `dataDir`, `dbPath`, `vaultDir`, `env` (the env-var
107
+ * overrides set), and an async `stop()` that tears the harness down
108
+ * (releases vault, removes the temp directory, restores env). Always
109
+ * `await` the call — vault.init is async, and unawaited failures
110
+ * become unhandled rejections.
111
+ *
112
+ * Concurrent harnesses with `initVault: true` share the
113
+ * process-global vault state via reference counting; stopping one
114
+ * harness leaves vault initialized for the remaining peers. The
115
+ * last `stop()` releases vault.
116
+ *
117
+ * @opts
118
+ * dataDir: string, // optional — pre-existing dir to use; harness mkdtemps if absent
119
+ * dbPath: string, // optional — defaults to `<dataDir>/db.sqlite`
120
+ * vaultDir: string, // optional — defaults to `<dataDir>/vault`
121
+ * envPrefix: string, // optional — env vars `<PREFIX>_DATA_DIR` / `_DB_PATH` / `_VAULT_DIR`; default no prefix
122
+ * env: object, // optional — additional env-var overrides; restored on stop()
123
+ * initVault: boolean, // optional — boot b.vault in plaintext mode against vaultDir; default true
124
+ * keepOnStop: boolean, // optional — leave dataDir in place after stop(); default false (rm -rf)
125
+ *
126
+ * @example
127
+ * var h = await b.testHarness.start({ envPrefix: "MYAPP", initVault: true });
128
+ * try {
129
+ * // ... operator's app boot reads process.env.MYAPP_DATA_DIR etc.
130
+ * // ... run tests ...
131
+ * } finally {
132
+ * await h.stop();
133
+ * }
134
+ */
135
+ async function start(opts) {
136
+ opts = opts || {};
137
+ validateOpts.optionalNonEmptyString(opts.dataDir, "start.dataDir",
138
+ TestHarnessError, "test-harness/bad-input");
139
+ if (opts.envPrefix !== undefined && (typeof opts.envPrefix !== "string" || !/^[A-Z][A-Z0-9_]*$/.test(opts.envPrefix))) { // allow:regex-no-length-cap — env-var prefix shape
140
+ throw new TestHarnessError("test-harness/bad-input",
141
+ "start: opts.envPrefix must be uppercase ASCII identifier (A-Z, 0-9, _)");
142
+ }
143
+ if (opts.env !== undefined && (opts.env === null || typeof opts.env !== "object" || Array.isArray(opts.env))) {
144
+ throw new TestHarnessError("test-harness/bad-input",
145
+ "start: opts.env must be a plain object if provided");
146
+ }
147
+
148
+ // Resolve / create dataDir.
149
+ var dataDir;
150
+ var weCreatedDataDir = false;
151
+ if (opts.dataDir) {
152
+ dataDir = nodePath.resolve(opts.dataDir);
153
+ nodeFs.mkdirSync(dataDir, { recursive: true });
154
+ } else {
155
+ // mkdtemp uses os.tmpdir + cryptographic suffix; collision-free
156
+ // even under SMOKE_PARALLEL=64 fan-out. Prefix surfaces the
157
+ // process owner for grep-on-leak diagnosis.
158
+ var prefix = nodePath.join(os.tmpdir(),
159
+ "blamejs-harness-" + bCrypto.generateToken(4) + "-"); // allow:raw-byte-literal — 4-byte token (8 hex) suffix
160
+ dataDir = nodeFs.mkdtempSync(prefix);
161
+ weCreatedDataDir = true;
162
+ }
163
+
164
+ var dbPath = opts.dbPath || nodePath.join(dataDir, "db.sqlite");
165
+ var vaultDir = opts.vaultDir || nodePath.join(dataDir, "vault");
166
+ nodeFs.mkdirSync(vaultDir, { recursive: true });
167
+
168
+ // Capture + set env vars. We restore on stop() — values absent
169
+ // pre-start are unset; values present are restored to their prior
170
+ // value. The harness is process-local; concurrent harnesses share
171
+ // process.env so the operator's envPrefix should be unique per
172
+ // harness (or omitted, in which case no env vars are set).
173
+ var envBackup = {};
174
+ function _setEnv(key, value) {
175
+ // First-write-wins on the backup so multiple writes to the same
176
+ // key (e.g. envPrefix + opts.env naming the same var) restore the
177
+ // ORIGINAL pre-harness value on stop(), not a harness-written
178
+ // intermediate. Object.prototype.hasOwnProperty.call guards
179
+ // against the `__proto__` / `constructor` key class.
180
+ if (!Object.prototype.hasOwnProperty.call(envBackup, key)) {
181
+ envBackup[key] = Object.prototype.hasOwnProperty.call(process.env, key)
182
+ ? process.env[key] : null;
183
+ }
184
+ process.env[key] = value;
185
+ }
186
+ if (opts.envPrefix) {
187
+ _setEnv(opts.envPrefix + "_DATA_DIR", dataDir);
188
+ _setEnv(opts.envPrefix + "_DB_PATH", dbPath);
189
+ _setEnv(opts.envPrefix + "_VAULT_DIR", vaultDir);
190
+ }
191
+ if (opts.env) {
192
+ for (var k in opts.env) {
193
+ if (Object.prototype.hasOwnProperty.call(opts.env, k)) {
194
+ _setEnv(k, String(opts.env[k]));
195
+ }
196
+ }
197
+ }
198
+
199
+ // Optional vault init. Default ON for the typical case where the
200
+ // operator's primitives compose vault. Operator opts out via
201
+ // `initVault: false` for tests that exercise vault.init themselves.
202
+ var initVault = opts.initVault !== false;
203
+ var ownsVaultRef = false;
204
+ if (initVault) {
205
+ try {
206
+ // vault.init is async; awaiting it ensures failures surface as
207
+ // a thrown TestHarnessError from start() (not an unhandled
208
+ // promise rejection after start() returns).
209
+ await vault().init({ dataDir: vaultDir, mode: "plaintext" });
210
+ _vaultRefCount += 1;
211
+ ownsVaultRef = true;
212
+ } catch (e) {
213
+ // Reset env + remove dataDir before re-throwing so the test
214
+ // doesn't leak a half-initialized state.
215
+ _restoreEnv(envBackup);
216
+ if (weCreatedDataDir && !opts.keepOnStop) {
217
+ try { nodeFs.rmSync(dataDir, { recursive: true, force: true }); }
218
+ catch (_e) { /* best-effort cleanup */ }
219
+ }
220
+ throw new TestHarnessError("test-harness/vault-init-failed",
221
+ "start: vault.init failed: " + (e && e.message || String(e)));
222
+ }
223
+ }
224
+
225
+ var stopped = false;
226
+ async function stop() {
227
+ if (stopped) return;
228
+ stopped = true;
229
+
230
+ // Vault teardown — _resetForTest is the framework convention for
231
+ // primitive scrub. Reference-counted so concurrent harnesses
232
+ // sharing the process-global vault don't tear it out from under
233
+ // each other; only the LAST owning harness's stop() resets.
234
+ if (initVault && ownsVaultRef) {
235
+ ownsVaultRef = false;
236
+ _vaultRefCount = Math.max(0, _vaultRefCount - 1);
237
+ if (_vaultRefCount === 0) {
238
+ try {
239
+ var v = vault();
240
+ if (typeof v._resetForTest === "function") v._resetForTest();
241
+ } catch (_e) { /* best-effort */ }
242
+ }
243
+ }
244
+
245
+ _restoreEnv(envBackup);
246
+
247
+ if (weCreatedDataDir && !opts.keepOnStop) {
248
+ try { nodeFs.rmSync(dataDir, { recursive: true, force: true }); }
249
+ catch (_e) { /* best-effort — operator can inspect on leak */ }
250
+ }
251
+ }
252
+
253
+ return {
254
+ dataDir: dataDir,
255
+ dbPath: dbPath,
256
+ vaultDir: vaultDir,
257
+ envPrefix: opts.envPrefix || null,
258
+ initVault: initVault,
259
+ stop: stop,
260
+ TestHarnessError: TestHarnessError,
261
+ };
262
+ }
263
+
264
+ function _restoreEnv(backup) {
265
+ for (var k in backup) {
266
+ if (!Object.prototype.hasOwnProperty.call(backup, k)) continue;
267
+ if (backup[k] === null) delete process.env[k];
268
+ else process.env[k] = backup[k];
269
+ }
270
+ }
271
+
272
+ module.exports = {
273
+ start: start,
274
+ TestHarnessError: TestHarnessError,
275
+ };
package/lib/watcher.js CHANGED
@@ -178,7 +178,145 @@ function _compileIgnore(patterns) {
178
178
  };
179
179
  }
180
180
 
181
- var ALLOWED_MODES = ["fs", "poll"];
181
+ var ALLOWED_MODES = ["fs", "poll", "auto"];
182
+
183
+ // Filesystem types that the recursive fs.watch backend doesn't deliver
184
+ // events on in practice. Detected from /proc/self/mountinfo when the
185
+ // watcher's root resolves into one of these mounts.
186
+ //
187
+ // - fuse / fuse.<driver>: Docker Desktop on macOS uses gRPC-FUSE for
188
+ // bind-mounts; Windows Docker Desktop with VirtioFS-backed WSL2 ships
189
+ // in the same family. Native Linux containers running on overlayfs
190
+ // inside a bind-mounted host directory don't propagate inotify events
191
+ // across the gRPC-FUSE boundary; libuv's recursive fs.watch
192
+ // silently observes no events for the lifetime of the watcher.
193
+ // - 9p: WSL2 host-to-Linux filesystem when running on Windows;
194
+ // doesn't propagate change events to Linux inotify.
195
+ // - virtiofs: newer Docker Desktop default on Apple Silicon Macs;
196
+ // inotify is forwarded but recursive coverage is unreliable.
197
+ // - cifs / smbfs / nfs / nfs4: network filesystems where the server
198
+ // doesn't push change notifications to the client kernel.
199
+ //
200
+ // Operators on a container running over one of these mounts default
201
+ // to poll under mode: "auto".
202
+ var AUTO_PROBE_POLL_FSTYPES = new Set([
203
+ "fuse",
204
+ "fuse.gcsfuse",
205
+ "fuse.grpcfuse",
206
+ "fuse.virtiofs",
207
+ "9p",
208
+ "virtiofs",
209
+ "cifs",
210
+ "smbfs",
211
+ "nfs",
212
+ "nfs4",
213
+ "vboxsf",
214
+ ]);
215
+
216
+ function _detectAutoMode(rootPath) {
217
+ // Sync probe — looks at the kernel's view of the mount carrying the
218
+ // watcher's root and decides whether fs.watch will deliver events.
219
+ // Three signals contribute, in priority order:
220
+ // 1. /proc/self/mountinfo entry for the root's longest-matching
221
+ // mount — if its fstype is in AUTO_PROBE_POLL_FSTYPES, poll.
222
+ // 2. Bind-mount detection — mountinfo field 4 ("root within source
223
+ // filesystem", per Documentation/filesystems/proc.rst §3.5) is
224
+ // "/" for a regular mount but the bound source path for a bind
225
+ // mount. Inside a container, a field-4 != "/" indicates a host
226
+ // bind-mount whose inotify chain may not propagate across the
227
+ // virtualization boundary; poll.
228
+ // 3. Otherwise — fs.
229
+ //
230
+ // Returns { mode, reason, fsType, inContainer }.
231
+ if (process.platform !== "linux") {
232
+ // macOS + Windows fs.watch backends use FSEvents + ReadDirectoryChangesW
233
+ // respectively, which DO deliver recursive events natively for
234
+ // operator-owned local filesystems. Containerized Linux is the
235
+ // failure mode this probe is built for.
236
+ return { mode: "fs", reason: "non-linux-host", fsType: null, inContainer: false };
237
+ }
238
+
239
+ var inContainer = false;
240
+ try { inContainer = nodeFs.existsSync("/.dockerenv"); }
241
+ catch (_e) { inContainer = false; }
242
+
243
+ var mountInfoRaw = null;
244
+ try { mountInfoRaw = nodeFs.readFileSync("/proc/self/mountinfo", "utf8"); }
245
+ catch (_e) { mountInfoRaw = null; }
246
+
247
+ if (!mountInfoRaw) {
248
+ // No mountinfo available — fall back to fs. Operator can still
249
+ // override explicitly via mode: "poll".
250
+ return { mode: "fs", reason: "no-mountinfo", fsType: null, inContainer: inContainer };
251
+ }
252
+
253
+ // Find the mount whose mount-point is the longest prefix of rootPath.
254
+ var lines = mountInfoRaw.split("\n");
255
+ var bestMatch = null;
256
+ var bestLen = -1;
257
+ for (var i = 0; i < lines.length; i += 1) {
258
+ var ln = lines[i];
259
+ if (!ln) continue;
260
+ // Format: <id> <parent> <major:minor> <root> <mountpoint> <options>
261
+ // [<optional-fields>...] - <fstype> <source> <super-options>
262
+ // The separator " - " divides the optional-fields half from the post-fields half.
263
+ var sepIdx = ln.indexOf(" - ");
264
+ if (sepIdx === -1) continue;
265
+ var preFields = ln.slice(0, sepIdx).split(" ");
266
+ var postFields = ln.slice(sepIdx + 3).split(" ");
267
+ if (preFields.length < 6 || postFields.length < 1) continue;
268
+ var rootField = preFields[3]; // "/" for regular mount; bound-source path for bind
269
+ var mountPoint = preFields[4];
270
+ var fstype = postFields[0];
271
+ if (typeof mountPoint !== "string" || mountPoint.length === 0) continue;
272
+ if (rootPath === mountPoint ||
273
+ (rootPath.length > mountPoint.length &&
274
+ rootPath.indexOf(mountPoint) === 0 &&
275
+ (mountPoint === "/" || rootPath.charCodeAt(mountPoint.length) === 47 /* / */))) {
276
+ if (mountPoint.length > bestLen) {
277
+ bestLen = mountPoint.length;
278
+ bestMatch = { mountPoint: mountPoint, rootField: rootField, fstype: fstype };
279
+ }
280
+ }
281
+ }
282
+
283
+ if (!bestMatch) {
284
+ return { mode: "fs", reason: "no-mount-match", fsType: null, inContainer: inContainer };
285
+ }
286
+
287
+ if (AUTO_PROBE_POLL_FSTYPES.has(bestMatch.fstype)) {
288
+ return {
289
+ mode: "poll",
290
+ reason: "fstype-non-inotify",
291
+ fsType: bestMatch.fstype,
292
+ inContainer: inContainer,
293
+ };
294
+ }
295
+
296
+ // Bind-mount detection via mountinfo field 4 ("root"). For a regular
297
+ // mount this is "/" — the entire source filesystem is mounted. For a
298
+ // bind-mount it's the path within the source filesystem that was
299
+ // bound onto the mount point (e.g. "/Users/me/data" on a Docker
300
+ // Desktop bind from macOS). When we're inside a container AND the
301
+ // best-matching mount carries a non-"/" root, the mount is a bind
302
+ // and inotify chains across the host/guest boundary are unreliable.
303
+ // (Operator can still force fs via mode: "fs"; force poll via mode: "poll".)
304
+ if (inContainer && bestMatch.rootField && bestMatch.rootField !== "/") {
305
+ return {
306
+ mode: "poll",
307
+ reason: "container-bind-mount",
308
+ fsType: bestMatch.fstype,
309
+ inContainer: inContainer,
310
+ };
311
+ }
312
+
313
+ return {
314
+ mode: "fs",
315
+ reason: "native-fs",
316
+ fsType: bestMatch.fstype,
317
+ inContainer: inContainer,
318
+ };
319
+ }
182
320
 
183
321
  function _validateOpts(opts) {
184
322
  validateOpts.requireObject(opts, "watcher.create", WatcherError, "watcher/bad-opts");
@@ -215,7 +353,12 @@ function create(opts) {
215
353
  var root = nodePath.resolve(opts.root);
216
354
  var debounceMs = (opts.debounceMs !== undefined) ? opts.debounceMs : DEFAULT_DEBOUNCE_MS;
217
355
  var maxPending = (opts.maxPending !== undefined) ? opts.maxPending : DEFAULT_MAX_PENDING;
218
- var mode = opts.mode || "fs";
356
+ var requestedMode = opts.mode || "fs";
357
+ var autoDecision = null;
358
+ if (requestedMode === "auto") {
359
+ autoDecision = _detectAutoMode(root);
360
+ }
361
+ var mode = autoDecision ? autoDecision.mode : requestedMode;
219
362
  var pollIntervalMs = opts.pollIntervalMs || DEFAULT_POLL_INTERVAL_MS;
220
363
  var pollMaxFiles = opts.pollMaxFiles || DEFAULT_POLL_MAX_FILES;
221
364
  var onChange = opts.onChange || function () {};
@@ -438,7 +581,16 @@ function create(opts) {
438
581
  }
439
582
  }
440
583
 
441
- _safeEmitAudit("watcher.started", { root: root, mode: mode });
584
+ if (autoDecision) {
585
+ _safeEmitAudit("watcher.mode_auto_decision", {
586
+ root: root,
587
+ chosen: autoDecision.mode,
588
+ reason: autoDecision.reason,
589
+ fsType: autoDecision.fsType,
590
+ inContainer: autoDecision.inContainer,
591
+ });
592
+ }
593
+ _safeEmitAudit("watcher.started", { root: root, mode: mode, requestedMode: requestedMode });
442
594
 
443
595
  function stop() {
444
596
  if (stopped) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.9.41",
3
+ "version": "0.9.43",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.6",
5
- "serialNumber": "urn:uuid:2ba814f6-24ab-4017-914e-f9ddba6c50ef",
5
+ "serialNumber": "urn:uuid:25095753-dd82-4e38-95b9-70ee29c706f8",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-15T14:40:55.638Z",
8
+ "timestamp": "2026-05-15T17:05:04.296Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.9.41",
22
+ "bom-ref": "@blamejs/core@0.9.43",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.9.41",
25
+ "version": "0.9.43",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.9.41",
29
+ "purl": "pkg:npm/%40blamejs/core@0.9.43",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.9.41",
57
+ "ref": "@blamejs/core@0.9.43",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]