@blamejs/core 0.16.7 → 0.16.9

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.9 (2026-07-10) — **`b.safeBuffer.quoteString` — one RFC quoted-string serializer behind eight header and protocol emitters — plus a consolidated per-primitive test suite and a static gate that refuses a documented primitive no test references.** Eight places in the framework serialized an RFC quoted-string by hand — escape backslash and DQUOTE, wrap in DQUOTEs — for Cache-Status and Signature-Input sf-strings, Link header parameters, Authentication-Results reason, IMAP quoted strings and METADATA values, ManageSieve responses, and Server-Timing descriptions. Those copies are consolidated into one primitive, b.safeBuffer.quoteString, so an unescaped quote can never terminate a string early and smuggle extra parameters into a protocol line, and the escaping cannot drift between emitters. Output is byte-identical to before. The test suite is reorganized so every primitive's tests live in a canonical per-primitive file (thirty-eight auxiliary files folded or renamed; every assertion preserved — the full suite passes with the exact same check count). On top of that reorganization, the comment-block validator gains a primitive-without-test check: a documented @primitive that no test references — neither the full dotted form nor its namespace plus a method invocation — now fails the static gate. One hundred six existing primitives currently lack any test reference; they are recorded in an explicit shrink-only register in the validator, each entry an open test-backfill item, and the gate refuses any new primitive shipping untested. **Added:** *b.safeBuffer.quoteString — RFC quoted-string serialization* — Coerces to string, escapes every backslash and DQUOTE with a leading backslash, and wraps the result in DQUOTEs — the quoted-string grammar shared by RFC 8941 §3.3.3 Structured Fields sf-string, RFC 8288 Link header parameters, RFC 8601 §2.2 Authentication-Results reason, RFC 3501 §4.3 IMAP quoted strings, and RFC 5804 §1.2 ManageSieve strings. Escaping only: a grammar with a restricted character range (sf-string is printable-ASCII; IMAP quoted strings cannot carry CR/LF) enforces its range check before calling it. · *Static gate: a documented primitive must be referenced by a test* — The comment-block validator now scans the test corpus for every @primitive: the full dotted form (b.namespace.method) anywhere under test/, or the namespace / owning module referenced together with the method invoked in the same test file — the same-file rule stops a ubiquitous method name like .create( in an unrelated test from counting as coverage. A primitive with no reference fails the pre-push static gate. One hundred six existing primitives currently have no reference; they are listed in a shrink-only register inside the validator — each entry an open test-backfill item that is deleted when its test lands — and a new primitive cannot ship untested. **Changed:** *Eight quoted-string emitters route through the one serializer* — Cache-Status key/detail, Signature-Input sf-strings, Link header parameter values, Authentication-Results reason, IMAP METADATA values and quoted strings, ManageSieve OK/NO/BYE/LISTSCRIPTS responses, and Server-Timing desc now compose b.safeBuffer.quoteString instead of hand-rolling the escape. Emitted bytes are unchanged. · *Per-primitive canonical test files* — Thirty-eight auxiliary test files are folded into (or renamed to) their primitive's canonical test file — one file per primitive domain, concern-named splits kept (e.g. http-client-cache / -stream / -throttle). No assertion was changed, added, or removed by the reorganization; the full suite passes with the exact same check count before and after. The b.*-surface reference gate that previously ran inside the smoke suite is superseded by the stronger validator check above. **Detectors:** *rfc-quoted-string-escape-owned-by-safeBuffer* — Flags an inline backslash-then-DQUOTE escape chain in lib/ outside the primitive's home — the quoted-string serializer re-implemented, whose escaping can drift from the canonical one. While being proven against the pre-consolidation tree it caught an eighth emitter the manual sweep had missed (an IMAP response-literal helper), which is consolidated in this release too.
12
+
13
+ - v0.16.8 (2026-07-10) — **`b.metrics.snapshot.render` now emits the labeled registry — counters, gauges and histogram buckets with their label sets — matching the live exposition endpoint byte-for-byte, plus a Public Suffix List refresh and supply-chain pin maintenance.** A snapshot written with b.metrics.snapshot.startWriter's registry option always carried the full labeled registry in its metrics field, but b.metrics.snapshot.render ignored that field: the prometheus format emitted only the flat numeric fields, so a sidecar wanting a complete /metrics exposition of labeled series had to re-implement label-value escaping and bucketed line formatting itself. Both render formats now emit the labeled series. The prometheus output is produced by the same family encoder the live exposition() endpoint uses — one encoder now backs the live exposition, the shadow registry and snapshot rendering — so series names and escaping are identical whichever source a scraper reads. Snapshot files are parsed defensively: a malformed family, a non-Prometheus metric or label name, or a non-numeric sample in a hand-edited snapshot file is dropped rather than rendered, so a tampered file cannot forge exposition lines. Separately, twelve documentation examples opened with require("blamejs").create(), a factory the export does not have; the examples are corrected and the @example-execution test no longer supplies a substitute create(), so any future example calling a method the shipped export lacks fails the suite. The vendored Public Suffix List is refreshed to the 2026-07-09 upstream revision, and the CI toolchain pins (github/codeql-action, the ClusterFuzzLite base-builder digest) are brought current. **Changed:** *One shared Prometheus family encoder behind every exposition surface* — The live registry exposition(), the shadow registry's prometheus render and the new snapshot rendering now share a single labeled-sample encoder (label ordering, value escaping, histogram bucket lines). Output of the existing surfaces is unchanged, including the guarantee that a label literally named constructor / prototype / __proto__ survives rendering. · *Public Suffix List refreshed to the 2026-07-09 upstream revision* — The vendored, signature-verified Public Suffix List bundle is rebuilt from the current upstream publication (previously 2026-07-02). This keeps DMARC / BIMI organizational-domain alignment and cookie-scope decisions current with registry changes. · *CI toolchain pins brought current* — github/codeql-action is pinned to v4.37.0 across the CodeQL and Scorecard workflows in one change (its init / analyze / upload-sarif sub-actions must move together — mixed versions break the CodeQL analysis run), and Dependabot now groups codeql-action updates into a single pull request. The ClusterFuzzLite base-builder image digest is bumped to the current upstream publication, with the oss-fuzz submission mirror kept in sync. **Fixed:** *b.metrics.snapshot.render emits the labeled registry from snap.metrics (#430)* — Snapshots written with startWriter's registry option carry every registered counter / gauge / histogram — label sets and bucket counts — in a structured metrics field, but render() only emitted the flat numeric fields. The prometheus format now renders those families with the same labeled / bucketed sample lines (name{label="value"} n, name_bucket{le=…}, name_sum, name_count) the live exposition() endpoint serves, family names verbatim rather than prefix-qualified, so dashboards see one series name regardless of scrape source; the text format lists each labeled sample as a name{label="value"} row. Malformed families, non-Prometheus metric / label names, and non-numeric samples in a snapshot file are dropped rather than rendered, so a hand-edited file cannot forge exposition lines. · *Twelve documentation examples no longer call a nonexistent create() factory* — @example blocks in b.a2a, b.cloudEvents, b.pqcSoftware and b.tlsExporter opened with var b = require("blamejs").create(), but the top-level export has no create() — copying those examples threw a TypeError on the first line. The examples now open with var b = require("blamejs"). The @example-execution test previously supplied a substitute create() to the examples' require, which masked exactly this drift; the substitute is removed, so an example calling any method the shipped export lacks now fails the suite. **Detectors:** *prometheus-exposition-escape-owned-by-metrics* — Flags a hand-rolled Prometheus escape chain (backslash-doubling plus newline escaping) outside lib/metrics.js — the signature of a second exposition encoder growing whose escaping can drift from the canonical one. The two-step backslash+quote escapes used by RFC quoted-string wire formats (sf-string, IMAP, Sieve, Link header) are out of scope.
14
+
11
15
  - v0.16.7 (2026-07-06) — **Restore two APIs that silently didn't work — the Clear-Site-Data header helper and data-subject export — and correct five documentation examples, all surfaced by a new test that executes every JSDoc @example end-to-end.** The comment-block validator only ever PARSE-checked each documentation @example, so an example could compile and still be dead — calling a method that no longer exists, passing an option the API rejects, or misreading a return shape. A new test now EXECUTES every self-contained @example against the real framework, and on its first run it caught two genuine framework defects plus five stale examples. b.middleware.clearSiteData.headerValue (documented @status stable since 0.15.9) was unreachable: the middleware export was a bare factory that never carried the headerValue helper (nor KNOWN_TYPES / DEFAULT_TYPES), so calling it threw — it is now attached to the export, mirroring b.middleware.idempotencyKey. b.subject.export / b.subject.exportData returned undefined instead of the documented empty object when no data-subject tables are declared, so an operator running an export before tagging any subjectField column got undefined and crashed on the first property access — it now returns {}. Five @example blocks are corrected to runnable code (b.guardRegex.gate's return usage; the invalid title option on b.openapi.create / b.asyncapi.create, which belongs under info; b.retention.complianceFloor; b.safeSchema.object). No security surface changes. **Added:** *JSDoc @example execution validation* — A new test executes every self-contained JSDoc @example against the real framework — not just parse-checks it — so an example that references a renamed or removed method, passes an option the API rejects, or misreads a return shape now fails the suite instead of shipping as dead documentation. Examples with side effects (network, database, filesystem, long-lived work) or that abstract external setup are skipped; the executed set runs in a sandbox with a wall-clock ceiling. **Fixed:** *b.middleware.clearSiteData.headerValue is reachable again* — b.middleware.clearSiteData was exported as a bare factory function, so the advertised b.middleware.clearSiteData.headerValue(types, label?) — plus KNOWN_TYPES and DEFAULT_TYPES — were undefined off the export and threw a TypeError, even though the helper (documented stable since 0.15.9) existed on the module. Those members are now attached to the middleware export, so the documented call works. · *b.subject.export / exportData returns {} when no data-subject tables are declared* — On the no-subject-tables path, export returned the value of its internal audit-write helper — which has no return statement, i.e. undefined — instead of the documented empty dump. An operator running a data-subject export before tagging any subjectField column therefore received undefined and crashed on Object.keys(dump) / dump.<table>. It now returns {} as documented (export and exportData are the same function). · *Five documentation examples corrected to runnable code* — b.guardRegex.gate's example now uses the gate return value correctly; b.openapi.create and b.asyncapi.create examples move title/version under info (title is not a top-level option); and the b.retention.complianceFloor and b.safeSchema.object examples are made self-contained. These are documentation-only corrections.
12
16
 
13
17
  - v0.16.6 (2026-07-05) — **Repair the ClusterFuzzLite / OSS-Fuzz build script so its fuzz targets actually install the jazzer.js runtime and pair with their seed corpora.** This patch fixes the OSS-Fuzz / ClusterFuzzLite build script (.clusterfuzzlite/build.sh), which was latently broken: it compiled every fuzz/<name>.fuzz.js harness without first installing @jazzer.js/core, so the generated targets referenced a runtime that was never present and could not start, and it named each seed-corpus archive after the .fuzz.js-stripped base (guard-csv_seed_corpus.zip) rather than the compiled target (guard-csv.fuzz), so the fuzzing engine never associated a corpus with its target and bootstrapped from nothing. Both failures were silent because the compile step still exits 0 and the in-repo CI fuzz workflows invoke jazzer.js directly rather than through this script — the script is the OSS-Fuzz-upstream integration spec. The build script now installs the jazzer.js runtime into the project-root node_modules before compiling and names each corpus after its target, and a codebase-patterns check locks both invariants in. The build image's inline documentation is corrected (jazzer.js is not present in the base image; the CI workflows do not consume this image) and now records why the upstream path is still latent (the base image ships Node 20 / GLIBC 2.31, below the framework's Node 22+ and jazzer.js's GLIBC 2.38 needs). No shipped framework code changes; fuzz and build-image assets are dev-only and are not part of the published package. **Fixed:** *OSS-Fuzz / ClusterFuzzLite build script installs the jazzer.js runtime before compiling* — compile_javascript_fuzzer emits a runnable that executes <project>/node_modules/@jazzer.js/core at fuzz time from a wholesale copy of the source tree, so the runtime must exist in the project-root node_modules at compile time. The build script never ran an install, so @jazzer.js/core (declared in fuzz/package.json) was absent and every compiled target referenced a runtime that wasn't there. The script now installs it before the compile loop. · *Fuzz seed corpora are paired with their compiled target* — compile_javascript_fuzzer names each target after basename -s .js (keeping the .fuzz stem, e.g. guard-csv.fuzz), but the script zipped each seed corpus under the .fuzz.js-stripped base (guard-csv_seed_corpus.zip). The fuzzing engine pairs <target>_seed_corpus.zip, so no corpus was ever associated and targets started from an empty corpus. Each corpus is now named after its target. · *Build-image documentation corrected* — The build image's comments claimed jazzer.js was pre-installed in the base image and that the CI fuzz workflows consume this image; neither is true. The comments now state that build.sh installs jazzer.js and that the CI workflows invoke jazzer.js directly, and record that the OSS-Fuzz-upstream path remains latent until the base image advances to Node 24 / a newer GLIBC. **Detectors:** *Fuzz-build invariants checked in codebase-patterns* — A cross-artifact check asserts that .clusterfuzzlite/build.sh installs a jazzer.js runtime before compile_javascript_fuzzer and names each seed-corpus archive after the compiled target, so neither gap can silently reappear.
package/lib/a2a.js CHANGED
@@ -117,7 +117,7 @@ function _validateCardShape(card, errorClass) {
117
117
  * `verifyCard` use the same canonicalizer internally.
118
118
  *
119
119
  * @example
120
- * var b = require("blamejs").create();
120
+ * var b = require("blamejs");
121
121
  * var bytes = b.a2a.canonicalize({
122
122
  * issuer: "agent.example.com",
123
123
  * agentId: "ops-bot-1",
@@ -159,7 +159,7 @@ function canonicalize(card) {
159
159
  * }
160
160
  *
161
161
  * @example
162
- * var b = require("blamejs").create();
162
+ * var b = require("blamejs");
163
163
  * var card = b.a2a.createCard({
164
164
  * issuer: "agent.example.com",
165
165
  * agentId: "ops-bot-1",
@@ -209,7 +209,7 @@ function createCard(opts) {
209
209
  * }
210
210
  *
211
211
  * @example
212
- * var b = require("blamejs").create();
212
+ * var b = require("blamejs");
213
213
  * var card = b.a2a.createCard({
214
214
  * issuer: "agent.example.com",
215
215
  * agentId: "ops-bot-1",
@@ -292,7 +292,7 @@ function signCard(card, privateKeyPem, opts) {
292
292
  * }
293
293
  *
294
294
  * @example
295
- * var b = require("blamejs").create();
295
+ * var b = require("blamejs");
296
296
  * var result = b.a2a.verifyCard(envelope, peerPublicKeyPem, {
297
297
  * expectedIssuer: "agent.example.com"
298
298
  * });
@@ -34,6 +34,7 @@
34
34
  * RFC 9211 Cache-Status header — documents which intermediate caches handled a request with structured `hit` / `fwd` / `ttl` parameters so operators diagnose cache-decision chains.
35
35
  */
36
36
 
37
+ var safeBuffer = require("./safe-buffer");
37
38
  var validateOpts = require("./validate-opts");
38
39
  var { defineClass } = require("./framework-error");
39
40
 
@@ -58,11 +59,6 @@ var BOOLEAN_PARAMS = Object.freeze(["hit", "stored", "collapsed"]);
58
59
  // passing other keys get passed-through verbatim as token=value.
59
60
  var KNOWN_PARAMS = Object.freeze(["hit", "fwd", "fwd-status", "ttl", "stored", "collapsed", "key", "detail"]);
60
61
 
61
- function _sfStringQuote(s) {
62
- // RFC 8941 sf-string — quoted-string with escaping for " and \.
63
- // Operator-supplied detail/key strings get the full quote-escape.
64
- return "\"" + String(s).replace(/\\/g, "\\\\").replace(/"/g, "\\\"") + "\"";
65
- }
66
62
 
67
63
  /**
68
64
  * @primitive b.cacheStatus.append
@@ -178,14 +174,14 @@ function entryString(entry) {
178
174
  throw new CacheStatusError("cache-status/bad-key",
179
175
  "entry.key must be a string when provided");
180
176
  }
181
- parts.push("key=" + _sfStringQuote(entry.key));
177
+ parts.push("key=" + safeBuffer.quoteString(entry.key));
182
178
  }
183
179
  if (entry.detail !== undefined && entry.detail !== null) {
184
180
  if (typeof entry.detail !== "string") {
185
181
  throw new CacheStatusError("cache-status/bad-detail",
186
182
  "entry.detail must be a string when provided");
187
183
  }
188
- parts.push("detail=" + _sfStringQuote(entry.detail));
184
+ parts.push("detail=" + safeBuffer.quoteString(entry.detail));
189
185
  }
190
186
  return parts.join("; ");
191
187
  }
@@ -110,7 +110,7 @@ function _genId() {
110
110
  * }
111
111
  *
112
112
  * @example
113
- * var b = require("blamejs").create();
113
+ * var b = require("blamejs");
114
114
  * var ce = b.cloudEvents.wrap({
115
115
  * source: "/services/orders",
116
116
  * type: "com.example.order.created",
@@ -214,7 +214,7 @@ function wrap(opts) {
214
214
  * envelope.
215
215
  *
216
216
  * @example
217
- * var b = require("blamejs").create();
217
+ * var b = require("blamejs");
218
218
  * var record = b.cloudEvents.parse({
219
219
  * specversion: "1.0",
220
220
  * id: "evt-1",
@@ -104,7 +104,7 @@ function _sfQuotedString(s) {
104
104
  "httpSig: parameter string contains non-printable byte at offset " + i);
105
105
  }
106
106
  }
107
- return "\"" + s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"") + "\"";
107
+ return safeBuffer.quoteString(s);
108
108
  }
109
109
 
110
110
  // _serializeCovered — RFC 9421 §2.5 covered-components list.
@@ -29,6 +29,7 @@
29
29
  * parameter never splits the list.
30
30
  */
31
31
 
32
+ var safeBuffer = require("./safe-buffer");
32
33
  var structuredFields = require("./structured-fields");
33
34
  var { defineClass } = require("./framework-error");
34
35
 
@@ -114,7 +115,7 @@ function parse(headerValue) {
114
115
  // common convention (RFC 8288 examples, REST pagination) quotes too.
115
116
  function _serParam(name, value) {
116
117
  if (value === "" || value === true) return name; // valueless parameter
117
- return name + "=\"" + String(value).replace(/\\/g, "\\\\").replace(/"/g, "\\\"") + "\"";
118
+ return name + "=" + safeBuffer.quoteString(value);
118
119
  }
119
120
 
120
121
  /**
package/lib/mail-auth.js CHANGED
@@ -2158,7 +2158,7 @@ function authResultsEmit(opts) {
2158
2158
  // (`\"`). Pre-v0.8.32 the framework collapsed `"` to `'` which
2159
2159
  // is lossy. Use the spec-correct escape so the receiver can
2160
2160
  // round-trip the original reason.
2161
- clause += ' reason="' + r.reason.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"';
2161
+ clause += " reason=" + safeBuffer.quoteString(r.reason);
2162
2162
  }
2163
2163
  // Method-specific properties (ptype.property=value triples per
2164
2164
  // RFC 8601 §2.3). Operators pass them as flat object keys.
@@ -789,7 +789,7 @@ function create(opts) {
789
789
  .then(function (rows) {
790
790
  if (Array.isArray(rows) && rows.length > 0) {
791
791
  var pairs = rows.map(function (r) {
792
- var v = r.value === null || r.value === undefined ? "NIL" : '"' + String(r.value).replace(/\\/g, "\\\\").replace(/"/g, "\\\"") + '"';
792
+ var v = r.value === null || r.value === undefined ? "NIL" : safeBuffer.quoteString(r.value);
793
793
  return r.entry + " " + v;
794
794
  }).join(" ");
795
795
  _writeUntagged(socket, "METADATA " + (mailbox === "" ? '""' : mailbox) + " (" + pairs + ")");
@@ -1247,7 +1247,7 @@ function create(opts) {
1247
1247
  for (var i = 0; i < folders.length; i += 1) {
1248
1248
  var f = folders[i];
1249
1249
  var attrs = (f.attributes || []).map(function (a) { return "\\" + a; }).join(" ");
1250
- _writeUntagged(socket, "LIST (" + attrs + ") \"/\" " + _quote(f.name));
1250
+ _writeUntagged(socket, "LIST (" + attrs + ") \"/\" " + safeBuffer.quoteString(f.name));
1251
1251
  }
1252
1252
  _writeTagged(socket, tag, "OK LIST completed");
1253
1253
  })
@@ -1282,7 +1282,7 @@ function create(opts) {
1282
1282
  var key = items[k].toUpperCase();
1283
1283
  if (info[key] !== undefined) parts.push(key + " " + info[key]);
1284
1284
  }
1285
- _writeUntagged(socket, "STATUS " + _quote(name) + " (" + parts.join(" ") + ")");
1285
+ _writeUntagged(socket, "STATUS " + safeBuffer.quoteString(name) + " (" + parts.join(" ") + ")");
1286
1286
  _writeTagged(socket, tag, "OK STATUS completed");
1287
1287
  })
1288
1288
  .catch(function (err) {
@@ -1780,7 +1780,6 @@ function create(opts) {
1780
1780
  try { socket.destroy(); } catch (_e2) { /* idempotent */ }
1781
1781
  connections.delete(socket);
1782
1782
  }
1783
- function _quote(s) { return '"' + String(s).replace(/\\/g, "\\\\").replace(/"/g, "\\\"") + '"'; }
1784
1783
  function _unquote(s) {
1785
1784
  if (typeof s !== "string") return "";
1786
1785
  if (s[0] === "\"" && s[s.length - 1] === "\"") return s.slice(1, -1);
@@ -730,7 +730,7 @@ function create(opts) {
730
730
  var s = list[i];
731
731
  var nm = String(s.name || "");
732
732
  var active = s.active === true ? " ACTIVE" : "";
733
- socket.write('"' + _quoteEscape(nm) + '"' + active + "\r\n");
733
+ socket.write(safeBuffer.quoteString(nm) + active + "\r\n");
734
734
  }
735
735
  _emit("mail.server.managesieve.listscripts",
736
736
  { connectionId: state.id, count: list.length });
@@ -813,24 +813,20 @@ function create(opts) {
813
813
  });
814
814
  }
815
815
 
816
- // RFC 5804 §1.2 quoted-string escaping: backslash + DQUOTE inside
817
- // the value get escaped with a leading backslash.
818
- function _quoteEscape(s) {
819
- return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); // allow:regex-no-length-cap — backslash + DQUOTE escape on bounded-input
820
- }
821
-
816
+ // RFC 5804 §1.2 quoted strings serialization routes through
817
+ // safeBuffer.quoteString (one quoted-string escaper framework-wide).
822
818
  function _writeOk(socket, msg) {
823
- try { socket.write('OK "' + _quoteEscape(msg) + '"\r\n'); } catch (_e) { /* socket down */ }
819
+ try { socket.write("OK " + safeBuffer.quoteString(msg) + "\r\n"); } catch (_e) { /* socket down */ }
824
820
  }
825
821
  function _writeOkWithTag(socket, tag, msg) {
826
- try { socket.write('OK (TAG "' + _quoteEscape(tag) + '") "' + _quoteEscape(msg) + '"\r\n'); }
822
+ try { socket.write("OK (TAG " + safeBuffer.quoteString(tag) + ") " + safeBuffer.quoteString(msg) + "\r\n"); }
827
823
  catch (_e) { /* socket down */ }
828
824
  }
829
825
  function _writeNo(socket, msg) {
830
- try { socket.write('NO "' + _quoteEscape(msg) + '"\r\n'); } catch (_e) { /* socket down */ }
826
+ try { socket.write("NO " + safeBuffer.quoteString(msg) + "\r\n"); } catch (_e) { /* socket down */ }
831
827
  }
832
828
  function _writeBye(socket, msg) {
833
- try { socket.write('BYE "' + _quoteEscape(msg) + '"\r\n'); } catch (_e) { /* socket down */ }
829
+ try { socket.write("BYE " + safeBuffer.quoteString(msg) + "\r\n"); } catch (_e) { /* socket down */ }
834
830
  }
835
831
  function _close(socket) {
836
832
  try { socket.end(); } catch (_e) { /* idempotent */ }
package/lib/metrics.js CHANGED
@@ -256,6 +256,84 @@ function _sortedLabelKeys(labelObj) {
256
256
  return keys;
257
257
  }
258
258
 
259
+ // Shared metric-family encoder for the Prometheus / OpenMetrics text
260
+ // formats. The live registry's exposition(), the shadow registry's
261
+ // prometheus render, and snapshot.render's labeled-registry path all
262
+ // route through here, so there is exactly one encoder for labeled /
263
+ // bucketed sample lines.
264
+ //
265
+ // family: { name, type, help, unit, buckets, entries }
266
+ // counter / gauge entries: [{ labels, value }]
267
+ // histogram entries: [{ labels, counts, sum, count, exemplars }]
268
+ function _renderFamilyLines(family, openMetrics, lines) {
269
+ // OpenMetrics §5.1.2 — counter sample lines MUST suffix with
270
+ // `_total`. The metadata `# HELP / # TYPE / # UNIT` lines MUST
271
+ // name the SAME family identifier the samples use, otherwise
272
+ // strict OpenMetrics parsers reject the family. Derive the
273
+ // exposition name once so metadata and sample lines agree.
274
+ var exposedName = family.name;
275
+ if (openMetrics && family.type === "counter" && !/_total$/.test(family.name)) { // allow:regex-no-length-cap — name-suffix check
276
+ exposedName = family.name + "_total";
277
+ }
278
+ if (family.help) lines.push("# HELP " + exposedName + " " + family.help);
279
+ lines.push("# TYPE " + exposedName + " " + family.type);
280
+ if (openMetrics && family.unit) lines.push("# UNIT " + exposedName + " " + family.unit);
281
+ var entries = family.entries;
282
+ if (family.type === "histogram") {
283
+ for (var k = 0; k < entries.length; k++) {
284
+ var entry = entries[k];
285
+ for (var bi = 0; bi < family.buckets.length; bi++) {
286
+ var bLabels = Object.assign(Object.create(null), entry.labels, { le: String(family.buckets[bi]) });
287
+ var bucketLine = family.name + "_bucket" + _renderLabels(bLabels) + " " + entry.counts[bi];
288
+ // OpenMetrics 1.0 §6.2 — exemplar trace + span IDs appended
289
+ // as `# {trace_id="...",span_id="..."} <value> <timestamp>`.
290
+ if (openMetrics && entry.exemplars && entry.exemplars[bi]) {
291
+ var ex = entry.exemplars[bi];
292
+ bucketLine += " # " + _renderLabels(ex.labels || {}) + " " + ex.value;
293
+ if (ex.timestamp) bucketLine += " " + ex.timestamp;
294
+ }
295
+ lines.push(bucketLine);
296
+ }
297
+ var infLabels = Object.assign(Object.create(null), entry.labels, { le: "+Inf" });
298
+ lines.push(family.name + "_bucket" + _renderLabels(infLabels) + " " + entry.counts[family.buckets.length]);
299
+ lines.push(family.name + "_sum" + _renderLabels(entry.labels) + " " + entry.sum);
300
+ lines.push(family.name + "_count" + _renderLabels(entry.labels) + " " + entry.count);
301
+ }
302
+ } else {
303
+ // exposedName only diverges from family.name for OpenMetrics
304
+ // counters (the `_total` suffix rule above), so using it
305
+ // unconditionally keeps Prometheus output byte-identical.
306
+ for (var v = 0; v < entries.length; v++) {
307
+ lines.push(exposedName + _renderLabels(entries[v].labels) + " " + entries[v].value);
308
+ }
309
+ }
310
+ }
311
+
312
+ // Flatten one family into `{ key → value }` sample pairs (key = sample
313
+ // name + rendered labels). The text-format snapshot renderer consumes
314
+ // these as synthetic field rows; _renderFamilyLines renders the same
315
+ // samples as wire-format lines (plus metadata + exemplars, which have
316
+ // no key/value representation).
317
+ function _familySamples(family) {
318
+ var out = Object.create(null);
319
+ for (var i = 0; i < family.entries.length; i++) {
320
+ var entry = family.entries[i];
321
+ if (family.type === "histogram") {
322
+ for (var bi = 0; bi < family.buckets.length; bi++) {
323
+ var bLabels = Object.assign(Object.create(null), entry.labels, { le: String(family.buckets[bi]) });
324
+ out[family.name + "_bucket" + _renderLabels(bLabels)] = entry.counts[bi];
325
+ }
326
+ var infLabels = Object.assign(Object.create(null), entry.labels, { le: "+Inf" });
327
+ out[family.name + "_bucket" + _renderLabels(infLabels)] = entry.counts[family.buckets.length];
328
+ out[family.name + "_sum" + _renderLabels(entry.labels)] = entry.sum;
329
+ out[family.name + "_count" + _renderLabels(entry.labels)] = entry.count;
330
+ } else {
331
+ out[family.name + _renderLabels(entry.labels)] = entry.value;
332
+ }
333
+ }
334
+ return out;
335
+ }
336
+
259
337
  // Combine default + per-call labels, validating against the metric's
260
338
  // declared labelNames. Throws if a label name isn't declared.
261
339
  function _resolveLabels(defaultLabels, declaredNames, callLabels) {
@@ -596,53 +674,17 @@ function create(opts) {
596
674
  var sortedNames = Array.from(metrics.keys()).sort();
597
675
  for (var i = 0; i < sortedNames.length; i++) {
598
676
  var m = metrics.get(sortedNames[i]);
599
- // OpenMetrics §5.1.2 — counter sample lines MUST suffix with
600
- // `_total`. The metadata `# HELP / # TYPE / # UNIT` lines MUST
601
- // name the SAME family identifier the samples use, otherwise
602
- // strict OpenMetrics parsers reject the family. Derive the
603
- // exposition name once at the top of the loop so both the
604
- // metadata lines and the sample lines agree.
605
- var exposedName = m.name;
606
- if (openMetrics && m.type === "counter" && !/_total$/.test(m.name)) {
607
- exposedName = m.name + "_total";
608
- }
609
- if (m.help) lines.push("# HELP " + exposedName + " " + m.help);
610
- lines.push("# TYPE " + exposedName + " " + m.type);
611
- if (openMetrics && m.unit) lines.push("# UNIT " + exposedName + " " + m.unit);
612
677
  var keys = Array.from(m.values.keys()).sort();
613
- if (m.type === "histogram") {
614
- for (var k = 0; k < keys.length; k++) {
615
- var entry = m.values.get(keys[k]);
616
- for (var bi = 0; bi < m.buckets.length; bi++) {
617
- var bLabels = Object.assign({}, entry.labels, { le: String(m.buckets[bi]) });
618
- var bucketLine = m.name + "_bucket" + _renderLabels(bLabels) + " " + entry.counts[bi];
619
- // OpenMetrics 1.0 §6.2 — exemplar trace + span IDs appended
620
- // as `# {trace_id="...",span_id="..."} <value> <timestamp>`.
621
- if (openMetrics && entry.exemplars && entry.exemplars[bi]) {
622
- var ex = entry.exemplars[bi];
623
- bucketLine += " # " + _renderLabels(ex.labels || {}) + " " + ex.value;
624
- if (ex.timestamp) bucketLine += " " + ex.timestamp;
625
- }
626
- lines.push(bucketLine);
627
- }
628
- var infLabels = Object.assign({}, entry.labels, { le: "+Inf" });
629
- lines.push(m.name + "_bucket" + _renderLabels(infLabels) + " " + entry.counts[m.buckets.length]);
630
- lines.push(m.name + "_sum" + _renderLabels(entry.labels) + " " + entry.sum);
631
- lines.push(m.name + "_count" + _renderLabels(entry.labels) + " " + entry.count);
632
- }
633
- } else if (m.type === "counter" && openMetrics) {
634
- // exposedName already carries the `_total` suffix when needed
635
- // (derived at the top of the loop so metadata + samples agree).
636
- for (var v = 0; v < keys.length; v++) {
637
- var ent = m.values.get(keys[v]);
638
- lines.push(exposedName + _renderLabels(ent.labels) + " " + ent.value);
639
- }
640
- } else {
641
- for (var v2 = 0; v2 < keys.length; v2++) {
642
- var ent2 = m.values.get(keys[v2]);
643
- lines.push(m.name + _renderLabels(ent2.labels) + " " + ent2.value);
644
- }
645
- }
678
+ var entries = [];
679
+ for (var k = 0; k < keys.length; k++) entries.push(m.values.get(keys[k]));
680
+ _renderFamilyLines({
681
+ name: m.name,
682
+ type: m.type,
683
+ help: m.help,
684
+ unit: m.unit,
685
+ buckets: m.buckets,
686
+ entries: entries,
687
+ }, openMetrics, lines);
646
688
  lines.push("");
647
689
  }
648
690
  if (openMetrics) lines.push("# EOF");
@@ -1157,6 +1199,19 @@ function snapshotRead(p) {
1157
1199
  * queries start returning the right answer once the new types reach
1158
1200
  * the scrape target.
1159
1201
  *
1202
+ * ## Labeled registry series
1203
+ *
1204
+ * A snapshot written with `startWriter`'s `registry` option carries the
1205
+ * registry's counters / gauges / histograms — label sets and histogram
1206
+ * bucket counts — in a structured `metrics` field. Both formats render
1207
+ * them: `prometheus` emits the same labeled / bucketed sample lines the
1208
+ * live `exposition()` endpoint serves, family names verbatim (NOT
1209
+ * `prefix`-qualified) so dashboards see one series name regardless of
1210
+ * scrape source; `text` lists each labeled sample as a
1211
+ * `name{label="value"}` row. A malformed family, metric / label name,
1212
+ * or non-numeric sample in a hand-edited snapshot file is dropped,
1213
+ * never rendered.
1214
+ *
1160
1215
  * @opts
1161
1216
  * format: "text" | "prometheus", // default: "text"
1162
1217
  * prefix: string, // prometheus-only; default: "blamejs"
@@ -1233,6 +1288,84 @@ function _renderText(fields, snap, opts) {
1233
1288
  return lines.join("\n") + "\n";
1234
1289
  }
1235
1290
 
1291
+ // Validate + normalize one serialized registry family from a snapshot's
1292
+ // `metrics` field (written by startWriter's `registry` option) into the
1293
+ // _renderFamilyLines shape. Snapshot files are read back from disk, so
1294
+ // this is a defensive reader: a malformed family, a non-Prometheus
1295
+ // metric / label name, or a non-numeric sample is dropped rather than
1296
+ // rendered — a hand-edited snapshot file must not be able to forge
1297
+ // exposition lines. Returns null when the whole family is unusable.
1298
+ function _normalizeSnapshotFamily(name, fam) {
1299
+ if (!fam || typeof fam !== "object" || Array.isArray(fam)) return null;
1300
+ // Same name contracts as the live registry (METRIC_NAME_RE allows the
1301
+ // colon forms _validateMetricName accepts, LABEL_NAME_RE does not) —
1302
+ // a family the live exposition emits must never be dropped here. The
1303
+ // length cap bounds the anchored test on untrusted snapshot-file bytes.
1304
+ if (name.length > 1024 || !METRIC_NAME_RE.test(name)) return null;
1305
+ var type = fam.type;
1306
+ if (type !== "counter" && type !== "gauge" && type !== "histogram") return null;
1307
+ var buckets = null;
1308
+ if (type === "histogram") {
1309
+ if (!Array.isArray(fam.buckets)) return null;
1310
+ buckets = [];
1311
+ for (var bi = 0; bi < fam.buckets.length; bi += 1) {
1312
+ if (typeof fam.buckets[bi] !== "number" || !isFinite(fam.buckets[bi])) return null;
1313
+ buckets.push(fam.buckets[bi]);
1314
+ }
1315
+ }
1316
+ var observations = Array.isArray(fam.observations) ? fam.observations : [];
1317
+ var entries = [];
1318
+ for (var i = 0; i < observations.length; i += 1) {
1319
+ var obs = observations[i];
1320
+ if (!obs || typeof obs !== "object") continue;
1321
+ // null-proto so a label literally named `__proto__` / `constructor`
1322
+ // lands as an own property instead of being swallowed by the
1323
+ // plain-object prototype setter.
1324
+ var labels = Object.create(null);
1325
+ var rawLabels = obs.labels && typeof obs.labels === "object" ? obs.labels : {};
1326
+ var lnames = Object.keys(rawLabels);
1327
+ for (var li = 0; li < lnames.length; li += 1) {
1328
+ if (lnames[li].length > 1024 || !LABEL_NAME_RE.test(lnames[li])) continue; // drop forged / oversized label names
1329
+ labels[lnames[li]] = String(rawLabels[lnames[li]]);
1330
+ }
1331
+ if (type === "histogram") {
1332
+ if (!Array.isArray(obs.counts) || obs.counts.length !== buckets.length + 1) continue;
1333
+ var countsOk = true;
1334
+ for (var ci = 0; ci < obs.counts.length; ci += 1) {
1335
+ if (typeof obs.counts[ci] !== "number" || !isFinite(obs.counts[ci])) { countsOk = false; break; }
1336
+ }
1337
+ if (!countsOk) continue;
1338
+ if (typeof obs.sum !== "number" || !isFinite(obs.sum)) continue;
1339
+ if (typeof obs.count !== "number" || !isFinite(obs.count)) continue;
1340
+ entries.push({ labels: labels, counts: obs.counts, sum: obs.sum, count: obs.count });
1341
+ } else {
1342
+ if (typeof obs.value !== "number" || !isFinite(obs.value)) continue; // numeric samples only
1343
+ entries.push({ labels: labels, value: obs.value });
1344
+ }
1345
+ }
1346
+ // Escape HELP text per the exposition format so a forged help string
1347
+ // can't inject metric lines (live registries carry operator-authored
1348
+ // help; snapshot files are untrusted bytes).
1349
+ var help = typeof fam.help === "string"
1350
+ ? fam.help.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "") // allow:regex-no-length-cap — fixed-char-set escape // allow:duplicate-regex — Prometheus help-text escape shape
1351
+ : "";
1352
+ return { name: name, type: type, help: help, buckets: buckets, entries: entries };
1353
+ }
1354
+
1355
+ // Walk a snapshot's serialized `metrics` object into validated families,
1356
+ // sorted by name for stable exposition output.
1357
+ function _normalizeSnapshotFamilies(metricsObj) {
1358
+ if (!metricsObj || typeof metricsObj !== "object" || Array.isArray(metricsObj)) return [];
1359
+ var families = [];
1360
+ // allow:bare-canonicalize-walk — stable exposition output ordering
1361
+ var names = Object.keys(metricsObj).sort();
1362
+ for (var i = 0; i < names.length; i += 1) {
1363
+ var family = _normalizeSnapshotFamily(names[i], metricsObj[names[i]]);
1364
+ if (family) families.push(family);
1365
+ }
1366
+ return families;
1367
+ }
1368
+
1236
1369
  function snapshotRender(snap, opts) {
1237
1370
  opts = opts || {};
1238
1371
  var format = opts.format || "text";
@@ -1241,8 +1374,25 @@ function snapshotRender(snap, opts) {
1241
1374
  "metrics.snapshot.render: snap must be a startWriter-produced object (got " + typeof snap + ")");
1242
1375
  }
1243
1376
  var fields = snap.fields;
1377
+ // Labeled registry families (issue #430) — a snapshot written with
1378
+ // startWriter's `registry` option carries every registered counter /
1379
+ // gauge / histogram under `metrics`. Both formats render them so a
1380
+ // sidecar consuming a snapshot written by another process gets the
1381
+ // full labeled series, not just the flat numeric fields.
1382
+ var families = _normalizeSnapshotFamilies(snap.metrics);
1244
1383
  if (format === "text") {
1245
- return _renderText(fields, snap, opts);
1384
+ var textFields = fields;
1385
+ if (families.length > 0) {
1386
+ // Labeled series become synthetic `name{label="value"}` rows; an
1387
+ // operator-supplied flat field wins on a (pathological) name
1388
+ // collision.
1389
+ var synth = Object.create(null);
1390
+ for (var tf = 0; tf < families.length; tf += 1) {
1391
+ Object.assign(synth, _familySamples(families[tf]));
1392
+ }
1393
+ textFields = Object.assign(synth, fields);
1394
+ }
1395
+ return _renderText(textFields, snap, opts);
1246
1396
  }
1247
1397
  if (format === "prometheus") {
1248
1398
  var prefix = opts.prefix || "blamejs";
@@ -1299,6 +1449,14 @@ function snapshotRender(snap, opts) {
1299
1449
  out.push("# TYPE " + emName + " gauge");
1300
1450
  out.push(emName + " " + ms);
1301
1451
  }
1452
+ // Registry families render through the same encoder the live
1453
+ // exposition() endpoint uses, names verbatim (NOT prefix-qualified),
1454
+ // so a scraper switching between the live /metrics route and the
1455
+ // snapshot sidecar sees identical series names.
1456
+ for (var fj = 0; fj < families.length; fj += 1) {
1457
+ if (out.length > 0) out.push(""); // blank-line family separator, mirrors exposition()
1458
+ _renderFamilyLines(families[fj], false, out);
1459
+ }
1302
1460
  return out.join("\n") + "\n";
1303
1461
  }
1304
1462
  throw new MetricsError("metrics-snapshot/bad-format",
@@ -1477,30 +1635,31 @@ function shadowRegistry(opts) {
1477
1635
  function _emitLabeled(name, labelMap, kind) {
1478
1636
  var metric = prefix + "_" + name;
1479
1637
  if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(metric)) return; // allow:regex-no-length-cap — Prometheus name-shape; metric length bounded by namespace + name caps
1480
- out.push("# TYPE " + metric + " " + kind);
1638
+ var entries = [];
1481
1639
  var lks = Object.keys(labelMap);
1482
1640
  for (var li = 0; li < lks.length; li += 1) {
1483
1641
  var lk = lks[li];
1484
- if (lk === "") { out.push(metric + " " + labelMap[lk]); continue; }
1485
1642
  // Read the structured label set kept alongside the canonical key (see
1486
1643
  // _coerceLabels) rather than re-parsing the key. No serialize-then-
1487
1644
  // split round-trip, so a `,` or `=` in a label value stays inside the
1488
1645
  // value and can never forge extra label pairs; and a label NAMED
1489
1646
  // `constructor` / `prototype` / `__proto__` survives instead of being
1490
1647
  // stripped by a prototype-pollution-hardened parse.
1491
- var labelObj = labelSets[lk];
1492
- if (!labelObj || typeof labelObj !== "object") { out.push(metric + " " + labelMap[lk]); continue; }
1493
- var lnames = Object.keys(labelObj).sort(); // allow:bare-canonicalize-walk deterministic label ordering in the exposition line
1494
- var formatted = [];
1495
- for (var pi = 0; pi < lnames.length; pi += 1) {
1496
- var lname = lnames[pi];
1497
- if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(lname)) continue; // allow:regex-no-length-cap — Prometheus label-name shape
1498
- // Prometheus exposition: escape `\`, `"`, `\n` in label values.
1499
- var lvalue = String(labelObj[lname]).replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n"); // allow:regex-no-length-cap — fixed-char-set escape // allow:duplicate-regex — Prometheus value escape shape
1500
- formatted.push(lname + '="' + lvalue + '"');
1648
+ var labelObj = lk === "" ? null : labelSets[lk];
1649
+ // null-proto so a label literally named `__proto__` /
1650
+ // `constructor` lands as an own property instead of being
1651
+ // swallowed by the plain-object prototype setter.
1652
+ var labels = Object.create(null);
1653
+ if (labelObj && typeof labelObj === "object") {
1654
+ var lnames = Object.keys(labelObj);
1655
+ for (var pi = 0; pi < lnames.length; pi += 1) {
1656
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(lnames[pi])) continue; // allow:regex-no-length-cap — Prometheus label-name shape
1657
+ labels[lnames[pi]] = labelObj[lnames[pi]];
1658
+ }
1501
1659
  }
1502
- out.push(metric + "{" + formatted.join(",") + "} " + labelMap[lk]);
1660
+ entries.push({ labels: labels, value: labelMap[lk] });
1503
1661
  }
1662
+ _renderFamilyLines({ name: metric, type: kind, help: "", entries: entries }, false, out);
1504
1663
  }
1505
1664
  var cn2 = Object.keys(counters);
1506
1665
  for (var ci = 0; ci < cn2.length; ci += 1) {
@@ -103,7 +103,7 @@ function _accessor(name) {
103
103
  * posture returns a stub whose primitive calls throw `PqcError`.
104
104
  *
105
105
  * @example
106
- * var b = require("blamejs").create();
106
+ * var b = require("blamejs");
107
107
  * if (b.pqcSoftware.isAvailable()) {
108
108
  * var ss = b.pqcSoftware.DEFAULT_KEM.keygen();
109
109
  * ss.publicKey.length;
@@ -128,7 +128,7 @@ function isAvailable() {
128
128
  * bundle is unavailable.
129
129
  *
130
130
  * @example
131
- * var b = require("blamejs").create();
131
+ * var b = require("blamejs");
132
132
  * var names = b.pqcSoftware.listAlgorithms();
133
133
  * names.indexOf("ml_kem_1024") >= 0;
134
134
  * // → true (when the vendored bundle is present)
@@ -235,7 +235,7 @@ Object.defineProperty(pqc, "DEFAULT_HASH_SIG", {
235
235
  * shared secrets are byte-identical (32 bytes per FIPS 203 §1).
236
236
  *
237
237
  * @example
238
- * var b = require("blamejs").create();
238
+ * var b = require("blamejs");
239
239
  * var result = b.pqcSoftware.runKnownAnswerTest();
240
240
  * result.ok;
241
241
  * // → true (or { ok: false, reason: "<diagnostic>" } when broken)