@blamejs/core 0.9.12 → 0.9.14

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.9.x
10
10
 
11
+ - v0.9.14 (2026-05-13) — **Audit-existing-code sweep: `safeSql.quoteIdentifier` adopted across every framework primitive that interpolates SQL identifiers; new `raw-sql-identifier-interpolation` detector seals the bug class.** Codex P1 on PR #44 flagged that `dbStore.get`'s expired-row cleanup was an unconditional `DELETE WHERE k = ?` between SELECT and DELETE — in a multi-process deployment another process could upsert the same key in the race window, and the unconditional delete would erase the fresh row. Fix: scope the delete by the observed `expires_at`. While reviewing, a wider gap surfaced — every `db.prepare("CREATE TABLE " + tableName + ...)` site in the framework had been concatenating identifiers raw, sometimes with inline shape-regex validation that varied per module. The framework already shipped `b.safeSql.quoteIdentifier(name, dialect?)` for exactly this; per the audit-existing-code rule the same patch (a) routes every site through the helper (`lib/audit.js` segregation-of-duties trigger DDL, `lib/dsr.js` ticket store, `lib/inbox.js` message-receive table, `lib/middleware/idempotency-key.js` dbStore, `lib/vault/rotate.js` column-rotation DDL) and (b) adds a `raw-sql-identifier-interpolation` codebase-patterns detector so the bug class can't return. The detector skips variables whose names signal already-quoted identifiers (`q<X>` / `Q_<X>` / `quoted<X>` prefix), so future primitives that use the helper read naturally. **Plus: bounded JSON parse for two file-/DB-backed primitives.** `b.metrics.snapshot.read` and `b.middleware.idempotencyKey.dbStore.get` previously used bare `JSON.parse` with `allow:bare-json-parse` markers; both are read by processes separate from where they were written (CLI/sidecar reads daemon-written snapshot; multi-process fleet shares DB) where a hostile or misbehaving writer could plant a multi-GB value and OOM the reader. Both now route through `b.safeJson.parse(raw, { maxBytes: 4 MiB })`. **Three new primitives — `b.crypto.hashFilesParallel`, `b.pqcAgent.reload`, `b.middleware.idempotencyKey.dbStore` — plus republish of v0.9.13's surface**. v0.9.13's npm-publish workflow failed at the wiki-e2e gate (the Codex P1 fix removed the `opts` parameter from `b.selfUpdate.standaloneVerifier.verify` but the `@signature` JSDoc still declared four arguments — three vs. four arity drift). The v0.9.13 git tag + GH release reached operators but the npm tarball did not; the registry stayed at v0.9.12. v0.9.14 carries v0.9.13's entire shipped surface (circuitBreaker.create({...}) opts-object fix + `b.selfUpdate.standaloneVerifier` + `b.metrics.snapshot` + `b.retry.withBreaker`) plus three additional Tier 2 primitives surfaced by the same downstream consumer that flagged the v0.9.13 batch. (1) **`b.crypto.hashFilesParallel(filePaths, opts?)`** — parallel multi-digest hashing for many files in a single read pass per file. Worker-pool concurrency cap (default `min(8, paths.length)`, 1..256), operator-tunable `algorithms` list (default `["sha256", "sha3-512"]`), optional `onProgress(completed, total)` callback (throws swallowed). Returns rows in the same order as input. The common consumer-side reason to reach for this is SBOM regeneration / vendor-data integrity sweeps / release-asset bundling — situations where N files each need both SHA-256 (legacy compat) and SHA-3-512 (PQC-first) digests and rolling a worker pool by hand has cost a downstream consumer the same two-loop, capture-N-promises, settle-Q boilerplate every release. (2) **`b.pqcAgent.reload()`** — tear down the lazily-built default agent and reset to null so the next `b.pqcAgent.agent` access rebuilds against current TLS posture + `b.network.tls.applyToContext` output. Long-running daemons that rotate the framework's TLS posture (TLS-pinset reload, certificate-pinset refresh, `C.TLS_GROUP_PREFERENCE` update behind a feature flag) need a way to re-source the outbound `https.Agent` without forking a new process. `reload()` calls `.destroy()` on the existing default agent (Node closes idle keep-alive sockets, lets in-flight sockets complete) then nulls the cache. Agents handed out via explicit `b.pqcAgent.create()` are unaffected. Returns `{ destroyed: boolean }`. (3) **`b.middleware.idempotencyKey.dbStore({ db, tableName?, init? })`** — persistent-backed store for the `idempotencyKey` middleware. Same three-method interface as `memoryStore` (`get` / `set` / `delete`) but stores records in any sqlite-shaped database (`{ prepare(sql) → { run, get, all } }`) — the framework's internal `b.db`, an operator-supplied better-sqlite3 instance, or a custom adapter. Use this instead of `memoryStore` when (a) multiple processes share the request-handling fleet so retries can land on a different process than the original, (b) the daemon may restart between the original request and the retry (graceful rolling deploy, OOM kill), or (c) audit / compliance review needs to walk historic idempotency-cache decisions queryable via `SELECT * FROM <tableName>`. TTL is lazily enforced at read time; `set()` upserts on conflict so concurrent retries on different processes don't error. The `tableName` is validated via `b.safeSql.validateIdentifier` (ASCII identifier shape, 63-char cap, no reserved words). (4) **Internal fix**: `b.apiSnapshot.read` now passes `maxBytes: 64 MiB` to `safeJson.parse` — the framework-generated snapshot file outgrew safeJson's 1 MiB default after v0.9.13. Operators consuming `@blamejs/core` via npm jump from v0.9.12 directly to v0.9.14; v0.9.13's content is included.
12
+ - v0.9.13 (2026-05-13) — **Two `b.circuitBreaker.create` / `b.retry` bug fixes plus three new operator-facing primitives: `b.selfUpdate.standaloneVerifier`, `b.metrics.snapshot`, `b.retry.withBreaker`**. (1) `b.circuitBreaker.create({...})` was rejecting the documented opts-object call shape with `name must be a non-empty string, got object` — every consumer following the docstring hit a hard error. The factory now reads `opts.name` (defaulting to empty string) and forwards to the internal `CircuitBreaker(name, opts)` constructor. (2) The breaker's open-circuit error code was documented as `retry/circuit-open` but the runtime threw `CIRCUIT_OPEN`. The docstring is corrected to match the runtime; an alias rename will follow with a deprecation cycle in a future minor. (3) **`b.selfUpdate.standaloneVerifier`** — zero-dep verifier for install-pipeline contexts that run BEFORE the framework is installed (Dockerfile build stages, `install.sh`, `update.sh`, SEA-bundle verification at deploy time). Surface: `verify(assetPath, sigPath, pubkeyPem, opts?)` returns `{ ok, sha3_512, sha256, alg }`. Streams the asset in 64 KiB chunks through SHA-256 + SHA-3-512 + the signature verifier in parallel — multi-GB SEA bundles don't OOM the install runner. Supports ECDSA P-384 (both IEEE-P1363 96-byte and DER encodings), Ed25519, and ML-DSA-87. Detects signature format from length so `verifier.verify(...)` runs exactly once (calling it twice returns stale state and silently passes tampered assets). Module is hermetic: `node:crypto` + `node:fs` only, no framework imports. Operators copy the file via `cp "$(node -p "require('@blamejs/core').selfUpdate.standaloneVerifier.path")" install/standalone-verifier.js` into version control on their side. (4) **`b.metrics.snapshot`** — out-of-process metrics export for long-running daemons. `startWriter({ path, intervalMs, fields })` atomically flushes a JSON snapshot (first flush synchronous so the file exists by return-time; subsequent flushes on the interval with `.unref()`; `stop()` clears + final-flushes). `read(path)` parses with shape validation (writtenAt + fields). `render(snap, { format, prefix })` produces operator-readable text or Prometheus 0.0.4 exposition (gauge metrics, prefixed; only finite numeric scalars with prom-compatible names emit; invalid-name / non-numeric / non-finite fields skipped silently). Lets a CLI process scrape a daemon's live metrics without opening an HTTP port. (5) **`b.retry.withBreaker(fn, { retry, breaker })`** — composition primitive collapsing the two-line wrapper every consumer rolls: `breaker.wrap(() => retry.withRetry(fn, opts.retry))`. One breaker call per retry loop (the retry budget is INSIDE the breaker's accounting, so a single transient burst doesn't open the breaker spuriously). Throws on non-function `fn` or breaker without `.wrap`.
11
13
  - v0.9.12 (2026-05-13) — **Republish of v0.9.10 / v0.9.11 — `npm audit signatures` grep widened for the npm-message variant that fires post-v0.9.11**. The publish workflow's "Verify npm registry signing chain" step treats an empty-tree result as success (the framework's zero-runtime-deps posture means `npm audit signatures --omit dev` finds nothing to audit). The exact phrasing has drifted across npm versions: older npm prints `found no installed dependencies to audit`; newer npm (the version on the GH Actions runner post-v0.9.11) prints `found no dependencies to audit that were installed from a supported registry`. The shell guard's grep only matched the older phrasing, so v0.9.11's publish failed at the audit-signatures gate even though every other step succeeded. The grep is now `no (installed )?dependencies to audit` — covers both known empty-tree variants. v0.9.10's broken-smoke fix (added `npm install` before smoke) plus v0.9.12's audit-signatures-grep fix together complete the publish-pipeline repair. v0.9.12 is functionally identical to v0.9.10's intended surface. Operators stuck at v0.9.9 (because v0.9.10 + v0.9.11 never reached the npm registry) jump directly to v0.9.12.
12
14
  - v0.9.11 (2026-05-13) — **Republish of v0.9.10 — npm-publish.yml now installs devDependencies before smoke**. v0.9.10's tag was pushed and the GitHub release published, but `npm-publish.yml`'s Framework-smoke step ran `node test/smoke.js` without first running `npm install`. The new bundler-output gate added in v0.9.10 requires `esbuild` (a devDependency) to be present, so the publish workflow failed at the smoke step before reaching the publish step — the v0.9.10 npm tarball was never published. The fix adds `npm install --no-audit --no-fund` to `.github/workflows/npm-publish.yml` directly before the smoke step (mirroring the same fix already present in `.github/workflows/ci.yml`). Operators consuming via `npm install @blamejs/core` should pull v0.9.11; functionally identical to the intended-v0.9.10 surface. Zero runtime deps invariant preserved.
13
15
  - v0.9.10 (2026-05-13) — **Bundler-output e2e gate** — `test/layer-5-integration/bundler-output.test.js`. Bundles the framework via `esbuild --bundle --platform=node` (also `--minify`), runs the bundled consumer, and asserts the four-layer vendor-data integrity surface (dual-hash + SLH-DSA signature + canary) survives bundling. The PSL canary roundtrips through `b.publicSuffix.isPublicSuffix(...)` after bundle exec — proves the `.data.js` payloads physically reached the bundle bytes, not just the runtime require shape. Plus a byte-search sentinel that grep's the produced bundle for the canary tokens directly (defense-in-depth, independent failure mode from the runtime path). Plus a SEA gate (Linux + Node >= 22 only) that runs `--experimental-sea-config` + `postject` to produce an actual single-executable binary and runs it. The whole class of bugs — dynamic-require breaks bundling, SEA `assets` map missing, esbuild static-trace failures — is now smoke-gated. Had this test existed when v0.9.8 published, it would have refused that release at smoke-time (the v0.9.8 dynamic-require defect produced bundles that exited with `vendor-data/module-missing` on first vendor-data access; this gate's `BUNDLE-OK psl=co.uk entries=3` stdout-check refuses that exit). No framework-surface changes.
@@ -241,7 +241,10 @@ function read(filePath) {
241
241
  "read: cannot read " + filePath + ": " + ((e && e.message) || String(e)));
242
242
  }
243
243
  var parsed;
244
- try { parsed = safeJson.parse(raw); }
244
+ // api-snapshot is framework-generated, not operator-supplied; grow
245
+ // the safeJson cap so wide-surface releases don't hit the 1 MiB
246
+ // default. Cap at the safeJson absolute max (64 MiB).
247
+ try { parsed = safeJson.parse(raw, { maxBytes: 64 * 1024 * 1024 }); } // allow:raw-byte-literal — internal-file maxBytes ceiling
245
248
  catch (e) {
246
249
  throw new ApiSnapshotError("api-snapshot/bad-json",
247
250
  "read: not valid JSON: " + ((e && e.message) || String(e)));
package/lib/audit.js CHANGED
@@ -55,6 +55,7 @@ var cluster = require("./cluster");
55
55
  var clusterStorage = require("./cluster-storage");
56
56
  var { generateToken } = require("./crypto");
57
57
  var cryptoField = require("./crypto-field");
58
+ var safeSql = require("./safe-sql");
58
59
  var dbRoleContext = require("./db-role-context");
59
60
  var handlers = require("./handlers");
60
61
  var { boot } = require("./log");
@@ -1334,37 +1335,48 @@ function bindActor(actorId, opts) {
1334
1335
  */
1335
1336
  function generateActorBindingTriggerSql(opts) {
1336
1337
  opts = opts || {};
1337
- var column = opts.column || "actorUserId";
1338
- var tableName = opts.tableName || "_blamejs_audit_log";
1339
- var allowRoles = Array.isArray(opts.allowRoles) ? opts.allowRoles : [];
1340
- var fnName = "_blamejs_audit_actor_binding_check";
1341
- var trigName = "_blamejs_audit_actor_binding_trig";
1338
+ var columnRaw = opts.column || "actorUserId";
1339
+ var tableNameRaw = opts.tableName || "_blamejs_audit_log";
1340
+ var allowRoles = Array.isArray(opts.allowRoles) ? opts.allowRoles : [];
1341
+ var fnNameRaw = "_blamejs_audit_actor_binding_check";
1342
+ var trigNameRaw = "_blamejs_audit_actor_binding_trig";
1343
+ // Quote-and-validate every identifier through safeSql.quoteIdentifier
1344
+ // so operator-supplied opts.column / opts.tableName / opts.roleMappingFn
1345
+ // can't reach raw concatenation. PostgreSQL + SQLite both use the
1346
+ // double-quote dialect.
1347
+ var qColumn = safeSql.quoteIdentifier(columnRaw, "postgres");
1348
+ var qTable = safeSql.quoteIdentifier(tableNameRaw, "postgres");
1349
+ var qFn = safeSql.quoteIdentifier(fnNameRaw, "postgres");
1350
+ var qTrig = safeSql.quoteIdentifier(trigNameRaw, "postgres");
1351
+ var qRoleMapFn = opts.roleMappingFn
1352
+ ? safeSql.quoteIdentifier(opts.roleMappingFn, "postgres")
1353
+ : null;
1342
1354
  var allowList = allowRoles.length === 0 ? "" :
1343
1355
  " IF current_user IN (" +
1344
1356
  allowRoles.map(function (r) { return "'" + r.replace(/'/g, "''") + "'"; }).join(", ") +
1345
1357
  ") THEN RETURN NEW; END IF;\n";
1346
- var roleMatch = opts.roleMappingFn
1347
- ? " IF " + opts.roleMappingFn + "(NEW.\"" + column + "\") IS DISTINCT FROM current_user THEN\n"
1348
- : " IF NEW.\"" + column + "\" IS DISTINCT FROM current_user THEN\n";
1358
+ var roleMatch = qRoleMapFn
1359
+ ? " IF " + qRoleMapFn + "(NEW." + qColumn + ") IS DISTINCT FROM current_user THEN\n"
1360
+ : " IF NEW." + qColumn + " IS DISTINCT FROM current_user THEN\n";
1349
1361
  var up =
1350
- "CREATE OR REPLACE FUNCTION " + fnName + "() RETURNS trigger AS $$\n" +
1362
+ "CREATE OR REPLACE FUNCTION " + qFn + "() RETURNS trigger AS $$\n" +
1351
1363
  "BEGIN\n" +
1352
1364
  allowList +
1353
1365
  roleMatch +
1354
- " RAISE EXCEPTION 'segregation-of-duties violation: actor=% does not match current_user=%', NEW.\"" + column + "\", current_user\n" +
1366
+ " RAISE EXCEPTION 'segregation-of-duties violation: actor=% does not match current_user=%', NEW." + qColumn + ", current_user\n" +
1355
1367
  " USING ERRCODE = 'P0001';\n" +
1356
1368
  " END IF;\n" +
1357
1369
  " RETURN NEW;\n" +
1358
1370
  "END;\n" +
1359
1371
  "$$ LANGUAGE plpgsql;\n" +
1360
- "DROP TRIGGER IF EXISTS " + trigName + " ON " + tableName + ";\n" +
1361
- "CREATE TRIGGER " + trigName + "\n" +
1362
- " BEFORE INSERT ON " + tableName + "\n" +
1363
- " FOR EACH ROW EXECUTE FUNCTION " + fnName + "();\n";
1372
+ "DROP TRIGGER IF EXISTS " + qTrig + " ON " + qTable + ";\n" +
1373
+ "CREATE TRIGGER " + qTrig + "\n" +
1374
+ " BEFORE INSERT ON " + qTable + "\n" +
1375
+ " FOR EACH ROW EXECUTE FUNCTION " + qFn + "();\n";
1364
1376
  var down =
1365
- "DROP TRIGGER IF EXISTS " + trigName + " ON " + tableName + ";\n" +
1366
- "DROP FUNCTION IF EXISTS " + fnName + "();\n";
1367
- return { up: up, down: down, functionName: fnName, triggerName: trigName };
1377
+ "DROP TRIGGER IF EXISTS " + qTrig + " ON " + qTable + ";\n" +
1378
+ "DROP FUNCTION IF EXISTS " + qFn + "();\n";
1379
+ return { up: up, down: down, functionName: fnNameRaw, triggerName: trigNameRaw };
1368
1380
  }
1369
1381
 
1370
1382
  // Boot-time check operators wire under sox-404 / soc2 posture. Verifies
@@ -34,11 +34,18 @@ var retry = require("./retry");
34
34
  * @related b.retry, b.httpClient
35
35
  *
36
36
  * Build a circuit-breaker. Returns a CircuitBreaker instance with
37
- * `wrap(fn)` (executes `fn` if the breaker is closed; throws RetryError
38
- * with `code: "retry/circuit-open"` when open), `state()`, `reset()`,
39
- * and `onStateChange(handler)` listener registration. Pass-through
40
- * factory: identical instance shape to `b.retry.CircuitBreaker`, with
41
- * the framework's `create(opts)` vocabulary.
37
+ * `wrap(fn)` (executes `fn` if the breaker is closed; throws an
38
+ * `Error` with `code: "CIRCUIT_OPEN"` + `isObjectStoreError: true` +
39
+ * `permanent: false` when open), `state()`, `reset()`, and
40
+ * `onStateChange(handler)` listener registration. Pass-through
41
+ * factory: identical instance shape to `b.retry.CircuitBreaker`,
42
+ * with the framework's `create(opts)` vocabulary.
43
+ *
44
+ * The `CIRCUIT_OPEN` error code is a pre-v1 artifact — every other
45
+ * framework error class uses namespaced codes (`retry/...`). The
46
+ * rename is deferred to v0.10 with a deprecation cycle so existing
47
+ * operators who match `err.code === "CIRCUIT_OPEN"` aren't broken
48
+ * in a patch.
42
49
  *
43
50
  * @opts
44
51
  * name: string, // identifier used in audit + state-change events
@@ -65,7 +72,15 @@ var retry = require("./retry");
65
72
  * result.value; // → 42
66
73
  */
67
74
  function create(opts) {
68
- return new retry.CircuitBreaker(opts || {});
75
+ // The CircuitBreaker class constructor is `(name, opts)` passing a
76
+ // single opts object lands it in the positional `name` slot, and the
77
+ // validator throws "name must be a non-empty string, got object."
78
+ // The factory's documented shape is `create({ name, ...opts })`;
79
+ // split the name out of opts before invoking the constructor.
80
+ // Caught by hermitstash-sync operator review against v0.9.12.
81
+ opts = opts || {};
82
+ var name = (opts && typeof opts.name === "string") ? opts.name : "";
83
+ return new retry.CircuitBreaker(name, opts);
69
84
  }
70
85
 
71
86
  module.exports = {
package/lib/crypto.js CHANGED
@@ -147,6 +147,150 @@ function hashFile(filePath, algorithm) {
147
147
  return hashStream(nodeFs.createReadStream(filePath), algorithm);
148
148
  }
149
149
 
150
+ // _hashFileMulti — single-pass stream of a file through N hashers in
151
+ // parallel. Returns { path, byteLength, <alg>: hex } for every
152
+ // `algorithms` entry. Used by hashFilesParallel below; not exported
153
+ // directly because the common case is the parallel-many shape.
154
+ function _hashFileMulti(filePath, algorithms) {
155
+ return new Promise(function (resolve, reject) {
156
+ var hashers = new Array(algorithms.length);
157
+ for (var i = 0; i < algorithms.length; i += 1) {
158
+ try { hashers[i] = nodeCrypto.createHash(algorithms[i]); }
159
+ catch (e) {
160
+ reject(new Error("crypto.hashFilesParallel: unknown algorithm '" +
161
+ algorithms[i] + "': " + (e && e.message ? e.message : String(e))));
162
+ return;
163
+ }
164
+ }
165
+ var byteLength = 0;
166
+ var stream = nodeFs.createReadStream(filePath);
167
+ stream.on("error", reject);
168
+ stream.on("data", function (chunk) {
169
+ byteLength += chunk.length;
170
+ for (var j = 0; j < hashers.length; j += 1) hashers[j].update(chunk);
171
+ });
172
+ stream.on("end", function () {
173
+ var out = { path: filePath, byteLength: byteLength };
174
+ for (var k = 0; k < hashers.length; k += 1) {
175
+ // Field name = algorithm with `-` → `_` so "sha3-512" surfaces
176
+ // as `out.sha3_512` (matches the standalone-verifier shape).
177
+ out[algorithms[k].replace(/-/g, "_")] = hashers[k].digest("hex");
178
+ }
179
+ resolve(out);
180
+ });
181
+ });
182
+ }
183
+
184
+ /**
185
+ * @primitive b.crypto.hashFilesParallel
186
+ * @signature b.crypto.hashFilesParallel(filePaths, opts?)
187
+ * @since 0.9.14
188
+ * @status stable
189
+ * @related b.crypto.hashFile, b.crypto.hashStream
190
+ *
191
+ * Hash many files in parallel, streaming each one through one or
192
+ * more digest algorithms in a single read pass. Returns an array of
193
+ * `{ path, byteLength, sha256, sha3_512, ... }` records in the same
194
+ * order as `filePaths`. Concurrency is operator-tunable; the default
195
+ * (`min(8, filePaths.length)`) matches the framework's
196
+ * hash-while-streaming convention elsewhere without saturating the
197
+ * fs read queue on spinning-disk hosts.
198
+ *
199
+ * The common consumer-side reason to reach for this primitive is
200
+ * SBOM regeneration / vendor-data integrity sweeps / release-asset
201
+ * bundling — situations where N files each need both SHA-256 (legacy
202
+ * compat) and SHA-3-512 (PQC-first) digests and rolling a worker
203
+ * pool by hand has cost a downstream consumer (`hermitstash-sync`
204
+ * 2026-05-13) the same two-loop, capture-N-promises, settle-Q boilerplate
205
+ * every release.
206
+ *
207
+ * @opts
208
+ * algorithms?: string[], // default ["sha256", "sha3-512"]; any node:crypto-known digest
209
+ * concurrency?: number, // default min(8, filePaths.length); 1..256
210
+ * onProgress?: function (completed, total) // best-effort; thrown errors swallowed
211
+ *
212
+ * @example
213
+ * var rows = await b.crypto.hashFilesParallel(
214
+ * ["/var/lib/blamejs/asset-a.bin",
215
+ * "/var/lib/blamejs/asset-b.bin"],
216
+ * { algorithms: ["sha256", "sha3-512"], concurrency: 4 }
217
+ * );
218
+ * // rows[0] → { path: "...asset-a.bin", byteLength: 4096,
219
+ * // sha256: "...", sha3_512: "..." }
220
+ */
221
+ function hashFilesParallel(filePaths, opts) {
222
+ if (!Array.isArray(filePaths)) {
223
+ return Promise.reject(new TypeError(
224
+ "crypto.hashFilesParallel: filePaths must be an array of non-empty strings"
225
+ ));
226
+ }
227
+ for (var i = 0; i < filePaths.length; i += 1) {
228
+ if (typeof filePaths[i] !== "string" || filePaths[i].length === 0) {
229
+ return Promise.reject(new TypeError(
230
+ "crypto.hashFilesParallel: filePaths[" + i + "] must be a non-empty string"
231
+ ));
232
+ }
233
+ }
234
+ opts = opts || {};
235
+ var algorithms = opts.algorithms !== undefined
236
+ ? opts.algorithms : ["sha256", "sha3-512"];
237
+ if (!Array.isArray(algorithms) || algorithms.length === 0) {
238
+ return Promise.reject(new TypeError(
239
+ "crypto.hashFilesParallel: opts.algorithms must be a non-empty array"
240
+ ));
241
+ }
242
+ for (var ai = 0; ai < algorithms.length; ai += 1) {
243
+ if (typeof algorithms[ai] !== "string" || algorithms[ai].length === 0) {
244
+ return Promise.reject(new TypeError(
245
+ "crypto.hashFilesParallel: opts.algorithms[" + ai + "] must be a non-empty string"
246
+ ));
247
+ }
248
+ }
249
+ var concurrency = opts.concurrency !== undefined
250
+ ? opts.concurrency
251
+ : Math.min(8, Math.max(1, filePaths.length)); // allow:raw-byte-literal — worker fan-out cap, not bytes
252
+ if (typeof concurrency !== "number" || !isFinite(concurrency) ||
253
+ concurrency < 1 || concurrency > 256 || // allow:raw-byte-literal — concurrency upper cap
254
+ Math.floor(concurrency) !== concurrency) {
255
+ return Promise.reject(new TypeError(
256
+ "crypto.hashFilesParallel: opts.concurrency must be an integer in [1, 256], got " + concurrency
257
+ ));
258
+ }
259
+ var onProgress = opts.onProgress;
260
+ if (onProgress !== undefined && typeof onProgress !== "function") {
261
+ return Promise.reject(new TypeError(
262
+ "crypto.hashFilesParallel: opts.onProgress must be a function when supplied"
263
+ ));
264
+ }
265
+ if (filePaths.length === 0) return Promise.resolve([]);
266
+
267
+ var results = new Array(filePaths.length);
268
+ var nextIdx = 0;
269
+ var completed = 0;
270
+ var total = filePaths.length;
271
+ function _worker() {
272
+ function _step() {
273
+ var idx = nextIdx;
274
+ nextIdx += 1;
275
+ if (idx >= total) return Promise.resolve();
276
+ return _hashFileMulti(filePaths[idx], algorithms).then(function (rec) {
277
+ results[idx] = rec;
278
+ completed += 1;
279
+ if (onProgress) {
280
+ try { onProgress(completed, total); }
281
+ catch (_e) { /* progress callback errors are not fatal */ }
282
+ }
283
+ return _step();
284
+ });
285
+ }
286
+ return _step();
287
+ }
288
+ var workerCount = Math.min(concurrency, total);
289
+ var workers = new Array(workerCount);
290
+ for (var w = 0; w < workerCount; w += 1) workers[w] = _worker();
291
+ return Promise.all(workers).then(function () { return results; });
292
+ }
293
+
150
294
  function random(byteLength) {
151
295
  var n = byteLength || 32;
152
296
  // SHAKE256 over OS-RNG bytes. The OS RNG (nodeCrypto.randomBytes) is
@@ -1374,6 +1518,7 @@ module.exports = {
1374
1518
  sha3Hash: sha3Hash,
1375
1519
  hmacSha3: hmacSha3,
1376
1520
  hashFile: hashFile,
1521
+ hashFilesParallel: hashFilesParallel,
1377
1522
  hashStream: hashStream,
1378
1523
  namespaceHash: namespaceHash,
1379
1524
  kdf: kdf,
package/lib/dsr.js CHANGED
@@ -113,6 +113,7 @@ var C = require("./constants");
113
113
  var bCrypto = require("./crypto");
114
114
  var lazyRequire = require("./lazy-require");
115
115
  var validateOpts = require("./validate-opts");
116
+ var safeSql = require("./safe-sql");
116
117
  var { defineClass } = require("./framework-error");
117
118
 
118
119
  var DsrError = defineClass("DsrError", { alwaysPermanent: true });
@@ -939,15 +940,21 @@ function dbTicketStore(opts) {
939
940
  throw new DsrError("dsr/bad-db",
940
941
  "dbTicketStore: opts.db must be a b.db-shaped handle (with runSql + prepare)");
941
942
  }
942
- var table = opts.table || "dsr_tickets";
943
- if (typeof table !== "string" || !/^[A-Za-z][A-Za-z0-9_]*$/.test(table)) {
943
+ var tableRaw = opts.table || "dsr_tickets";
944
+ var qTable, qEmailIdx, qStatusIdx;
945
+ try {
946
+ qTable = safeSql.quoteIdentifier(tableRaw, "sqlite");
947
+ qEmailIdx = safeSql.quoteIdentifier(tableRaw + "_email_idx", "sqlite");
948
+ qStatusIdx = safeSql.quoteIdentifier(tableRaw + "_status_idx", "sqlite");
949
+ } catch (sqlErr) {
944
950
  throw new DsrError("dsr/bad-table",
945
- "dbTicketStore: table must be a SQL identifier ([A-Za-z][A-Za-z0-9_]*)");
951
+ "dbTicketStore: table must be a valid SQL identifier: " +
952
+ (sqlErr && sqlErr.message ? sqlErr.message : String(sqlErr)));
946
953
  }
947
954
 
948
955
  // Auto-provision schema if not already present. Idempotent.
949
956
  function ensureSchema() {
950
- db.runSql("CREATE TABLE IF NOT EXISTS " + table + " (" +
957
+ db.runSql("CREATE TABLE IF NOT EXISTS " + qTable + " (" +
951
958
  "id TEXT PRIMARY KEY, " +
952
959
  "type TEXT NOT NULL, " +
953
960
  "status TEXT NOT NULL, " +
@@ -961,16 +968,16 @@ function dbTicketStore(opts) {
961
968
  "posture TEXT, " +
962
969
  "payload TEXT NOT NULL" +
963
970
  ")");
964
- db.runSql("CREATE INDEX IF NOT EXISTS " + table + "_email_idx ON " +
965
- table + " (subject_email)");
966
- db.runSql("CREATE INDEX IF NOT EXISTS " + table + "_status_idx ON " +
967
- table + " (status)");
971
+ db.runSql("CREATE INDEX IF NOT EXISTS " + qEmailIdx + " ON " +
972
+ qTable + " (subject_email)");
973
+ db.runSql("CREATE INDEX IF NOT EXISTS " + qStatusIdx + " ON " +
974
+ qTable + " (status)");
968
975
  }
969
976
  ensureSchema();
970
977
 
971
978
  return {
972
979
  insert: async function (ticket) {
973
- var stmt = db.prepare("INSERT INTO " + table +
980
+ var stmt = db.prepare("INSERT INTO " + qTable +
974
981
  " (id, type, status, subject_id, subject_email, subject_phone, " +
975
982
  " submitted_at, deadline_at, processed_at, verification_level, posture, payload) " +
976
983
  " VALUES ($id, $type, $status, $sid, $email, $phone, $submittedAt, " +
@@ -991,14 +998,14 @@ function dbTicketStore(opts) {
991
998
  });
992
999
  },
993
1000
  get: async function (id) {
994
- var rows = db.prepare("SELECT payload FROM " + table + " WHERE id = $id")
1001
+ var rows = db.prepare("SELECT payload FROM " + qTable + " WHERE id = $id")
995
1002
  .all({ $id: id });
996
1003
  if (!rows || rows.length === 0) return null;
997
1004
  return JSON.parse(rows[0].payload); // allow:bare-json-parse — payload was JSON.stringify-ed by this same store, never from operator/network input
998
1005
  },
999
1006
  list: async function (filter) {
1000
1007
  filter = filter || {};
1001
- var sql = "SELECT payload FROM " + table;
1008
+ var sql = "SELECT payload FROM " + qTable;
1002
1009
  var conds = [];
1003
1010
  var params = {};
1004
1011
  if (filter.status) {
@@ -1021,7 +1028,7 @@ function dbTicketStore(opts) {
1021
1028
  return rows.map(function (r) { return JSON.parse(r.payload); }); // allow:bare-json-parse — payload was JSON.stringify-ed by this same store, never from operator/network input
1022
1029
  },
1023
1030
  update: async function (id, ticket) {
1024
- var stmt = db.prepare("UPDATE " + table + " SET " +
1031
+ var stmt = db.prepare("UPDATE " + qTable + " SET " +
1025
1032
  " type = $type, status = $status, subject_id = $sid, " +
1026
1033
  " subject_email = $email, subject_phone = $phone, " +
1027
1034
  " submitted_at = $submittedAt, deadline_at = $deadlineAt, " +
@@ -1051,10 +1058,10 @@ function dbTicketStore(opts) {
1051
1058
  // Bulk-delete tickets in terminal states whose retentionUntil
1052
1059
  // is in the past. Returns the number of rows removed.
1053
1060
  var asOf = (typeof asOfMs === "number" && isFinite(asOfMs)) ? asOfMs : Date.now();
1054
- var rows = db.prepare("SELECT id, payload FROM " + table +
1061
+ var rows = db.prepare("SELECT id, payload FROM " + qTable +
1055
1062
  " WHERE status IN ('completed','partially_completed','cancelled','rejected','expired')").all({});
1056
1063
  var purged = 0;
1057
- var del = db.prepare("DELETE FROM " + table + " WHERE id = $id");
1064
+ var del = db.prepare("DELETE FROM " + qTable + " WHERE id = $id");
1058
1065
  for (var i = 0; i < rows.length; i++) {
1059
1066
  try {
1060
1067
  var t = JSON.parse(rows[i].payload); // allow:bare-json-parse — payload was JSON.stringify-ed by this same store, never from operator/network input
@@ -1066,7 +1073,7 @@ function dbTicketStore(opts) {
1066
1073
  }
1067
1074
  return purged;
1068
1075
  },
1069
- _table: table,
1076
+ _table: tableRaw,
1070
1077
  _ensureSchema: ensureSchema,
1071
1078
  };
1072
1079
  }
package/lib/inbox.js CHANGED
@@ -143,7 +143,13 @@ function create(opts) {
143
143
  _validateTableName(opts.table);
144
144
 
145
145
  var externalDb = opts.externalDb;
146
- var table = opts.table;
146
+ var tableRaw = opts.table;
147
+ // Identifiers reach SQL through safeSql.quoteIdentifier — runs
148
+ // validateIdentifier internally + emits the dialect-correct quoted
149
+ // form. sqlite + postgres both use the double-quote dialect (per
150
+ // lib/safe-sql.js), so one quoted form serves both inbox paths.
151
+ var qTable = safeSql.quoteIdentifier(tableRaw, "sqlite");
152
+ var qIndex = safeSql.quoteIdentifier(tableRaw + "_received_at_idx", "sqlite");
147
153
  var retentionDays = (typeof opts.retentionDays === "number" && opts.retentionDays > 0) // allow:numeric-opt-Infinity
148
154
  ? opts.retentionDays : 30; // allow:raw-byte-literal — default retention days
149
155
  var auditOn = opts.audit !== false;
@@ -226,7 +232,7 @@ function create(opts) {
226
232
 
227
233
  if (dialect === "postgres") {
228
234
  var rs = await txn.query(
229
- "INSERT INTO " + table +
235
+ "INSERT INTO " + qTable +
230
236
  " (message_id, source, received_at, metadata_json) " +
231
237
  " VALUES ($1, $2, " + nowExpr + ", $3::jsonb) " +
232
238
  " ON CONFLICT (source, message_id) DO NOTHING " +
@@ -248,7 +254,7 @@ function create(opts) {
248
254
  // that the framework can't prevent. RETURNING 1 collapses both
249
255
  // round-trips into one and removes the changes() dependency.
250
256
  var sqlInsert = await txn.query(
251
- "INSERT OR IGNORE INTO " + table +
257
+ "INSERT OR IGNORE INTO " + qTable +
252
258
  " (message_id, source, received_at, metadata_json) " +
253
259
  " VALUES (?, ?, " + nowExpr + ", ?) RETURNING 1",
254
260
  [receiveOpts.messageId, receiveOpts.source, metaJson]);
@@ -268,7 +274,7 @@ function create(opts) {
268
274
  _validateReceiveOpts(receiveOpts, "markProcessed");
269
275
  var nowExpr = _utcNowExpr(externalDb);
270
276
  var dialect = (externalDb.dialect === "postgres") ? "postgres" : "sqlite";
271
- var sql = "UPDATE " + table +
277
+ var sql = "UPDATE " + qTable +
272
278
  " SET processed_at = " + nowExpr +
273
279
  " WHERE source = " + (dialect === "postgres" ? "$1" : "?") +
274
280
  " AND message_id = " + (dialect === "postgres" ? "$2" : "?");
@@ -318,7 +324,7 @@ function create(opts) {
318
324
  var dialect = (xdb && xdb.dialect === "postgres") ? "postgres" : "sqlite";
319
325
  if (dialect === "postgres") {
320
326
  await xdb.query(
321
- "CREATE TABLE IF NOT EXISTS " + table + " (" +
327
+ "CREATE TABLE IF NOT EXISTS " + qTable + " (" +
322
328
  " message_id TEXT NOT NULL," +
323
329
  " source TEXT NOT NULL," +
324
330
  " received_at TIMESTAMPTZ NOT NULL DEFAULT NOW()," +
@@ -327,11 +333,11 @@ function create(opts) {
327
333
  " PRIMARY KEY (source, message_id)" +
328
334
  ")");
329
335
  await xdb.query(
330
- "CREATE INDEX IF NOT EXISTS " + table + "_received_at_idx " +
331
- "ON " + table + " (received_at)");
336
+ "CREATE INDEX IF NOT EXISTS " + qIndex + " " +
337
+ "ON " + qTable + " (received_at)");
332
338
  } else {
333
339
  await xdb.query(
334
- "CREATE TABLE IF NOT EXISTS " + table + " (" +
340
+ "CREATE TABLE IF NOT EXISTS " + qTable + " (" +
335
341
  " message_id TEXT NOT NULL," +
336
342
  " source TEXT NOT NULL," +
337
343
  " received_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP," +
@@ -340,8 +346,8 @@ function create(opts) {
340
346
  " PRIMARY KEY (source, message_id)" +
341
347
  ")");
342
348
  await xdb.query(
343
- "CREATE INDEX IF NOT EXISTS " + table + "_received_at_idx " +
344
- "ON " + table + " (received_at)");
349
+ "CREATE INDEX IF NOT EXISTS " + qIndex + " " +
350
+ "ON " + qTable + " (received_at)");
345
351
  }
346
352
  }
347
353
 
@@ -351,7 +357,7 @@ function create(opts) {
351
357
  await externalDb.transaction(async function (xdb) {
352
358
  if (dialect === "postgres") {
353
359
  var rs = await xdb.query(
354
- "DELETE FROM " + table +
360
+ "DELETE FROM " + qTable +
355
361
  " WHERE received_at < NOW() - $1::interval " +
356
362
  " AND (processed_at IS NOT NULL OR received_at < NOW() - $2::interval)",
357
363
  [retentionDays + " days", (retentionDays * 2) + " days"]);
@@ -360,7 +366,7 @@ function create(opts) {
360
366
  var staleDate = new Date(Date.now() - retentionDays * C.TIME.days(1)).toISOString();
361
367
  var unprocStaleDate = new Date(Date.now() - retentionDays * 2 * C.TIME.days(1)).toISOString();
362
368
  await xdb.query(
363
- "DELETE FROM " + table +
369
+ "DELETE FROM " + qTable +
364
370
  " WHERE received_at < ? " +
365
371
  " AND (processed_at IS NOT NULL OR received_at < ?)",
366
372
  [staleDate, unprocStaleDate]);
@@ -378,7 +384,7 @@ function create(opts) {
378
384
  async function isFresh(receiveOpts) {
379
385
  _validateReceiveOpts(receiveOpts, "isFresh");
380
386
  var dialect = (externalDb.dialect === "postgres") ? "postgres" : "sqlite";
381
- var sql = "SELECT 1 FROM " + table +
387
+ var sql = "SELECT 1 FROM " + qTable +
382
388
  " WHERE source = " + (dialect === "postgres" ? "$1" : "?") +
383
389
  " AND message_id = " + (dialect === "postgres" ? "$2" : "?");
384
390
  var rs = await externalDb.transaction(async function (xdb) {
@@ -395,7 +401,7 @@ function create(opts) {
395
401
  var stats = await externalDb.transaction(async function (xdb) {
396
402
  var sql = "SELECT COUNT(*) AS total," +
397
403
  " COUNT(processed_at) AS processed " +
398
- " FROM " + table +
404
+ " FROM " + qTable +
399
405
  (sourceFilter ? " WHERE source = " +
400
406
  (dialect === "postgres" ? "$1" : "?") : "");
401
407
  var args = sourceFilter ? [sourceFilter] : [];
@@ -418,7 +424,7 @@ function create(opts) {
418
424
  sweep: sweep,
419
425
  isFresh: isFresh,
420
426
  getStats: getReceiveStats,
421
- table: table,
427
+ table: tableRaw,
422
428
  retentionDays: retentionDays,
423
429
  };
424
430
  }