@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 +4 -0
- package/lib/cache.js +152 -0
- package/lib/db.js +16 -4
- package/lib/dual-control.js +139 -143
- package/lib/redis-client.js +14 -1
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
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
|
-
|
|
1441
|
-
|
|
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 + ")");
|
package/lib/dual-control.js
CHANGED
|
@@ -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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
return { error: "
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
record.
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
if (
|
|
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
|
|
398
|
-
|
|
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:
|
|
401
|
-
approverCount:
|
|
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:
|
|
401
|
+
grantId: rec.grantId,
|
|
408
402
|
status: status,
|
|
409
|
-
approvedBy:
|
|
410
|
-
needs:
|
|
411
|
-
expiresAt:
|
|
412
|
-
consumeUnlockAt:
|
|
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
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
return { error: "grant-already-consumed", grantId: record.grantId };
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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:
|
|
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:
|
|
425
|
+
return { grantId: rec.grantId, status: "revoked" };
|
|
433
426
|
}
|
|
434
427
|
|
|
435
428
|
async function consume(grantId, args) {
|
|
436
429
|
args = args || {};
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
return { ready: false, reason: "
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
return { ready: false, reason: "already-consumed" };
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
|
|
473
|
-
//
|
|
474
|
-
await cache.del(_key(
|
|
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:
|
|
477
|
-
approvedBy:
|
|
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:
|
|
483
|
-
action:
|
|
484
|
-
resource:
|
|
485
|
-
approvedBy:
|
|
486
|
-
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
|
|
package/lib/redis-client.js
CHANGED
|
@@ -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
|
-
|
|
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
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:
|
|
5
|
+
"serialNumber": "urn:uuid:949239a7-6f00-4f14-9e96-43c3b5935fb9",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
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.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.13.40",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.13.
|
|
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.
|
|
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.
|
|
57
|
+
"ref": "@blamejs/core@0.13.40",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|