@blamejs/core 0.16.35 → 0.16.37

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.16.x
10
10
 
11
+ - v0.16.37 (2026-07-16) — **Three fail-open / injection fixes: the age gate no longer admits a user whose age fails to compute, the query builder's OFFSET-without-LIMIT runs on every backend, and metrics exemplars can no longer carry an unsanitized value into the scrape stream.** Three defects, each in a class the framework treats as security-relevant. b.middleware.ageGate classified a non-finite age (a NaN or Infinity returned by getAge when a birth field fails to parse) as an adult -- admitting the request with none of the child-safety privacy defaults -- instead of treating an uncomputable age as unknown. b.sql (and b.db.from / b.db.collection over it) emitted a bare OFFSET with no LIMIT, which is valid only on Postgres and is a syntax error on SQLite -- the framework's own backend -- and MySQL, so a valid builder chain failed to run on two of three dialects. And b.metrics exemplars rendered their labels, value, and timestamp into the OpenMetrics scrape surface without the credential-scrub and numeric-coercion regular labels receive, so an operator-supplied exemplar could leak a credential-shaped label or inject a forged metric line through exemplar.value / exemplar.timestamp. **Fixed:** *Age gate treats a non-finite age as unknown, not as an adult* — b.middleware.ageGate now classifies a non-finite age (NaN or +/-Infinity, the shape getAge returns when a birth field fails to parse or date math goes wrong) as "unknown" rather than letting it fall through to "above-threshold". Because typeof NaN === "number" and every comparison against NaN is false, an uncomputable age previously bypassed the below-threshold branch and was admitted as a confirmed adult -- with none of the child-safety privacy defaults (Cache-Control: private, no-store; Referrer-Policy: no-referrer; the privacy-posture header) the unknown path applies. When the birth value is request-derived this is attacker-influenced. A non-finite age is now handled exactly like a null return: the request is classified unknown and the privacy defaults are applied. · *Query builder OFFSET without LIMIT runs on SQLite and MySQL, not only Postgres* — b.sql SELECT (and the b.db.from / b.db.collection consumers built on it) emitted a bare "OFFSET n" when .offset() was set without .limit(). A bare OFFSET is valid only on Postgres; SQLite (the framework's own node:sqlite backend) and MySQL both reject it as a syntax error, so a valid builder chain produced SQL that failed to prepare on two of the three supported dialects, including the default one. The builder now emits the dialect's unbounded-limit sentinel before the OFFSET -- SQLite LIMIT -1, MySQL the maximum unsigned BIGINT, Postgres LIMIT ALL -- so one query text runs unchanged across all three. Statements that set an explicit LIMIT are byte-for-byte unchanged. · *Metrics exemplars are sanitized on the scrape surface the same way regular labels are* — b.metrics histogram exemplars rendered their labels, value, and timestamp into the OpenMetrics /metrics exposition -- the same broadly-readable scrape surface regular labels reach -- without the sanitization regular labels receive. Exemplar label values bypassed the credential scrubber, so a credential-shaped value an operator attached to an exemplar (e.g. tapping a raw header alongside trace context) egressed in cleartext (CWE-532); and exemplar.value / exemplar.timestamp were appended to the exposition line raw, so a non-numeric operator-supplied value such as "1.0\n# forged 999" could inject a forged metric line. Exemplar labels now flow through the same credential scrubber as regular labels, and exemplar value and timestamp are coerced to a finite number (value falling back to the observed value, timestamp to none) at store time, so only sanitized labels and bare numbers ever reach the wire. Trace context (trace_id / span_id) and numeric values pass through unchanged.
12
+
13
+ - v0.16.36 (2026-07-16) — **DKIM simple header canonicalization now signs and verifies the DKIM-Signature header verbatim, so simple-canon signatures round-trip and interoperate.** b.mail.dkim's simple header canonicalization (c=simple/... in a signature) signs and verifies each header exactly as it appears on the wire, per RFC 6376 §3.4.1. Two defects broke that for the DKIM-Signature header itself: the signer canonicalized the header UNFOLDED while emitting it FOLDED on the wire, and the verifier prefixed a spurious extra space to the parsed header value. Together they meant a simple-header-canonicalization signature never matched the bytes that were signed -- neither the framework's own signatures nor an RFC-compliant peer's would verify under simple canon. The signer now canonicalizes and emits the same folded header (appending the signature to the folded, b-emptied form), and the verifier canonicalizes the parsed header verbatim, so a simple-canon signature round-trips and is byte-compatible with other implementations. Relaxed canonicalization, the common default, is unchanged. Separately, a supply-chain gate now fails the build if a vendored component in lib/vendor/MANIFEST.json has no attribution entry in NOTICE. **Added:** *b.safeBuffer.byteLengthOfIfMeasurable — measure a value's byte length, or null when it is not a byte carrier* — byteLengthOfIfMeasurable(value) returns the byte length of a string, Buffer, or Uint8Array, and null for anything else (a plain Array, an array-like object, a number, null). It is the safe way to cap the size of an untrusted metadata bag whose byte field may be any shape: measure the cap only when the value is measurable, rather than gating byteLengthOf (which throws on a non-byte-carrier) on a hand-rolled length check that admits array-likes and crashes. The image and PDF content guards now compose it for their byte caps instead of each vetting the type inline. **Fixed:** *DKIM simple header canonicalization signs and verifies the DKIM-Signature header verbatim* — Under simple header canonicalization (RFC 6376 §3.4.1) the DKIM-Signature header is signed and verified byte-for-byte as it appears on the wire, including its folding. b.mail.dkim canonicalized the header UNFOLDED when computing the signature but emitted it FOLDED on the wire, and the verifier prepended an extra space to the parsed header value before canonicalizing -- so the bytes signed never matched the bytes verified. A simple-header-canonicalization signature therefore never verified, whether produced by the framework or by an RFC-compliant peer. The signer now canonicalizes the folded, b-emptied header and builds the wire header by appending the signature to it, and the verifier canonicalizes the parsed (folded) header verbatim -- including the DKIM-Signature field name exactly as it appears on the wire, so a peer that signs a lowercase dkim-signature: field name also verifies -- so a simple-canon signature round-trips and interoperates. This was fail-closed -- a broken simple-canon signature was reported as a verification failure, never a false pass -- and relaxed canonicalization (the common default, which normalizes folding and whitespace on both sides) was and remains correct. **Detectors:** *Every vendored component in the manifest must be attributed in NOTICE* — A gate fails the build when a component recorded in lib/vendor/MANIFEST.json has no attribution entry in the NOTICE file. Third-party components ship with their license and attribution obligations; this catches a vendored library or data file added to the manifest without its NOTICE entry before the package is published, rather than after a downstream scanner flags the omission.
14
+
11
15
  - v0.16.35 (2026-07-16) — **A resumed saga that later fails now compensates the steps it completed before the crash, and a webhook delivery retries a transient DNS failure during its safety re-check instead of dead-lettering.** Two durability fixes in the agent orchestration and webhook delivery primitives. b.agent.saga rebuilt its completed-step list from empty when a saga resumed from a persisted checkpoint, so a failure after the resume compensated only the steps that ran in that resumed pass -- the steps completed before the crash were never rolled back, defeating the saga's whole purpose (a charge committed before the crash would never be refunded when a later step failed). Resume now seeds the completed-step list with the steps that finished before the crash, so a failure compensates the full set in reverse order. Separately, b.webhook.dispatcher re-checks a delivery's destination for an SSRF rebind just before each attempt; that check resolves the destination host, and a transient resolver failure (a temporary DNS error) during it was treated as a permanent failure and dead-lettered the delivery on the first attempt. The re-check now dead-letters only a genuine SSRF refusal or malformed URL and treats a transient resolver fault as retryable, like every other transport error. **Added:** *b.webhook.dispatcher accepts a dnsLookup override for the destination SSRF check* — b.webhook.dispatcher now accepts an optional dnsLookup(host) resolver, forwarded to the SSRF destination check, so an operator can point destination resolution at a specific resolver (and a test can drive the transient-versus-permanent classification offline). It defaults to the framework's DNS-over-TLS resolver, unchanged. **Fixed:** *A resumed saga compensates the steps it completed before the crash, not only those in the resumed run* — b.agent.saga runs a sequence of steps and, on a step failure, compensates the completed steps in reverse order. When a saga resumed from a persisted checkpoint, it started its completed-step list empty, so a failure after the resume compensated only the steps that ran in the resumed pass -- the steps completed before the crash were left uncompensated. For a distributed transaction that is the exact failure the pattern exists to prevent: work committed before the crash (a charge, a reservation, an external call) would never be rolled back when a later step failed. Resume now seeds the completed-step list with the steps that finished before the crash, reconstructed from the saga definition, so a subsequent failure compensates the full completed set in reverse; compensation runs against the resumed state, which already reflects those steps' effects, and the failing step itself is not compensated. To keep that reseeding safe against replay, resume also refuses a saga the state store marks terminal (failed-and-compensated or completed) rather than re-running its compensators, and the state-store interface documents that compensators must be idempotent -- a crash mid-compensation can replay a compensation on the next resume, so compensating twice must be safe. · *Webhook delivery retries a transient DNS failure during its SSRF re-check instead of dead-lettering* — b.webhook.dispatcher re-validates a delivery's destination against SSRF (private / loopback / metadata IPs, or a rebind since registration) just before each attempt, which resolves the destination host. A transient resolver failure during that resolution -- a lookup timeout or system/resolve failure -- was caught and marked a permanent failure, dead-lettering the delivery on its first attempt rather than retrying. The re-check now classifies the failure: a genuine SSRF refusal or malformed URL dead-letters, a resolver failure honors the framework DNS resolver's own terminal-versus-transient verdict (a permanent failure such as a host with no addresses or a removed record dead-letters immediately, a transient one is retried on the backoff curve, capped at maxAttempts), matching how the dispatcher already treats a transient DNS error during the delivery POST itself. So a webhook is no longer lost to a momentary DNS blip, a genuine rebind to an internal address is still dead-lettered immediately, and a permanently unresolvable destination dead-letters without burning every retry attempt.
12
16
 
13
17
  - v0.16.34 (2026-07-16) — **The DPoP middleware returns the correct multiple-proof rejection when a request carries a repeated DPoP header, instead of mislabeling it as a missing proof.** RFC 9449 §4.1 permits only one DPoP header value per request. b.middleware.dpop rejected a request that carried the header as an array -- repeated DPoP: lines a custom server or proxy did not collapse -- but its array-shape check sat after the missing-header guard, and an array is not a string, so the missing-header guard always ran first. A duplicated DPoP proof was therefore rejected as a missing proof (and, when a DPoP nonce was required, answered with use_dpop_nonce, prompting the client into a pointless nonce-retry loop) rather than with the invalid_dpop_proof / multiple-DPoP-headers rejection the specification calls for. Both paths already refused the request, so this was never a fail-open -- only an incorrect diagnostic and a wasted round trip. The array-shape check now runs before the missing-header guard, so a repeated DPoP header is rejected with the correct error. **Fixed:** *DPoP middleware rejects a repeated DPoP header with the correct multiple-proof error* — b.middleware.dpop enforces the RFC 9449 §4.1 single-value rule, but its Array.isArray check for a repeated header (when a server or proxy delivered the DPoP header as an array rather than a comma-joined string) sat after the non-string / empty guard. Because an array fails the non-string check first, the dedicated multiple-DPoP-headers branch never ran: a duplicated proof was reported as a missing DPoP header, and under a required-nonce policy it returned use_dpop_nonce, driving the client into a fruitless nonce-retry loop. The array-shape check now runs first, so a repeated DPoP header is rejected with invalid_dpop_proof and a multiple-DPoP-headers message. This changes only the error code and message for that malformed-request case; both orderings already refused the request, so no valid request is affected.
@@ -194,15 +194,15 @@ function _detectIssues(metadata, opts) {
194
194
  snippet: "image metadata is not an object" }];
195
195
  }
196
196
 
197
- // Measure the byte cap only for the types safeBuffer.byteLengthOf accepts
198
- // (string / Buffer / Uint8Array). A hostile bag whose `bytes` is a plain
199
- // Array or an array-like object still carries a numeric `.length` but is NOT
200
- // measurable measuring it threw TypeError, breaking validate's documented
201
- // never-throw contract. Skip the cap for those (magic detection below is
202
- // O(1)-bounded regardless of size) instead of crashing the caller.
197
+ // Measure the byte cap only for measurable values. A hostile bag whose
198
+ // `bytes` is a plain Array or array-like object carries a numeric `.length`
199
+ // but is NOT a byte-carrier measuring it would throw, breaking validate's
200
+ // documented never-throw contract; byteLengthOfIfMeasurable returns null for
201
+ // those, so the cap is skipped (magic detection below is O(1)-bounded
202
+ // regardless of size) instead of crashing the caller.
203
203
  var bytes = metadata.bytes;
204
- if (bytes && (Buffer.isBuffer(bytes) || typeof bytes === "string" || bytes instanceof Uint8Array) &&
205
- safeBuffer.byteLengthOf(bytes) > opts.maxBytes) {
204
+ var byteCount = safeBuffer.byteLengthOfIfMeasurable(bytes);
205
+ if (byteCount !== null && byteCount > opts.maxBytes) {
206
206
  return [{ kind: "image-cap", severity: "high",
207
207
  ruleId: "image.image-cap",
208
208
  snippet: "image bytes exceed maxBytes " + opts.maxBytes }];
package/lib/guard-pdf.js CHANGED
@@ -156,16 +156,16 @@ function _detectIssues(metadata, opts) {
156
156
  snippet: "pdf metadata is not an object" }];
157
157
  }
158
158
 
159
- // Measure the byte cap only for the types safeBuffer.byteLengthOf accepts
160
- // (string / Buffer / Uint8Array). A hostile bag whose `bytes` is a plain
161
- // Array or an array-like object still carries a numeric `.length` but is NOT
162
- // measurable measuring it threw TypeError, breaking validate's documented
163
- // never-throw-on-hostile-metadata contract. Skip the cap for those (magic
164
- // detection reads only the leading bytes, O(1)-bounded regardless of the
165
- // reported size) instead of crashing the caller.
159
+ // Measure the byte cap only for measurable values. A hostile bag whose
160
+ // `bytes` is a plain Array or array-like object carries a numeric `.length`
161
+ // but is NOT a byte-carrier measuring it would throw, breaking validate's
162
+ // documented never-throw-on-hostile-metadata contract; byteLengthOfIfMeasurable
163
+ // returns null for those, so the cap is skipped (magic detection reads only
164
+ // the leading bytes, O(1)-bounded regardless of the reported size) instead of
165
+ // crashing the caller.
166
166
  var bytes = metadata.bytes;
167
- if (bytes && (Buffer.isBuffer(bytes) || typeof bytes === "string" || bytes instanceof Uint8Array) &&
168
- safeBuffer.byteLengthOf(bytes) > opts.maxBytes) {
167
+ var byteCount = safeBuffer.byteLengthOfIfMeasurable(bytes);
168
+ if (byteCount !== null && byteCount > opts.maxBytes) {
169
169
  return [{ kind: "pdf-cap", severity: "high",
170
170
  ruleId: "pdf.pdf-cap",
171
171
  snippet: "pdf bytes exceed maxBytes " + opts.maxBytes }];
package/lib/mail-dkim.js CHANGED
@@ -413,18 +413,42 @@ function create(opts) {
413
413
  });
414
414
  } catch (_e) { /* drop-silent */ }
415
415
  }
416
- // Append the unsigned DKIM-Signature header without trailing CRLF
417
- // per RFC 6376 §3.7.
418
- var dkimHeaderForSigning = canonHeader === "simple"
419
- ? _canonHeaderSimple("DKIM-Signature", " " + unsignedSigValue)
420
- : _canonHeaderRelaxed("DKIM-Signature", unsignedSigValue);
416
+ // Append the unsigned DKIM-Signature header (b= value emptied) without a
417
+ // trailing CRLF per RFC 6376 §3.7. Under SIMPLE header canonicalization the
418
+ // header is signed and verified BYTE-FOR-BYTE as it appears on the wire —
419
+ // including its folding so the signer must canonicalize the FOLDED form,
420
+ // exactly as the verifier does when it strips b= from the parsed (folded)
421
+ // wire header. (Relaxed unfolds on both sides, so it signs the unfolded
422
+ // value.) The empty-b header folds identically to the final header up to the
423
+ // last `b=` line (folding breaks only at "; " tag boundaries and b= is the
424
+ // last tag), so we can build the wire header by appending the signature to
425
+ // this folded empty-b header, and the verifier recovers exactly these bytes.
426
+ var foldedEmptyB = _foldSignatureHeader(unsignedSigValue);
427
+ var dkimHeaderForSigning;
428
+ if (canonHeader === "simple") {
429
+ // Simple canon is verbatim (RFC 6376 §3.4.1): the canonical form of the
430
+ // DKIM-Signature header (b= emptied) is the folded wire header exactly as
431
+ // it will appear — which is precisely foldedEmptyB ("DKIM-Signature: " +
432
+ // the folded, b-emptied value). No re-spacing or re-canonicalization.
433
+ dkimHeaderForSigning = foldedEmptyB;
434
+ } else {
435
+ dkimHeaderForSigning = _canonHeaderRelaxed("DKIM-Signature", unsignedSigValue);
436
+ }
421
437
  canonicalizedHeaders += dkimHeaderForSigning.replace(/\r\n$/, "");
422
438
 
423
439
  var signature = _signString(canonicalizedHeaders, keyObject, algorithm);
424
- // Replace the empty `b=` placeholder with the actual base64 signature.
425
- var finalSigValue = sigTags.slice(0, -1).concat(["b=" + signature]).join("; ");
426
440
 
427
- var dkimHeaderLine = _foldSignatureHeader(finalSigValue) + "\r\n";
441
+ // Wire header. Under simple canon, append the signature to the SAME folded
442
+ // empty-b header we signed, so a verifier parsing the folded wire and
443
+ // stripping b= reconstructs the signed bytes exactly. Under relaxed, re-fold
444
+ // the completed value (folding is normalized away on verify).
445
+ var dkimHeaderLine;
446
+ if (canonHeader === "simple") {
447
+ dkimHeaderLine = foldedEmptyB + signature + "\r\n";
448
+ } else {
449
+ var finalSigValue = sigTags.slice(0, -1).concat(["b=" + signature]).join("; ");
450
+ dkimHeaderLine = _foldSignatureHeader(finalSigValue) + "\r\n";
451
+ }
428
452
 
429
453
  _emit("dkim.sign.success", {
430
454
  bodyLength: body.length,
@@ -816,13 +840,23 @@ function _verifySingleSignature(rfc822, parsedHeaders, sigHeader, keyTags, sigTa
816
840
  // structure instead.
817
841
  var unsignedSigValue = _stripBTagValue(sigHeader.value);
818
842
  // The signature header is canonicalized under its true field name (§3.7).
819
- // For the ARC reuse the header on the wire is ARC-Message-Signature, signed
820
- // under that name; the synthetic renames it to DKIM-Signature only so this
821
- // verifier finds it, so the canonical form must restore the real name or the
822
- // b= value never matches what the relay signed.
823
- var sigCanonName = verifyOpts.arcAmsReuse ? "ARC-Message-Signature" : "DKIM-Signature";
843
+ // Under simple canon the name is verbatim, so it must be the EXACT on-wire
844
+ // spelling the peer signed (sigHeader.name _findDkimSignatureHeaders matches
845
+ // the field name case-insensitively and _parseHeaders preserves its casing),
846
+ // not a hardcoded "DKIM-Signature"; a peer that emits `dkim-signature:` signs
847
+ // that lowercase name and would otherwise never verify. (Relaxed canon
848
+ // lowercases the name, so the on-wire spelling doesn't matter there.) For the
849
+ // ARC reuse the header on the wire is ARC-Message-Signature, signed under that
850
+ // name; the synthetic renames it to DKIM-Signature only so this verifier finds
851
+ // it, so the canonical form must restore the real ARC name.
852
+ var sigCanonName = verifyOpts.arcAmsReuse ? "ARC-Message-Signature" : sigHeader.name;
853
+ // Simple canon is verbatim: the parsed sigHeader.value already carries the
854
+ // single space the wire places after the colon and its folding, so it is
855
+ // canonicalized as-is (b= emptied) — NOT re-spaced. (A prior `" " +` prefix
856
+ // here injected a spurious second space, so simple-canon signatures — ours
857
+ // and any RFC-compliant peer's — never matched what was signed.)
824
858
  canonicalizedHeaders += canonHeader === "simple"
825
- ? _canonHeaderSimple(sigCanonName, " " + unsignedSigValue).replace(/\r\n$/, "")
859
+ ? _canonHeaderSimple(sigCanonName, unsignedSigValue).replace(/\r\n$/, "")
826
860
  : _canonHeaderRelaxed(sigCanonName, unsignedSigValue).replace(/\r\n$/, "");
827
861
 
828
862
  // 3. Verify the signature.
package/lib/metrics.js CHANGED
@@ -211,6 +211,34 @@ function _validateLabelValue(value) {
211
211
  return coerced;
212
212
  }
213
213
 
214
+ // Redact every value of a free-form label map through the credential
215
+ // scrubber. Exemplar labels (trace_id / span_id, or any operator-supplied
216
+ // pair passed to histogram.observe) are rendered verbatim into the
217
+ // OpenMetrics exposition — the SAME scrape surface regular labels reach — so
218
+ // they get the same scrub. Redacting at STORE time mirrors how _resolveLabels
219
+ // scrubs regular labels before they land in entry.labels, keeping
220
+ // _renderFamilyLines a verbatim renderer for every exposition path that shares
221
+ // it (CWE-532).
222
+ function _redactLabelMap(labelObj) {
223
+ var out = {};
224
+ if (!labelObj || typeof labelObj !== "object") return out;
225
+ var keys = Object.keys(labelObj);
226
+ for (var i = 0; i < keys.length; i++) out[keys[i]] = _validateLabelValue(labelObj[keys[i]]);
227
+ return out;
228
+ }
229
+
230
+ // Coerce an exemplar's value / timestamp to a finite number. Per OpenMetrics
231
+ // 1.0 §6.2 both are numeric, but _renderFamilyLines appends them to the
232
+ // exposition line RAW (unlike labels, which _escapeLabelValue quotes). A
233
+ // non-numeric operator-supplied field — `exemplar.value = "1\n# forged 9"` —
234
+ // would otherwise inject a forged metric line into every scrape. Reject any
235
+ // non-finite value to the caller-supplied fallback so only a bare number ever
236
+ // reaches the wire.
237
+ function _numericExemplarField(value, fallback) {
238
+ var n = typeof value === "number" ? value : Number(value);
239
+ return isFinite(n) ? n : fallback;
240
+ }
241
+
214
242
  // Serialize a labels object to a canonical Map key. Routed through
215
243
  // canonical-json so the framework has one canonical-sort source of
216
244
  // truth for sorted-keys serialization (avoiding the silent-data-loss
@@ -290,7 +318,11 @@ function _renderFamilyLines(family, openMetrics, lines) {
290
318
  if (openMetrics && entry.exemplars && entry.exemplars[bi]) {
291
319
  var ex = entry.exemplars[bi];
292
320
  bucketLine += " # " + _renderLabels(ex.labels || {}) + " " + ex.value;
293
- if (ex.timestamp) bucketLine += " " + ex.timestamp;
321
+ // Present-vs-missing on the coerced timestamp is `is a number`, not
322
+ // truthiness: _numericExemplarField stores a finite number or null,
323
+ // and the Unix epoch is a valid timestamp of 0 — a truthiness guard
324
+ // would silently drop it.
325
+ if (typeof ex.timestamp === "number") bucketLine += " " + ex.timestamp;
294
326
  }
295
327
  lines.push(bucketLine);
296
328
  }
@@ -644,9 +676,9 @@ function create(opts) {
644
676
  // arg; the registry only records what's passed in.
645
677
  if (exemplar && typeof exemplar === "object") {
646
678
  entry.exemplars[i] = {
647
- labels: exemplar.labels || {},
648
- value: exemplar.value !== undefined ? exemplar.value : arg.value,
649
- timestamp: exemplar.timestamp || null,
679
+ labels: _redactLabelMap(exemplar.labels),
680
+ value: _numericExemplarField(exemplar.value, arg.value),
681
+ timestamp: _numericExemplarField(exemplar.timestamp, null),
650
682
  };
651
683
  }
652
684
  }
@@ -140,7 +140,13 @@ function create(opts) {
140
140
  }
141
141
 
142
142
  var classification;
143
- if (age === null || typeof age !== "number") classification = "unknown";
143
+ // A non-finite age (NaN from parseInt on a malformed birth field or
144
+ // date math, ±Infinity) is NOT a confirmed adult: typeof NaN === "number"
145
+ // and `NaN < consentRequired` is false, so without the isFinite guard the
146
+ // classifier fell through to "above-threshold" and dropped every privacy
147
+ // default for a user whose age simply failed to compute. Treat it as
148
+ // "unknown" (privacy headers applied), consistent with a null return.
149
+ if (age === null || typeof age !== "number" || !isFinite(age)) classification = "unknown";
144
150
  else if (consentRequired !== null && age < consentRequired) classification = "below-threshold";
145
151
  else classification = "above-threshold";
146
152
 
@@ -366,6 +366,38 @@ function byteLengthOf(value, encoding) {
366
366
  "Buffer, or Uint8Array; got " + (value === null ? "null" : typeof value));
367
367
  }
368
368
 
369
+ /**
370
+ * @primitive b.safeBuffer.byteLengthOfIfMeasurable
371
+ * @signature b.safeBuffer.byteLengthOfIfMeasurable(value)
372
+ * @since 0.16.36
373
+ * @related b.safeBuffer.byteLengthOf
374
+ *
375
+ * Like `byteLengthOf`, but returns `null` for a value that is not a
376
+ * measurable byte-carrier (a plain `Array`, an array-like object with a
377
+ * numeric `.length`, a number, ...) instead of throwing.
378
+ *
379
+ * For capping the size of an UNTRUSTED metadata bag whose byte field may be
380
+ * any shape: a content guard measures its cap only when the value is
381
+ * measurable and treats an unmeasurable value as uncapped-here — its
382
+ * magic/shape inspection reads only the leading bytes, so it is O(1)-bounded
383
+ * regardless of a claimed `.length` — rather than throwing out of its
384
+ * documented never-throw-on-hostile-metadata inspection contract. Route a
385
+ * hostile-metadata byte cap through this instead of gating `byteLengthOf` on
386
+ * a hand-rolled `typeof x.length === "number"` check (which admits array-likes
387
+ * and crashes `byteLengthOf`).
388
+ *
389
+ * @example
390
+ * var b = require("blamejs");
391
+ * b.safeBuffer.byteLengthOfIfMeasurable("abc"); // → 3
392
+ * b.safeBuffer.byteLengthOfIfMeasurable([1, 2, 3]); // → null (a plain Array)
393
+ * b.safeBuffer.byteLengthOfIfMeasurable({ length: 1e9 }); // → null (array-like)
394
+ */
395
+ function byteLengthOfIfMeasurable(value) {
396
+ if (typeof value === "string") return Buffer.byteLength(value, "utf8");
397
+ if (Buffer.isBuffer(value) || value instanceof Uint8Array) return value.length;
398
+ return null;
399
+ }
400
+
369
401
  // ---- boundedChunkCollector ----
370
402
  //
371
403
  // Replaces the unbounded `chunks.push(c); ... Buffer.concat(chunks)`
@@ -912,6 +944,7 @@ module.exports = {
912
944
  toBuffer: toBuffer,
913
945
  makeByteCoercer: makeByteCoercer,
914
946
  byteLengthOf: byteLengthOf,
947
+ byteLengthOfIfMeasurable: byteLengthOfIfMeasurable,
915
948
  boundedChunkCollector: boundedChunkCollector,
916
949
  collectStream: collectStream,
917
950
  secureZero: secureZero,
package/lib/sql.js CHANGED
@@ -1760,7 +1760,21 @@ class SelectBuilder extends Builder {
1760
1760
  if (h.sql) { sql += " HAVING " + h.sql; for (var hi = 0; hi < h.params.length; hi++) params.push(h.params[hi]); }
1761
1761
 
1762
1762
  if (this._orderBy.length > 0) sql += " ORDER BY " + this._orderBy.join(", ");
1763
- if (this._limit !== null) sql += " LIMIT " + this._limit;
1763
+ // OFFSET without LIMIT is valid only on Postgres; SQLite and MySQL both
1764
+ // reject a bare OFFSET as a syntax error (there is no bare-OFFSET spelling
1765
+ // for "skip N, return the rest" on those backends). Emit the dialect's
1766
+ // unbounded-limit sentinel so one query text runs unchanged across all
1767
+ // three: SQLite `LIMIT -1`, MySQL the max unsigned BIGINT, Postgres
1768
+ // `LIMIT ALL`. When a real LIMIT is set the existing token is emitted
1769
+ // untouched, so limit-present statements are byte-for-byte unchanged.
1770
+ var limitToken = null;
1771
+ if (this._limit !== null) {
1772
+ limitToken = String(this._limit);
1773
+ } else if (this._offset !== null) {
1774
+ limitToken = dialect === "sqlite" ? "-1"
1775
+ : (dialect === "mysql" ? "18446744073709551615" : "ALL");
1776
+ }
1777
+ if (limitToken !== null) sql += " LIMIT " + limitToken;
1764
1778
  if (this._offset !== null) sql += " OFFSET " + this._offset;
1765
1779
 
1766
1780
  if (this._lockMode !== null) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.16.35",
3
+ "version": "0.16.37",
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:19f33cb5-0c9f-4b8e-81a7-381d0d4b9eaf",
5
+ "serialNumber": "urn:uuid:b15701b8-cd2a-49f6-bbcd-c91646be481f",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-16T15:13:48.546Z",
8
+ "timestamp": "2026-07-16T19:30:55.778Z",
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.16.35",
22
+ "bom-ref": "@blamejs/core@0.16.37",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.16.35",
25
+ "version": "0.16.37",
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.16.35",
29
+ "purl": "pkg:npm/%40blamejs/core@0.16.37",
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.16.35",
57
+ "ref": "@blamejs/core@0.16.37",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]