@blamejs/core 0.13.27 → 0.13.29

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,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.13.x
10
10
 
11
+ - 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
+
13
+ - 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.
14
+
11
15
  - v0.13.27 (2026-05-28) — **Documentation corrected across api-encrypt, mail-crypto, mail-store, and calendar.** A set of JSDoc corrections where the documented contract had drifted from the code. The most actionable: b.middleware.apiEncrypt's @opts named the keypair fields secretKey / ecSecretKey, but the middleware requires privateKey / ecPrivateKey (the shape b.crypto.generateEncryptionKeyPair returns), so a keypair built from the docs threw INVALID_KEYPAIR at construction; the same block documented a wrong custom-nonceStore interface and an example calling a non-existent b.crypto.keypair(). Also corrected: the b.mail.crypto facade and the PGP module described S/MIME sign/verify and PGP encrypt/decrypt/WKD as deferred when they ship and are live; b.mail.crypto.smime.checkCert documented a return shape whose field names did not match what it returns; and b.mailStore.create listed a destroy method it does not expose. No code behavior changed — only the docs were wrong. **Fixed:** *`b.middleware.apiEncrypt` options documented with the correct field names* — The `@opts` listed the keypair as `{ publicKey, secretKey, ecPublicKey, ecSecretKey }`, but the middleware requires `{ publicKey, privateKey, ecPublicKey, ecPrivateKey }` — the shape `b.crypto.generateEncryptionKeyPair()` returns — and threw `INVALID_KEYPAIR` for the documented shape. The custom `nonceStore` interface was documented as `{ has, add, prune }` but the middleware calls `{ checkAndInsert, purgeExpired, close }`, and the example called a non-existent `b.crypto.keypair()`. All three now match the implementation (`b.crypto.generateEncryptionKeyPair()`). · *`b.mail.crypto` S/MIME and PGP availability described accurately* — The `b.mail.crypto` facade said S/MIME `sign()` / `verify()` were deferred, and the PGP module's intro said in-process encrypt / decrypt and WKD discovery would 'ship in v0.10.14'. All of these are implemented and live (PGP encrypt/decrypt/WKD were promoted to the stable surface in v0.11.32; S/MIME sign/verify/verifyAll run on the `b.cms` substrate). The docs now describe them as available; the genuinely-deferred PGP v6-signature-packet support remains noted as deferred. · *`b.mail.crypto.smime.checkCert` return shape documented correctly* — The doc and example described the result as `{ subjectCN, issuerCN, validFrom, validTo, keyAlg, keyBits, sigAlg }`; the function returns `{ subject, issuer, validFrom, validTo, sigAlgName, sigAlgOid, keyType, fingerprint256 }` (full DN strings, no key-size field). The documented shape now matches. · *`b.mailStore.create` method list matches the returned handle* — The doc listed a `destroy` method the handle does not expose and omitted `search` / `moveMessages` / `hardExpunge` that it does. The list now reflects the actual methods. · *Smaller doc corrections* — `b.calendar` fromIcal / toIcal / validate now document that they also handle Task (VTODO), Note (VJOURNAL), and Group, not just Event. `b.middleware.requireAuth`'s `prefersJson` default is documented as Accept / X-Requested-With only (Content-Type is intentionally not a signal, as the module already noted). The default CSP nonce is 24 base64 chars (16 bytes), not 22. `b.mail.bimi`'s returned `evidenceDocument` is noted as echoed from the operator-supplied option rather than pulled from the certificate.
12
16
 
13
17
  - v0.13.26 (2026-05-28) — **`b.cryptoField.unsealRow` nulls a sealed column on unseal failure instead of returning the forged ciphertext.** When a sealed column failed to unseal — a DB-write attacker's forged `vault:<…>` payload, or a valid ciphertext copied into a different row so the AAD no longer matches — unsealRow recorded the failure on the audit chain but then kept the original attacker-crafted string in the field rather than nulling it, despite the documented contract that downstream sees 'no value'. A write-back guard discarded the intended null on the failure path. The column is now nulled on any unseal failure, so a forged or cross-row-copied value never reaches downstream code as if it were a real plaintext. Valid values round-trip unchanged and genuinely-unsealed pass-through values are still kept. This hardens every sealed-column reader, including the agent idempotency / orchestrator / tenant rows sealed in 0.13.25. **Fixed:** *Audit checkpoint docs name the actual signature algorithm* — `b.audit.checkpoint` / `b.audit.verifyCheckpoints` described the anchor signature as ML-DSA-87, but the checkpoint is signed with the configured `b.auditSign` algorithm — SLH-DSA-SHAKE-256f by default (ML-DSA-87 / ML-DSA-65 are opt-in). The docs and the verify-failure reason now refer to the post-quantum signature without naming a specific algorithm the operator may not be using. · *`b.storage.chunkScratch` example and assembly description corrected* — The `assemble()` example omitted the mandatory `chunkEncryptionKeys` argument (one sealed key per chunk, returned by `saveChunk`), so it would have thrown as written; it now collects and passes the keys. The prose no longer claims the primitive writes a final file with an 'atomic finalize' — `assemble()` concatenates the chunks in order and returns the assembled bytes for the caller to persist. **Security:** *Sealed columns are nulled on unseal failure (forged / cross-row ciphertext)* — `b.cryptoField.unsealRow` now nulls a sealed field when its value fails to unseal — a crafted `vault:`/`vault.aad:` payload written by a DB-write attacker, or a valid ciphertext copied to a different row (AAD mismatch). Previously the field kept the attacker-controlled string, so downstream code could read the forged ciphertext as if it were the plaintext. The audit emit (`system.crypto.unseal_failed`) is unchanged. Valid round-trips and not-actually-sealed pass-through values are unaffected. A regression test pins the forged-value, cross-row-copy, and pass-through cases.
package/lib/cookies.js CHANGED
@@ -127,8 +127,8 @@ function _scrubAttr(s) {
127
127
  * `parseSafe`.
128
128
  *
129
129
  * @example
130
- * var jar = b.cookies.parse("session=abc; theme=%22dark%22");
131
- * // → { session: "abc", theme: "dark" }
130
+ * var jar = b.cookies.parse("session=abc; theme=dark%20mode");
131
+ * // → { session: "abc", theme: "dark mode" } // percent-decoded
132
132
  */
133
133
  function parse(cookieHeader) {
134
134
  var out = {};
@@ -18,7 +18,14 @@
18
18
  * for content gating and `b.guardFilename.gate({ profile: "strict" })`
19
19
  * for filename gating. Operators opt out via `contentSafety: null`
20
20
  * / `filenameSafety: null` (audited at create time so a security
21
- * review can find the disabled-on-deploy rows). Per-chunk hooks
21
+ * review can find the disabled-on-deploy rows). The byte-level
22
+ * content gate inspects the reassembled buffer, so it runs on uploads
23
+ * up to `maxStreamReassemblyBytes` (default 64 MiB); a larger upload
24
+ * is handed to `onFinalize` as a stream and the byte-content gate is
25
+ * skipped (MIME-sniff + filename gates still run, and the skip emits a
26
+ * `fileUpload.content_safety_skipped` warning audit). To guarantee
27
+ * content-gating of a type, cap `maxFileBytes` at or below
28
+ * `maxStreamReassemblyBytes`. Per-chunk hooks
22
29
  * (`onChunk`) are the integration point for virus scanners and
23
30
  * schema-shape checks; rejecting from the hook surfaces as a
24
31
  * permanent `FileUploadError`.
@@ -1076,6 +1083,16 @@ function create(opts) {
1076
1083
  // Clear the streaming alias if present — sanitized fits in memory.
1077
1084
  bodyStream = null;
1078
1085
  }
1086
+ } else if (safetyGate && typeof safetyGate.check === "function" && !bodyBuffer) {
1087
+ // A content-safety gate is configured for this extension, but the
1088
+ // upload streamed past maxStreamReassemblyBytes and was never
1089
+ // reassembled into a buffer the byte-level gate can inspect. The
1090
+ // MIME-sniff and filename gates still ran; the per-extension
1091
+ // content gate did NOT. Surface it (rather than skipping silently)
1092
+ // via an observability counter so operators can alert, lower
1093
+ // maxStreamReassemblyBytes, or cap maxFileBytes to force
1094
+ // content-gating of this type.
1095
+ _emitObs("fileUpload.content_safety_skipped_streamed", 1, { ext: safetyExt });
1079
1096
  }
1080
1097
  }
1081
1098
 
@@ -53,16 +53,20 @@ var AgeGateError = defineClass("AgeGateError", { alwaysPermanent: true });
53
53
  * Classifies the request against an operator-supplied age predicate
54
54
  * and applies COPPA / UK Children's Code / California AADC defaults
55
55
  * (no-store cache, no-referrer, X-Privacy-Posture header) for
56
- * below-threshold + unknown-age requests. When `requireAge` is set
57
- * and the request is below threshold without a parental-consent
58
- * record, refuses with HTTP 451. Every classification decision is
59
- * audited.
56
+ * below-threshold + unknown-age requests. `requireAge` is the hard
57
+ * legal floor: a request classified below threshold without a
58
+ * parental-consent record is refused with HTTP 451. It is evaluated
59
+ * within the consent classification, so it takes effect only when
60
+ * `consentRequired` is also set (that is what classifies a request as
61
+ * below-threshold); `requireAge` alone, with `consentRequired: null`,
62
+ * never classifies a request as below-threshold and so the 451 never
63
+ * fires. Every classification decision is audited.
60
64
  *
61
65
  * @opts
62
66
  * {
63
67
  * getAge: function(req): number|null, // required
64
- * requireAge: number|null, // 451 below this
65
- * consentRequired: number|null, // require consent below
68
+ * requireAge: number|null, // 451 floor; requires consentRequired set
69
+ * consentRequired: number|null, // require consent below; enables below-threshold classification
66
70
  * hasParentalConsent: function(req): boolean,
67
71
  * skipPaths: string[],
68
72
  * errorMessage: string,
@@ -60,7 +60,7 @@ var audit = lazyRequire(function () { return require("../audit"); });
60
60
  *
61
61
  * @opts
62
62
  * {
63
- * kind: "ai-interaction"|"deepfake"|"emotion-recognition"|"biometric-categorisation"|"synthetic-content",
63
+ * kind: "ai-interaction"|"ai-generated-content"|"emotion-recognition"|"biometric-categorisation"|"deep-fake"|"ai-text-public-interest",
64
64
  * deployerName: string,
65
65
  * policyUri: string,
66
66
  * mode: "header"|"html", // default "header"
@@ -104,7 +104,7 @@ var CANONICAL_POSITIONS = Object.freeze({
104
104
  /**
105
105
  * @primitive b.middleware.composePipeline
106
106
  * @signature b.middleware.composePipeline(entries, opts?)
107
- * @since 0.9.44
107
+ * @since 0.9.43
108
108
  * @status stable
109
109
  * @related b.middleware.requestId, b.middleware.requireAuth, b.middleware.idempotencyKey
110
110
  *
@@ -34,7 +34,7 @@ var contextMod = lazyRequire(function () { return require("../flag-evaluation-co
34
34
  * @primitive b.middleware.flagContext
35
35
  * @signature b.middleware.flagContext(opts)
36
36
  * @since 0.1.0
37
- * @related b.flagClient.getBoolean
37
+ * @related b.flag.create
38
38
  *
39
39
  * Extracts an OpenFeature evaluation context onto `req.flagCtx` so
40
40
  * downstream handlers and multiple flag clients read a consistent
@@ -582,6 +582,15 @@ function create(opts) {
582
582
  async function fail(jobId, errorMessage, retryDelayMs) {
583
583
  await _ensureConnected();
584
584
  var nowMs = Date.now();
585
+ // b.queue.consume passes the object form `{ retryDelayMs }` (matching
586
+ // the queue-local backend); accept it as well as a bare-number third
587
+ // arg. Without this the object failed the `typeof === "number"` test
588
+ // below and the delay was forced to 0, so the documented exponential
589
+ // backoff was silently discarded and a failing job re-leased
590
+ // immediately on the redis backend (retry storm).
591
+ if (retryDelayMs && typeof retryDelayMs === "object") {
592
+ retryDelayMs = retryDelayMs.retryDelayMs;
593
+ }
585
594
  if (typeof retryDelayMs !== "number" || !isFinite(retryDelayMs) || retryDelayMs < 0) {
586
595
  retryDelayMs = 0;
587
596
  }
package/lib/queue.js CHANGED
@@ -17,6 +17,18 @@
17
17
  * and `nats` are listed as deferred and surface a clear error if
18
18
  * selected.
19
19
  *
20
+ * `local` and `redis` are driven by the generic `b.queue.consume`
21
+ * loop and the lifecycle below (framework-side leasing, deterministic
22
+ * backoff, DLQ, and the sweep timer). `sqs` is an SQS-native adapter
23
+ * with a different model: `complete` / `fail` delete or re-deliver by
24
+ * the message's `receiptHandle` (returned by `lease()`, threaded back
25
+ * by the caller), and DLQ + visibility-expiry are handled server-side
26
+ * by the SQS queue's RedrivePolicy — so `sqs` is driven directly
27
+ * (lease → handle → complete/fail), not by `b.queue.consume`, and it
28
+ * does not use the framework DLQ / sweep described below. See
29
+ * `lib/queue-sqs.js` for its action map and the features that require
30
+ * operator wiring.
31
+ *
20
32
  * Job lifecycle:
21
33
  * enqueued (status='pending', availableAt set by delaySeconds)
22
34
  * ↓ availableAt reached + consumer leases
@@ -271,7 +283,11 @@ function enqueue(queueName, payload, opts) {
271
283
  * through `handler(job, ctx)`. Handler resolution marks the job
272
284
  * `done`; rejection bumps the attempt counter and either re-pends
273
285
  * with deterministic exponential backoff (1s base, 5min cap, no
274
- * jitter) or routes to the DLQ when `attempts >= maxAttempts`.
286
+ * jitter) or routes to the DLQ when `attempts >= maxAttempts`. This
287
+ * loop drives the `local` and `redis` backends; the `sqs` backend uses
288
+ * SQS-native receipt-handle complete/fail and server-side redrive and
289
+ * is driven directly rather than by this consumer (see the module
290
+ * intro).
275
291
  *
276
292
  * Returns a consumer state handle whose `.cancel()` aborts the poll
277
293
  * loop immediately (without waiting for the next `pollIntervalMs`
package/lib/router.js CHANGED
@@ -1229,7 +1229,10 @@ function serveStatic(dir) {
1229
1229
  var rel = req.pathname;
1230
1230
  if (rel.includes("\0")) return next();
1231
1231
  var filePath = nodePath.resolve(nodePath.join(root, rel));
1232
- if (!filePath.startsWith(root)) return next();
1232
+ // Anchor on `root + sep` (not a bare prefix) so a sibling directory
1233
+ // that shares the root's name as a prefix — e.g. root `/srv/public`
1234
+ // vs `/srv/public-evil` — cannot satisfy the containment check.
1235
+ if (filePath !== root && !filePath.startsWith(root + nodePath.sep)) return next();
1233
1236
  if (!nodeFs.existsSync(filePath) || nodeFs.statSync(filePath).isDirectory()) return next();
1234
1237
 
1235
1238
  var ext = nodePath.extname(filePath).toLowerCase();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.13.27",
3
+ "version": "0.13.29",
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.5",
5
- "serialNumber": "urn:uuid:ffa5e6b8-d243-4ea1-813c-7fa23f7cf2ae",
5
+ "serialNumber": "urn:uuid:1b2ee4db-fd00-4369-8863-c2ad23ac201b",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-28T23:00:13.047Z",
8
+ "timestamp": "2026-05-29T00:21:21.126Z",
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.27",
22
+ "bom-ref": "@blamejs/core@0.13.29",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.13.27",
25
+ "version": "0.13.29",
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.27",
29
+ "purl": "pkg:npm/%40blamejs/core@0.13.29",
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.27",
57
+ "ref": "@blamejs/core@0.13.29",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]