@blamejs/blamejs-shop 0.4.91 → 0.4.92

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 (46) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/lib/asset-manifest.json +1 -1
  3. package/lib/vendor/MANIFEST.json +49 -41
  4. package/lib/vendor/blamejs/CHANGELOG.md +6 -0
  5. package/lib/vendor/blamejs/SECURITY.md +1 -0
  6. package/lib/vendor/blamejs/api-snapshot.json +208 -2
  7. package/lib/vendor/blamejs/examples/wiki/test/e2e.js +7 -4
  8. package/lib/vendor/blamejs/examples/wiki/test/integration.js +15 -12
  9. package/lib/vendor/blamejs/index.js +2 -0
  10. package/lib/vendor/blamejs/lib/audit-sign.js +34 -1
  11. package/lib/vendor/blamejs/lib/backup/manifest.js +191 -44
  12. package/lib/vendor/blamejs/lib/codepoint-class.js +284 -77
  13. package/lib/vendor/blamejs/lib/framework-error.js +14 -0
  14. package/lib/vendor/blamejs/lib/fsm.js +80 -24
  15. package/lib/vendor/blamejs/lib/log.js +32 -0
  16. package/lib/vendor/blamejs/lib/middleware/rate-limit.js +18 -2
  17. package/lib/vendor/blamejs/lib/middleware/request-id.js +24 -4
  18. package/lib/vendor/blamejs/lib/request-helpers.js +50 -0
  19. package/lib/vendor/blamejs/lib/safe-path.js +24 -10
  20. package/lib/vendor/blamejs/lib/sql.js +133 -0
  21. package/lib/vendor/blamejs/lib/totp.js +98 -33
  22. package/lib/vendor/blamejs/lib/ws-client.js +39 -28
  23. package/lib/vendor/blamejs/package.json +1 -1
  24. package/lib/vendor/blamejs/release-notes/v0.15.21.json +51 -0
  25. package/lib/vendor/blamejs/release-notes/v0.15.22.json +18 -0
  26. package/lib/vendor/blamejs/release-notes/v0.15.23.json +22 -0
  27. package/lib/vendor/blamejs/test/00-primitives.js +80 -0
  28. package/lib/vendor/blamejs/test/_smoke-worker.js +81 -0
  29. package/lib/vendor/blamejs/test/integration/federation-auth.test.js +7 -4
  30. package/lib/vendor/blamejs/test/integration/mail-crypto-smime.test.js +7 -4
  31. package/lib/vendor/blamejs/test/layer-0-primitives/backup-manifest-signature.test.js +91 -0
  32. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +65 -0
  33. package/lib/vendor/blamejs/test/layer-0-primitives/codepoint-class.test.js +58 -0
  34. package/lib/vendor/blamejs/test/layer-0-primitives/defineguard-default-gate-posture-caps.test.js +5 -2
  35. package/lib/vendor/blamejs/test/layer-0-primitives/dpop-middleware-replaystore-required.test.js +9 -1
  36. package/lib/vendor/blamejs/test/layer-0-primitives/fsm.test.js +99 -0
  37. package/lib/vendor/blamejs/test/layer-0-primitives/money.test.js +30 -0
  38. package/lib/vendor/blamejs/test/layer-0-primitives/otlp-attr-redaction.test.js +9 -6
  39. package/lib/vendor/blamejs/test/layer-0-primitives/rate-limit-xff-spoofing.test.js +36 -0
  40. package/lib/vendor/blamejs/test/layer-0-primitives/request-helpers.test.js +33 -0
  41. package/lib/vendor/blamejs/test/layer-0-primitives/request-id-async-context.test.js +117 -0
  42. package/lib/vendor/blamejs/test/layer-0-primitives/safe-path.test.js +64 -0
  43. package/lib/vendor/blamejs/test/layer-0-primitives/sql.test.js +96 -0
  44. package/lib/vendor/blamejs/test/layer-0-primitives/ws-client.test.js +55 -0
  45. package/lib/vendor/blamejs/test/smoke.js +93 -10
  46. package/package.json +1 -1
@@ -5170,6 +5170,48 @@ var KNOWN_ANTIPATTERNS = [
5170
5170
  allowlist: ["lib/x509-chain.js"],
5171
5171
  reason: "basicConstraints cA:TRUE enforcement is owned by x509Chain.issuerValidlyIssued / x509Chain.isCaCert; tsa/mail-bimi/mail-crypto-smime route through it. Any lib file calling X.checkIssued(Y) (Y!=X) directly bypasses the cA check and must use x509Chain instead. lib/x509-chain.js is the home of the primitive.",
5172
5172
  },
5173
+ {
5174
+ id: "fingerprint-pin-against-claimed-field-not-recomputed",
5175
+ primitive: "b.auditSign.fingerprintOf",
5176
+ scanScope: "lib",
5177
+ skipCommentLines: true,
5178
+ // A fingerprint PIN (expectedFingerprint, supplied out-of-band by the
5179
+ // verifier) must be checked against a fingerprint RECOMPUTED from the
5180
+ // signature block's own publicKey — fingerprintOf(block.publicKey) — NOT
5181
+ // the block's self-asserted `.fingerprint` field. An attacker controls that
5182
+ // field: sign arbitrary bytes with their OWN key, set `fingerprint` to the
5183
+ // trusted value, and a `block.fingerprint === expectedFingerprint` check
5184
+ // passes while the signature still verifies under the attacker's key (the
5185
+ // backup-manifest verifyBytes/verifySignature P1 substitution bug). Binding
5186
+ // the pin to fingerprintOf(publicKey) — the key the signature is actually
5187
+ // verified under — closes the substitution. Fires on either argument order.
5188
+ regex: /(?:[\w.]+\.fingerprint\s*[!=]==\s*[\w.]*\bexpectedFingerprint\b|\bexpectedFingerprint\b\s*[!=]==\s*[\w.]+\.fingerprint\b)/,
5189
+ allowlist: [],
5190
+ reason: "A fingerprint pin must be compared against auditSign.fingerprintOf(<the block's publicKey>) — the key the signature verifies under — never the block's untrusted self-asserted `.fingerprint` field (an attacker sets that to the trusted value while signing with their own key). backup/manifest.js _verifyPayloadAgainstBlock recomputes via derivedFingerprint = fingerprintOf(sig.publicKey). Any `X.fingerprint === expectedFingerprint` (either order) re-introduces the substitution bypass and must recompute instead.",
5191
+ },
5192
+ {
5193
+ id: "platform-parameterized-containment-resolves-with-runtime-path-module",
5194
+ primitive: "b.safePath (target-platform lexical resolve)",
5195
+ scanScope: "lib",
5196
+ skipCommentLines: true,
5197
+ // A path validator parameterized by opts.platform must resolve AND bound its
5198
+ // LEXICAL containment with the TARGET platform's path module
5199
+ // (`pathMod = isWin ? nodePath.win32 : nodePath.posix`), not the runtime
5200
+ // node:path. The per-segment walk already splits on the target separator
5201
+ // (`sep = isWin ? /[\\/]/ : /\//`); if the lexical resolve uses runtime
5202
+ // semantics they disagree. On a POSIX host validating opts.platform:
5203
+ // "windows", runtime node:path treats `\` as an ordinary filename char, so
5204
+ // `ok\..\..\outside` was NOT collapsed and slipped past the boundary slice —
5205
+ // resolving to `<base>/ok\..\..\outside`, a path that escapes the base once a
5206
+ // Windows consumer reads the backslashes (Codex P1, PR #372). The realpath
5207
+ // check hits the live filesystem and legitimately keeps a SEPARATE runtime
5208
+ // resolve under distinctly-named vars (rtBaseResolved / rtJoined / rtSep), so
5209
+ // those don't match this anchor. Fires if the lexical `joined` reverts to
5210
+ // `nodePath.resolve(baseResolved, ...)` or `sepChar` to `nodePath.sep`.
5211
+ regex: /\bjoined\s*=\s*nodePath\.resolve\s*\(\s*baseResolved|\bvar\s+sepChar\s*=\s*nodePath\.sep\b/,
5212
+ allowlist: [],
5213
+ reason: "b.safePath.resolve refuses traversal lexically by resolving rel under base and slicing on the containment boundary. When validating for a non-runtime platform (opts.platform), the lexical resolve + boundary separator MUST come from the target module (pathMod = isWin ? nodePath.win32 : nodePath.posix) so they share the per-segment walk's platform semantics — otherwise a separator the target treats as a delimiter (the Windows backslash) is treated as a filename char by the runtime resolver and a cross-platform `ok\\..\\..\\outside` traversal escapes the base. The realpath check keeps a separate runtime resolve (rtBaseResolved/rtJoined/rtSep) because it touches the live FS. Reverting the lexical `joined`/`sepChar` to runtime nodePath re-opens the cross-platform traversal hole.",
5214
+ },
5173
5215
  {
5174
5216
  id: "compose-pipeline-settle-on-response-ended-not-return",
5175
5217
  primitive: "b.middleware.composePipeline",
@@ -9360,6 +9402,29 @@ var KNOWN_ANTIPATTERNS = [
9360
9402
  allowlist: [],
9361
9403
  reason: "#123 macOS codebase-patterns watchdog hang. _scanShardInWorker rejected on worker error/exit without w.terminate(), so an errored worker thread stayed alive holding open handles; the parent then could not exit and the smoke run ran to the 25-min watchdog on memory-starved macOS-arm64 runners (it hung this very release's CI). Every settle path must reap the worker via w.terminate() first; the fix funnels message/error/exit through a settle() guard that terminates before resolve/reject. Fires on the bare `w.once(\"error\", reject)` shape; silent once error/exit route through settle().",
9362
9404
  },
9405
+ {
9406
+ // A test file must invoke its run()/IIFE ONLY under
9407
+ // `if (require.main === module)`. The smoke worker REQUIRES each test
9408
+ // module and then awaits its exported run(); a module-level `run()` (or
9409
+ // `run().then(...process.exit...)`) at column 0 fires a SECOND, unawaited
9410
+ // run() at require-time that races the worker's result print — and if it
9411
+ // calls process.exit() it exits the worker BEFORE the result line is
9412
+ // written, which the parent reports as the unattributable "no result line"
9413
+ // / "fork failed". Found via the worker's late-error + leaked-handle audit
9414
+ // sweep (defineguard-default-gate-posture-caps / dpop-middleware-
9415
+ // replaystore-required / otlp-attr-redaction + two integration files), the
9416
+ // same slow-runner-flake root as #123. Export run and guard the
9417
+ // self-execution. Structural test-harness-contract drift a behavioral test
9418
+ // can't assert (the race only surfaces under require, intermittently on a
9419
+ // slow runner) — the detector is the guard.
9420
+ id: "test-unguarded-module-level-run",
9421
+ primitive: "a test file's run()/self-execution must sit under `if (require.main === module)` (export run for the smoke worker to await) — a bare module-level run() at column 0 re-runs at require-time and races / exits the worker",
9422
+ scanScope: "test",
9423
+ regex: /^run\s*\(/m,
9424
+ skipCommentLines: true,
9425
+ allowlist: [],
9426
+ reason: "The smoke worker requires each test module and awaits its exported run(); a column-0 `run()` / `run().then(...process.exit...)` fires a second unawaited run() at require-time that races the worker's result print (and process.exit() exits before the result line, read as 'no result line' / 'fork failed' on a slow runner). Export `run` and wrap the invocation in `if (require.main === module)`. Fires on any `run(` at the start of a line; `function run()`, `module.exports = { run }`, and an indented `run()` inside the require-main guard stay silent.",
9427
+ },
9363
9428
  {
9364
9429
  // `Promise + setTimeout` direct sleep in tests is forbidden;
9365
9430
  // tests waiting on an asynchronous condition MUST use
@@ -65,9 +65,67 @@ function testFirstControlCharOffset() {
65
65
  check("firstControlCharOffset: CR found when only allowLf", g("a\rb", { allowLf: true }) === 1);
66
66
  }
67
67
 
68
+ // #332 — the catalog is now exported on the public b. surface so a consumer
69
+ // can build a custom free-text screen without reaching into the internal
70
+ // module path or re-rolling the bidi / control / zero-width regexes.
71
+ function testPublicSurface() {
72
+ var b = helpers.b;
73
+ check("b.codepointClass is on the public surface", typeof b.codepointClass === "object");
74
+ // The detectors / classifier the issue names are reachable + functional.
75
+ check("b.codepointClass.detectCharThreats is a function", typeof b.codepointClass.detectCharThreats === "function");
76
+ check("b.codepointClass.assertNoCharThreats is a function", typeof b.codepointClass.assertNoCharThreats === "function");
77
+ check("b.codepointClass.applyCharStripPolicies is a function", typeof b.codepointClass.applyCharStripPolicies === "function");
78
+ check("b.codepointClass.scriptFor is a function", typeof b.codepointClass.scriptFor === "function");
79
+ check("b.codepointClass.detectMixedScripts is a function", typeof b.codepointClass.detectMixedScripts === "function");
80
+ // The compiled regexes / constants are reachable.
81
+ check("b.codepointClass.BIDI_RE is a RegExp", b.codepointClass.BIDI_RE instanceof RegExp);
82
+ check("b.codepointClass.C0_CTRL_RE is a RegExp", b.codepointClass.C0_CTRL_RE instanceof RegExp);
83
+ check("b.codepointClass.ZERO_WIDTH_RE is a RegExp", b.codepointClass.ZERO_WIDTH_RE instanceof RegExp);
84
+ check("b.codepointClass.NULL_BYTE is the NUL char", b.codepointClass.NULL_BYTE === "\x00");
85
+
86
+ // Functional smoke: a bidi-override Trojan-source payload is detected; a
87
+ // Cyrillic confusable mixed into a Latin label is flagged.
88
+ var bidi = "abc" + b.codepointClass.fromCp(0x202E) + "def";
89
+ var issues = b.codepointClass.detectCharThreats(bidi, { bidiPolicy: "reject" }, "free-text");
90
+ check("public detectCharThreats flags a bidi override", issues.length >= 1 && issues[0].kind === "bidi-override");
91
+
92
+ var spoof = "pa" + b.codepointClass.fromCp(0x0443) + "pal"; // Cyrillic u (U+0443)
93
+ var scripts = b.codepointClass.detectMixedScripts(spoof);
94
+ check("public detectMixedScripts flags a Latin/Cyrillic confusable",
95
+ Array.isArray(scripts) && scripts.indexOf("latin") !== -1 && scripts.indexOf("cyrillic") !== -1);
96
+
97
+ // strip policy removes the override (sanitize path).
98
+ var cleaned = b.codepointClass.applyCharStripPolicies(bidi, { bidiPolicy: "strip" });
99
+ check("public applyCharStripPolicies strips the override", cleaned === "abcdef");
100
+
101
+ // The composition helpers are reachable + correct on the public surface too,
102
+ // so a consumer building its own screen doesn't reach into the internal path.
103
+ check("b.codepointClass.hex4", b.codepointClass.hex4(0x202E) === "\\u202E");
104
+ check("b.codepointClass.charClass",
105
+ b.codepointClass.charClass([0x200E, [0x202A, 0x202E]]) === "\\u200E\\u202A-\\u202E");
106
+ check("b.codepointClass.fromCp", b.codepointClass.fromCp(0x41) === "A");
107
+ check("b.codepointClass.escapeRegExp",
108
+ b.codepointClass.escapeRegExp("a.b*c") === "a\\.b\\*c");
109
+ check("b.codepointClass.isAsciiAlnum",
110
+ b.codepointClass.isAsciiAlnum(0x5a) === true && b.codepointClass.isAsciiAlnum(0x2d) === false);
111
+ check("b.codepointClass.isUnreserved",
112
+ b.codepointClass.isUnreserved(0x7e) === true && b.codepointClass.isUnreserved(0x2f) === false);
113
+ check("b.codepointClass.isForbiddenControlChar",
114
+ b.codepointClass.isForbiddenControlChar(0x00) === true && b.codepointClass.isForbiddenControlChar(0x41) === false);
115
+ check("b.codepointClass.firstControlCharOffset",
116
+ b.codepointClass.firstControlCharOffset("ok\x00bad") === 2 && b.codepointClass.firstControlCharOffset("clean") === -1);
117
+ check("b.codepointClass.decodeNumericEntities",
118
+ b.codepointClass.decodeNumericEntities("&#106;avascript:") === "javascript:" &&
119
+ b.codepointClass.decodeNumericEntities("&#106avascript:") === "javascript:");
120
+ // The remaining catalog constants the issue lists are reachable.
121
+ check("b.codepointClass.BOM_CHAR",
122
+ typeof b.codepointClass.BOM_CHAR === "string" && b.codepointClass.BOM_CHAR.charCodeAt(0) === 0xFEFF);
123
+ }
124
+
68
125
  async function run() {
69
126
  testIsForbiddenControlChar();
70
127
  testFirstControlCharOffset();
128
+ testPublicSurface();
71
129
  }
72
130
 
73
131
  module.exports = { run: run };
@@ -26,5 +26,8 @@ async function run() {
26
26
  process.stdout.write("OK — defineGuard default-gate posture-cap tests\n");
27
27
  }
28
28
 
29
- run().then(function () { process.exit(0); })
30
- .catch(function (e) { process.stderr.write((e && e.stack ? e.stack : String(e)) + "\n"); process.exit(1); });
29
+ module.exports = { run: run };
30
+ if (require.main === module) {
31
+ run().then(function () { process.exit(0); })
32
+ .catch(function (e) { process.stderr.write((e && e.stack ? e.stack : String(e)) + "\n"); process.exit(1); });
33
+ }
@@ -43,4 +43,12 @@ function run() {
43
43
  process.stdout.write("OK — dpop middleware replayStore-required tests\n");
44
44
  }
45
45
 
46
- run();
46
+ module.exports = { run: run };
47
+ if (require.main === module) {
48
+ // run() is synchronous here — wrap in Promise.resolve().then so the standalone
49
+ // CLI path works whether run is sync or async (a bare run().catch throws
50
+ // "Cannot read properties of undefined" on a sync run that returns undefined).
51
+ Promise.resolve().then(run).catch(function (e) {
52
+ process.stderr.write((e && e.stack ? e.stack : String(e)) + "\n"); process.exit(1);
53
+ });
54
+ }
@@ -255,6 +255,102 @@ async function testAuditEmission() {
255
255
  check("transition succeeded under best-effort audit", i.state === "paid");
256
256
  }
257
257
 
258
+ // Tap b.audit.safeEmit (the sink namespaced(...) routes through) for the
259
+ // duration of `fn`, returning the captured records. Restores in a finally so
260
+ // a thrown assertion never leaves the global sink monkeypatched.
261
+ async function _withAuditCapture(fn) {
262
+ var events = [];
263
+ var orig = b.audit.safeEmit;
264
+ b.audit.safeEmit = function (rec) { events.push(rec); return orig.apply(this, arguments); };
265
+ try {
266
+ await fn(events);
267
+ // namespaced emit fires on setImmediate via safeEmit's wrapper path — let
268
+ // the microtask/immediate queue flush before reading.
269
+ await new Promise(function (r) { setImmediate(r); });
270
+ } finally {
271
+ b.audit.safeEmit = orig;
272
+ }
273
+ return events;
274
+ }
275
+
276
+ // #345 Gap 1 — side-effect-free destination resolver. Mirrors can() but
277
+ // returns the to-state so a consumer can build an external compare-and-swap's
278
+ // `SET status = <to>` without calling transition() (which mutates + audits).
279
+ async function testTargetResolver() {
280
+ var f = _orderFactory();
281
+ var i = f.create({ initialContext: {} }); // no address → ship guard refuses
282
+ check("target(pay) → paid", i.target("pay") === "paid");
283
+ check("target(cancel) → canceled", i.target("cancel") === "canceled");
284
+ check("target(ship) illegal from placed → null", i.target("ship") === null);
285
+ check("target(unknown) → null", i.target("teleport") === null);
286
+ check("target(non-string) → null", i.target(42) === null);
287
+ check("target did not mutate state", i.state === "placed");
288
+ check("target did not push history", i.history.length === 0);
289
+
290
+ await i.transition("pay");
291
+ // From paid: ship is guard-refused (no address) → target null, matching can().
292
+ check("target(ship) guard-refused → null", i.target("ship") === null);
293
+ check("can(ship) guard-refused → false", i.can("ship") === false);
294
+ i.context.address = "addr";
295
+ check("target(ship) guard-pass → shipped", i.target("ship") === "shipped");
296
+ check("can/target agree after guard flip", i.can("ship") === (i.target("ship") !== null));
297
+ }
298
+
299
+ // #345 Gap 2 — { audit: false } suppresses the built-in emit so a composition
300
+ // driving an external claim can emit its own enriched record after the claim.
301
+ async function testAuditSuppression() {
302
+ var f = _orderFactory();
303
+ var events = await _withAuditCapture(async function () {
304
+ var i = f.create({ initialContext: { address: "addr" } });
305
+ await i.transition("pay", { audit: false });
306
+ check("transition still committed under audit:false", i.state === "paid");
307
+ });
308
+ var fsmEvents = events.filter(function (e) {
309
+ return e && /\.transition$/.test(String(e.action || e.event || ""));
310
+ });
311
+ check("audit:false suppressed the fsm transition emit", fsmEvents.length === 0);
312
+
313
+ // Default (audit on) DOES emit.
314
+ var events2 = await _withAuditCapture(async function () {
315
+ var i2 = f.create({ initialContext: { address: "addr" } });
316
+ await i2.transition("pay");
317
+ });
318
+ check("default transition emits an audit record", events2.length >= 1);
319
+ }
320
+
321
+ // Hunt finding (HIGH) — a throwing onEnter commits the state change but used
322
+ // to skip the audit emit entirely, leaving an unaudited state transition (a
323
+ // compliance hole). The emit must still fire, stamped failure + the error.
324
+ async function testOnEnterThrowStillAudits() {
325
+ var f = b.fsm.define({
326
+ name: "hooky", initial: "a",
327
+ states: { a: {}, b: { onEnter: function () { throw new Error("hook boom"); } } },
328
+ transitions: [ { from: "a", to: "b", on: "go" } ],
329
+ });
330
+ var events = await _withAuditCapture(async function () {
331
+ var i = f.create();
332
+ var threw = null;
333
+ try { await i.transition("go"); } catch (e) { threw = e; }
334
+ check("onEnter throw still propagates to caller", !!threw && threw.message === "hook boom");
335
+ check("state committed despite onEnter throw", i.state === "b");
336
+ check("history recorded the transition", i.history.length === 1);
337
+ });
338
+ var ev = events[events.length - 1] || {};
339
+ check("onEnter-throw now emits an audit record", events.length >= 1);
340
+ check("onEnter-throw audit outcome = failure", ev.outcome === "failure");
341
+ check("onEnter-throw audit carries the error",
342
+ ev.metadata && /hook boom/.test(JSON.stringify(ev.metadata)));
343
+
344
+ // Happy path keeps outcome success.
345
+ var f2 = _orderFactory();
346
+ var okEvents = await _withAuditCapture(async function () {
347
+ var i = f2.create({ initialContext: { address: "addr" } });
348
+ await i.transition("pay");
349
+ });
350
+ check("happy-path audit outcome = success",
351
+ (okEvents[okEvents.length - 1] || {}).outcome === "success");
352
+ }
353
+
258
354
  function testDefineBadStateName() {
259
355
  var threw = null;
260
356
  try {
@@ -358,6 +454,9 @@ async function run() {
358
454
  await testToJsonRestoreRoundtrip();
359
455
  await testConcurrentTransitionsSerialize();
360
456
  await testAuditEmission();
457
+ await testTargetResolver();
458
+ await testAuditSuppression();
459
+ await testOnEnterThrowStillAudits();
361
460
  testDefineBadStateName();
362
461
  testDefineBadTransitionName();
363
462
  testDefineMissingInitial();
@@ -38,6 +38,36 @@ function run() {
38
38
  check("roundMinor: non-integer minor throws money/bad-minor-units",
39
39
  (function () { try { b.money.roundMinor(1.5, 5n); return null; } catch (e) { return e.code; } })()
40
40
  === "money/bad-minor-units");
41
+
42
+ // ---- Money.roundToIncrement: cash rounding on a Money value (#336) ----
43
+ // The issue's primary ask: snap a Money total to a coarser cash increment
44
+ // (Swiss Rappenrundung to 0.05, Swedish öre to 0.10, psychological steps)
45
+ // and get a NEW Money in the SAME currency — integer-only, negative-safe.
46
+ check("roundToIncrement is a Money method",
47
+ typeof b.money.of("12.32", "CHF").roundToIncrement === "function");
48
+ var chf = b.money.of("12.32", "CHF").roundToIncrement(5, { mode: "half-up" });
49
+ check("roundToIncrement: CHF 12.32 → nearest 0.05 half-up = 12.30",
50
+ chf instanceof b.money.Money && chf.toMinorUnits() === 1230n && chf.currency === "CHF");
51
+ check("roundToIncrement: returns same currency tag",
52
+ b.money.of("19.97", "SEK").roundToIncrement(10).currency === "SEK");
53
+ check("roundToIncrement: SEK 19.97 → nearest 0.10 (default half-even) = 20.00",
54
+ b.money.of("19.97", "SEK").roundToIncrement(10).toMinorUnits() === 2000n);
55
+ check("roundToIncrement: JPY psychological 100-step",
56
+ b.money.fromMinorUnits(1234n, "JPY").roundToIncrement(100, { mode: "half-up" }).toMinorUnits() === 1200n);
57
+ // Negative amount (refund preview) — remainder sign handled correctly.
58
+ check("roundToIncrement: negative CHF -12.32 half-up → -12.30",
59
+ b.money.of("-12.32", "CHF").roundToIncrement(5, { mode: "half-up" }).toMinorUnits() === -1230n);
60
+ // Modes carry through to the Money method.
61
+ check("roundToIncrement: floor toward -inf",
62
+ b.money.fromMinorUnits(27n, "USD").roundToIncrement(10, { mode: "floor" }).toMinorUnits() === 20n);
63
+ check("roundToIncrement: ceiling toward +inf",
64
+ b.money.fromMinorUnits(21n, "USD").roundToIncrement(10, { mode: "ceiling" }).toMinorUnits() === 30n);
65
+ check("roundToIncrement: immutable — original unchanged",
66
+ (function () { var m = b.money.of("12.32", "CHF"); m.roundToIncrement(5, { mode: "half-up" }); return m.toMinorUnits(); })() === 1232n);
67
+ check("roundToIncrement: bad mode throws money/bad-rounding-mode",
68
+ (function () { try { b.money.of("1.00", "CHF").roundToIncrement(5, { mode: "nope" }); return null; } catch (e) { return e.code; } })()
69
+ === "money/bad-rounding-mode");
70
+
41
71
  check("b.money.Money is the prototype", b.money.of("1.00", "USD") instanceof b.money.Money);
42
72
  check("CURRENCIES is frozen", Object.isFrozen(b.money.CURRENCIES));
43
73
  check("CURRENCIES.USD exponent", b.money.CURRENCIES.USD === 2);
@@ -167,9 +167,12 @@ async function run() {
167
167
  process.stdout.write("OK — otlp attribute redaction tests\n");
168
168
  }
169
169
 
170
- run().then(function () {
171
- process.exit(0);
172
- }).catch(function (e) {
173
- process.stderr.write((e && e.stack ? e.stack : String(e)) + "\n");
174
- process.exit(1);
175
- });
170
+ module.exports = { run: run };
171
+ if (require.main === module) {
172
+ run().then(function () {
173
+ process.exit(0);
174
+ }).catch(function (e) {
175
+ process.stderr.write((e && e.stack ? e.stack : String(e)) + "\n");
176
+ process.exit(1);
177
+ });
178
+ }
@@ -66,6 +66,42 @@ async function run() {
66
66
  var c2again = await _runMw(proxMw, _req("10.0.0.9", "203.0.113.1"));
67
67
  check("trusted proxy: client A second request blocked", c2again.next === false);
68
68
 
69
+ // #352 — ipKeyMode "prefix64": an IPv6 end-site owns a whole /64 and can
70
+ // rotate the low 64 bits freely. Under the default exact key each rotated
71
+ // address is a fresh bucket (the bypass); under prefix64 they share the
72
+ // /64 bucket, so the per-IP limit actually binds. IPv4 stays exact.
73
+ var v6a = "2001:db8:1:2:dead:beef:0:1";
74
+ var v6b = "2001:db8:1:2:ffff:ffff:ffff:ffff"; // same /64, rotated low bits
75
+ var v6c = "2001:db8:1:3::1"; // different /64
76
+
77
+ // Baseline (exact): rotating the low 64 bits evades the limit.
78
+ var exactMw = b.middleware.rateLimit({ backend: "memory", burst: 1, refillPerSecond: 0.001 });
79
+ var e1 = await _runMw(exactMw, _req(v6a));
80
+ var e2 = await _runMw(exactMw, _req(v6b));
81
+ check("exact key: low-64 rotation mints a fresh bucket (the bypass)",
82
+ e1.next === true && e2.next === true);
83
+
84
+ // prefix64: the rotated address shares the /64 bucket → second blocked.
85
+ var p64Mw = b.middleware.rateLimit({ backend: "memory", burst: 1, refillPerSecond: 0.001, ipKeyMode: "prefix64" });
86
+ var f1 = await _runMw(p64Mw, _req(v6a));
87
+ var f2 = await _runMw(p64Mw, _req(v6b));
88
+ var f3 = await _runMw(p64Mw, _req(v6c));
89
+ check("prefix64: first /64 request allowed", f1.next === true);
90
+ check("prefix64: low-64 rotation hits the SAME /64 bucket (blocked)", f2.next === false);
91
+ check("prefix64: a different /64 gets its own bucket (allowed)", f3.next === true);
92
+
93
+ // prefix64 keeps IPv4 exact — two distinct IPv4 hosts are distinct buckets.
94
+ var p64v4 = b.middleware.rateLimit({ backend: "memory", burst: 1, refillPerSecond: 0.001, ipKeyMode: "prefix64" });
95
+ var g1 = await _runMw(p64v4, _req("198.51.100.10"));
96
+ var g2 = await _runMw(p64v4, _req("198.51.100.11"));
97
+ check("prefix64: distinct IPv4 hosts stay distinct (v4 exact)", g1.next === true && g2.next === true);
98
+
99
+ // Bad ipKeyMode refused at construction.
100
+ var threwMode = null;
101
+ try { b.middleware.rateLimit({ backend: "memory", burst: 1, refillPerSecond: 0.001, ipKeyMode: "bogus" }); }
102
+ catch (e) { threwMode = e; }
103
+ check("create: bad ipKeyMode refused", threwMode !== null && /ipKeyMode/.test(threwMode.message));
104
+
69
105
  console.log("[rate-limit-xff-spoofing] OK — " + helpers.getChecks() + " checks passed");
70
106
  }
71
107
 
@@ -15,6 +15,7 @@ function testSurface() {
15
15
  check("b.requestHelpers exposed", typeof b.requestHelpers === "object");
16
16
  check("resolveRoute is a function", typeof b.requestHelpers.resolveRoute === "function");
17
17
  check("captureResponseStatus is a function", typeof b.requestHelpers.captureResponseStatus === "function");
18
+ check("ipKey is a function", typeof b.requestHelpers.ipKey === "function");
18
19
  }
19
20
 
20
21
  function testResolveRoutePrefersRoutePattern() {
@@ -460,10 +461,42 @@ function testIpPrefixMasking() {
460
461
  check("ipPrefix rejects an out-of-range v4 octet", ip("999.0.0.1") === "");
461
462
  }
462
463
 
464
+ // #352 — ipKey: IPv4 verbatim (exact host), IPv6 collapsed to /64 so one
465
+ // end-site can't rotate the low 64 bits to mint unlimited rate-limit keys.
466
+ function testIpKeyForRateLimit() {
467
+ var k = b.requestHelpers.ipKey;
468
+ check("ipKey is a function", typeof k === "function");
469
+ // IPv4 kept exact (no /24 masking, no /32 suffix) — one IPv4 is one host.
470
+ check("ipKey v4 exact verbatim", k("203.0.113.47") === "203.0.113.47");
471
+ check("ipKey v4 distinct hosts → distinct keys", k("203.0.113.47") !== k("203.0.113.48"));
472
+ // IPv6 → /64; rotating the low 64 bits yields the SAME key (the defense).
473
+ var k64 = k("2001:db8:1:2:dead:beef:0:1");
474
+ check("ipKey v6 → /64 suffix, low bits zeroed",
475
+ /\/64$/.test(k64) && k64.indexOf("dead") === -1);
476
+ check("ipKey v6 low-64 rotation → same key (rotation defeated)",
477
+ k("2001:db8:1:2:dead:beef:0:1") === k("2001:db8:1:2:ffff:ffff:ffff:ffff"));
478
+ check("ipKey v6 different /64 → different key",
479
+ k("2001:db8:1:2::1") !== k("2001:db8:1:3::1"));
480
+ // Configurable width.
481
+ check("ipKey v6 ipv6Bits=48 widens the bucket", /\/48$/.test(k("2001:db8:1:2::1", { ipv6Bits: 48 })));
482
+ // IPv4-mapped IPv6 folds to its exact dotted host.
483
+ check("ipKey folds ::ffff: mapped v4 to the exact v4 host",
484
+ k("::ffff:203.0.113.47") === "203.0.113.47");
485
+ // Defensive defaults — never throws, "" on bad input (caller falls back).
486
+ check("ipKey '' for non-string", k(null) === "" && k(12345) === "");
487
+ check("ipKey '' for empty", k("") === "");
488
+ check("ipKey '' for unparseable", k("not-an-ip") === "");
489
+ check("ipKey '' for out-of-range v4 octet", k("999.0.0.1") === "");
490
+ // Distinct from ipPrefix: ipKey keeps v4 exact where ipPrefix masks to /24.
491
+ check("ipKey v4 != ipPrefix v4 (exact vs /24)",
492
+ k("203.0.113.47") !== b.requestHelpers.ipPrefix("203.0.113.47"));
493
+ }
494
+
463
495
  async function run() {
464
496
  testSurface();
465
497
  testSafeHeadersDistinct();
466
498
  testIpPrefixMasking();
499
+ testIpKeyForRateLimit();
467
500
  testClientIpDefaultIgnoresXff();
468
501
  testClientIpPeerGatedTrustedPeer();
469
502
  testClientIpPeerGatedUntrustedPeerIgnoresXff();
@@ -0,0 +1,117 @@
1
+ "use strict";
2
+ /**
3
+ * b.middleware.requestId({ asyncContext: true }) — #353.
4
+ *
5
+ * The b.router dispatch model is boolean-`next`: the router awaits each
6
+ * middleware, then runs the route handler in its own loop AFTER the middleware
7
+ * returns. A middleware that wraps `next()` in an AsyncLocalStorage callback
8
+ * (`als.run(store, () => next())`) therefore loses the binding the instant
9
+ * `next()` returns — before any awaited handler code runs — so
10
+ * `b.log.getRequestId()` reads null inside the handler. asyncContext:true binds
11
+ * the id with `AsyncLocalStorage.enterWith` (via b.log.enterRequestId) so it
12
+ * persists forward across the awaited route chain, while each request stays
13
+ * isolated because the HTTP server runs each request in its own async context.
14
+ *
15
+ * Every request below goes through a real http.Server so the enterWith binding
16
+ * is scoped to the request's own async context (driving app.handle() directly
17
+ * from the test would leak the binding into the test runner's context — the
18
+ * exact non-isolated manual-dispatch case enterWith is NOT meant for).
19
+ */
20
+
21
+ var helpers = require("../helpers");
22
+ var http = require("node:http");
23
+ var b = helpers.b;
24
+ var check = helpers.check;
25
+
26
+ // Spin up a one-route app on an ephemeral port, fire a single GET with the
27
+ // given inbound id, resolve { body, hdr }, then close the server.
28
+ function _serveOnce(mwOpts, id) {
29
+ var app = b.router.create();
30
+ app.use(b.middleware.requestId(mwOpts));
31
+ app.get("/x", async function (req, res) {
32
+ // Cross an event-loop turn (a real awaited gap) before reading the ALS, so
33
+ // this exercises the boolean-next-dispatch survival the issue is about.
34
+ await new Promise(function (r) { setImmediate(r); });
35
+ res.end(JSON.stringify({ reqField: req.requestId, als: b.log.getRequestId() }));
36
+ });
37
+ var srv = http.createServer(function (req, res) { app.handle(req, res); });
38
+ return new Promise(function (resolve, reject) {
39
+ srv.listen(0, function () {
40
+ var port = srv.address().port;
41
+ http.get({ port: port, path: "/x", agent: false, headers: id ? { "x-request-id": id } : {} }, function (r) {
42
+ var d = ""; r.on("data", function (c) { d += c; });
43
+ // Await the server close so the listening handle is released before the
44
+ // test resolves — a fire-and-forget srv.close() leaves a TCPServerWrap
45
+ // lingering (the leak the harness audit flags).
46
+ r.on("end", function () { srv.close(function () { resolve({ body: JSON.parse(d), hdr: r.headers["x-request-id"] }); }); });
47
+ }).on("error", function (e) { srv.close(function () { reject(e); }); });
48
+ });
49
+ });
50
+ }
51
+
52
+ function testSurface() {
53
+ check("b.log.enterRequestId is a function", typeof b.log.enterRequestId === "function");
54
+ check("requestId factory is a function", typeof b.middleware.requestId === "function");
55
+ }
56
+
57
+ // asyncContext:true binds the id into the log ALS so an awaited handler reads
58
+ // it via b.log.getRequestId().
59
+ async function testAsyncContextBindsAls() {
60
+ var r = await _serveOnce({ asyncContext: true }, "abcdefgh-async-1");
61
+ check("asyncContext: req.requestId set", r.body.reqField === "abcdefgh-async-1");
62
+ check("asyncContext: getRequestId() works in awaited handler", r.body.als === "abcdefgh-async-1");
63
+ check("asyncContext: response header reflected", r.hdr === "abcdefgh-async-1");
64
+ }
65
+
66
+ // Default mode binds nothing — req.requestId is set but the ALS stays null.
67
+ async function testDefaultModeDoesNotBindAls() {
68
+ var r = await _serveOnce({}, "plain-no-async-2");
69
+ check("default: req.requestId still set", r.body.reqField === "plain-no-async-2");
70
+ check("default: ALS NOT bound (null)", r.body.als === null);
71
+ }
72
+
73
+ // The correctness crux: concurrent requests through a real server each see
74
+ // their OWN id via the ALS, with no cross-request bleed.
75
+ async function testConcurrentRequestsIsolated() {
76
+ var app = b.router.create();
77
+ app.use(b.middleware.requestId({ asyncContext: true }));
78
+ app.get("/x", async function (req, res) {
79
+ // A real awaited gap; the concurrency comes from the concurrent http.get
80
+ // calls below (each request its own async context on a real server).
81
+ await new Promise(function (r) { setImmediate(r); });
82
+ res.end(JSON.stringify({ als: b.log.getRequestId() }));
83
+ });
84
+ var srv = http.createServer(function (req, res) { app.handle(req, res); });
85
+ await new Promise(function (r) { srv.listen(0, r); });
86
+ var port = srv.address().port;
87
+ function get(id) {
88
+ return new Promise(function (resolve) {
89
+ http.get({ port: port, path: "/x", agent: false, headers: { "x-request-id": id } }, function (r) {
90
+ var d = ""; r.on("data", function (c) { d += c; });
91
+ r.on("end", function () { resolve({ id: id, body: JSON.parse(d), hdr: r.headers["x-request-id"] }); });
92
+ });
93
+ });
94
+ }
95
+ var results = await Promise.all([
96
+ get("req-AAAAAA01"), get("req-BBBBBB02"), get("req-CCCCCC03"),
97
+ get("req-DDDDDD04"), get("req-EEEEEE05"),
98
+ ]);
99
+ await new Promise(function (r) { srv.close(r); }); // release the listening handle
100
+ var allIsolated = results.every(function (r) { return r.body.als === r.id; });
101
+ check("concurrent: every request's ALS id is its own (no bleed)", allIsolated);
102
+ var hdrsMatch = results.every(function (r) { return r.hdr === r.id; });
103
+ check("concurrent: every response header matches its request", hdrsMatch);
104
+ }
105
+
106
+ async function run() {
107
+ testSurface();
108
+ await testAsyncContextBindsAls();
109
+ await testDefaultModeDoesNotBindAls();
110
+ await testConcurrentRequestsIsolated();
111
+ }
112
+
113
+ module.exports = { run: run };
114
+ if (require.main === module) {
115
+ run().then(function () { console.log("OK"); })
116
+ .catch(function (e) { console.error(e); process.exit(1); });
117
+ }
@@ -65,6 +65,68 @@ function testErrorClassExported() {
65
65
  check("SafePathError exported", typeof b.safePath.SafePathError === "function");
66
66
  }
67
67
 
68
+ // #371 — opts.platform gates the per-segment naming rules AND the lexical
69
+ // containment resolution. The lexical resolve + boundary use the TARGET
70
+ // platform's path module (nodePath.win32 / nodePath.posix) so the resolved
71
+ // output's separator matches the boundary slice. Validating against the
72
+ // OPPOSITE platform's rules (the recommended cross-platform pattern) used to
73
+ // refuse every in-base path with safe-path/escapes-base because the boundary
74
+ // slice compared the runtime-separated nodePath.resolve output against the
75
+ // opts.platform separator. With target-platform resolution the in-base path is
76
+ // accepted AND a genuine traversal is still refused under any override.
77
+ function testCrossPlatformContainment() {
78
+ var nodePath = require("node:path");
79
+ var other = process.platform === "win32" ? "linux" : "windows";
80
+ var pathMod = other === "windows" ? nodePath.win32 : nodePath.posix;
81
+ // Drive-prefixed base on Windows, posix base elsewhere.
82
+ var base = process.platform === "win32" ? "C:/srv/uploads" : "/srv/uploads";
83
+
84
+ var inBase = b.safePath.resolveOrNull(base, "file.txt", { platform: other });
85
+ check("opposite-platform override resolves an in-base file (not null)",
86
+ typeof inBase === "string" && inBase.indexOf("file.txt") !== -1);
87
+ var nested = b.safePath.resolveOrNull(base, "a/b/file.txt", { platform: other });
88
+ check("opposite-platform override resolves a nested in-base path",
89
+ typeof nested === "string" && nested.indexOf("file.txt") !== -1);
90
+ var v = b.safePath.validate(base, "data/x.json", { platform: other });
91
+ check("opposite-platform override validate() ok=true", v.ok === true && typeof v.resolved === "string");
92
+ // Containment is still ENFORCED under the override — a real traversal refused.
93
+ check("opposite-platform override still refuses a forward-slash traversal",
94
+ b.safePath.resolveOrNull(base, "../../etc/passwd", { platform: other }) === null);
95
+ // The containment boundary uses the TARGET platform's separator, so the
96
+ // resolved path begins with the target-resolved base.
97
+ check("resolved path begins with the target-resolved base",
98
+ typeof inBase === "string" && inBase.indexOf(pathMod.resolve(base)) === 0);
99
+ }
100
+
101
+ // #371 P1 — cross-platform backslash traversal. A POSIX host validating with
102
+ // opts.platform: "windows" must collapse Windows separators (\) and `..` the
103
+ // SAME way the per-segment walk does. The lexical resolve previously used the
104
+ // runtime path module: on POSIX, node:path treats \ as an ordinary filename
105
+ // character, so `ok\..\..\outside` slipped past containment and resolved to
106
+ // `<base>/ok\..\..\outside` — a path that escapes the base once a Windows
107
+ // consumer interprets the backslashes. Validating FOR windows now resolves with
108
+ // nodePath.win32 on every host, so the traversal is refused. (On a Windows host
109
+ // win32 IS the runtime, so this also guards the same case there.)
110
+ function testCrossPlatformBackslashTraversalRefused() {
111
+ var BS = String.fromCharCode(92); // backslash without source-escaping ambiguity
112
+ var base = "/srv/uploads";
113
+ var trav = "ok" + BS + ".." + BS + ".." + BS + "outside"; // ok\..\..\outside
114
+ check("windows-target backslash traversal refused (resolveOrNull → null)",
115
+ b.safePath.resolveOrNull(base, trav, { platform: "windows" }) === null);
116
+ var v = b.safePath.validate(base, trav, { platform: "windows" });
117
+ check("windows-target backslash traversal refused (validate ok=false)",
118
+ v.ok === false && v.code === "safe-path/escapes-base");
119
+ var threw = false, code = null;
120
+ try { b.safePath.resolve(base, trav, { platform: "windows" }); }
121
+ catch (e) { threw = true; code = e && e.code; }
122
+ check("windows-target backslash traversal refused (resolve throws escapes-base)",
123
+ threw === true && code === "safe-path/escapes-base");
124
+ // A nested-but-in-base Windows path with backslashes still resolves.
125
+ var ok = b.safePath.resolveOrNull(base, "a" + BS + "b" + BS + "c.txt", { platform: "windows" });
126
+ check("windows-target in-base backslash path resolves",
127
+ typeof ok === "string" && ok.indexOf("c.txt") !== -1);
128
+ }
129
+
68
130
  function run() {
69
131
  testHappyPath();
70
132
  testRefusalClasses();
@@ -72,6 +134,8 @@ function run() {
72
134
  testResolveOrNullReturnsNull();
73
135
  testValidateReturnsVerdict();
74
136
  testErrorClassExported();
137
+ testCrossPlatformContainment();
138
+ testCrossPlatformBackslashTraversalRefused();
75
139
  }
76
140
 
77
141
  if (require.main === module) run();