@blamejs/blamejs-shop 0.2.15 → 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.
- package/CHANGELOG.md +3 -1
- package/README.md +1 -1
- package/lib/asset-manifest.json +5 -1
- package/lib/customers.js +71 -4
- package/lib/storefront.js +462 -0
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +20 -0
- package/lib/vendor/blamejs/api-snapshot.json +6 -2
- package/lib/vendor/blamejs/examples/wiki/docker-compose.prod.yml +29 -0
- package/lib/vendor/blamejs/examples/wiki/docker-compose.yml +7 -0
- package/lib/vendor/blamejs/lib/agent-idempotency.js +15 -3
- package/lib/vendor/blamejs/lib/agent-orchestrator.js +39 -0
- package/lib/vendor/blamejs/lib/app-shutdown.js +32 -0
- package/lib/vendor/blamejs/lib/bounded-map.js +102 -0
- package/lib/vendor/blamejs/lib/cache.js +184 -23
- package/lib/vendor/blamejs/lib/cert.js +68 -11
- package/lib/vendor/blamejs/lib/cluster-storage.js +114 -10
- package/lib/vendor/blamejs/lib/db.js +205 -15
- package/lib/vendor/blamejs/lib/dual-control.js +139 -143
- package/lib/vendor/blamejs/lib/i18n.js +10 -1
- package/lib/vendor/blamejs/lib/mail-crypto-smime.js +43 -9
- package/lib/vendor/blamejs/lib/network-dns.js +10 -2
- package/lib/vendor/blamejs/lib/nonce-store.js +39 -12
- package/lib/vendor/blamejs/lib/queue-local.js +2 -2
- package/lib/vendor/blamejs/lib/redis-client.js +14 -1
- package/lib/vendor/blamejs/lib/subject.js +8 -1
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.13.33.json +26 -0
- package/lib/vendor/blamejs/release-notes/v0.13.34.json +48 -0
- package/lib/vendor/blamejs/release-notes/v0.13.35.json +35 -0
- package/lib/vendor/blamejs/release-notes/v0.13.36.json +27 -0
- package/lib/vendor/blamejs/release-notes/v0.13.37.json +18 -0
- package/lib/vendor/blamejs/release-notes/v0.13.38.json +27 -0
- package/lib/vendor/blamejs/release-notes/v0.13.39.json +27 -0
- package/lib/vendor/blamejs/release-notes/v0.13.40.json +22 -0
- package/lib/vendor/blamejs/release-notes/v0.13.41.json +27 -0
- package/lib/vendor/blamejs/release-notes/v0.13.42.json +18 -0
- package/lib/vendor/blamejs/test/20-db.js +191 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/agent-idempotency.test.js +20 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/agent-orchestrator.test.js +33 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/api-encrypt.test.js +35 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/app-shutdown.test.js +64 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/bounded-map.test.js +87 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/cache.test.js +48 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/cert.test.js +170 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/cluster-storage.test.js +125 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +92 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/dual-control.test.js +32 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-crypto-smime.test.js +31 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/redis-client.test.js +14 -0
- package/package.json +1 -1
|
@@ -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,
|
|
@@ -511,32 +531,109 @@ function _clusterBackend(cfg) {
|
|
|
511
531
|
var storedExpires = (expiresAt === Infinity) ? Number.MAX_SAFE_INTEGER : expiresAt;
|
|
512
532
|
var now = clock();
|
|
513
533
|
var ck = _composedKey(key);
|
|
514
|
-
// SQLite + Postgres both honor ON CONFLICT (cacheKey) DO UPDATE.
|
|
515
|
-
await clusterStorage.execute(
|
|
516
|
-
"INSERT INTO _blamejs_cache (cacheKey, valueJson, expiresAt, updatedAt) " +
|
|
517
|
-
"VALUES (?, ?, ?, ?) " +
|
|
518
|
-
"ON CONFLICT (cacheKey) DO UPDATE SET " +
|
|
519
|
-
"valueJson = ?, expiresAt = ?, updatedAt = ?",
|
|
520
|
-
[ck, json, storedExpires, now, json, storedExpires, now]
|
|
521
|
-
);
|
|
522
|
-
// Tag handling: drop any prior tags for this key (tags can change
|
|
523
|
-
// across sets), then INSERT the new ones. The PRIMARY KEY on
|
|
524
|
-
// (cacheKey, tag) makes the INSERT idempotent if duplicate tags
|
|
525
|
-
// sneak in.
|
|
526
534
|
var tags = meta && Array.isArray(meta.tags) ? meta.tags : null;
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
535
|
+
// The value UPSERT and the tag-index rewrite (DELETE prior tags, then
|
|
536
|
+
// INSERT the new set) must commit as ONE unit. Done as separate
|
|
537
|
+
// statements they race: two concurrent set()s on the same key can
|
|
538
|
+
// interleave their DELETE/INSERT pairs, leaving a tag index that no
|
|
539
|
+
// longer matches the value row — so a later invalidateTag misses the
|
|
540
|
+
// key (a stale, possibly authorization-bearing, value survives a wipe).
|
|
541
|
+
// Wrapping them in a transaction makes a concurrent set see either the
|
|
542
|
+
// whole prior state or the whole new state, never a mix.
|
|
543
|
+
// SQLite + Postgres both honor ON CONFLICT (cacheKey) DO UPDATE.
|
|
544
|
+
await clusterStorage.transaction(async function (tx) {
|
|
545
|
+
await tx.execute(
|
|
546
|
+
"INSERT INTO _blamejs_cache (cacheKey, valueJson, expiresAt, updatedAt) " +
|
|
547
|
+
"VALUES (?, ?, ?, ?) " +
|
|
548
|
+
"ON CONFLICT (cacheKey) DO UPDATE SET " +
|
|
549
|
+
"valueJson = ?, expiresAt = ?, updatedAt = ?",
|
|
550
|
+
[ck, json, storedExpires, now, json, storedExpires, now]
|
|
551
|
+
);
|
|
552
|
+
// Drop any prior tags for this key (tags can change across sets),
|
|
553
|
+
// then INSERT the new ones. The PRIMARY KEY on (cacheKey, tag) makes
|
|
554
|
+
// the INSERT idempotent if duplicate tags sneak in.
|
|
555
|
+
await tx.execute(
|
|
556
|
+
"DELETE FROM _blamejs_cache_tags WHERE cacheKey = ?",
|
|
557
|
+
[ck]
|
|
558
|
+
);
|
|
559
|
+
if (tags && tags.length > 0) {
|
|
560
|
+
for (var i = 0; i < tags.length; i++) {
|
|
561
|
+
await tx.execute(
|
|
562
|
+
"INSERT INTO _blamejs_cache_tags (cacheKey, tag) VALUES (?, ?) " +
|
|
563
|
+
"ON CONFLICT (cacheKey, tag) DO NOTHING",
|
|
564
|
+
[ck, tags[i]]
|
|
565
|
+
);
|
|
566
|
+
}
|
|
538
567
|
}
|
|
568
|
+
});
|
|
569
|
+
}
|
|
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;
|
|
539
634
|
}
|
|
635
|
+
throw _err("UPDATE_CONTENTION",
|
|
636
|
+
"cache.update: exceeded " + maxRetries + " retries under write contention for key");
|
|
540
637
|
}
|
|
541
638
|
|
|
542
639
|
async function del(key) {
|
|
@@ -672,6 +769,7 @@ function _clusterBackend(cfg) {
|
|
|
672
769
|
name: "cluster",
|
|
673
770
|
get: get,
|
|
674
771
|
set: set,
|
|
772
|
+
update: _updateRow,
|
|
675
773
|
del: del,
|
|
676
774
|
has: has,
|
|
677
775
|
clear: clear,
|
|
@@ -1014,6 +1112,68 @@ function create(opts) {
|
|
|
1014
1112
|
emitObs("cache.set", { namespace: namespace });
|
|
1015
1113
|
}
|
|
1016
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
|
+
|
|
1017
1177
|
async function del(key) {
|
|
1018
1178
|
_ensureOpen("del");
|
|
1019
1179
|
_validateKey(key, "cache.del");
|
|
@@ -1308,6 +1468,7 @@ function create(opts) {
|
|
|
1308
1468
|
return {
|
|
1309
1469
|
get: get,
|
|
1310
1470
|
set: set,
|
|
1471
|
+
update: update,
|
|
1311
1472
|
del: del,
|
|
1312
1473
|
has: has,
|
|
1313
1474
|
clear: clear,
|
|
@@ -54,6 +54,7 @@ var atomicFile = require("./atomic-file");
|
|
|
54
54
|
var safeJson = require("./safe-json");
|
|
55
55
|
var { defineClass } = require("./framework-error");
|
|
56
56
|
var C = require("./constants");
|
|
57
|
+
var { boot } = require("./log");
|
|
57
58
|
|
|
58
59
|
var acme = lazyRequire(function () { return require("./acme"); });
|
|
59
60
|
var vault = lazyRequire(function () { return require("./vault"); });
|
|
@@ -62,6 +63,7 @@ var networkTls = lazyRequire(function () { return require("./network-tls"); }
|
|
|
62
63
|
var bCrypto = lazyRequire(function () { return require("./crypto"); });
|
|
63
64
|
|
|
64
65
|
var CertError = defineClass("CertError");
|
|
66
|
+
var log = boot("cert");
|
|
65
67
|
|
|
66
68
|
var DEFAULT_RENEW_INTERVAL_MS = C.TIME.hours(6);
|
|
67
69
|
var DEFAULT_MIN_DAYS_BEFORE_EXPIRY = 14;
|
|
@@ -140,8 +142,13 @@ function _createSealedDiskStorage(opts) {
|
|
|
140
142
|
if (!nodeFs.existsSync(p)) return null;
|
|
141
143
|
try { return safeJson.parse(nodeFs.readFileSync(p, "utf8"), { maxBytes: C.BYTES.kib(16) }); }
|
|
142
144
|
catch (e) {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
+
// meta.json is a derived index (expiry + fingerprint), not a
|
|
146
|
+
// source of truth — the sealed cert is. A corrupt meta must not
|
|
147
|
+
// block renewal: treat it as absent so _ensureCert re-derives it
|
|
148
|
+
// from a fresh issue rather than throwing out of start().
|
|
149
|
+
log.warn("cert: meta.json for '" + certName + "' unreadable (" +
|
|
150
|
+
e.message + ") — treating as absent, will re-derive");
|
|
151
|
+
return null;
|
|
145
152
|
}
|
|
146
153
|
},
|
|
147
154
|
|
|
@@ -413,8 +420,21 @@ function create(opts) {
|
|
|
413
420
|
? nodeFs.readFileSync(nodePath.join(storage.rootDir, "account/jwk.json.sealed"))
|
|
414
421
|
: null;
|
|
415
422
|
if (sealedBuf) {
|
|
416
|
-
var
|
|
417
|
-
|
|
423
|
+
var jwk;
|
|
424
|
+
try {
|
|
425
|
+
var plain = (opts.storage.vault || vault().getDefaultStore()).unseal(sealedBuf);
|
|
426
|
+
jwk = safeJson.parse(plain.toString("utf8"), { maxBytes: C.BYTES.kib(64) });
|
|
427
|
+
} catch (e) {
|
|
428
|
+
// The ACME account key binds existing order + authorization
|
|
429
|
+
// history, so it is NOT auto-regenerated on corruption (unlike a
|
|
430
|
+
// re-issuable cert) — that would silently abandon the account.
|
|
431
|
+
// Fail with an actionable error naming the file + recovery instead
|
|
432
|
+
// of letting a raw decrypt/parse error escape out of start().
|
|
433
|
+
throw new CertError("cert/account-key-unreadable",
|
|
434
|
+
"cert: ACME account key 'account/jwk.json.sealed' is unreadable (" +
|
|
435
|
+
e.message + "). Restore it from backup, or delete it to register " +
|
|
436
|
+
"a fresh ACME account (this abandons prior order history).");
|
|
437
|
+
}
|
|
418
438
|
return _accountKeyFromJwk(jwk);
|
|
419
439
|
}
|
|
420
440
|
var pair = nodeCrypto.generateKeyPairSync("ec", { namedCurve: "P-256" });
|
|
@@ -555,18 +575,55 @@ function create(opts) {
|
|
|
555
575
|
// for emergency reissue / key rollover when the existing cert is
|
|
556
576
|
// structurally fine but operationally compromised (suspected key
|
|
557
577
|
// disclosure, CA misissuance investigation, posture-driven rotation).
|
|
578
|
+
// A corrupt sealed cert/key is RECOVERABLE state — the CA re-issues on
|
|
579
|
+
// demand. Treat an unreadable sealed file like a missing one (log +
|
|
580
|
+
// re-issue) rather than letting a raw unseal/decrypt error escape out of
|
|
581
|
+
// start(): on a managed restart the same corrupt file is read on every
|
|
582
|
+
// boot, so throwing here is an unrecoverable crash loop, and a corrupt
|
|
583
|
+
// file must never be handled worse than an absent one (which already
|
|
584
|
+
// falls through to issue). The ACME account key is the one piece NOT
|
|
585
|
+
// auto-recovered this way — see _loadOrGenerateAccountKey.
|
|
586
|
+
async function _readSealedOrReissue(relPath, certName) {
|
|
587
|
+
try {
|
|
588
|
+
return await storage.readSealed(relPath);
|
|
589
|
+
} catch (e) {
|
|
590
|
+
log.warn("cert: sealed '" + relPath + "' unreadable (" + e.message +
|
|
591
|
+
") — re-issuing as if absent");
|
|
592
|
+
_emitAudit("cert.sealed.corrupt", "recovered", { path: relPath, name: certName });
|
|
593
|
+
return null;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
558
597
|
async function _ensureCert(certManifest, forceIssue) {
|
|
559
598
|
var meta = await storage.readMeta(certManifest.name);
|
|
560
|
-
var certBuf = await
|
|
561
|
-
var keyBuf = await
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
599
|
+
var certBuf = await _readSealedOrReissue(certManifest.name + "/cert.pem", certManifest.name);
|
|
600
|
+
var keyBuf = await _readSealedOrReissue(certManifest.name + "/key.pem", certManifest.name);
|
|
601
|
+
// Base the renewal decision on the SEALED cert's OWN notAfter, not the
|
|
602
|
+
// plaintext meta.json index. meta is a derived convenience copy; if it
|
|
603
|
+
// drifts from — or is tampered relative to — the actual cert (a far-
|
|
604
|
+
// future meta.expiresAt over an actually-expiring cert), trusting it
|
|
605
|
+
// would skip renewal and serve an expired cert. Re-derive expiry +
|
|
606
|
+
// fingerprint from the cert itself; if it won't parse, treat it as a
|
|
607
|
+
// corrupt sealed cert and re-issue (same recovery as an unreadable one).
|
|
608
|
+
var actual = null;
|
|
609
|
+
if (certBuf) {
|
|
610
|
+
try { actual = _certMeta(certBuf.toString("utf8")); }
|
|
611
|
+
catch (e) {
|
|
612
|
+
log.warn("cert: sealed cert for '" + certManifest.name + "' will not parse (" +
|
|
613
|
+
e.message + ") — re-issuing");
|
|
614
|
+
_emitAudit("cert.sealed.corrupt", "recovered",
|
|
615
|
+
{ path: certManifest.name + "/cert.pem", name: certManifest.name });
|
|
616
|
+
certBuf = null;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
if (!forceIssue && actual && certBuf && keyBuf &&
|
|
620
|
+
actual.expiresAt > Date.now() + minDaysBeforeExpiry * C.TIME.days(1)) {
|
|
621
|
+
// Cached, and the cert's own notAfter is comfortably in the future.
|
|
565
622
|
loadedContexts[certManifest.name] = {
|
|
566
623
|
cert: certBuf.toString("utf8"),
|
|
567
624
|
key: keyBuf.toString("utf8"),
|
|
568
|
-
expiresAt:
|
|
569
|
-
fingerprintSha256:
|
|
625
|
+
expiresAt: actual.expiresAt,
|
|
626
|
+
fingerprintSha256: actual.fingerprintSha256,
|
|
570
627
|
sniNames: certManifest.domains.slice(),
|
|
571
628
|
};
|
|
572
629
|
return loadedContexts[certManifest.name];
|
|
@@ -261,6 +261,33 @@ function placeholderize(sql, dialect) {
|
|
|
261
261
|
* );
|
|
262
262
|
* // → { rows: [ { counter: 43, row_hash: "..." } ], rowCount: 1 }
|
|
263
263
|
*/
|
|
264
|
+
// Single-node transaction serialization. node:sqlite is synchronous and
|
|
265
|
+
// the framework shares ONE local connection, so a SQLite transaction is
|
|
266
|
+
// connection-global: any statement that runs between this connection's
|
|
267
|
+
// BEGIN and COMMIT lands INSIDE the transaction. `_activeTx` is a promise
|
|
268
|
+
// held for the duration of a single-node transaction(); execute() waits it
|
|
269
|
+
// out before running so a concurrent statement can't interleave into the
|
|
270
|
+
// open transaction on the shared connection. It is null in cluster mode
|
|
271
|
+
// (the pool gives each transaction its own connection, so the DB enforces
|
|
272
|
+
// isolation and no global lock is needed).
|
|
273
|
+
var _activeTx = null;
|
|
274
|
+
|
|
275
|
+
// Raw local exec — synchronous, no transaction-lock wait. Used by execute()
|
|
276
|
+
// AFTER the lock wait and by transaction() for its own statements (which
|
|
277
|
+
// must NOT wait on the lock they themselves hold). Because node:sqlite is
|
|
278
|
+
// synchronous this runs atomically to completion with no interleaving.
|
|
279
|
+
function _localExec(sql, params) {
|
|
280
|
+
var stmt = _localDb().prepare(sql);
|
|
281
|
+
// Heuristic: if the statement returns rows (SELECT or has RETURNING),
|
|
282
|
+
// use .all(); otherwise .run() and report changes as rowCount.
|
|
283
|
+
if (/^\s*SELECT\b/i.test(sql) || /\bRETURNING\b/i.test(sql)) {
|
|
284
|
+
var rows = stmt.all.apply(stmt, params || []);
|
|
285
|
+
return { rows: rows, rowCount: rows.length };
|
|
286
|
+
}
|
|
287
|
+
var info = stmt.run.apply(stmt, params || []);
|
|
288
|
+
return { rows: [], rowCount: info.changes };
|
|
289
|
+
}
|
|
290
|
+
|
|
264
291
|
async function execute(sql, params) {
|
|
265
292
|
if (typeof sql !== "string") {
|
|
266
293
|
throw new ClusterStorageError("sql must be a string", "cluster-storage/bad-arg");
|
|
@@ -275,17 +302,93 @@ async function execute(sql, params) {
|
|
|
275
302
|
return result;
|
|
276
303
|
}
|
|
277
304
|
|
|
278
|
-
// Local SQLite path.
|
|
279
|
-
//
|
|
280
|
-
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
305
|
+
// Local SQLite path. Wait out any open single-node transaction so this
|
|
306
|
+
// statement can't interleave into it on the shared connection. The loop
|
|
307
|
+
// re-checks after each wait (a new transaction may have started while we
|
|
308
|
+
// waited); once it exits, `_localExec` runs synchronously to completion,
|
|
309
|
+
// so no transaction can begin between the check and the statement.
|
|
310
|
+
while (_activeTx) { try { await _activeTx; } catch (_e) { /* tx failed — proceed */ } }
|
|
311
|
+
return _localExec(sql, params);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* @primitive b.clusterStorage.transaction
|
|
316
|
+
* @signature b.clusterStorage.transaction(fn)
|
|
317
|
+
* @since 0.13.38
|
|
318
|
+
* @status stable
|
|
319
|
+
* @related b.clusterStorage.execute
|
|
320
|
+
*
|
|
321
|
+
* Run `fn` inside an atomic transaction against the active backend, so a
|
|
322
|
+
* multi-statement read-modify-write commits all-or-nothing. `fn` receives a
|
|
323
|
+
* transaction handle exposing the same `execute` / `executeOne` /
|
|
324
|
+
* `executeAll` surface as the module — but scoped to the open transaction.
|
|
325
|
+
* Use the handle's methods inside `fn`; calling the module-level
|
|
326
|
+
* `b.clusterStorage.execute` from within `fn` would deadlock single-node
|
|
327
|
+
* (it waits for the very transaction `fn` is running).
|
|
328
|
+
*
|
|
329
|
+
* Cluster mode dispatches to the external DB's transaction (its own pooled
|
|
330
|
+
* connection + deadlock retry). Single-node serializes against other
|
|
331
|
+
* transactions and against `execute` on the shared SQLite connection.
|
|
332
|
+
*
|
|
333
|
+
* @example
|
|
334
|
+
* await b.clusterStorage.transaction(async function (tx) {
|
|
335
|
+
* var row = await tx.executeOne("SELECT v FROM t WHERE k = ?", ["x"]);
|
|
336
|
+
* await tx.execute("UPDATE t SET v = ? WHERE k = ?", [row.v + 1, "x"]);
|
|
337
|
+
* });
|
|
338
|
+
*/
|
|
339
|
+
async function transaction(fn) {
|
|
340
|
+
if (typeof fn !== "function") {
|
|
341
|
+
throw new ClusterStorageError("transaction requires a function", "cluster-storage/bad-arg");
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (cluster.isClusterMode()) {
|
|
345
|
+
var dialect = cluster.dialect();
|
|
346
|
+
return await externalDb.transaction(async function (txClient) {
|
|
347
|
+
function txExec(sql, params) {
|
|
348
|
+
var translated = placeholderize(resolveTables(sql), dialect);
|
|
349
|
+
return txClient.query(translated, params || []);
|
|
350
|
+
}
|
|
351
|
+
var txHandle = {
|
|
352
|
+
execute: txExec,
|
|
353
|
+
executeOne: async function (sql, params) {
|
|
354
|
+
var r = await txExec(sql, params); return r.rows.length > 0 ? r.rows[0] : null;
|
|
355
|
+
},
|
|
356
|
+
executeAll: async function (sql, params) {
|
|
357
|
+
var r = await txExec(sql, params); return r.rows;
|
|
358
|
+
},
|
|
359
|
+
};
|
|
360
|
+
return await fn(txHandle);
|
|
361
|
+
}, { backend: cluster.externalDbBackend() });
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Single-node: serialize this transaction behind any other open one, then
|
|
365
|
+
// hold `_activeTx` so concurrent execute()/transaction() calls wait.
|
|
366
|
+
while (_activeTx) { try { await _activeTx; } catch (_e) { /* prior tx failed */ } }
|
|
367
|
+
var releaseTx;
|
|
368
|
+
_activeTx = new Promise(function (resolve) { releaseTx = resolve; });
|
|
369
|
+
function txExecLocal(sql, params) { return Promise.resolve(_localExec(sql, params)); }
|
|
370
|
+
var localHandle = {
|
|
371
|
+
execute: txExecLocal,
|
|
372
|
+
executeOne: async function (sql, params) {
|
|
373
|
+
var r = await txExecLocal(sql, params); return r.rows.length > 0 ? r.rows[0] : null;
|
|
374
|
+
},
|
|
375
|
+
executeAll: async function (sql, params) {
|
|
376
|
+
var r = await txExecLocal(sql, params); return r.rows;
|
|
377
|
+
},
|
|
378
|
+
};
|
|
379
|
+
try {
|
|
380
|
+
_localExec("BEGIN", []);
|
|
381
|
+
try {
|
|
382
|
+
var result = await fn(localHandle);
|
|
383
|
+
_localExec("COMMIT", []);
|
|
384
|
+
return result;
|
|
385
|
+
} catch (e) {
|
|
386
|
+
try { _localExec("ROLLBACK", []); } catch (_e) { /* already errored */ }
|
|
387
|
+
throw e;
|
|
388
|
+
}
|
|
389
|
+
} finally {
|
|
390
|
+
var r = releaseTx; _activeTx = null; r();
|
|
286
391
|
}
|
|
287
|
-
var info = stmt.run.apply(stmt, params);
|
|
288
|
-
return { rows: [], rowCount: info.changes };
|
|
289
392
|
}
|
|
290
393
|
|
|
291
394
|
// Convenience wrappers for the two common patterns.
|
|
@@ -344,6 +447,7 @@ module.exports = {
|
|
|
344
447
|
execute: execute,
|
|
345
448
|
executeOne: executeOne,
|
|
346
449
|
executeAll: executeAll,
|
|
450
|
+
transaction: transaction,
|
|
347
451
|
tableName: tableName,
|
|
348
452
|
resolveTables: resolveTables,
|
|
349
453
|
placeholderize: placeholderize,
|