@blamejs/core 0.13.38 → 0.13.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.13.x
10
10
 
11
+ - v0.13.40 (2026-05-29) — **Redis client stops leaking a socket and blocking exit after close; DB exit-handler registers once.** Two handle-lifecycle fixes. The Redis client's reconnect backoff used an untracked, non-unref'd timer: during a backoff window it alone could keep the event loop alive (a process that won't exit), and a reconnect scheduled before close() fired afterward and opened a fresh socket because the connect path didn't re-check the closing flag. The timer is now tracked, unref'd, cancelled in close(), and the connect path refuses to re-open once closing. Separately, the encrypted database registered its process-exit final-flush handler on every init(), so repeated init/close cycles (long test runs, hot reload) accumulated 'exit' listeners toward the MaxListenersExceeded warning; it now registers once for the process lifetime. **Fixed:** *Redis client cancels its reconnect timer on close and won't re-open a closed connection* — The reconnect backoff scheduled `setTimeout(reconnect, delay)` without keeping a handle, without `unref()`, and the reconnect path checked only `connected`/`connecting` — not `closing`. So a backoff window could by itself hold the process open (it won't exit), and a reconnect scheduled before `close()` would fire afterward and open a fresh socket with listeners — a leak after explicit close. The timer is now tracked and `unref()`'d (a backoff no longer blocks exit), cancelled in `close()`, and the connect path returns early once closing so no socket is opened after close. · *Encrypted DB registers its process-exit flush handler once, not per init()* — `b.db.init()` in encrypted mode added a `process.on("exit")` final-flush handler on every call. Across repeated init/close cycles — long test suites, hot reload, embedded re-inits — these accumulated and tripped Node's MaxListenersExceeded warning (and grew memory slightly). The handler is now registered once for the process lifetime, guarded by a module flag, and still flushes whichever encrypted DB is open at exit time.
12
+
13
+ - v0.13.39 (2026-05-29) — **Dual-control approvals are atomic — no quorum bypass or double-consume under concurrency.** The dual-control approval store read a grant, mutated it in memory, and wrote it back with an await in between — a non-atomic read-modify-write. Under concurrent calls (two approvals, or a retried one) each could act on a stale snapshot: the same approver could be appended twice and reach the M-of-N quorum with a single human, or a single-use grant could be consumed twice. approve / consume / revoke / cancel now commit through a new atomic cache.update primitive, so the check and the mutation are one indivisible step. The new b.cache.update is available to application code too — the memory backend is atomic by single-thread, and the cluster backend uses a transaction with compare-and-set so a concurrent writer on another node cannot lose an update. **Added:** *b.cache.update(key, mutatorFn, opts?) — atomic read-modify-write* — Reads the current value, calls `mutatorFn(current | null)`, and commits the result in one operation so a concurrent writer cannot clobber the change — the lost-update race that makes a plain `get` → mutate → `set` unsafe for counters, sets, and quorum state. The memory backend is atomic by single-thread; the cluster backend runs a transaction with a compare-and-set (and retries on contention) so the guarantee holds across nodes. `mutatorFn` returns `{ value }` to commit, `{ abort: data }` to leave the entry untouched and surface `data`, or `{ delete: true }` to remove it; the call resolves to `{ updated, value }`, `{ updated, deleted }`, or `{ aborted }`. **Security:** *Dual-control quorum and single-use guarantees hold under concurrent approvals* — `b.dualControl` persisted each grant through a cache read → in-memory mutate → write-back. Because the read and the write were separate awaited steps, two concurrent `approve` calls (or a retried one behind a load balancer) could each read the same pre-approval snapshot, so the duplicate-approver guard passed twice and the same approver was counted toward the M-of-N quorum twice — reaching quorum with one human. The same shape let two concurrent `consume` calls each see an unconsumed grant and both run the destructive operation. `approve` / `consume` / `revoke` / `cancel` now perform the check-and-mutate atomically via `cache.update`, so exactly one concurrent caller wins each transition.
14
+
11
15
  - v0.13.38 (2026-05-29) — **Atomic cache tag invalidation, and a clusterStorage.transaction primitive for multi-statement framework writes.** The cluster cache stored a value and its tag index with separate statements, so two concurrent writes to the same key could interleave their tag updates and leave the index out of step with the value — a later tag-based invalidation would then miss the key, letting a stale (possibly authorization-bearing) value survive a wipe. The value and tag writes now commit as one atomic unit. The enabling piece is a new b.clusterStorage.transaction primitive that runs a multi-statement read-modify-write all-or-nothing against the active backend — the external DB's pooled transaction in cluster mode, and a serialized transaction on the shared SQLite connection in single-node mode (so no concurrent statement can interleave into an open transaction). **Added:** *b.clusterStorage.transaction(fn) — atomic multi-statement framework-state writes* — Runs `fn` inside one transaction against the active backend so a multi-statement read-modify-write commits all-or-nothing. `fn` receives a transaction handle with the same `execute` / `executeOne` / `executeAll` surface, scoped to the open transaction. Cluster mode uses the external DB's pooled transaction (with its deadlock retry); single-node mode serializes against other transactions and against `execute` on the shared SQLite connection, so a concurrent statement cannot interleave into an open transaction. Use the handle's methods inside `fn` — calling the module-level `execute` from within `fn` would wait on the very transaction it is running. **Fixed:** *Cluster cache tag invalidation can no longer miss a key under concurrent writes* — The cluster cache wrote a value (`INSERT ... ON CONFLICT DO UPDATE`) and then rewrote its tag index (`DELETE` prior tags, `INSERT` new ones) as separate statements. Two concurrent `set()`s on the same key could interleave those tag statements, leaving the tag index inconsistent with the value — so a later `invalidateTag` could miss the key and a stale value would survive the wipe. The value and tag writes now run inside a single `clusterStorage.transaction`, so a concurrent writer observes either the whole prior state or the whole new state, never a mix.
12
16
 
13
17
  - v0.13.37 (2026-05-29) — **Encrypted-mode DB refuses writes before a full tmpfs corrupts it.** In encrypted-at-rest mode the live SQLite working copy is on a tmpfs (Docker's /dev/shm defaults to 64 MiB). If that fills — an append-only audit chain and session rows grow over time — SQLite hits ENOSPC and the working copy is corrupted. Earlier releases made that corruption self-heal on the next boot by rolling back to the last encrypted snapshot, but the rollback still loses writes since the last flush. This adds the prevention side: a periodic free-space probe refuses growth writes (INSERT / UPDATE / REPLACE) with a clear db/storage-low error once free space drops below a threshold, before the tmpfs fills. DELETE and reads stay available so retention can reclaim space and the application keeps serving, and the refusal lifts automatically once free space recovers. The threshold defaults to 16 MiB of headroom and is tunable; the guard is encrypted-mode only. **Security:** *Free-space guard refuses growth writes before the tmpfs working copy fills* — `b.db` in encrypted-at-rest mode now probes free space on the tmpfs holding the working copy and, when it falls below `minFreeBytes` (default 16 MiB), refuses `INSERT` / `UPDATE` / `REPLACE` with a clear `db/storage-low` error instead of letting the write run the mount out of space and corrupt the database. `DELETE`, reads, and DDL stay available so retention can prune and the app keeps serving; the refusal clears automatically when free space recovers. The error message points at the cause (Docker's 64 MiB `/dev/shm` default) and the fix (`shm_size` / `--shm-size`, or pruning). Set `minFreeBytes` to tune the headroom, or `0` to disable. This complements the existing boot-time recovery: prevention (fail clear, keep recent writes) ahead of recovery (roll back to the last snapshot).
package/lib/cache.js CHANGED
@@ -331,6 +331,25 @@ function _memoryBackend(cfg) {
331
331
  return true;
332
332
  }
333
333
 
334
+ // Atomic read-modify-write. Single-process V8 is single-threaded and the
335
+ // read + mutatorFn decision run with no `await` before the write, so no
336
+ // concurrent task can interleave between reading the current value and
337
+ // committing the new one. Same contract as the cluster backend's update.
338
+ async function _updateEntry(key, mutatorFn, expiresAt, meta) {
339
+ var now = clock();
340
+ var entry = entries.get(key);
341
+ var current = (entry && !_isExpired(entry, now)) ? entry.value : null;
342
+ var decision = mutatorFn(current);
343
+ if (decision && decision.abort !== undefined) return { aborted: decision.abort };
344
+ if (decision && decision.delete === true) {
345
+ if (entry) { _untrack(key, entry); entries.delete(key); }
346
+ return { updated: true, deleted: true };
347
+ }
348
+ var effExpires = (decision.expiresAt !== undefined) ? decision.expiresAt : expiresAt;
349
+ await set(key, decision.value, effExpires, meta);
350
+ return { updated: true, value: decision.value };
351
+ }
352
+
334
353
  async function has(key) {
335
354
  var entry = entries.get(key);
336
355
  if (!entry) return false;
@@ -420,6 +439,7 @@ function _memoryBackend(cfg) {
420
439
  name: "memory",
421
440
  get: get,
422
441
  set: set,
442
+ update: _updateEntry,
423
443
  del: del,
424
444
  has: has,
425
445
  clear: clear,
@@ -548,6 +568,74 @@ function _clusterBackend(cfg) {
548
568
  });
549
569
  }
550
570
 
571
+ // Atomic read-modify-write. Reads the current value, calls mutatorFn,
572
+ // and commits the result in one transaction — with a compare-and-set
573
+ // (UPDATE ... WHERE valueJson = <the exact bytes we read>) so a
574
+ // concurrent writer on another node cannot clobber the change (lost
575
+ // update). On a CAS miss the whole thing retries against the fresh
576
+ // value. Single-node serializes via clusterStorage.transaction, so the
577
+ // CAS never misses there; the retry only fires in cluster mode.
578
+ // mutatorFn(current|null) returns { value } to commit, { abort: data }
579
+ // to leave the row untouched and surface `data`, or { delete: true }.
580
+ async function _updateRow(key, mutatorFn, expiresAt, meta) {
581
+ var ck = _composedKey(key);
582
+ var maxRetries = 5;
583
+ for (var attempt = 0; attempt < maxRetries; attempt++) {
584
+ var outcome = await clusterStorage.transaction(async function (tx) {
585
+ var now = clock();
586
+ var row = await tx.executeOne(
587
+ "SELECT valueJson, expiresAt FROM _blamejs_cache WHERE cacheKey = ?", [ck]);
588
+ var oldRaw = null;
589
+ var current = null;
590
+ if (row && row.expiresAt > now) {
591
+ oldRaw = row.valueJson;
592
+ var stored = row.valueJson;
593
+ if (typeof stored === "string" && stored.indexOf(CACHE_SEAL_PREFIX) === 0) {
594
+ stored = vault().unseal(stored.substring(CACHE_SEAL_PREFIX.length));
595
+ }
596
+ current = safeJson.parse(stored, { maxBytes: C.BYTES.mib(64) });
597
+ }
598
+ var decision = mutatorFn(current);
599
+ if (decision && decision.abort !== undefined) return { aborted: decision.abort };
600
+ if (decision && decision.delete === true) {
601
+ if (oldRaw !== null) {
602
+ await tx.execute("DELETE FROM _blamejs_cache WHERE cacheKey = ? AND valueJson = ?", [ck, oldRaw]);
603
+ await tx.execute("DELETE FROM _blamejs_cache_tags WHERE cacheKey = ?", [ck]);
604
+ }
605
+ return { updated: true, deleted: true };
606
+ }
607
+ var json = safeJson.stringify(decision.value);
608
+ if (meta && meta.seal === true) json = CACHE_SEAL_PREFIX + vault().seal(json);
609
+ // The mutator may pin the entry's expiry to the value's own
610
+ // lifetime (e.g. a grant whose expiresAt the mutator just read);
611
+ // otherwise the caller-resolved ttl applies.
612
+ var effExpires = (decision.expiresAt !== undefined) ? decision.expiresAt : expiresAt;
613
+ var storedExpires = (effExpires === Infinity) ? Number.MAX_SAFE_INTEGER : effExpires;
614
+ if (oldRaw === null) {
615
+ // Row was absent/expired — insert, but lose the race if another
616
+ // writer inserted concurrently (ON CONFLICT DO NOTHING → 0 rows).
617
+ var ins = await tx.execute(
618
+ "INSERT INTO _blamejs_cache (cacheKey, valueJson, expiresAt, updatedAt) " +
619
+ "VALUES (?, ?, ?, ?) ON CONFLICT (cacheKey) DO NOTHING",
620
+ [ck, json, storedExpires, now]);
621
+ if (!ins || ins.rowCount !== 1) return { conflict: true };
622
+ } else {
623
+ // CAS: only commit if the row still holds the exact bytes we read.
624
+ var upd = await tx.execute(
625
+ "UPDATE _blamejs_cache SET valueJson = ?, expiresAt = ?, updatedAt = ? " +
626
+ "WHERE cacheKey = ? AND valueJson = ?",
627
+ [json, storedExpires, now, ck, oldRaw]);
628
+ if (!upd || upd.rowCount !== 1) return { conflict: true };
629
+ }
630
+ return { updated: true, value: decision.value };
631
+ });
632
+ if (outcome && outcome.conflict) continue; // value moved under us — retry
633
+ return outcome;
634
+ }
635
+ throw _err("UPDATE_CONTENTION",
636
+ "cache.update: exceeded " + maxRetries + " retries under write contention for key");
637
+ }
638
+
551
639
  async function del(key) {
552
640
  var ck = _composedKey(key);
553
641
  var result = await clusterStorage.execute(
@@ -681,6 +769,7 @@ function _clusterBackend(cfg) {
681
769
  name: "cluster",
682
770
  get: get,
683
771
  set: set,
772
+ update: _updateRow,
684
773
  del: del,
685
774
  has: has,
686
775
  clear: clear,
@@ -1023,6 +1112,68 @@ function create(opts) {
1023
1112
  emitObs("cache.set", { namespace: namespace });
1024
1113
  }
1025
1114
 
1115
+ /**
1116
+ * @primitive b.cache.update
1117
+ * @signature b.cache.update(key, mutatorFn, opts?)
1118
+ * @since 0.13.39
1119
+ * @status stable
1120
+ * @related b.cache.create
1121
+ *
1122
+ * Atomic read-modify-write. Reads the current value, calls
1123
+ * `mutatorFn(current | null)`, and commits the result in one operation
1124
+ * so a concurrent writer cannot clobber the change (lost update) — the
1125
+ * race that makes a plain `get` → mutate → `set` unsafe for counters,
1126
+ * sets, and quorum state. The memory backend is atomic by single-thread;
1127
+ * the cluster backend uses a transaction with compare-and-set + retry.
1128
+ *
1129
+ * `mutatorFn` returns one of: `{ value }` to commit the new value,
1130
+ * `{ abort: data }` to leave the entry untouched and surface `data` to
1131
+ * the caller, or `{ delete: true }` to remove the entry. The call
1132
+ * resolves to `{ updated: true, value }`, `{ updated: true, deleted: true }`,
1133
+ * or `{ aborted: data }`.
1134
+ *
1135
+ * @opts
1136
+ * ttlMs: number | Infinity, // lifetime of the written value; default the instance ttlMs
1137
+ * seal: boolean, // cluster backend only — seal the value at rest
1138
+ *
1139
+ * @example
1140
+ * await counters.update("hits", function (n) {
1141
+ * return { value: (n || 0) + 1 };
1142
+ * });
1143
+ */
1144
+ async function update(key, mutatorFn, callerOpts) {
1145
+ _ensureOpen("update");
1146
+ _validateKey(key, "cache.update");
1147
+ if (typeof mutatorFn !== "function") {
1148
+ throw _err("BAD_OPT", "cache.update: mutatorFn must be a function, got " + typeof mutatorFn);
1149
+ }
1150
+ if (typeof backend.update !== "function") {
1151
+ throw _err("UNSUPPORTED",
1152
+ "cache.update is unsupported by the '" + (backend.name || "custom") + "' backend " +
1153
+ "(memory + cluster implement it; a custom backend must provide update for atomic RMW).");
1154
+ }
1155
+ var ttlMs = _resolveTtl(callerOpts, "update");
1156
+ var expiresAt = (ttlMs === Infinity) ? Infinity : (clock() + ttlMs);
1157
+ var seal = !!(callerOpts && callerOpts.seal === true);
1158
+ if (seal && backend.name !== "cluster") {
1159
+ throw _err("BAD_OPT",
1160
+ "cache.update: seal: true is only supported on the cluster backend " +
1161
+ "(this cache instance uses '" + (backend.name || "custom") + "').");
1162
+ }
1163
+ var result;
1164
+ try { result = await backend.update(key, mutatorFn, expiresAt, { ttlMs: ttlMs, seal: seal }); }
1165
+ catch (e) {
1166
+ emitObs("cache.backend.failed", { namespace: namespace, op: "update" });
1167
+ _backendFailedAudit("update", e);
1168
+ throw e;
1169
+ }
1170
+ if (result && (result.updated || result.deleted)) {
1171
+ emitObs("cache.update", { namespace: namespace });
1172
+ if (result.deleted) { softExpiry.delete(key); _publishInvalidation({ kind: "del", key: key }); }
1173
+ }
1174
+ return result;
1175
+ }
1176
+
1026
1177
  async function del(key) {
1027
1178
  _ensureOpen("del");
1028
1179
  _validateKey(key, "cache.del");
@@ -1317,6 +1468,7 @@ function create(opts) {
1317
1468
  return {
1318
1469
  get: get,
1319
1470
  set: set,
1471
+ update: update,
1320
1472
  del: del,
1321
1473
  has: has,
1322
1474
  clear: clear,
package/lib/db.js CHANGED
@@ -142,6 +142,12 @@ var storageProbeTimer = null; // periodic free-space probe handle
142
142
  var writesRefused = false; // true when free space < minFreeBytes
143
143
  var minFreeBytes = 0; // refuse growth writes below this (0 = guard off)
144
144
  var statfsProbe = null; // free-space reader (fs.statfsSync; injectable for tests)
145
+ // The process-exit final-flush handler is registered ONCE at first
146
+ // encrypted init. Re-registering per init() leaked an 'exit' listener on
147
+ // every init/close cycle (MaxListenersExceeded in long test runs / hot
148
+ // reload); the flag makes it idempotent. The handler reads live module
149
+ // state at exit time, so a later re-init is still covered.
150
+ var _exitHandlerRegistered = false;
145
151
  var dataDir = null;
146
152
  var initialized = false;
147
153
  var dataResidency = null; // operator's declared region config (validated by storage backends)
@@ -1436,10 +1442,16 @@ async function init(opts) {
1436
1442
 
1437
1443
  // Final encrypt on process exit. We don't try to unlink the plaintext
1438
1444
  // here — the SQLite handle may still be open, and the OS reclaims tmpfs
1439
- // on reboot anyway. close() does the orderly shutdown.
1440
- process.on("exit", function () {
1441
- try { encryptToDisk(); } catch (_e) { /* exit handler silent */ }
1442
- });
1445
+ // on reboot anyway. close() does the orderly shutdown. Registered ONCE
1446
+ // (guarded by the module flag) — re-registering per init() leaked an
1447
+ // 'exit' listener on every init/close cycle. The handler reads live
1448
+ // module state, so it still flushes whatever DB is open at exit.
1449
+ if (!_exitHandlerRegistered) {
1450
+ _exitHandlerRegistered = true;
1451
+ process.on("exit", function () {
1452
+ try { if (atRest === "encrypted") encryptToDisk(); } catch (_e) { /* exit handler — silent */ }
1453
+ });
1454
+ }
1443
1455
  }
1444
1456
 
1445
1457
  log("ready (mode: " + atRest + ", path: " + dbPath + ")");
@@ -301,27 +301,27 @@ function create(opts) {
301
301
 
302
302
  async function cancel(args) {
303
303
  if (!args || typeof args !== "object") throw _err("BAD_ARG", "cancel: args required");
304
- var record = await _load(args.grantId);
305
- if (!record) return { error: "grant-not-found", grantId: args.grantId };
306
- if (record.consumedAt !== null) return { error: "grant-already-consumed", grantId: record.grantId };
307
- if (record.revokedAt !== null) return { error: "grant-revoked", grantId: record.grantId };
308
- if (record.cancelledAt !== null) return { error: "grant-already-cancelled", grantId: record.grantId };
309
304
  var actorId = _actorIdOf(args.cancelledBy);
310
- if (actorId !== record.requestedBy) {
311
- // Cancellation by anyone other than the requester is a revoke,
312
- // not a cancel. Surface explicitly.
313
- return { error: "only-requester-can-cancel", grantId: record.grantId,
314
- requestedBy: record.requestedBy };
315
- }
316
- record.cancelledAt = Date.now();
317
- record.cancelledReason = args.reason || null;
318
- var ttlRemaining = Math.max(1, record.expiresAt - Date.now());
319
- await cache.set(_key(record.grantId), record, { ttlMs: ttlRemaining });
305
+ var outcome = await cache.update(_key(args.grantId), function (record) {
306
+ if (!record) return { abort: { response: { error: "grant-not-found", grantId: args.grantId } } };
307
+ if (record.consumedAt !== null) return { abort: { response: { error: "grant-already-consumed", grantId: record.grantId } } };
308
+ if (record.revokedAt !== null) return { abort: { response: { error: "grant-revoked", grantId: record.grantId } } };
309
+ if (record.cancelledAt !== null) return { abort: { response: { error: "grant-already-cancelled", grantId: record.grantId } } };
310
+ // Cancellation by anyone other than the requester is a revoke, not a
311
+ // cancel — surface explicitly.
312
+ if (actorId !== record.requestedBy) {
313
+ return { abort: { response: { error: "only-requester-can-cancel", grantId: record.grantId, requestedBy: record.requestedBy } } };
314
+ }
315
+ record.cancelledAt = Date.now();
316
+ record.cancelledReason = args.reason || null;
317
+ return { value: record, expiresAt: record.expiresAt };
318
+ }, { ttlMs: ttlMs });
319
+ if (outcome.aborted) return outcome.aborted.response;
320
+ var rec = outcome.value;
320
321
  _emit("dual.grant.cancelled",
321
- { grantId: record.grantId, action: record.action,
322
- cancelledBy: actorId, reason: args.reason || null },
322
+ { grantId: rec.grantId, action: rec.action, cancelledBy: actorId, reason: args.reason || null },
323
323
  "success", args.req);
324
- return { grantId: record.grantId, status: "cancelled" };
324
+ return { grantId: rec.grantId, status: "cancelled" };
325
325
  }
326
326
 
327
327
  async function _load(grantId) {
@@ -334,156 +334,152 @@ function create(opts) {
334
334
 
335
335
  async function approve(args) {
336
336
  if (!args || typeof args !== "object") throw _err("BAD_ARG", "approve: args required");
337
- var record = await _load(args.grantId);
338
- if (!record) {
339
- return { error: "grant-not-found", grantId: args.grantId };
340
- }
341
- if (record.consumedAt !== null) {
342
- return { error: "grant-already-consumed", grantId: record.grantId };
343
- }
344
- if (record.revokedAt !== null) {
345
- return { error: "grant-revoked", grantId: record.grantId, revokedReason: record.revokedReason };
346
- }
347
- if (record.cancelledAt !== null) {
348
- return { error: "grant-cancelled", grantId: record.grantId };
349
- }
350
- if (record.expiresAt < Date.now()) {
351
- _emit("dual.grant.expired", { grantId: record.grantId, action: record.action },
352
- "failure", args.req);
353
- await cache.del(_key(record.grantId));
354
- return { error: "grant-expired", grantId: record.grantId };
355
- }
356
337
  var approverId = _actorIdOf(args.approver);
357
338
  if (!approverId) throw _err("BAD_ARG", "approve: args.approver must be an actor with a stable id");
358
- if (forbidSelfApprove && approverId === record.requestedBy) {
359
- _emit("dual.grant.self_approval_denied",
360
- { grantId: record.grantId, action: record.action, approver: approverId },
361
- "denied", args.req);
362
- return { error: "self-approval-forbidden", grantId: record.grantId };
363
- }
364
- if (!_approverRoleOk(args.approver)) {
365
- _emit("dual.grant.role_denied",
366
- { grantId: record.grantId, action: record.action, approver: approverId,
367
- requiredRoles: approverRoles,
368
- actorRoles: (args.approver && Array.isArray(args.approver.roles)) ? args.approver.roles : [] },
369
- "denied", args.req);
370
- return { error: "approver-role-required", grantId: record.grantId,
371
- requiredRoles: approverRoles };
372
- }
373
- if (record.approvedBy.indexOf(approverId) !== -1) {
374
- return { error: "already-approved-by-this-actor", grantId: record.grantId,
375
- approvedBy: record.approvedBy };
376
- }
339
+ // Pre-compute the pure, record-independent checks so the mutator stays
340
+ // side-effect-free (it may re-run under cluster CAS contention).
341
+ var roleOk = _approverRoleOk(args.approver);
377
342
  var reasonProblem = _checkReason(args.reason, "approve");
378
- if (reasonProblem) {
379
- return Object.assign({ grantId: record.grantId }, reasonProblem);
380
- }
381
- record.approvedBy.push(approverId);
382
- record.approvalsAt.push(Date.now());
383
- record.approvalReasons.push(args.reason || null);
384
- if (approverRoles && args.approver && Array.isArray(args.approver.roles)) {
385
- // Record which of the required roles satisfied the approval —
386
- // useful when an audit reviewer needs to confirm the actor
387
- // approved as e.g. their security-officer role and not their
388
- // engineer role.
389
- var hits = args.approver.roles.filter(function (r) { return approverRoles.indexOf(r) !== -1; });
390
- record.approverRoleHits.push(hits);
391
- }
392
- var status = "pending";
393
- if (record.approvedBy.length >= record.minApprovers) {
394
- status = "approved";
395
- if (record.quorumReachedAt === null) record.quorumReachedAt = Date.now();
343
+ var roleHits = (approverRoles && args.approver && Array.isArray(args.approver.roles))
344
+ ? args.approver.roles.filter(function (r) { return approverRoles.indexOf(r) !== -1; })
345
+ : null;
346
+
347
+ // Atomic read-modify-write: the duplicate-approver guard and the
348
+ // approvedBy append commit together, so two concurrent approvals (or a
349
+ // retried one) cannot each read a stale snapshot and append the same
350
+ // approver twice the quorum-bypass the get/set version allowed.
351
+ var outcome = await cache.update(_key(args.grantId), function (record) {
352
+ if (!record) return { abort: { response: { error: "grant-not-found", grantId: args.grantId } } };
353
+ if (record.consumedAt !== null) return { abort: { response: { error: "grant-already-consumed", grantId: record.grantId } } };
354
+ if (record.revokedAt !== null) return { abort: { response: { error: "grant-revoked", grantId: record.grantId, revokedReason: record.revokedReason } } };
355
+ if (record.cancelledAt !== null) return { abort: { response: { error: "grant-cancelled", grantId: record.grantId } } };
356
+ if (record.expiresAt < Date.now()) {
357
+ return { abort: { response: { error: "grant-expired", grantId: record.grantId },
358
+ event: "dual.grant.expired", meta: { grantId: record.grantId, action: record.action }, outcome: "failure" } };
359
+ }
360
+ if (forbidSelfApprove && approverId === record.requestedBy) {
361
+ return { abort: { response: { error: "self-approval-forbidden", grantId: record.grantId },
362
+ event: "dual.grant.self_approval_denied",
363
+ meta: { grantId: record.grantId, action: record.action, approver: approverId }, outcome: "denied" } };
364
+ }
365
+ if (!roleOk) {
366
+ return { abort: { response: { error: "approver-role-required", grantId: record.grantId, requiredRoles: approverRoles },
367
+ event: "dual.grant.role_denied",
368
+ meta: { grantId: record.grantId, action: record.action, approver: approverId, requiredRoles: approverRoles,
369
+ actorRoles: (args.approver && Array.isArray(args.approver.roles)) ? args.approver.roles : [] }, outcome: "denied" } };
370
+ }
371
+ if (record.approvedBy.indexOf(approverId) !== -1) {
372
+ return { abort: { response: { error: "already-approved-by-this-actor", grantId: record.grantId, approvedBy: record.approvedBy.slice() } } };
373
+ }
374
+ if (reasonProblem) return { abort: { response: Object.assign({ grantId: record.grantId }, reasonProblem) } };
375
+ record.approvedBy.push(approverId);
376
+ record.approvalsAt.push(Date.now());
377
+ record.approvalReasons.push(args.reason || null);
378
+ // Record which required role satisfied the approval — an audit
379
+ // reviewer can confirm the actor approved as e.g. security-officer.
380
+ if (roleHits) record.approverRoleHits.push(roleHits);
381
+ if (record.approvedBy.length >= record.minApprovers && record.quorumReachedAt === null) {
382
+ record.quorumReachedAt = Date.now();
383
+ }
384
+ return { value: record, expiresAt: record.expiresAt };
385
+ }, { ttlMs: ttlMs });
386
+
387
+ if (outcome.aborted) {
388
+ var ab = outcome.aborted;
389
+ if (ab.event) _emit(ab.event, ab.meta, ab.outcome, args.req);
390
+ return ab.response;
396
391
  }
397
- var ttlRemaining = Math.max(1, record.expiresAt - Date.now());
398
- await cache.set(_key(record.grantId), record, { ttlMs: ttlRemaining });
392
+ var rec = outcome.value;
393
+ var status = (rec.approvedBy.length >= rec.minApprovers) ? "approved" : "pending";
394
+ var consumeUnlockAt = rec.quorumReachedAt !== null ? rec.quorumReachedAt + rec.consumeLockMs : null;
399
395
  _emit("dual.grant.approved",
400
- { grantId: record.grantId, action: record.action, approver: approverId,
401
- approverCount: record.approvedBy.length, needs: record.minApprovers,
402
- status: status, reason: args.reason || null,
403
- consumeUnlockAt: record.quorumReachedAt !== null
404
- ? record.quorumReachedAt + record.consumeLockMs : null },
396
+ { grantId: rec.grantId, action: rec.action, approver: approverId,
397
+ approverCount: rec.approvedBy.length, needs: rec.minApprovers,
398
+ status: status, reason: args.reason || null, consumeUnlockAt: consumeUnlockAt },
405
399
  "success", args.req);
406
400
  return {
407
- grantId: record.grantId,
401
+ grantId: rec.grantId,
408
402
  status: status,
409
- approvedBy: record.approvedBy.slice(),
410
- needs: record.minApprovers,
411
- expiresAt: record.expiresAt,
412
- consumeUnlockAt: record.quorumReachedAt !== null
413
- ? record.quorumReachedAt + record.consumeLockMs : null,
403
+ approvedBy: rec.approvedBy.slice(),
404
+ needs: rec.minApprovers,
405
+ expiresAt: rec.expiresAt,
406
+ consumeUnlockAt: consumeUnlockAt,
414
407
  };
415
408
  }
416
409
 
417
410
  async function revoke(args) {
418
411
  if (!args || typeof args !== "object") throw _err("BAD_ARG", "revoke: args required");
419
- var record = await _load(args.grantId);
420
- if (!record) return { error: "grant-not-found", grantId: args.grantId };
421
- if (record.consumedAt !== null) {
422
- return { error: "grant-already-consumed", grantId: record.grantId };
423
- }
424
- record.revokedAt = Date.now();
425
- record.revokedReason = args.reason || null;
426
- var ttlRemaining = Math.max(1, record.expiresAt - Date.now());
427
- await cache.set(_key(record.grantId), record, { ttlMs: ttlRemaining });
412
+ var revokedById = _actorIdOf(args.revokedBy);
413
+ var outcome = await cache.update(_key(args.grantId), function (record) {
414
+ if (!record) return { abort: { response: { error: "grant-not-found", grantId: args.grantId } } };
415
+ if (record.consumedAt !== null) return { abort: { response: { error: "grant-already-consumed", grantId: record.grantId } } };
416
+ record.revokedAt = Date.now();
417
+ record.revokedReason = args.reason || null;
418
+ return { value: record, expiresAt: record.expiresAt };
419
+ }, { ttlMs: ttlMs });
420
+ if (outcome.aborted) return outcome.aborted.response;
421
+ var rec = outcome.value;
428
422
  _emit("dual.grant.denied",
429
- { grantId: record.grantId, action: record.action,
430
- revokedBy: _actorIdOf(args.revokedBy), reason: args.reason || null },
423
+ { grantId: rec.grantId, action: rec.action, revokedBy: revokedById, reason: args.reason || null },
431
424
  "denied", args.req);
432
- return { grantId: record.grantId, status: "revoked" };
425
+ return { grantId: rec.grantId, status: "revoked" };
433
426
  }
434
427
 
435
428
  async function consume(grantId, args) {
436
429
  args = args || {};
437
- var record = await _load(grantId);
438
- if (!record) return { ready: false, reason: "grant-not-found" };
439
- if (record.revokedAt !== null) {
440
- return { ready: false, reason: "revoked" };
441
- }
442
- if (record.cancelledAt !== null) {
443
- return { ready: false, reason: "cancelled" };
444
- }
445
- if (record.consumedAt !== null) {
446
- return { ready: false, reason: "already-consumed" };
447
- }
448
- if (record.expiresAt < Date.now()) {
449
- _emit("dual.grant.expired", { grantId: record.grantId, action: record.action },
450
- "failure", args.req);
451
- await cache.del(_key(record.grantId));
452
- return { ready: false, reason: "expired" };
453
- }
454
- if (record.approvedBy.length < record.minApprovers) {
455
- return { ready: false, reason: "not-enough-approvers",
456
- approvedBy: record.approvedBy.slice(), needs: record.minApprovers };
457
- }
458
- // Cooling-off lock: ANY approval-quorum-reached grant can't consume
459
- // until consumeLockMs has passed since the final approval. Defends
460
- // against rapid-burst compromise of requester+approver.
461
- if ((record.consumeLockMs || 0) > 0 && record.quorumReachedAt !== null) {
462
- var unlockAt = record.quorumReachedAt + record.consumeLockMs;
463
- if (Date.now() < unlockAt) {
464
- _emit("dual.grant.consume_locked",
465
- { grantId: record.grantId, action: record.action,
466
- unlockAt: unlockAt, waitMs: unlockAt - Date.now() },
467
- "denied", args.req);
468
- return { ready: false, reason: "consume-locked", unlockAt: unlockAt,
469
- waitMs: unlockAt - Date.now() };
430
+ // Atomic read-modify-write: the consumedAt check and the consumedAt set
431
+ // commit together (compare-and-set), so two concurrent consumes cannot
432
+ // both observe consumedAt === null and both proceed — exactly one wins
433
+ // and runs the destructive operation. The get/set version let a
434
+ // single-use grant be consumed twice.
435
+ var outcome = await cache.update(_key(grantId), function (record) {
436
+ if (!record) return { abort: { response: { ready: false, reason: "grant-not-found" } } };
437
+ if (record.revokedAt !== null) return { abort: { response: { ready: false, reason: "revoked" } } };
438
+ if (record.cancelledAt !== null) return { abort: { response: { ready: false, reason: "cancelled" } } };
439
+ if (record.consumedAt !== null) return { abort: { response: { ready: false, reason: "already-consumed" } } };
440
+ if (record.expiresAt < Date.now()) {
441
+ return { abort: { response: { ready: false, reason: "expired" }, del: true,
442
+ event: "dual.grant.expired", meta: { grantId: record.grantId, action: record.action }, outcome: "failure" } };
470
443
  }
444
+ if (record.approvedBy.length < record.minApprovers) {
445
+ return { abort: { response: { ready: false, reason: "not-enough-approvers",
446
+ approvedBy: record.approvedBy.slice(), needs: record.minApprovers } } };
447
+ }
448
+ // Cooling-off lock: a quorum-reached grant can't consume until
449
+ // consumeLockMs has passed since the final approval — defends against
450
+ // rapid-burst compromise of requester + approver.
451
+ if ((record.consumeLockMs || 0) > 0 && record.quorumReachedAt !== null) {
452
+ var unlockAt = record.quorumReachedAt + record.consumeLockMs;
453
+ if (Date.now() < unlockAt) {
454
+ return { abort: { response: { ready: false, reason: "consume-locked", unlockAt: unlockAt, waitMs: unlockAt - Date.now() },
455
+ event: "dual.grant.consume_locked",
456
+ meta: { grantId: record.grantId, action: record.action, unlockAt: unlockAt, waitMs: unlockAt - Date.now() }, outcome: "denied" } };
457
+ }
458
+ }
459
+ record.consumedAt = Date.now();
460
+ return { value: record, expiresAt: record.expiresAt };
461
+ }, { ttlMs: ttlMs });
462
+
463
+ if (outcome.aborted) {
464
+ var ab = outcome.aborted;
465
+ if (ab.event) _emit(ab.event, ab.meta, ab.outcome, args.req);
466
+ if (ab.del) { try { await cache.del(_key(grantId)); } catch (_e) { /* sweep handles it */ } }
467
+ return ab.response;
471
468
  }
472
- record.consumedAt = Date.now();
473
- // Drop the grant from the cache after consume single-use by design.
474
- await cache.del(_key(record.grantId));
469
+ var rec = outcome.value;
470
+ // Single-use by design drop the (now consumed) grant from the cache.
471
+ await cache.del(_key(rec.grantId));
475
472
  _emit("dual.grant.consumed",
476
- { grantId: record.grantId, action: record.action,
477
- approvedBy: record.approvedBy.slice(),
478
- approvalReasons: record.approvalReasons.slice() },
473
+ { grantId: rec.grantId, action: rec.action,
474
+ approvedBy: rec.approvedBy.slice(), approvalReasons: rec.approvalReasons.slice() },
479
475
  "success", args.req);
480
476
  return {
481
477
  ready: true,
482
- grantId: record.grantId,
483
- action: record.action,
484
- resource: record.resource,
485
- approvedBy: record.approvedBy.slice(),
486
- requestedBy:record.requestedBy,
478
+ grantId: rec.grantId,
479
+ action: rec.action,
480
+ resource: rec.resource,
481
+ approvedBy: rec.approvedBy.slice(),
482
+ requestedBy:rec.requestedBy,
487
483
  };
488
484
  }
489
485
 
@@ -182,6 +182,11 @@ function create(opts) {
182
182
  var connected = false;
183
183
  var connecting = false;
184
184
  var closing = false;
185
+ // Tracked + unref'd reconnect timer. Tracked so close() can cancel a
186
+ // pending backoff (otherwise a reconnect scheduled before close fires
187
+ // after it and opens a fresh socket); unref'd so a backoff window doesn't
188
+ // by itself keep the event loop alive (the process-won't-exit class).
189
+ var reconnectTimer = null;
185
190
  var rxBuffer = Buffer.alloc(0);
186
191
  // FIFO of in-flight commands awaiting a response
187
192
  var pending = [];
@@ -210,7 +215,11 @@ function create(opts) {
210
215
  }
211
216
  reconnectAttempt++;
212
217
  var delay = Math.min(C.TIME.seconds(30), 100 * Math.pow(2, reconnectAttempt - 1));
213
- setTimeout(function () { _connect().catch(function () { /* will reschedule */ }); }, delay);
218
+ reconnectTimer = setTimeout(function () {
219
+ reconnectTimer = null;
220
+ _connect().catch(function () { /* will reschedule */ });
221
+ }, delay);
222
+ if (typeof reconnectTimer.unref === "function") reconnectTimer.unref();
214
223
  }
215
224
 
216
225
  function _drainPending(err) {
@@ -290,6 +299,9 @@ function create(opts) {
290
299
  }
291
300
 
292
301
  async function _connect() {
302
+ // A reconnect timer scheduled before close() can still fire afterward;
303
+ // refuse to re-open once closing so it doesn't leak a fresh socket.
304
+ if (closing) return;
293
305
  if (connected) return;
294
306
  if (connecting) {
295
307
  // Wait until current connect attempt resolves
@@ -423,6 +435,7 @@ function create(opts) {
423
435
 
424
436
  async function close() {
425
437
  closing = true;
438
+ if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
426
439
  var err = _err("CLOSED", "redis client closed");
427
440
  _drainPending(err);
428
441
  if (socket) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.13.38",
3
+ "version": "0.13.40",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:fc7082b3-a639-421e-8e57-fac70e7ced00",
5
+ "serialNumber": "urn:uuid:949239a7-6f00-4f14-9e96-43c3b5935fb9",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-29T16:34:17.392Z",
8
+ "timestamp": "2026-05-29T17:53:59.118Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.13.38",
22
+ "bom-ref": "@blamejs/core@0.13.40",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.13.38",
25
+ "version": "0.13.40",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.13.38",
29
+ "purl": "pkg:npm/%40blamejs/core@0.13.40",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.13.38",
57
+ "ref": "@blamejs/core@0.13.40",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]