@blamejs/core 0.13.29 → 0.13.31
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 +4 -0
- package/lib/circuit-breaker.js +6 -4
- package/lib/mcp.js +2 -3
- package/lib/retry.js +26 -0
- package/lib/safe-decompress.js +3 -2
- package/lib/safe-icap.js +3 -2
- package/lib/safe-mime.js +1 -1
- package/lib/safe-smtp.js +1 -1
- package/lib/vault-aad.js +2 -2
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.13.x
|
|
10
10
|
|
|
11
|
+
- v0.13.31 (2026-05-28) — **Circuit-breaker onStateChange callback now fires; mcp / vault-aad doc corrections.** b.circuitBreaker documented an `onStateChange` callback (both an option and an `onStateChange(handler)` registration method) plus a state-change payload, but the callback was never invoked — only an observability event fired. The callback is now implemented: it fires on every transition with `{ name, from, to, at }`, the registration method works, and a non-function handler is rejected at construction. The same primitive's docs are corrected to name the real accessor (`getState()`, not `state()`) and drop a never-read `audit` option. Plus two doc-only corrections: b.mcp.toolResult.sanitize described composing b.guardHtml / b.ai.input.classify (it uses built-in detection) and documented a `classifyInput` option it never read; and b.vault.aad's prose said HKDF-SHAKE256 where the derivation is SHAKE256 (the AEAD AAD-binding itself is unchanged and sound). **Fixed:** *`b.circuitBreaker` onStateChange callback is invoked on every transition* — The `onStateChange` option and the `onStateChange(handler)` registration method are now wired: each registered handler is called with `{ name, from, to, at }` on every state transition (closed→open, open→half, half→closed/open), alongside the existing `breaker.state.change` observability event. A non-function `onStateChange` is rejected at construction. Previously the documented callback never fired. The docs are also corrected to name the real state accessor `getState()` (there is a `state` property, so `state()` was never a method) and to drop a never-read `audit` option. · *`b.mcp.toolResult.sanitize` documents its actual detection and options* — The prose said the sanitizer composes `b.guardHtml`'s strict profile and `b.ai.input.classify`; it uses built-in dangerous-HTML and prompt-injection-marker detection. The `@opts` also listed a `classifyInput` override the function never read. The prose now describes the built-in detection and the unwired `classifyInput` option is removed. The fail-closed refusal behavior (default `posture: "refuse"`) is unchanged. · *`b.vault.aad` derivation named correctly (SHAKE256)* — The module prose described the per-binding key derivation as HKDF-SHAKE256; it is SHAKE256 over the vault root concatenated with the binding inputs (no HKDF extract/expand). The AEAD AAD-binding to (table, row, column, schema version) — the file's actual security guarantee — is unchanged and sound; only the KDF name in the doc was wrong.
|
|
12
|
+
|
|
13
|
+
- v0.13.30 (2026-05-28) — **Doc corrections in the safe-* parsers (defaults, an error code, an example, a status list).** Four documentation corrections in the safe-* input parsers; no code behavior changed. The parsers' enforced limits and controls are unchanged — these align the docs with what the code already does. b.safeMime.parse's documented default transfer-encoding allowlist listed `binary`, which is excluded by default (opt-in per RFC 3030 BINARYMIME). b.safeDecompress documented a refusal code (`output-too-large`) it never emits — an absolute-size bomb surfaces under `decompress-failed`. b.safeSmtp.findDotTerminator's example output was off by one. b.safeIcap's intro status-code summary omitted 404 / 405 / 408 (the detailed block already listed them). **Fixed:** *`b.safeMime.parse` documents the actual default transfer-encoding allowlist* — The `@opts` default listed `7bit/8bit/binary/qp/base64`, but `binary` is deliberately excluded by default (RFC 3030 BINARYMIME is opt-in); the default is `7bit/8bit/quoted-printable/base64`. The doc now matches, so operators don't expect inbound `Content-Transfer-Encoding: binary` parts to pass without opting in. · *`b.safeDecompress` names the real absolute-size-bomb refusal code* — The refusal-posture list documented `safe-decompress/output-too-large` for a bomb-by-absolute-size, but that code is never emitted — zlib's `maxOutputLength` throws before allocation and the failure surfaces as `safe-decompress/decompress-failed`. The doc now names the code an operator branching on the result will actually see (the ratio, output-byte, and compressed-input caps are unchanged and enforced). · *`b.safeSmtp.findDotTerminator` example output corrected* — The example claimed the `\r\n.\r\n` terminator in `"Hello world.\r\n.\r\n"` is at index 13; it is at index 12. The example now shows 12 (the implementation was already correct). · *`b.safeIcap` intro status-code summary lists 404 / 405 / 408* — The intro summary said only `100 / 200 / 204 / 400 / 403 / 5xx` are honored, but the parser also accepts `404 / 405 / 408` (legitimate RFC 3507 §4.3.3 codes, already listed in the detailed `parse` block). The intro summary now matches.
|
|
14
|
+
|
|
11
15
|
- v0.13.29 (2026-05-28) — **Doc corrections: AI Act disclosure kind values, SQS queue model, age-gate coupling.** Documentation corrections. The most actionable: b.middleware.aiActDisclosure's @opts listed two EU AI Act transparency `kind` values (`deepfake` and `synthetic-content`) that the middleware does not accept, so they threw at construction; the accepted values use the hyphenated Art. 50 spellings (e.g. `deep-fake`) and include a text-public-interest variant the enum omitted — an operator copying the documented values crashed a compliance middleware at boot. The b.queue docs implied the SQS backend is driven by the generic b.queue.consume loop like local/redis; SQS is actually an SQS-native adapter (complete/fail by message receipt handle, server-side redrive) driven directly, and the docs now say so. b.middleware.ageGate's `requireAge` 451 floor is documented as taking effect only alongside `consentRequired` (it was silently inert without it). Plus a compose-pipeline @since and a flag-context @related correction. No code behavior changed. **Fixed:** *`b.middleware.aiActDisclosure` documents the accepted `kind` values* — The `@opts` listed `kind` as `ai-interaction | deepfake | emotion-recognition | biometric-categorisation | synthetic-content`. Two of those — `deepfake` and `synthetic-content` — are not accepted and threw at construction; the EU AI Act Art. 50 values use hyphenated spellings (e.g. `deep-fake`, the generated-content variant) and include `ai-text-public-interest`, which the documented enum omitted. The `@opts` now lists the full set the middleware accepts. · *`b.queue` SQS backend documented as SQS-native, not consume-driven* — The module docs implied the `sqs` backend is interchangeable with `local`/`redis` under the generic `b.queue.consume` loop. SQS is an SQS-native adapter: `complete` / `fail` act on the message's `receiptHandle` (returned by `lease()`, threaded back by the caller), and DLQ + visibility-expiry are handled server-side by the queue's RedrivePolicy. The docs now state that `sqs` is driven directly (lease → handle → complete/fail) rather than by `b.queue.consume`, and does not use the framework DLQ / sweep. · *`b.middleware.ageGate` documents the `requireAge` / `consentRequired` coupling* — `requireAge` (the HTTP 451 legal floor) is evaluated within the consent classification, so it takes effect only when `consentRequired` is also set — `requireAge` alone, with `consentRequired: null`, never classifies a request as below-threshold and the 451 never fires. The `@opts` and prose now state this coupling instead of presenting `requireAge` as a standalone threshold. · *Smaller doc corrections* — `b.middleware.composePipeline`'s `@since` is corrected to 0.9.43 (its actual ship version). `b.middleware.flagContext`'s `@related` pointed at a non-existent `b.flagClient.getBoolean`; it now references `b.flag.create`.
|
|
12
16
|
|
|
13
17
|
- v0.13.28 (2026-05-28) — **Queue retry backoff now applies on the Redis backend; static-serve path-containment edge closed.** Two behavioral fixes plus doc corrections. The Redis queue backend silently discarded the documented retry backoff: b.queue.consume passes the delay as `{ retryDelayMs }` (the shape the local backend reads), but the Redis backend's fail() accepted only a bare-number third argument, so the object failed its numeric check and the delay was forced to 0 — a failing job re-leased immediately instead of waiting 1s/2s/4s/…, a retry storm under failure. The Redis backend now accepts the object form, so the exponential backoff applies as documented (verified by an integration test against real Redis). Separately, b.router.serveStatic's path-containment check used a bare string prefix, so a sibling directory whose name extends the root (root `/srv/public` vs `/srv/public-evil`) could pass; it now anchors on a path separator. Also: b.fileUpload now surfaces (via an observability counter) when a configured content-safety gate is skipped because an upload streamed past the reassembly cap, and documents that boundary; and b.cookies.parse's example output is corrected. **Fixed:** *Redis queue backend honors the documented retry backoff* — `b.queue.consume` re-pends a failed job with deterministic exponential backoff (1s base, 5min cap) by calling the backend's `fail()` with `{ retryDelayMs }`. The Redis backend's `fail()` accepted only a bare-number third argument, so the object failed its `typeof === "number"` check and the delay was reset to 0 — a failing job became immediately re-leasable, hot-looping instead of backing off. `fail()` now accepts both the object form (as the local backend does) and a bare number, so the backoff applies on Redis. An integration test against real Redis pins it. · *`b.router.serveStatic` path-containment anchors on a separator* — The containment check was `resolvedPath.startsWith(root)`, which a sibling directory sharing the root's name as a prefix (root `/srv/public` vs `/srv/public-evil`) could satisfy. It now requires the resolved path to equal `root` or start with `root + path.sep`, closing the sibling-prefix edge (`b.staticServe.create` remains the hardened serving path, with realpath + filename gating). · *`b.fileUpload` surfaces content-safety gate skips on oversized streamed uploads* — The byte-level content-safety gate inspects the reassembled buffer, so it runs on uploads up to `maxStreamReassemblyBytes` (default 64 MiB); a larger upload is handed to `onFinalize` as a stream and the byte-content gate is skipped (MIME-sniff and filename gates still run). That skip now emits a `fileUpload.content_safety_skipped_streamed` observability counter instead of passing silently, and the limit is documented. To guarantee content-gating of a type, cap `maxFileBytes` at or below `maxStreamReassemblyBytes`. · *`b.cookies.parse` example output corrected* — The example claimed `theme=%22dark%22` parses to `theme: "dark"`, but quote-stripping runs before percent-decoding, so the literal quotes survive. The example now uses `theme=dark%20mode` → `theme: "dark mode"`, which demonstrates percent-decoding without the quote-strip-ordering quirk.
|
package/lib/circuit-breaker.js
CHANGED
|
@@ -36,8 +36,10 @@ var retryHelper = require("./retry");
|
|
|
36
36
|
* Build a circuit-breaker. Returns a CircuitBreaker instance with
|
|
37
37
|
* `wrap(fn)` (executes `fn` if the breaker is closed; throws an
|
|
38
38
|
* `Error` with `code: "CIRCUIT_OPEN"` + `isObjectStoreError: true` +
|
|
39
|
-
* `permanent: false` when open), `
|
|
40
|
-
* `onStateChange(handler)` listener registration
|
|
39
|
+
* `permanent: false` when open), `getState()`, `reset()`, and
|
|
40
|
+
* `onStateChange(handler)` listener registration (the handler, and the
|
|
41
|
+
* `onStateChange` opt, receive `{ name, from, to, at }` on every
|
|
42
|
+
* transition). Pass-through
|
|
41
43
|
* factory: identical instance shape to `b.retry.CircuitBreaker`,
|
|
42
44
|
* with the framework's `create(opts)` vocabulary.
|
|
43
45
|
*
|
|
@@ -53,8 +55,8 @@ var retryHelper = require("./retry");
|
|
|
53
55
|
* failureThreshold: number, // failures in the closed state before opening
|
|
54
56
|
* cooldownMs: number, // milliseconds the breaker stays open before probing
|
|
55
57
|
* successThreshold: number, // probe successes required to close from half-open
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
+
* onStateChange: Function, // ({ name, from, to, at }) → void; also emits the
|
|
59
|
+
* // `breaker.state.change` observability event
|
|
58
60
|
*
|
|
59
61
|
* @example
|
|
60
62
|
* var cb = b.circuitBreaker.create({
|
package/lib/mcp.js
CHANGED
|
@@ -399,10 +399,10 @@ function serverGuard(opts) {
|
|
|
399
399
|
* exfiltration endpoints. The framework's defense:
|
|
400
400
|
*
|
|
401
401
|
* - Strip / refuse executable HTML (`<script>` / `<iframe>` /
|
|
402
|
-
* `javascript:` URLs)
|
|
402
|
+
* `javascript:` URLs) via built-in dangerous-HTML detection
|
|
403
403
|
* - Refuse known prompt-injection markers ("ignore previous
|
|
404
404
|
* instructions", "system: you are now ...", role-claim prefixes)
|
|
405
|
-
*
|
|
405
|
+
* via a built-in injection-marker matcher
|
|
406
406
|
* - Cap text length so a tool can't blow the host's context window
|
|
407
407
|
* out from under it
|
|
408
408
|
* - Refuse content with `image_url` / `audio_url` / `resource_link`
|
|
@@ -418,7 +418,6 @@ function serverGuard(opts) {
|
|
|
418
418
|
* posture?: "refuse" | "sanitize" | "audit-only", // default "refuse"
|
|
419
419
|
* maxTextBytes?: number, // default 64 KiB per content block
|
|
420
420
|
* allowedHosts?: string[], // for image/audio/resource_link refs
|
|
421
|
-
* classifyInput?: fn(text)→{verdict, score} | null, // default b.ai.input.classify
|
|
422
421
|
* }
|
|
423
422
|
*
|
|
424
423
|
* @example
|
package/lib/retry.js
CHANGED
|
@@ -187,6 +187,10 @@ function _validateBreakerOpts(name, opts) {
|
|
|
187
187
|
throw new TypeError("retry.CircuitBreaker: successThreshold must be a positive integer, got " +
|
|
188
188
|
typeof opts.successThreshold + " " + JSON.stringify(opts.successThreshold));
|
|
189
189
|
}
|
|
190
|
+
if (opts.onStateChange != null && typeof opts.onStateChange !== "function") {
|
|
191
|
+
throw new TypeError("retry.CircuitBreaker: onStateChange must be a function, got " +
|
|
192
|
+
typeof opts.onStateChange);
|
|
193
|
+
}
|
|
190
194
|
}
|
|
191
195
|
|
|
192
196
|
// ---- Public surface ----
|
|
@@ -381,6 +385,19 @@ class CircuitBreaker {
|
|
|
381
385
|
this.consecutiveFailures = 0;
|
|
382
386
|
this.consecutiveSuccesses = 0;
|
|
383
387
|
this.openedAt = 0;
|
|
388
|
+
this._stateListeners = [];
|
|
389
|
+
if (typeof merged.onStateChange === "function") this._stateListeners.push(merged.onStateChange);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Register a state-change listener. Called with { name, from, to, at }
|
|
393
|
+
// on every transition (same payload the constructor's onStateChange
|
|
394
|
+
// opt receives). Returns this for chaining.
|
|
395
|
+
onStateChange(handler) {
|
|
396
|
+
if (typeof handler !== "function") {
|
|
397
|
+
throw new TypeError("retry.CircuitBreaker.onStateChange: handler must be a function, got " + typeof handler);
|
|
398
|
+
}
|
|
399
|
+
this._stateListeners.push(handler);
|
|
400
|
+
return this;
|
|
384
401
|
}
|
|
385
402
|
|
|
386
403
|
// Wrap an async function. The breaker observes outcomes and may fail-fast.
|
|
@@ -415,7 +432,16 @@ class CircuitBreaker {
|
|
|
415
432
|
_transition(from, to) {
|
|
416
433
|
if (from === to) return;
|
|
417
434
|
this.state = to;
|
|
435
|
+
var at = Date.now();
|
|
418
436
|
_emitEvent("breaker.state.change", 1, { name: this.name, from: from, to: to });
|
|
437
|
+
if (this._stateListeners.length > 0) {
|
|
438
|
+
var payload = { name: this.name, from: from, to: to, at: at };
|
|
439
|
+
for (var i = 0; i < this._stateListeners.length; i++) {
|
|
440
|
+
// Best-effort: a throwing listener must not derail the breaker's
|
|
441
|
+
// own state machine (the transition has already been applied).
|
|
442
|
+
try { this._stateListeners[i](payload); } catch (_e) { /* drop-silent */ }
|
|
443
|
+
}
|
|
444
|
+
}
|
|
419
445
|
}
|
|
420
446
|
|
|
421
447
|
_onSuccess() {
|
package/lib/safe-decompress.js
CHANGED
|
@@ -37,8 +37,9 @@
|
|
|
37
37
|
* - Any algorithm not in the allowlist (including operator-typo'd).
|
|
38
38
|
*
|
|
39
39
|
* Refusal posture:
|
|
40
|
-
* - `safe-decompress/
|
|
41
|
-
* (zlib's own `maxOutputLength`
|
|
40
|
+
* - `safe-decompress/decompress-failed` — bomb-by-absolute-size
|
|
41
|
+
* (zlib's own `maxOutputLength` refuses before alloc; the throw is
|
|
42
|
+
* caught and surfaced under this code)
|
|
42
43
|
* - `safe-decompress/ratio-exceeded` — expansion > `maxRatio`
|
|
43
44
|
* (zlib accepted the bytes; our post-decompress ratio check
|
|
44
45
|
* refuses, freeing the bytes immediately)
|
package/lib/safe-icap.js
CHANGED
|
@@ -37,8 +37,9 @@
|
|
|
37
37
|
* `\r\n`; intermediaries that accept bare-LF then desync against
|
|
38
38
|
* this parser).
|
|
39
39
|
* - **Status-code allowlist** — only `100` / `200` / `204` / `400`
|
|
40
|
-
* / `403` / 5xx are honored. RFC 3507
|
|
41
|
-
* the legal ICAP response codes; an
|
|
40
|
+
* / `403` / `404` / `405` / `408` / 5xx are honored. RFC 3507
|
|
41
|
+
* §4.3.3 enumerates these as the legal ICAP response codes; an
|
|
42
|
+
* unexpected `1xx` continuation
|
|
42
43
|
* or `3xx` redirect is refused because it's a classic header-
|
|
43
44
|
* injection class (attacker smuggles `ICAP/1.0 100 X-Inject:`
|
|
44
45
|
* through a permissive proxy).
|
package/lib/safe-mime.js
CHANGED
|
@@ -125,7 +125,7 @@ var DEFAULT_TRANSFER_ENCODINGS = Object.freeze([
|
|
|
125
125
|
* maxBodyBytes: number, // default 25 MiB
|
|
126
126
|
* maxMessageBytes: number, // default 50 MiB
|
|
127
127
|
* charsetAllowlist: string[], // default UTF-8 / US-ASCII / common legacy 8-bit
|
|
128
|
-
* transferEncodingAllowlist: string[], // default 7bit/8bit/
|
|
128
|
+
* transferEncodingAllowlist: string[], // default 7bit/8bit/quoted-printable/base64 (binary is opt-in, RFC 3030 BINARYMIME)
|
|
129
129
|
*
|
|
130
130
|
* @example
|
|
131
131
|
* var msg = b.safeMime.parse(messageBuffer);
|
package/lib/safe-smtp.js
CHANGED
|
@@ -61,7 +61,7 @@ var SafeSmtpError = defineClass("SafeSmtpError", { alwaysPermanent: true });
|
|
|
61
61
|
* @example
|
|
62
62
|
* var body = Buffer.from("Hello world.\r\n.\r\n");
|
|
63
63
|
* b.safeSmtp.findDotTerminator(body);
|
|
64
|
-
* // →
|
|
64
|
+
* // → 12 (index of \r in the terminating \r\n.\r\n)
|
|
65
65
|
*
|
|
66
66
|
* b.safeSmtp.findDotTerminator(Buffer.from("incomplete body"));
|
|
67
67
|
* // → -1
|
package/lib/vault-aad.js
CHANGED
|
@@ -35,8 +35,8 @@
|
|
|
35
35
|
* .isAadSealed(value) → boolean
|
|
36
36
|
*
|
|
37
37
|
* Per the framework's security-first stance:
|
|
38
|
-
* - Symmetric key derivation uses
|
|
39
|
-
*
|
|
38
|
+
* - Symmetric key derivation uses SHAKE256 (matching the vault's
|
|
39
|
+
* KDF) over the vault root key concatenated with the
|
|
40
40
|
* canonicalized AAD.
|
|
41
41
|
* - AEAD: XChaCha20-Poly1305 with the AAD threaded into the tag.
|
|
42
42
|
* - 24-byte nonce, generated fresh per-seal via
|
package/package.json
CHANGED
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.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:8b9568ba-9033-4b96-9ef8-bf087633e14d",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
8
|
+
"timestamp": "2026-05-29T01:34:10.018Z",
|
|
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.13.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.13.31",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.13.
|
|
25
|
+
"version": "0.13.31",
|
|
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.13.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.13.31",
|
|
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.13.
|
|
57
|
+
"ref": "@blamejs/core@0.13.31",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|