@blamejs/core 0.6.66 → 0.6.67

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,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.6.x
10
10
 
11
+ - **0.6.67** (2026-05-03) — canonical-JSON walker extracted to `lib/canonical-json.js` and applied to `lib/audit-tools.js` + `lib/config-drift.js`. Same bug class as v0.6.60 (pagination) and v0.6.66 (audit-chain) lived in two more sites: `audit-tools._canonicalize` (used for backup-bundle JSONL serialisation, requires byte-equivalence with audit-chain) and `config-drift._stableStringify` (used to hash framework config for post-boot tamper detection). Both still had the silent-data-loss walk — Date → `{}`, Buffer → `{"0":97,…}`, Map / Set → `{}`, BigInt throws, circular references stack-overflow. New `lib/canonical-json.js` exposes `stringify(value, opts?)` with `opts.bufferAs = "hex" | "reject"` (default `"hex"` for crypto / config / audit; `"reject"` for pagination's strict-cursor policy). All four call sites — audit-chain, audit-tools, config-drift, pagination — now route through it; the four near-identical inline walkers collapse to one. **Stored-row + bundle compatibility** preserved: existing audit rows verify identically (their `metadata` columns are JSON strings); backup bundles produced pre-v0.6.67 round-trip correctly because byte output for plain types is unchanged. **Tests** — 17 layer-0 audit-chain assertions from v0.6.66 cover the unified walker; existing pagination tests cover `bufferAs: "reject"`. Smoke 7251 / wiki e2e 178 / Linux 86.3s / eslint clean / shellcheck clean.
12
+
11
13
  - **0.6.66** (2026-05-03) — `b.auditChain.canonicalize` deep-walks values and rejects non-plain types instead of silently round-tripping garbage. Same bug class as the v0.6.60 pagination canonicalize fix: pre-fix the walker did `Object.keys(row).map(...)` and JSON.stringify'd whatever fell through, which silently encoded **Map / Set / RegExp** as `{}`, **Symbol / function** as missing keys, **BigInt** as a thrown `Do not know how to serialize` mid-emit (DoS-shape on any operator routing bigint IDs into audit metadata), and **circular references** as the unwrapped JSON.stringify error message. The post-fix walker: BigInt → decimal string (no data loss for large account IDs / 64-bit counters from external-DB drivers); Date → ISO string (already worked via JSON.stringify but now explicit before the recursive walk); Map / Set / RegExp / Symbol / function reject with a clear constructor-name message; circular refs detected via WeakSet and rejected as a framework error. Walks recursively so nested structures (`{ a: [BigInt(1), BigInt(2)] }`, `{ a: { b: Uint8Array(…) } }`) all serialise correctly. **Stored-row compat** — existing audit rows verify identically because their stored `metadata` columns are already JSON strings; the change only affects EMIT-time serialisation. **Tests** — 17 new layer-0 assertions in `test/00-primitives.js`. Smoke 7234 → 7251 / wiki e2e 178 / Linux 86.1s / eslint clean / shellcheck clean.
12
14
 
13
15
  - **0.6.65** (2026-05-03) — `b.scheduler.parseCron` rejects step values exceeding the field's range. Pre-fix `*/99999 * * * *` was silently accepted and degenerated to "minute 0 of every hour" (because the for-loop adding values stopped at the first iteration when `step > range`); an operator typing the typo got a once-per-hour schedule when they probably meant once per N minutes. Now `_parseCronField` rejects with `scheduler/invalid-cron` when `step > (range.max - range.min + 1)`. The bound is inclusive so `*/60 * * * *` (= "minute 0 of every hour" written with a redundant explicit step) still accepts. **Tests** — 4 new layer-0 assertions: `*/60` accepts, `*/61` rejects, `*/99999` rejects (the silent-degenerate case), `* */25 * * *` rejects (step > 24-hour range). Smoke 7230 → 7234 / wiki e2e 178 / Linux 86.9s / eslint clean / shellcheck clean.
@@ -18,6 +18,7 @@
18
18
  * lib/audit-sign.js. This module owns the chain hash math only;
19
19
  * verification is O(n) and walks every row at boot.
20
20
  */
21
+ var canonicalJson = require("./canonical-json");
21
22
  var { sha3Hash } = require("./crypto");
22
23
 
23
24
  // All-zero SHA3-512 (128 hex chars) — sentinel prevHash for the first row.
@@ -25,58 +26,19 @@ var ZERO_HASH = "0".repeat(128);
25
26
 
26
27
  // Canonicalize a row for hashing. Excludes the hash/nonce columns themselves
27
28
  // and any caller-specified columns. Sorted keys, JSON-encoded values; Buffer
28
- // values converted to hex for stable byte serialization.
29
- //
30
- // Deeply walks the value tree so non-plain types reject cleanly instead of
31
- // silently round-tripping as `{}` (Map / Set / RegExp), as `{"0":97,…}`
32
- // (Buffer / typed-array nested inside arrays / objects), or as missing
33
- // keys (Symbol / function). BigInt converts to its decimal string —
34
- // large account IDs / monotonic counters from external-DB drivers
35
- // frequently land as BigInt, and crashing the audit emit on those would
36
- // turn them into availability incidents. Circular refs throw a clean
37
- // framework Error rather than the raw JSON.stringify message.
38
- function _scrub(value, seen) {
39
- if (value === null || typeof value === "undefined") return null;
40
- var t = typeof value;
41
- if (t === "string" || t === "boolean" || t === "number") return value;
42
- if (t === "bigint") return String(value);
43
- if (t === "symbol" || t === "function") {
44
- throw new Error("audit-chain canonicalize: " + t + " value is not " +
45
- "serialisable; convert to a string before emit");
46
- }
47
- if (Buffer.isBuffer(value)) return value.toString("hex");
48
- if (value instanceof Uint8Array) return Buffer.from(value).toString("hex");
49
- if (value instanceof Date) return value.toISOString();
50
- // After the primitives + Buffer + Date, any remaining "object" must be
51
- // a plain object or array. Map / Set / RegExp / class instances all
52
- // reject with a clear constructor name in the error so operators see
53
- // exactly which audit metadata field is the culprit.
54
- if (value instanceof Map || value instanceof Set || value instanceof RegExp) {
55
- throw new Error("audit-chain canonicalize: " + value.constructor.name +
56
- " is not serialisable; convert to a plain primitive / array / object first");
57
- }
58
- seen = seen || new WeakSet();
59
- if (seen.has(value)) {
60
- throw new Error("audit-chain canonicalize: circular reference in audit row");
61
- }
62
- seen.add(value);
63
- if (Array.isArray(value)) return value.map(function (v) { return _scrub(v, seen); });
64
- var keys = Object.keys(value).sort();
65
- var out = {};
66
- for (var i = 0; i < keys.length; i++) {
67
- out[keys[i]] = _scrub(value[keys[i]], seen);
68
- }
69
- return out;
70
- }
71
-
29
+ // values converted to hex for stable byte serialization. Routes through
30
+ // the shared `lib/canonical-json` walker so the four canonicalize sites
31
+ // (this one, audit-tools, config-drift, pagination) share one
32
+ // implementation of the bug-class fix that started in v0.6.60 and
33
+ // completed in v0.6.67.
72
34
  function canonicalize(row, excludeKeys) {
73
35
  var ex = new Set(excludeKeys || []);
74
36
  var keys = Object.keys(row).filter(function (k) { return !ex.has(k); }).sort();
75
37
  var pairs = {};
76
38
  for (var i = 0; i < keys.length; i++) {
77
- pairs[keys[i]] = _scrub(row[keys[i]]);
39
+ pairs[keys[i]] = row[keys[i]];
78
40
  }
79
- return JSON.stringify(pairs);
41
+ return canonicalJson.stringify(pairs);
80
42
  }
81
43
 
82
44
  // Compute a row's hash given its predecessor's hash, the row's logical fields
@@ -55,6 +55,7 @@ var fs = require("fs");
55
55
  var path = require("path");
56
56
  var atomicFile = require("./atomic-file");
57
57
  var auditChain = require("./audit-chain");
58
+ var canonicalJson = require("./canonical-json");
58
59
  var auditSign = require("./audit-sign");
59
60
  var backupCrypto = require("./backup/crypto");
60
61
  var clusterStorage = require("./cluster-storage");
@@ -116,20 +117,12 @@ function _requireOutDir(outDir, kind) {
116
117
  }
117
118
  }
118
119
 
119
- // Canonical-JSON: JSON.stringify with sorted keys at every depth. Mirrors
120
- // backup-manifest's serialize so operators have one canonicalization rule.
121
- function _canonicalize(value) {
122
- if (value === null || typeof value !== "object") return JSON.stringify(value);
123
- if (Array.isArray(value)) {
124
- return "[" + value.map(_canonicalize).join(",") + "]";
125
- }
126
- var keys = Object.keys(value).sort();
127
- var parts = [];
128
- for (var i = 0; i < keys.length; i++) {
129
- parts.push(JSON.stringify(keys[i]) + ":" + _canonicalize(value[keys[i]]));
130
- }
131
- return "{" + parts.join(",") + "}";
132
- }
120
+ // Canonical-JSON via the shared lib/canonical-json walker same bytes
121
+ // as audit-chain.canonicalize, config-drift._stableStringify, and
122
+ // pagination._canonicalize for the same input. Pre-v0.6.67 each site
123
+ // had its own copy of the walk, all carrying the same silent-loss bug
124
+ // for Date / Buffer / Map / Set / BigInt / circular refs.
125
+ function _canonicalize(value) { return canonicalJson.stringify(value); }
133
126
 
134
127
  // Convert a single audit_log row to its on-disk-canonical JSON shape.
135
128
  // Buffers become hex strings (matches audit-chain.canonicalize). Used
@@ -0,0 +1,100 @@
1
+ "use strict";
2
+ /**
3
+ * Canonical JSON — deterministic stringify with sorted keys at every depth.
4
+ *
5
+ * Replaces the four near-identical implementations that grew up across
6
+ * `lib/audit-chain.js`, `lib/audit-tools.js`, `lib/config-drift.js`, and
7
+ * `lib/pagination.js`. They all walked `typeof === "object"` with
8
+ * `Object.keys(...).sort()` and silently round-tripped Date as `{}`,
9
+ * Buffer as `{"0":97,"1":98,…}`, Map / Set / RegExp as `{}`, Symbol /
10
+ * function as missing keys, and BigInt as a thrown
11
+ * `Do not know how to serialize a BigInt` mid-emit. Circular references
12
+ * stack-overflowed instead of producing a clean framework error.
13
+ *
14
+ * The walk:
15
+ *
16
+ * primitives + null + undefined → JSON.stringify (undefined → "null")
17
+ * bigint → decimal string ("123" not 123n)
18
+ * Date → ISO string
19
+ * Buffer / Uint8Array → hex (when bufferAs = "hex", default)
20
+ * throw (when bufferAs = "reject")
21
+ * Map / Set / RegExp → throw with constructor name
22
+ * symbol / function → throw with type name
23
+ * circular reference → throw via WeakSet detection
24
+ * plain array → recurse, preserve order
25
+ * plain object → recurse with sorted keys
26
+ *
27
+ * Two consumer policies on Buffer / Uint8Array are documented because
28
+ * the framework historically chose differently per call site:
29
+ *
30
+ * bufferAs: "hex" audit-chain / audit-tools / config-drift —
31
+ * binary data is legitimate (cert PEMs, key
32
+ * material, hash bytes); preserve as hex so the
33
+ * canonical output is reversible.
34
+ * bufferAs: "reject" pagination — cursor state is operator-supplied
35
+ * primitive data; binary in a cursor is almost
36
+ * always a bug; reject loudly.
37
+ *
38
+ * Operators don't call this directly — it's a framework-internal walker.
39
+ */
40
+
41
+ function _scrub(value, seen, bufferAs) {
42
+ if (value === null || typeof value === "undefined") return null;
43
+ var t = typeof value;
44
+ if (t === "string" || t === "boolean" || t === "number") return value;
45
+ if (t === "bigint") return String(value);
46
+ if (t === "symbol" || t === "function") {
47
+ throw new Error("canonical-json: " + t + " value is not " +
48
+ "serialisable; convert to a string before passing in");
49
+ }
50
+ // Buffer / Uint8Array — policy-driven
51
+ if (Buffer.isBuffer(value)) {
52
+ if (bufferAs === "reject") {
53
+ throw new Error("canonical-json: Buffer is not serialisable in this " +
54
+ "context (bufferAs=reject); convert to a string or hex first");
55
+ }
56
+ return value.toString("hex");
57
+ }
58
+ if (value instanceof Uint8Array) {
59
+ if (bufferAs === "reject") {
60
+ throw new Error("canonical-json: Uint8Array is not serialisable in " +
61
+ "this context (bufferAs=reject); convert to a string or hex first");
62
+ }
63
+ return Buffer.from(value).toString("hex");
64
+ }
65
+ if (value instanceof Date) return value.toISOString();
66
+ // After primitives + Date + Buffer + Uint8Array, any remaining "object"
67
+ // must be a plain object or array. Map / Set / RegExp / class instances
68
+ // all reject so the silent-data-loss class is closed.
69
+ if (value instanceof Map || value instanceof Set || value instanceof RegExp) {
70
+ throw new Error("canonical-json: " + value.constructor.name +
71
+ " is not serialisable; convert to a plain primitive / array / object first");
72
+ }
73
+ seen = seen || new WeakSet();
74
+ if (seen.has(value)) {
75
+ throw new Error("canonical-json: circular reference detected");
76
+ }
77
+ seen.add(value);
78
+ if (Array.isArray(value)) {
79
+ return value.map(function (v) { return _scrub(v, seen, bufferAs); });
80
+ }
81
+ var keys = Object.keys(value).sort();
82
+ var out = {};
83
+ for (var i = 0; i < keys.length; i++) {
84
+ out[keys[i]] = _scrub(value[keys[i]], seen, bufferAs);
85
+ }
86
+ return out;
87
+ }
88
+
89
+ // Return the deterministic JSON string. opts.bufferAs picks the Buffer
90
+ // policy ("hex" default, "reject" for callers like pagination).
91
+ function stringify(value, opts) {
92
+ var bufferAs = (opts && opts.bufferAs) || "hex";
93
+ if (bufferAs !== "hex" && bufferAs !== "reject") {
94
+ throw new Error("canonical-json: bufferAs must be 'hex' or 'reject'; got " +
95
+ JSON.stringify(bufferAs));
96
+ }
97
+ return JSON.stringify(_scrub(value, null, bufferAs));
98
+ }
99
+
100
+ module.exports = { stringify: stringify };
@@ -48,6 +48,7 @@
48
48
  var fs = require("node:fs");
49
49
  var path = require("node:path");
50
50
  var auditSign = require("./audit-sign");
51
+ var canonicalJson = require("./canonical-json");
51
52
  var crypto = require("./crypto");
52
53
  var lazyRequire = require("./lazy-require");
53
54
  var validateOpts = require("./validate-opts");
@@ -61,21 +62,13 @@ var _err = ConfigDriftError.factory;
61
62
  var SIDECAR_NAME = "config-baseline.sig";
62
63
  var SIDECAR_VERSION = 1;
63
64
 
64
- // Stable JSON serialization: deterministic key order so the same
65
- // snapshot always hashes to the same digest. Without this, an object
66
- // reordered between boots would falsely flag as drift.
67
- function _stableStringify(value) {
68
- if (value === null || typeof value !== "object") return JSON.stringify(value);
69
- if (Array.isArray(value)) {
70
- return "[" + value.map(_stableStringify).join(",") + "]";
71
- }
72
- var keys = Object.keys(value).sort();
73
- var pairs = [];
74
- for (var i = 0; i < keys.length; i++) {
75
- pairs.push(JSON.stringify(keys[i]) + ":" + _stableStringify(value[keys[i]]));
76
- }
77
- return "{" + pairs.join(",") + "}";
78
- }
65
+ // Stable JSON serialization via the shared lib/canonical-json walker.
66
+ // Deterministic key order so the same snapshot always hashes to the same
67
+ // digest. Pre-v0.6.67 the in-line implementation silently lost Date /
68
+ // Map / Set / Buffer / BigInt content; the shared walker handles all
69
+ // of those + circular refs. Same bytes as audit-chain / audit-tools /
70
+ // pagination would produce for the same input.
71
+ function _stableStringify(value) { return canonicalJson.stringify(value); }
79
72
 
80
73
  function _hashSnapshot(snapshot) {
81
74
  return crypto.sha3Hash(_stableStringify(snapshot));
package/lib/pagination.js CHANGED
@@ -96,6 +96,7 @@
96
96
  */
97
97
 
98
98
  var nodeCrypto = require("node:crypto");
99
+ var canonicalJson = require("./canonical-json");
99
100
  var crypto = require("./crypto");
100
101
  var { defineClass } = require("./framework-error");
101
102
 
@@ -106,41 +107,17 @@ var TAG_BYTES = 16; // 128-bit HMAC tag truncated from SHA3-512
106
107
  var DEFAULT_LIMIT = 25;
107
108
  var DEFAULT_MAX_LIMIT = 100;
108
109
 
109
- // Canonical JSON sorted keys at every depth. Mirrors safe-schema /
110
- // audit-tools so verifier and producer hash exactly the same bytes.
111
- //
112
- // Cursor state must contain only plain data (string, number, boolean,
113
- // null, plain object, array, Date). Buffer / typed-arrays / Map / Set /
114
- // RegExp are rejected because the prior `Object.keys` walk silently
115
- // serialised them as `{}` (Date) or `{"0":97,"1":98,…}` (Buffer) — the
116
- // operator's data round-tripped through encode/decode as garbage. Date
117
- // gets an explicit ISO-string conversion (matches stdlib JSON.stringify).
118
- // Circular references throw cleanly instead of stack-overflowing.
119
- function _canonicalize(value, seen) {
120
- if (value === null || typeof value !== "object") return JSON.stringify(value);
121
- if (value instanceof Date) return JSON.stringify(value.toISOString());
122
- if (Buffer.isBuffer(value) || value instanceof Uint8Array ||
123
- value instanceof Map || value instanceof Set ||
124
- value instanceof RegExp) {
125
- throw new PaginationError("pagination/bad-state",
126
- "cursor state cannot contain " + value.constructor.name +
127
- "; convert to a plain primitive (string / number / iso-string) first");
128
- }
129
- seen = seen || new WeakSet();
130
- if (seen.has(value)) {
131
- throw new PaginationError("pagination/bad-state",
132
- "cursor state contains a circular reference");
133
- }
134
- seen.add(value);
135
- if (Array.isArray(value)) {
136
- return "[" + value.map(function (v) { return _canonicalize(v, seen); }).join(",") + "]";
137
- }
138
- var keys = Object.keys(value).sort();
139
- var parts = [];
140
- for (var i = 0; i < keys.length; i++) {
141
- parts.push(JSON.stringify(keys[i]) + ":" + _canonicalize(value[keys[i]], seen));
110
+ // Canonical JSON via the shared lib/canonical-json walker with
111
+ // bufferAs: "reject" cursor state must contain only plain data, so
112
+ // Buffer / Uint8Array reject loudly. Map / Set / RegExp / Symbol /
113
+ // function / circular always reject; Date ISO; BigInt → decimal
114
+ // string. Wraps the walker's generic Error in a PaginationError so
115
+ // callers can route on `pagination/bad-state`.
116
+ function _canonicalize(value) {
117
+ try { return canonicalJson.stringify(value, { bufferAs: "reject" }); }
118
+ catch (e) {
119
+ throw new PaginationError("pagination/bad-state", e.message);
142
120
  }
143
- return "{" + parts.join(",") + "}";
144
121
  }
145
122
 
146
123
  function _toBuf(secret) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.6.66",
3
+ "version": "0.6.67",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -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:cfffbb8d-56d9-4859-af94-b238e781b705",
5
+ "serialNumber": "urn:uuid:28be00e0-fbc9-4324-9213-5bfe704f8667",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-03T14:41:25.970Z",
8
+ "timestamp": "2026-05-03T14:50:38.247Z",
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.6.66",
22
+ "bom-ref": "@blamejs/core@0.6.67",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.6.66",
25
+ "version": "0.6.67",
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.6.66",
29
+ "purl": "pkg:npm/%40blamejs/core@0.6.67",
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.6.66",
57
+ "ref": "@blamejs/core@0.6.67",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]