@blamejs/blamejs-shop 0.2.14 → 0.2.17

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.
Files changed (52) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +2 -2
  3. package/lib/admin.js +970 -1
  4. package/lib/asset-manifest.json +5 -1
  5. package/lib/customers.js +71 -4
  6. package/lib/storefront.js +462 -0
  7. package/lib/vendor/MANIFEST.json +2 -2
  8. package/lib/vendor/blamejs/CHANGELOG.md +20 -0
  9. package/lib/vendor/blamejs/api-snapshot.json +6 -2
  10. package/lib/vendor/blamejs/examples/wiki/docker-compose.prod.yml +29 -0
  11. package/lib/vendor/blamejs/examples/wiki/docker-compose.yml +7 -0
  12. package/lib/vendor/blamejs/lib/agent-idempotency.js +15 -3
  13. package/lib/vendor/blamejs/lib/agent-orchestrator.js +39 -0
  14. package/lib/vendor/blamejs/lib/app-shutdown.js +32 -0
  15. package/lib/vendor/blamejs/lib/bounded-map.js +102 -0
  16. package/lib/vendor/blamejs/lib/cache.js +184 -23
  17. package/lib/vendor/blamejs/lib/cert.js +68 -11
  18. package/lib/vendor/blamejs/lib/cluster-storage.js +114 -10
  19. package/lib/vendor/blamejs/lib/db.js +205 -15
  20. package/lib/vendor/blamejs/lib/dual-control.js +139 -143
  21. package/lib/vendor/blamejs/lib/i18n.js +10 -1
  22. package/lib/vendor/blamejs/lib/mail-crypto-smime.js +43 -9
  23. package/lib/vendor/blamejs/lib/network-dns.js +10 -2
  24. package/lib/vendor/blamejs/lib/nonce-store.js +39 -12
  25. package/lib/vendor/blamejs/lib/queue-local.js +2 -2
  26. package/lib/vendor/blamejs/lib/redis-client.js +14 -1
  27. package/lib/vendor/blamejs/lib/subject.js +8 -1
  28. package/lib/vendor/blamejs/package.json +1 -1
  29. package/lib/vendor/blamejs/release-notes/v0.13.33.json +26 -0
  30. package/lib/vendor/blamejs/release-notes/v0.13.34.json +48 -0
  31. package/lib/vendor/blamejs/release-notes/v0.13.35.json +35 -0
  32. package/lib/vendor/blamejs/release-notes/v0.13.36.json +27 -0
  33. package/lib/vendor/blamejs/release-notes/v0.13.37.json +18 -0
  34. package/lib/vendor/blamejs/release-notes/v0.13.38.json +27 -0
  35. package/lib/vendor/blamejs/release-notes/v0.13.39.json +27 -0
  36. package/lib/vendor/blamejs/release-notes/v0.13.40.json +22 -0
  37. package/lib/vendor/blamejs/release-notes/v0.13.41.json +27 -0
  38. package/lib/vendor/blamejs/release-notes/v0.13.42.json +18 -0
  39. package/lib/vendor/blamejs/test/20-db.js +191 -0
  40. package/lib/vendor/blamejs/test/layer-0-primitives/agent-idempotency.test.js +20 -0
  41. package/lib/vendor/blamejs/test/layer-0-primitives/agent-orchestrator.test.js +33 -0
  42. package/lib/vendor/blamejs/test/layer-0-primitives/api-encrypt.test.js +35 -0
  43. package/lib/vendor/blamejs/test/layer-0-primitives/app-shutdown.test.js +64 -0
  44. package/lib/vendor/blamejs/test/layer-0-primitives/bounded-map.test.js +87 -0
  45. package/lib/vendor/blamejs/test/layer-0-primitives/cache.test.js +48 -0
  46. package/lib/vendor/blamejs/test/layer-0-primitives/cert.test.js +170 -0
  47. package/lib/vendor/blamejs/test/layer-0-primitives/cluster-storage.test.js +125 -0
  48. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +92 -1
  49. package/lib/vendor/blamejs/test/layer-0-primitives/dual-control.test.js +32 -0
  50. package/lib/vendor/blamejs/test/layer-0-primitives/mail-crypto-smime.test.js +31 -0
  51. package/lib/vendor/blamejs/test/layer-0-primitives/redis-client.test.js +14 -0
  52. package/package.json +1 -1
@@ -132,6 +132,22 @@ var encPath = null; // encrypted-at-rest path (null in plain mode)
132
132
  var encKey = null; // DB encryption key buffer (null in plain mode)
133
133
  var encTimer = null; // periodic encrypt interval handle
134
134
  var atRest = null; // 'encrypted' or 'plain'
135
+ // Tmpfs free-space guard (encrypted mode). The working copy lives on a
136
+ // bounded tmpfs (Docker /dev/shm defaults to 64 MiB); if it fills, SQLite
137
+ // hits ENOSPC and corrupts the working copy. A periodic probe refuses
138
+ // growth writes (INSERT/UPDATE/REPLACE) before that happens — fail-clear
139
+ // instead of corrupt-then-recover. DELETE + reads stay available so
140
+ // retention can reclaim space and the app can keep serving.
141
+ var storageProbeTimer = null; // periodic free-space probe handle
142
+ var writesRefused = false; // true when free space < minFreeBytes
143
+ var minFreeBytes = 0; // refuse growth writes below this (0 = guard off)
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;
135
151
  var dataDir = null;
136
152
  var initialized = false;
137
153
  var dataResidency = null; // operator's declared region config (validated by storage backends)
@@ -672,13 +688,30 @@ function loadOrCreateDbKey(dataDirPath, keyPathOverride) {
672
688
  function decryptToTmp() {
673
689
  if (!encPath || !nodeFs.existsSync(encPath)) return;
674
690
  // If a plaintext file already exists in tmpfs from a prior process, prefer
675
- // the newer mtime (crash recovery — operator's most recent state wins).
691
+ // the newer mtime (crash recovery — operator's most recent state wins)
692
+ // but ONLY if it is a readable SQLite file. A working copy corrupted by
693
+ // an unclean shutdown or a full tmpfs (e.g. Docker's 64 MiB /dev/shm
694
+ // default overflowing) would otherwise be kept on every boot, and
695
+ // db.init's integrity gate would fail identically forever — an
696
+ // unrecoverable crash loop. When the newer plaintext fails a fast
697
+ // integrity probe, discard it and fall through to re-decrypt the
698
+ // last-good db.enc snapshot. db.enc itself is never modified here, so
699
+ // this only ever rolls back to the persistent encrypted copy; if THAT
700
+ // is also corrupt, db.init still fails loudly (no silent data loss).
676
701
  if (nodeFs.existsSync(dbPath)) {
677
702
  var plainStat = nodeFs.statSync(dbPath);
678
703
  var encStat = nodeFs.statSync(encPath);
679
704
  if (plainStat.mtimeMs > encStat.mtimeMs && plainStat.size > 0) {
680
- log("plaintext is newer than encrypted — keeping plaintext (crash recovery)");
681
- return;
705
+ if (_tmpWorkingCopyIsHealthy(dbPath)) {
706
+ log("plaintext is newer than encrypted — keeping plaintext (crash recovery)");
707
+ return;
708
+ }
709
+ log("newer tmpfs working copy failed its integrity probe (corrupt — likely an " +
710
+ "unclean shutdown or a full /dev/shm); discarding it and re-decrypting from " +
711
+ "db.enc (auto-recovery to the last-good encrypted snapshot)");
712
+ try { nodeFs.unlinkSync(dbPath); } catch (_e) { /* fall through to overwrite */ }
713
+ try { nodeFs.unlinkSync(dbPath + "-wal"); } catch (_e) { /* may not exist */ }
714
+ try { nodeFs.unlinkSync(dbPath + "-shm"); } catch (_e) { /* may not exist */ }
682
715
  }
683
716
  }
684
717
  var packed = nodeFs.readFileSync(encPath);
@@ -696,10 +729,90 @@ function decryptToTmp() {
696
729
  }
697
730
  }
698
731
 
732
+ // Fast "is this a usable SQLite file" probe for the crash-recovery path.
733
+ // Opens the candidate working copy and runs PRAGMA quick_check(1) (far
734
+ // cheaper than full integrity_check — header + page-structure sanity,
735
+ // enough to catch a "database disk image is malformed" / truncated /
736
+ // non-DB file). Any throw (malformed image, not-a-DB) or non-"ok" result
737
+ // is unhealthy. The probe handle is always closed so it never holds the
738
+ // tmpfs file open against the subsequent real open.
739
+ function _tmpWorkingCopyIsHealthy(p) {
740
+ var probe = null;
741
+ try {
742
+ probe = new DatabaseSync(p);
743
+ var rows = probe.prepare("PRAGMA quick_check(1)").all();
744
+ return rows.length >= 1 && rows[0] && rows[0].quick_check === "ok";
745
+ } catch (_e) {
746
+ return false;
747
+ } finally {
748
+ if (probe) { try { probe.close(); } catch (_e2) { /* already gone */ } }
749
+ }
750
+ }
751
+
699
752
  function _dbEncAad(dir) {
700
753
  return Buffer.from("blamejs.db-enc.v1\0" + (dir || ""), "utf8");
701
754
  }
702
755
 
756
+ // Probe free space on the tmpfs holding the working copy and flip the
757
+ // write-refusal flag. Encrypted mode only (the bounded-tmpfs surface);
758
+ // guard disabled when minFreeBytes is 0. A probe failure leaves the flag
759
+ // unchanged — we never refuse writes on a stat error (that would be a
760
+ // self-inflicted outage). Growth writes (INSERT/UPDATE/REPLACE) are gated
761
+ // by the prepare() wrapper installed in init(); DELETE + reads always pass
762
+ // so retention can reclaim space and the app keeps serving.
763
+ function _probeStorageHeadroom() {
764
+ if (atRest !== "encrypted" || !minFreeBytes || !dbPath || !statfsProbe) return;
765
+ var free;
766
+ try {
767
+ var st = statfsProbe(nodePath.dirname(dbPath));
768
+ free = st.bavail * st.bsize;
769
+ if (!isFinite(free)) return;
770
+ } catch (_e) { return; }
771
+ if (free < minFreeBytes && !writesRefused) {
772
+ writesRefused = true;
773
+ log.error("storage low: " + free + " bytes free on the tmpfs working-copy mount (< " +
774
+ minFreeBytes + ") — refusing growth writes (INSERT/UPDATE/REPLACE) until space " +
775
+ "recovers. Raise shm_size / --shm-size, or let retention prune. DELETE + reads still serve.");
776
+ try {
777
+ audit.safeEmit({ action: "db.storage.low", outcome: "failure",
778
+ metadata: { freeBytes: free, minFreeBytes: minFreeBytes } });
779
+ } catch (_e2) { /* drop-silent — observability */ }
780
+ } else if (free >= minFreeBytes && writesRefused) {
781
+ writesRefused = false;
782
+ log("storage recovered: " + free + " bytes free — growth writes re-enabled");
783
+ try {
784
+ audit.safeEmit({ action: "db.storage.recovered", outcome: "success",
785
+ metadata: { freeBytes: free } });
786
+ } catch (_e3) { /* drop-silent */ }
787
+ }
788
+ }
789
+
790
+ // Install the growth-write gate on the SQLite handle: shadow prepare() so
791
+ // INSERT/UPDATE/REPLACE statements throw db/storage-low when the tmpfs is
792
+ // critically low, instead of proceeding into an ENOSPC corruption. Reads,
793
+ // DELETE, PRAGMA, and DDL pass through ungated. Called once in init() after
794
+ // schema setup so init's own writes are never gated (writesRefused is false
795
+ // until the first probe anyway).
796
+ function _installWriteGate() {
797
+ var rawPrepare = database.prepare.bind(database);
798
+ database.prepare = function (sql) {
799
+ var stmt = rawPrepare(sql);
800
+ if (/^\s*(?:INSERT|UPDATE|REPLACE)\b/i.test(sql)) {
801
+ var rawRun = stmt.run.bind(stmt);
802
+ stmt.run = function () {
803
+ if (writesRefused) {
804
+ throw _dbErr("db/storage-low",
805
+ "db: refusing write — the encrypted-mode working copy is on a tmpfs with less than " +
806
+ minFreeBytes + " bytes free (Docker /dev/shm defaults to 64 MiB). Raise shm_size / " +
807
+ "--shm-size, or let retention prune expired rows. DELETE and reads remain available.");
808
+ }
809
+ return rawRun.apply(stmt, arguments);
810
+ };
811
+ }
812
+ return stmt;
813
+ };
814
+ }
815
+
703
816
  function encryptToDisk() {
704
817
  if (!encPath) return;
705
818
  // Force WAL checkpoint so the .db file holds all committed transactions.
@@ -878,11 +991,11 @@ async function init(opts) {
878
991
  if (!nodeFs.existsSync(tmpDir)) nodeFs.mkdirSync(tmpDir, { recursive: true });
879
992
 
880
993
  // D-H7 — if the resolved tmpDir is NOT actually tmpfs, the
881
- // plaintext DB file lives on persistent storage. statvfs/statfs
882
- // isn't in stable Node, but on Linux we can check that tmpDir
883
- // resolves under /dev/shm or /run/shm as a heuristic. On other
994
+ // plaintext DB file lives on persistent storage. We check that tmpDir
995
+ // resolves under /dev/shm or /run/shm on Linux as a heuristic; on other
884
996
  // platforms we warn that the operator must verify tmpfs binding
885
- // out-of-band.
997
+ // out-of-band. (Free-space headroom is enforced separately via
998
+ // fs.statfsSync in the storage guard below.)
886
999
  if (process.platform === "linux") {
887
1000
  var realTmp = "";
888
1001
  try { realTmp = nodeFs.realpathSync(tmpDir); } catch (_e) { /* stat best-effort */ }
@@ -904,6 +1017,21 @@ async function init(opts) {
904
1017
  dbPath = nodePath.join(tmpDir, "blamejs-" + generateToken(C.BYTES.bytes(16)) + ".db");
905
1018
  encKey = loadOrCreateDbKey(dataDir, opts.dbKeyPath);
906
1019
 
1020
+ // Tmpfs free-space guard. Default headroom is 16 MiB below which growth
1021
+ // writes are refused (fail-clear) before the working copy fills its
1022
+ // bounded tmpfs and corrupts. opts.minFreeBytes tunes it; 0 disables.
1023
+ // opts._statfsForTest injects a free-space reader for tests.
1024
+ if (opts.minFreeBytes !== undefined) {
1025
+ require("./numeric-bounds").requireNonNegativeFiniteIntIfPresent(
1026
+ opts.minFreeBytes, "db.init: opts.minFreeBytes", DbError, "db/bad-min-free-bytes");
1027
+ minFreeBytes = opts.minFreeBytes;
1028
+ } else {
1029
+ minFreeBytes = C.BYTES.mib(16);
1030
+ }
1031
+ statfsProbe = typeof opts._statfsForTest === "function"
1032
+ ? opts._statfsForTest
1033
+ : (typeof nodeFs.statfsSync === "function" ? nodeFs.statfsSync : null);
1034
+
907
1035
  cleanStaleTmpDbs(tmpDir);
908
1036
  decryptToTmp();
909
1037
  } else {
@@ -961,7 +1089,25 @@ async function init(opts) {
961
1089
  // the freshly-decrypted-into-tmpfs file (<1 second on a typical
962
1090
  // multi-MB DB) and the result is "ok" or a list of issues.
963
1091
  if (opts.skipBootIntegrityCheck !== true) {
964
- var ic = database.prepare("PRAGMA integrity_check").all();
1092
+ var ic;
1093
+ try {
1094
+ ic = database.prepare("PRAGMA integrity_check").all();
1095
+ } catch (corruptErr) {
1096
+ // SQLite throws "database disk image is malformed" / "file is not a
1097
+ // database" when the file is too corrupt to even run the check.
1098
+ // Translate the raw native error into an actionable one — the most
1099
+ // common operational cause in encrypted mode is a too-small tmpfs.
1100
+ throw new DbError("db/integrity-check-failed",
1101
+ "database is corrupt at boot — SQLite: " +
1102
+ ((corruptErr && corruptErr.message) || String(corruptErr)) + ". " +
1103
+ (atRest === "encrypted"
1104
+ ? "Encrypted mode runs the live DB as a tmpfs working copy (" + dbPath +
1105
+ "); a recurring failure here usually means the tmpfs is too small " +
1106
+ "(Docker's /dev/shm defaults to 64 MiB — raise it via shm_size / " +
1107
+ "--shm-size), or db.enc itself is corrupt (restore <dataDir>/db.enc " +
1108
+ "from backup)."
1109
+ : "Restore the database file (" + dbPath + ") from backup."));
1110
+ }
965
1111
  var icIssues = ic.map(function (r) { return r && r.integrity_check; })
966
1112
  .filter(function (s) { return s && s !== "ok"; });
967
1113
  if (icIssues.length > 0) {
@@ -1282,12 +1428,30 @@ async function init(opts) {
1282
1428
  }
1283
1429
  }, C.TIME.minutes(5), { name: "db-periodic-encrypt" });
1284
1430
 
1431
+ // Tmpfs free-space guard. Install the growth-write gate now (after all
1432
+ // of init's own writes), then probe on a short interval so the
1433
+ // refuse-writes flag tracks a fast-filling tmpfs (the 5-minute encrypt
1434
+ // cadence is far too coarse to catch a fill in time). The guard is a
1435
+ // no-op when minFreeBytes is 0 or no statfs reader is available.
1436
+ if (minFreeBytes && statfsProbe) {
1437
+ _installWriteGate();
1438
+ _probeStorageHeadroom(); // seed the flag from current free space
1439
+ storageProbeTimer = safeAsync.repeating(_probeStorageHeadroom,
1440
+ C.TIME.seconds(10), { name: "db-storage-probe" });
1441
+ }
1442
+
1285
1443
  // Final encrypt on process exit. We don't try to unlink the plaintext
1286
1444
  // here — the SQLite handle may still be open, and the OS reclaims tmpfs
1287
- // on reboot anyway. close() does the orderly shutdown.
1288
- process.on("exit", function () {
1289
- try { encryptToDisk(); } catch (_e) { /* exit handler silent */ }
1290
- });
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
+ }
1291
1455
  }
1292
1456
 
1293
1457
  log("ready (mode: " + atRest + ", path: " + dbPath + ")");
@@ -1924,6 +2088,11 @@ function close() {
1924
2088
  encTimer.stop();
1925
2089
  encTimer = null;
1926
2090
  }
2091
+ if (storageProbeTimer) {
2092
+ storageProbeTimer.stop();
2093
+ storageProbeTimer = null;
2094
+ }
2095
+ writesRefused = false;
1927
2096
  // Drop prepared-statement cache so the underlying Statement handles
1928
2097
  // release ahead of database.close().
1929
2098
  _prepareCache.clear();
@@ -1942,11 +2111,19 @@ function close() {
1942
2111
  // Order: encrypt while the DB is still open (so the file is consistent),
1943
2112
  // then close the SQLite handle (releases the file lock on Windows),
1944
2113
  // THEN unlink the plaintext sidecar files.
1945
- try { encryptToDisk(); } catch (e) {
1946
- log.error("close: final encrypt failed: " + e.message);
2114
+ var encryptOk = false;
2115
+ try { encryptToDisk(); encryptOk = true; } catch (e) {
2116
+ log.error("close: final encrypt failed: " + e.message +
2117
+ " — keeping the plaintext working copy so the next boot can recover " +
2118
+ "the latest writes (db.enc still holds the prior snapshot)");
1947
2119
  }
1948
2120
  try { database.close(); } catch (_e) { /* already closed */ }
1949
- if (atRest === "encrypted") removePlaintextFiles();
2121
+ // Only discard the plaintext working copy once it has been safely
2122
+ // re-encrypted. If the final encrypt failed (full /dev/shm, disk-full),
2123
+ // the working copy is the ONLY carrier of writes since the last periodic
2124
+ // flush — keep it so decryptToTmp's newer-mtime recovery picks it up next
2125
+ // boot (integrity-probed, falling back to db.enc if it is itself corrupt).
2126
+ if (atRest === "encrypted" && encryptOk) removePlaintextFiles();
1950
2127
  database = null;
1951
2128
  initialized = false;
1952
2129
  }
@@ -2440,6 +2617,7 @@ function _cascadeStep(name, ref) {
2440
2617
  // Test helpers — not part of public contract
2441
2618
  function _resetForTest() {
2442
2619
  if (encTimer) { encTimer.stop(); encTimer = null; }
2620
+ if (storageProbeTimer) { storageProbeTimer.stop(); storageProbeTimer = null; }
2443
2621
  try { if (database) database.close(); }
2444
2622
  catch (e) { log.debug("test-reset close failed", { error: e.message }); }
2445
2623
  database = null;
@@ -2448,10 +2626,20 @@ function _resetForTest() {
2448
2626
  encKey = null;
2449
2627
  atRest = null;
2450
2628
  dataDir = null;
2629
+ minFreeBytes = 0;
2630
+ statfsProbe = null;
2631
+ writesRefused = false;
2451
2632
  initialized = false;
2452
2633
  cryptoField.clearForTest();
2453
2634
  }
2454
2635
 
2636
+ // Test seam — force a storage-headroom probe synchronously (the production
2637
+ // path runs it on a 10s timer) and read the resulting refuse-writes flag.
2638
+ function _probeStorageForTest() {
2639
+ _probeStorageHeadroom();
2640
+ return { writesRefused: writesRefused, minFreeBytes: minFreeBytes };
2641
+ }
2642
+
2455
2643
 
2456
2644
  /**
2457
2645
  * @primitive b.db.vacuumAfterErase
@@ -3102,6 +3290,8 @@ module.exports = {
3102
3290
  _cascadeStep("redact", _resetRedact);
3103
3291
  _cascadeStep("external-db", _resetExternalDb);
3104
3292
  },
3293
+ // Test seam for the tmpfs free-space guard — force a probe + read the flag.
3294
+ _probeStorageForTest: _probeStorageForTest,
3105
3295
  // Helper for audit.checkpoint to write the rollback-detection sidecar
3106
3296
  _writeAuditTip: function (tip) {
3107
3297
  if (!dataDir) return;
@@ -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