@blamejs/core 0.14.26 → 0.15.0
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 +6 -0
- package/README.md +2 -2
- package/index.js +4 -0
- package/lib/agent-envelope-mac.js +104 -0
- package/lib/agent-event-bus.js +105 -4
- package/lib/agent-posture-chain.js +8 -42
- package/lib/ai-content-detect.js +9 -10
- package/lib/api-key.js +107 -74
- package/lib/atomic-file.js +62 -4
- package/lib/audit-chain.js +47 -11
- package/lib/audit-sign.js +77 -2
- package/lib/audit-tools.js +79 -51
- package/lib/audit.js +249 -123
- package/lib/auth/openid-federation.js +108 -47
- package/lib/backup/index.js +13 -10
- package/lib/break-glass.js +202 -144
- package/lib/cache.js +174 -105
- package/lib/chain-writer.js +38 -16
- package/lib/cli.js +19 -14
- package/lib/cluster-provider-db.js +130 -104
- package/lib/cluster-storage.js +119 -22
- package/lib/cluster.js +119 -71
- package/lib/compliance.js +169 -4
- package/lib/consent.js +73 -24
- package/lib/constants.js +16 -11
- package/lib/crypto-field.js +474 -92
- package/lib/db-declare-row-policy.js +35 -22
- package/lib/db-file-lifecycle.js +3 -2
- package/lib/db-query.js +497 -255
- package/lib/db-schema.js +209 -44
- package/lib/db.js +176 -95
- package/lib/error-page.js +14 -1
- package/lib/external-db-migrate.js +229 -139
- package/lib/external-db.js +25 -15
- package/lib/file-upload.js +52 -7
- package/lib/framework-error.js +14 -1
- package/lib/framework-files.js +73 -0
- package/lib/framework-schema.js +695 -394
- package/lib/gate-contract.js +649 -1
- package/lib/guard-agent-registry.js +26 -44
- package/lib/guard-all.js +1 -0
- package/lib/guard-auth.js +42 -112
- package/lib/guard-cidr.js +33 -154
- package/lib/guard-csv.js +46 -113
- package/lib/guard-domain.js +34 -157
- package/lib/guard-dsn.js +27 -43
- package/lib/guard-email.js +47 -69
- package/lib/guard-envelope.js +19 -32
- package/lib/guard-event-bus-payload.js +24 -42
- package/lib/guard-event-bus-topic.js +25 -43
- package/lib/guard-filename.js +42 -106
- package/lib/guard-graphql.js +42 -123
- package/lib/guard-html.js +53 -108
- package/lib/guard-idempotency-key.js +24 -42
- package/lib/guard-image.js +46 -103
- package/lib/guard-imap-command.js +18 -32
- package/lib/guard-jmap.js +16 -30
- package/lib/guard-json.js +38 -108
- package/lib/guard-jsonpath.js +38 -171
- package/lib/guard-jwt.js +49 -179
- package/lib/guard-list-id.js +25 -41
- package/lib/guard-list-unsubscribe.js +27 -43
- package/lib/guard-mail-compose.js +24 -42
- package/lib/guard-mail-move.js +26 -44
- package/lib/guard-mail-query.js +28 -46
- package/lib/guard-mail-reply.js +24 -42
- package/lib/guard-mail-sieve.js +24 -42
- package/lib/guard-managesieve-command.js +17 -31
- package/lib/guard-markdown.js +37 -104
- package/lib/guard-message-id.js +26 -45
- package/lib/guard-mime.js +39 -151
- package/lib/guard-oauth.js +54 -135
- package/lib/guard-pdf.js +45 -101
- package/lib/guard-pop3-command.js +21 -31
- package/lib/guard-posture-chain.js +24 -42
- package/lib/guard-regex.js +33 -107
- package/lib/guard-saga-config.js +24 -42
- package/lib/guard-shell.js +42 -172
- package/lib/guard-smtp-command.js +48 -54
- package/lib/guard-snapshot-envelope.js +24 -42
- package/lib/guard-sql.js +1491 -0
- package/lib/guard-stream-args.js +24 -43
- package/lib/guard-svg.js +47 -65
- package/lib/guard-template.js +35 -172
- package/lib/guard-tenant-id.js +26 -45
- package/lib/guard-time.js +32 -154
- package/lib/guard-trace-context.js +25 -44
- package/lib/guard-uuid.js +32 -153
- package/lib/guard-xml.js +38 -113
- package/lib/guard-yaml.js +51 -163
- package/lib/http-client.js +37 -9
- package/lib/inbox.js +120 -107
- package/lib/legal-hold.js +107 -50
- package/lib/log-stream-cloudwatch.js +47 -31
- package/lib/log-stream-otlp.js +32 -18
- package/lib/mail-crypto-smime.js +2 -6
- package/lib/mail-greylist.js +2 -6
- package/lib/mail-helo.js +2 -6
- package/lib/mail-journal.js +85 -64
- package/lib/mail-rbl.js +2 -6
- package/lib/mail-scan.js +2 -6
- package/lib/mail-server-jmap.js +117 -12
- package/lib/mail-spam-score.js +2 -6
- package/lib/mail-store.js +287 -154
- package/lib/middleware/body-parser.js +71 -25
- package/lib/middleware/csrf-protect.js +19 -8
- package/lib/middleware/fetch-metadata.js +17 -7
- package/lib/middleware/idempotency-key.js +54 -38
- package/lib/middleware/rate-limit.js +102 -32
- package/lib/middleware/security-headers.js +21 -5
- package/lib/migrations.js +108 -66
- package/lib/network-heartbeat.js +7 -0
- package/lib/nonce-store.js +31 -9
- package/lib/object-store/azure-blob-bucket-ops.js +9 -4
- package/lib/object-store/azure-blob.js +57 -3
- package/lib/object-store/sigv4.js +10 -0
- package/lib/observability.js +87 -0
- package/lib/otel-export.js +25 -1
- package/lib/outbox.js +136 -82
- package/lib/parsers/safe-xml.js +47 -7
- package/lib/pqc-agent.js +44 -0
- package/lib/pubsub-cluster.js +42 -20
- package/lib/queue-local.js +202 -139
- package/lib/queue-redis.js +9 -1
- package/lib/queue-sqs.js +6 -0
- package/lib/redact.js +68 -11
- package/lib/redis-client.js +160 -31
- package/lib/retention.js +82 -39
- package/lib/router.js +212 -5
- package/lib/safe-dns.js +29 -45
- package/lib/safe-ical.js +18 -33
- package/lib/safe-icap.js +27 -43
- package/lib/safe-sieve.js +21 -40
- package/lib/safe-sql.js +124 -3
- package/lib/safe-vcard.js +18 -33
- package/lib/scheduler.js +35 -12
- package/lib/seeders.js +122 -74
- package/lib/session-stores.js +42 -14
- package/lib/session.js +109 -72
- package/lib/sql.js +3885 -0
- package/lib/ssrf-guard.js +51 -4
- package/lib/static.js +177 -34
- package/lib/subject.js +55 -17
- package/lib/vault/index.js +3 -2
- package/lib/vault/passphrase-ops.js +3 -2
- package/lib/vault/rotate.js +104 -64
- package/lib/vendor-data.js +2 -0
- package/lib/websocket.js +35 -5
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/redact.js
CHANGED
|
@@ -309,6 +309,7 @@ function _redact(value, depth, maxDepth, marker, parentKey) {
|
|
|
309
309
|
function _resetForTest() {
|
|
310
310
|
sensitiveFieldsSet = new Set(SENSITIVE_FIELDS);
|
|
311
311
|
customDetectors = [];
|
|
312
|
+
outboundInstallCount = 0;
|
|
312
313
|
}
|
|
313
314
|
|
|
314
315
|
// ---- Classifier presets (for outbound DLP) ----
|
|
@@ -625,6 +626,14 @@ function classifyDefaults(opts) {
|
|
|
625
626
|
|
|
626
627
|
var OUTBOUND_INSTALL_REGISTRY = new WeakMap();
|
|
627
628
|
|
|
629
|
+
// Count of primitive instances currently wrapped by an outbound-DLP
|
|
630
|
+
// interceptor. A WeakMap can't be enumerated, so this counter is the
|
|
631
|
+
// cheap "is anything installed?" signal b.compliance.set reads to decide
|
|
632
|
+
// whether to warn that a DLP-floor posture was pinned without wiring.
|
|
633
|
+
// Incremented per primitive on install, decremented on uninstall; never
|
|
634
|
+
// negative.
|
|
635
|
+
var outboundInstallCount = 0;
|
|
636
|
+
|
|
628
637
|
function _emitDlp(action, outcome, metadata) {
|
|
629
638
|
try {
|
|
630
639
|
audit().safeEmit({
|
|
@@ -740,15 +749,15 @@ function installOutboundDlp(opts) {
|
|
|
740
749
|
|
|
741
750
|
if (opts.httpClient) {
|
|
742
751
|
var u1 = _installHttpClient(opts.httpClient, classifier, opts);
|
|
743
|
-
if (u1) { uninstallers.push(u1); installed.httpClient = true; }
|
|
752
|
+
if (u1) { uninstallers.push(u1); installed.httpClient = true; outboundInstallCount += 1; }
|
|
744
753
|
}
|
|
745
754
|
if (opts.mail) {
|
|
746
755
|
var u2 = _installMail(opts.mail, classifier, opts);
|
|
747
|
-
if (u2) { uninstallers.push(u2); installed.mail = true; }
|
|
756
|
+
if (u2) { uninstallers.push(u2); installed.mail = true; outboundInstallCount += 1; }
|
|
748
757
|
}
|
|
749
758
|
if (opts.webhook) {
|
|
750
759
|
var u3 = _installWebhook(opts.webhook, classifier, opts);
|
|
751
|
-
if (u3) { uninstallers.push(u3); installed.webhook = true; }
|
|
760
|
+
if (u3) { uninstallers.push(u3); installed.webhook = true; outboundInstallCount += 1; }
|
|
752
761
|
}
|
|
753
762
|
|
|
754
763
|
_emitDlp("dlp.outbound.installed", "success", {
|
|
@@ -761,12 +770,37 @@ function installOutboundDlp(opts) {
|
|
|
761
770
|
uninstall: function () {
|
|
762
771
|
while (uninstallers.length > 0) {
|
|
763
772
|
var fn = uninstallers.pop();
|
|
764
|
-
try { fn();
|
|
773
|
+
try { fn(); if (outboundInstallCount > 0) outboundInstallCount -= 1; }
|
|
774
|
+
catch (_e) { /* best-effort */ }
|
|
765
775
|
}
|
|
766
776
|
},
|
|
767
777
|
};
|
|
768
778
|
}
|
|
769
779
|
|
|
780
|
+
/**
|
|
781
|
+
* @primitive b.redact.isOutboundDlpInstalled
|
|
782
|
+
* @signature b.redact.isOutboundDlpInstalled()
|
|
783
|
+
* @since 0.14.27
|
|
784
|
+
* @compliance hipaa, pci-dss, gdpr, soc2, fapi2
|
|
785
|
+
* @related b.redact.installForPosture, b.redact.installOutboundDlp
|
|
786
|
+
*
|
|
787
|
+
* Returns `true` when at least one primitive instance (httpClient /
|
|
788
|
+
* mail / webhook) currently carries an outbound-DLP interceptor.
|
|
789
|
+
* `b.compliance.set` reads this to decide whether to emit the one-time
|
|
790
|
+
* `compliance.posture.outbound_dlp_unwired` warning when a posture whose
|
|
791
|
+
* floor implies outbound DLP is pinned without any wiring. Read-only.
|
|
792
|
+
*
|
|
793
|
+
* @example
|
|
794
|
+
* b.redact.isOutboundDlpInstalled(); // → false
|
|
795
|
+
* var dlp = b.redact.installForPosture("hipaa", { httpClient: myHttp });
|
|
796
|
+
* b.redact.isOutboundDlpInstalled(); // → true
|
|
797
|
+
* dlp.uninstall();
|
|
798
|
+
* b.redact.isOutboundDlpInstalled(); // → false
|
|
799
|
+
*/
|
|
800
|
+
function isOutboundDlpInstalled() {
|
|
801
|
+
return outboundInstallCount > 0;
|
|
802
|
+
}
|
|
803
|
+
|
|
770
804
|
function _resolvePosturePatterns(name) {
|
|
771
805
|
var n = String(name).toLowerCase();
|
|
772
806
|
if (n === "pci-dss" || n === "pci") {
|
|
@@ -779,6 +813,15 @@ function _resolvePosturePatterns(name) {
|
|
|
779
813
|
return ["pan", "credit-card", "iban", "pem", "aws-access-key", "jwt", "api-key-shape"];
|
|
780
814
|
}
|
|
781
815
|
if (n === "soc2" || n === "gdpr") {
|
|
816
|
+
// Known fidelity collapse: soc2 and gdpr share one outbound-DLP
|
|
817
|
+
// pattern set here. They are distinct regimes — GDPR's special-
|
|
818
|
+
// category personal data (Art. 9) is broader than the SOC 2 Trust
|
|
819
|
+
// Services criteria this preset targets — but the built-in
|
|
820
|
+
// value-shape detectors don't yet distinguish them, so the same
|
|
821
|
+
// pattern list backs both. Operators needing GDPR-specific shapes
|
|
822
|
+
// pass an explicit classifier (classifyDefaults) rather than the
|
|
823
|
+
// posture preset. This is intentional, not an accidental alias —
|
|
824
|
+
// documented so it isn't mistaken for per-regime handling.
|
|
782
825
|
return ["ssn", "ein", "pem", "ssh-private", "aws-access-key", "api-key-shape"];
|
|
783
826
|
}
|
|
784
827
|
throw new DlpError("redact-dlp/unknown-posture",
|
|
@@ -959,9 +1002,12 @@ function _summarizeHit(h) {
|
|
|
959
1002
|
return { label: h.label, action: h.action, where: h.where };
|
|
960
1003
|
}
|
|
961
1004
|
|
|
962
|
-
// Posture-coordinated install —
|
|
963
|
-
// to wire
|
|
964
|
-
// b.compliance
|
|
1005
|
+
// Posture-coordinated install — the operator passes the primitive
|
|
1006
|
+
// instances to wire (httpClient / mail / webhook); the posture name
|
|
1007
|
+
// selects the default classifier. b.compliance.set does NOT call this:
|
|
1008
|
+
// it holds no httpClient / mail / webhook handles, so it cannot install
|
|
1009
|
+
// outbound interceptors. Operators wire DLP explicitly by calling this
|
|
1010
|
+
// (or installOutboundDlp) with their own primitive instances.
|
|
965
1011
|
/**
|
|
966
1012
|
* @primitive b.redact.installForPosture
|
|
967
1013
|
* @signature b.redact.installForPosture(posture, primitives)
|
|
@@ -970,10 +1016,20 @@ function _summarizeHit(h) {
|
|
|
970
1016
|
* @compliance hipaa, pci-dss, gdpr, soc2, fapi2
|
|
971
1017
|
* @related b.redact.installOutboundDlp, b.redact.classifyDefaults
|
|
972
1018
|
*
|
|
973
|
-
* Posture-coordinated install —
|
|
974
|
-
* `
|
|
975
|
-
*
|
|
976
|
-
*
|
|
1019
|
+
* Posture-coordinated install — picks the default classifier for
|
|
1020
|
+
* `posture` and wraps the operator-supplied `primitives.httpClient` /
|
|
1021
|
+
* `.mail` / `.webhook` so every outbound payload runs through it. A
|
|
1022
|
+
* thin convenience over `installOutboundDlp`; direct callers usually
|
|
1023
|
+
* want `installOutboundDlp` because it accepts the full hook surface.
|
|
1024
|
+
*
|
|
1025
|
+
* The operator MUST call this with the primitive instances — pinning a
|
|
1026
|
+
* posture via `b.compliance.set` does NOT auto-install outbound DLP,
|
|
1027
|
+
* because the compliance coordinator holds no httpClient / mail /
|
|
1028
|
+
* webhook handles. When a posture whose floor implies outbound DLP
|
|
1029
|
+
* (hipaa / pci-dss / gdpr / soc2 / fapi-2.0) is pinned without this
|
|
1030
|
+
* call, `b.compliance.set` emits a one-time
|
|
1031
|
+
* `compliance.posture.outbound_dlp_unwired` audit warning so the gap is
|
|
1032
|
+
* grep-able in the audit chain.
|
|
977
1033
|
*
|
|
978
1034
|
* @example
|
|
979
1035
|
* var dlp = b.redact.installForPosture("hipaa", {
|
|
@@ -999,6 +1055,7 @@ module.exports = {
|
|
|
999
1055
|
classifyDefaults: classifyDefaults,
|
|
1000
1056
|
installOutboundDlp: installOutboundDlp,
|
|
1001
1057
|
installForPosture: installForPosture,
|
|
1058
|
+
isOutboundDlpInstalled: isOutboundDlpInstalled,
|
|
1002
1059
|
CLASSIFIER_PATTERNS: CLASSIFIER_PATTERNS,
|
|
1003
1060
|
MARKER: DEFAULT_MARKER,
|
|
1004
1061
|
SENSITIVE_FIELDS: SENSITIVE_FIELDS,
|
package/lib/redis-client.js
CHANGED
|
@@ -28,7 +28,6 @@ var net = require("node:net");
|
|
|
28
28
|
var nodeTls = require("node:tls");
|
|
29
29
|
var nodeUrl = require("node:url");
|
|
30
30
|
var C = require("./constants");
|
|
31
|
-
var safeAsync = require("./safe-async");
|
|
32
31
|
var validateOpts = require("./validate-opts");
|
|
33
32
|
var ipUtils = require("./ip-utils");
|
|
34
33
|
var { RedisError } = require("./framework-error");
|
|
@@ -215,11 +214,28 @@ function create(opts) {
|
|
|
215
214
|
var connected = false;
|
|
216
215
|
var connecting = false;
|
|
217
216
|
var closing = false;
|
|
217
|
+
// Shared in-flight connect. Every _connect() call returns the SAME
|
|
218
|
+
// promise while a connect is in progress, so concurrent callers all
|
|
219
|
+
// observe the same resolve/reject instead of polling a flag. It is
|
|
220
|
+
// ALWAYS settled (resolve on ready, reject on socket-error / connect-
|
|
221
|
+
// timeout / AUTH-or-SELECT failure) and cleared the moment it settles
|
|
222
|
+
// so the next caller starts a fresh attempt — a connect that fails
|
|
223
|
+
// can never leave a never-settling promise behind for the next
|
|
224
|
+
// awaiter to wedge on.
|
|
225
|
+
var connectPromise = null;
|
|
218
226
|
// Tracked + unref'd reconnect timer. Tracked so close() can cancel a
|
|
219
227
|
// pending backoff (otherwise a reconnect scheduled before close fires
|
|
220
228
|
// after it and opens a fresh socket); unref'd so a backoff window doesn't
|
|
221
229
|
// by itself keep the event loop alive (the process-won't-exit class).
|
|
230
|
+
// Single-flight: a non-null reconnectTimer means a backoff is already
|
|
231
|
+
// pending — socket-error AND socket-close firing for the same failure
|
|
232
|
+
// must not stack two timers (which would burn the reconnect budget at
|
|
233
|
+
// 2x and open redundant sockets).
|
|
222
234
|
var reconnectTimer = null;
|
|
235
|
+
// Set once the reconnect budget is exhausted. Makes the give-up path
|
|
236
|
+
// idempotent (drains pending+backlog exactly once) and stops a stray
|
|
237
|
+
// close/error after give-up from re-draining or racing a later success.
|
|
238
|
+
var gaveUp = false;
|
|
223
239
|
var rxBuffer = Buffer.alloc(0);
|
|
224
240
|
// FIFO of in-flight commands awaiting a response
|
|
225
241
|
var pending = [];
|
|
@@ -239,18 +255,31 @@ function create(opts) {
|
|
|
239
255
|
|
|
240
256
|
function _scheduleReconnect() {
|
|
241
257
|
if (closing) return;
|
|
258
|
+
// Single-flight: a socket failure surfaces as both an `error` and a
|
|
259
|
+
// `close` event. Without this guard each one schedules its own timer,
|
|
260
|
+
// stacking two reconnects for one failure — the budget burns at 2x
|
|
261
|
+
// and two fresh sockets open. A pending backoff already covers the
|
|
262
|
+
// failure, so a second call is a no-op.
|
|
263
|
+
if (reconnectTimer !== null) return;
|
|
242
264
|
if (maxReconnectAttempts >= 0 && reconnectAttempt >= maxReconnectAttempts) {
|
|
243
|
-
// Drain pending
|
|
265
|
+
// Reconnect budget exhausted. Drain pending + backlog exactly once;
|
|
266
|
+
// a later stray close/error must not re-drain or race a future
|
|
267
|
+
// success path.
|
|
268
|
+
if (gaveUp) return;
|
|
269
|
+
gaveUp = true;
|
|
244
270
|
var err = _err("RECONNECT_GAVE_UP",
|
|
245
271
|
"redis: gave up after " + reconnectAttempt + " reconnect attempts");
|
|
246
272
|
_drainPending(err);
|
|
247
273
|
return;
|
|
248
274
|
}
|
|
249
275
|
reconnectAttempt++;
|
|
276
|
+
// Exponential backoff capped at 30s. Base 100ms is the first-retry
|
|
277
|
+
// delay (not a duration unit), so it stays a literal; the cap routes
|
|
278
|
+
// through C.TIME.
|
|
250
279
|
var delay = Math.min(C.TIME.seconds(30), 100 * Math.pow(2, reconnectAttempt - 1));
|
|
251
280
|
reconnectTimer = setTimeout(function () {
|
|
252
281
|
reconnectTimer = null;
|
|
253
|
-
_connect().catch(function () { /*
|
|
282
|
+
_connect().catch(function () { /* failure reschedules via the teardown path */ });
|
|
254
283
|
}, delay);
|
|
255
284
|
if (typeof reconnectTimer.unref === "function") reconnectTimer.unref();
|
|
256
285
|
}
|
|
@@ -261,7 +290,10 @@ function create(opts) {
|
|
|
261
290
|
batch.forEach(function (p) { p.reject(err); });
|
|
262
291
|
var bl = backlog.slice();
|
|
263
292
|
backlog.length = 0;
|
|
264
|
-
bl.forEach(function (p) {
|
|
293
|
+
bl.forEach(function (p) {
|
|
294
|
+
if (p.timer) { clearTimeout(p.timer); p.timer = null; }
|
|
295
|
+
p.reject(err);
|
|
296
|
+
});
|
|
265
297
|
}
|
|
266
298
|
|
|
267
299
|
function _onData(chunk) {
|
|
@@ -312,39 +344,74 @@ function create(opts) {
|
|
|
312
344
|
}
|
|
313
345
|
}
|
|
314
346
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
347
|
+
// Single teardown path for a lost socket. A failure surfaces as an
|
|
348
|
+
// `error` event AND a `close` event (and `error` then destroys the
|
|
349
|
+
// socket, which fires `close` again) — three callbacks for ONE lost
|
|
350
|
+
// connection. Routing all of them here, guarded by a "are we still
|
|
351
|
+
// attached to this socket" check, means pending is drained once and
|
|
352
|
+
// exactly one reconnect is scheduled (the single-flight guard in
|
|
353
|
+
// _scheduleReconnect absorbs the rest). `err` is the diagnostic to
|
|
354
|
+
// reject in-flight commands with.
|
|
355
|
+
function _teardownSocket(err) {
|
|
356
|
+
// Already torn down for this socket (the sibling event already ran).
|
|
357
|
+
if (!connected && socket === null) {
|
|
358
|
+
// Still let a stray event re-arm a reconnect if one isn't pending
|
|
359
|
+
// and we haven't been closed — but never re-drain pending.
|
|
360
|
+
if (!closing) _scheduleReconnect();
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
318
363
|
connected = false;
|
|
319
|
-
|
|
364
|
+
var dead = socket;
|
|
320
365
|
socket = null;
|
|
366
|
+
if (dead) {
|
|
367
|
+
try {
|
|
368
|
+
dead.removeListener("error", _onSocketError);
|
|
369
|
+
dead.removeListener("close", _onSocketClose);
|
|
370
|
+
dead.removeListener("data", _onData);
|
|
371
|
+
dead.destroy();
|
|
372
|
+
} catch (_e) { /* best-effort socket teardown */ }
|
|
373
|
+
}
|
|
374
|
+
_drainPending(err);
|
|
321
375
|
if (!closing) _scheduleReconnect();
|
|
322
376
|
}
|
|
323
377
|
|
|
378
|
+
function _onSocketError(err) {
|
|
379
|
+
_teardownSocket(_err("SOCKET",
|
|
380
|
+
"redis socket error: " + ((err && err.message) || String(err))));
|
|
381
|
+
}
|
|
382
|
+
|
|
324
383
|
function _onSocketClose() {
|
|
325
|
-
|
|
326
|
-
if (!closing) {
|
|
327
|
-
var err = _err("SOCKET_CLOSED", "redis socket closed unexpectedly");
|
|
328
|
-
_drainPending(err);
|
|
329
|
-
socket = null;
|
|
330
|
-
_scheduleReconnect();
|
|
331
|
-
}
|
|
384
|
+
_teardownSocket(_err("SOCKET_CLOSED", "redis socket closed unexpectedly"));
|
|
332
385
|
}
|
|
333
386
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
387
|
+
// _connect() — public entry. Returns a promise that ALWAYS settles.
|
|
388
|
+
// Concurrent callers (and the reconnect timer) share the single
|
|
389
|
+
// in-flight connectPromise rather than each starting a parallel dial,
|
|
390
|
+
// and they all observe the same resolve/reject. A previous version
|
|
391
|
+
// polled a `connecting` flag in a `while (connecting) await sleep(20)`
|
|
392
|
+
// loop; if a failure path failed to clear that flag the waiter spun
|
|
393
|
+
// forever. The shared promise removes that wedge — the promise is
|
|
394
|
+
// cleared the instant it settles, so a failed connect can never leave
|
|
395
|
+
// a never-settling promise behind.
|
|
396
|
+
function _connect() {
|
|
397
|
+
if (closing) return Promise.resolve();
|
|
398
|
+
if (connected) return Promise.resolve();
|
|
399
|
+
if (connectPromise) return connectPromise;
|
|
400
|
+
connectPromise = _doConnect();
|
|
401
|
+
// Clear the shared promise once it settles (either way) so the next
|
|
402
|
+
// _connect() starts a fresh attempt instead of re-awaiting a stale
|
|
403
|
+
// settled promise.
|
|
404
|
+
var clear = function () { connectPromise = null; };
|
|
405
|
+
connectPromise.then(clear, clear);
|
|
406
|
+
return connectPromise;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async function _doConnect() {
|
|
344
410
|
connecting = true;
|
|
345
411
|
rxBuffer = Buffer.alloc(0);
|
|
412
|
+
var newSocket = null;
|
|
346
413
|
try {
|
|
347
|
-
|
|
414
|
+
newSocket = await new Promise(function (resolve, reject) {
|
|
348
415
|
var sock;
|
|
349
416
|
var timer = setTimeout(function () {
|
|
350
417
|
try { if (sock) sock.destroy(); } catch (_e) { /* best-effort socket teardown */ }
|
|
@@ -371,16 +438,19 @@ function create(opts) {
|
|
|
371
438
|
}
|
|
372
439
|
sock.once("error", onErr);
|
|
373
440
|
});
|
|
441
|
+
socket = newSocket;
|
|
374
442
|
socket.setNoDelay(true);
|
|
375
443
|
socket.on("data", _onData);
|
|
376
444
|
socket.on("error", _onSocketError);
|
|
377
445
|
socket.on("close", _onSocketClose);
|
|
378
446
|
connected = true;
|
|
379
|
-
reconnectAttempt = 0;
|
|
380
447
|
|
|
381
448
|
// Auth + select db on (re)connect — without resetting the
|
|
382
449
|
// backlog of commands queued during disconnect. Send these
|
|
383
450
|
// BEFORE the backlog so the server is ready when backlog flushes.
|
|
451
|
+
// A failure here (wrong password, server SELECT rejection, socket
|
|
452
|
+
// dropped mid-AUTH) must not leave connected=true on a half-open
|
|
453
|
+
// socket — the catch below tears the socket down and rethrows.
|
|
384
454
|
if (password) {
|
|
385
455
|
var authArgs = username ? ["AUTH", username, password] : ["AUTH", password];
|
|
386
456
|
await _sendNoQueue(authArgs);
|
|
@@ -389,15 +459,47 @@ function create(opts) {
|
|
|
389
459
|
await _sendNoQueue(["SELECT", String(db)]);
|
|
390
460
|
}
|
|
391
461
|
|
|
392
|
-
//
|
|
462
|
+
// Connect fully succeeded — only now reset the backoff counter +
|
|
463
|
+
// the give-up latch so a future disconnect gets a fresh budget.
|
|
464
|
+
reconnectAttempt = 0;
|
|
465
|
+
gaveUp = false;
|
|
466
|
+
connecting = false;
|
|
467
|
+
|
|
468
|
+
// Flush backlog. Clear each queued entry's not-connected timeout
|
|
469
|
+
// before it goes on the wire — the in-flight command timeout in
|
|
470
|
+
// _writeAndAwait now owns its lifetime.
|
|
393
471
|
var bl = backlog.slice();
|
|
394
472
|
backlog.length = 0;
|
|
395
|
-
bl.forEach(function (entry) {
|
|
473
|
+
bl.forEach(function (entry) {
|
|
474
|
+
if (entry.timer) { clearTimeout(entry.timer); entry.timer = null; }
|
|
475
|
+
_writeAndAwait(entry.args, entry.resolve, entry.reject);
|
|
476
|
+
});
|
|
396
477
|
} catch (err) {
|
|
397
478
|
connecting = false;
|
|
479
|
+
connected = false;
|
|
480
|
+
// Tear down a half-open socket (came up, then AUTH/SELECT failed)
|
|
481
|
+
// so we never leave connected=false with a live socket whose data/
|
|
482
|
+
// error/close handlers would fire against stale state. If the
|
|
483
|
+
// socket-error handler already ran it set socket=null.
|
|
484
|
+
var dead = socket || newSocket;
|
|
485
|
+
socket = null;
|
|
486
|
+
if (dead) {
|
|
487
|
+
try {
|
|
488
|
+
dead.removeListener("error", _onSocketError);
|
|
489
|
+
dead.removeListener("close", _onSocketClose);
|
|
490
|
+
dead.removeListener("data", _onData);
|
|
491
|
+
dead.destroy();
|
|
492
|
+
} catch (_e) { /* best-effort socket teardown */ }
|
|
493
|
+
}
|
|
494
|
+
// A failed dial (reset before ready) or an AUTH/SELECT failure must keep
|
|
495
|
+
// the reconnect loop alive. The post-ready error/close handlers that
|
|
496
|
+
// normally drive reconnect are not attached yet during the dial, so
|
|
497
|
+
// without scheduling here a connection lost mid-dial rejects the connect
|
|
498
|
+
// promise and the client never reconnects. Single-flight + budget-guarded
|
|
499
|
+
// by _scheduleReconnect; the caller still observes this attempt's rejection.
|
|
500
|
+
if (!closing) _scheduleReconnect();
|
|
398
501
|
throw err;
|
|
399
502
|
}
|
|
400
|
-
connecting = false;
|
|
401
503
|
}
|
|
402
504
|
|
|
403
505
|
// Internal helper that bypasses the connect-pending backlog (used
|
|
@@ -448,7 +550,28 @@ function create(opts) {
|
|
|
448
550
|
return;
|
|
449
551
|
}
|
|
450
552
|
if (!connected) {
|
|
451
|
-
|
|
553
|
+
// Reconnect budget exhausted and no reconnect is in flight — a
|
|
554
|
+
// backlogged command here would never be flushed (nothing will
|
|
555
|
+
// reconnect to drain it) and would wedge the caller forever.
|
|
556
|
+
// Reject immediately instead.
|
|
557
|
+
if (gaveUp && reconnectTimer === null && !connecting) {
|
|
558
|
+
reject(_err("RECONNECT_GAVE_UP",
|
|
559
|
+
"redis: client disconnected and reconnect budget exhausted"));
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
// Queued until the next successful connect flushes the backlog.
|
|
563
|
+
// Bound it with a timeout so a connect that never completes (the
|
|
564
|
+
// backend is down for the whole window) settles the caller with
|
|
565
|
+
// a clear error instead of leaving the await pending forever.
|
|
566
|
+
var entry = { args: args, resolve: resolve, reject: reject, timer: null };
|
|
567
|
+
entry.timer = setTimeout(function () {
|
|
568
|
+
var idx = backlog.indexOf(entry);
|
|
569
|
+
if (idx !== -1) backlog.splice(idx, 1);
|
|
570
|
+
reject(_err("COMMAND_TIMEOUT",
|
|
571
|
+
"redis " + args[0] + " timed out while queued (client not connected)"));
|
|
572
|
+
}, commandTimeoutMs);
|
|
573
|
+
if (typeof entry.timer.unref === "function") entry.timer.unref();
|
|
574
|
+
backlog.push(entry);
|
|
452
575
|
return;
|
|
453
576
|
}
|
|
454
577
|
_writeAndAwait(args, resolve, reject);
|
|
@@ -469,6 +592,9 @@ function create(opts) {
|
|
|
469
592
|
async function close() {
|
|
470
593
|
closing = true;
|
|
471
594
|
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
|
595
|
+
// Drop the shared connect promise so a re-create after close (or a
|
|
596
|
+
// late awaiter) doesn't re-await a stale in-flight attempt.
|
|
597
|
+
connectPromise = null;
|
|
472
598
|
var err = _err("CLOSED", "redis client closed");
|
|
473
599
|
_drainPending(err);
|
|
474
600
|
if (socket) {
|
|
@@ -492,8 +618,11 @@ function create(opts) {
|
|
|
492
618
|
_state: function () {
|
|
493
619
|
return {
|
|
494
620
|
connected: connected, closing: closing,
|
|
621
|
+
connecting: connecting,
|
|
495
622
|
pending: pending.length, backlog: backlog.length,
|
|
496
623
|
reconnect: reconnectAttempt,
|
|
624
|
+
reconnectPending: reconnectTimer !== null,
|
|
625
|
+
gaveUp: gaveUp,
|
|
497
626
|
host: host, port: port, db: db, tls: useTls,
|
|
498
627
|
connectTimeoutMs: connectTimeoutMs,
|
|
499
628
|
commandTimeoutMs: commandTimeoutMs,
|
package/lib/retention.js
CHANGED
|
@@ -46,6 +46,7 @@ var C = require("./constants");
|
|
|
46
46
|
var lazyRequire = require("./lazy-require");
|
|
47
47
|
var validateOpts = require("./validate-opts");
|
|
48
48
|
var safeSql = require("./safe-sql");
|
|
49
|
+
var sql = require("./sql");
|
|
49
50
|
var { defineClass } = require("./framework-error");
|
|
50
51
|
|
|
51
52
|
var audit = lazyRequire(function () { return require("./audit"); });
|
|
@@ -55,6 +56,25 @@ var legalHold = lazyRequire(function () { return require("./legal-hold"); });
|
|
|
55
56
|
var RetentionError = defineClass("RetentionError", { alwaysPermanent: true });
|
|
56
57
|
var _err = RetentionError.factory;
|
|
57
58
|
|
|
59
|
+
// Resolve the b.sql dialect for the operator-supplied handle. The framework's
|
|
60
|
+
// local b.db handle is always node:sqlite (db.js pins { dialect: "sqlite",
|
|
61
|
+
// quoteName: true }) and exposes no .dialect, so this defaults to "sqlite" —
|
|
62
|
+
// every sweep statement runs against that handle via .prepare(). An operator
|
|
63
|
+
// handle that DOES advertise a dialect (string or () -> string) has it
|
|
64
|
+
// threaded through so the emitted identifier quoting + idioms match the
|
|
65
|
+
// backend the handle dispatches to. quoteName stays on for every retention
|
|
66
|
+
// statement: the rule's table / ageField / softDeleteField identifiers are
|
|
67
|
+
// validated then quoted by construction (no clusterStorage prefix rewrite on
|
|
68
|
+
// this operator-app-schema path).
|
|
69
|
+
function _handleDialect(db) {
|
|
70
|
+
if (db && typeof db.dialect === "function") {
|
|
71
|
+
try { var d = db.dialect(); return typeof d === "string" ? d : "sqlite"; }
|
|
72
|
+
catch (_e) { return "sqlite"; }
|
|
73
|
+
}
|
|
74
|
+
if (db && typeof db.dialect === "string") return db.dialect;
|
|
75
|
+
return "sqlite";
|
|
76
|
+
}
|
|
77
|
+
|
|
58
78
|
// Identifier-level SQLi defense: every operator-supplied table name,
|
|
59
79
|
// column name, and cascade FK must pass safeSql.validateIdentifier
|
|
60
80
|
// before reaching SQL string concatenation. Without this gate a
|
|
@@ -196,6 +216,11 @@ function create(opts) {
|
|
|
196
216
|
throw _err("BAD_OPT", "create: opts.db is required (a b.db handle with .prepare(sql))");
|
|
197
217
|
}
|
|
198
218
|
var db = opts.db;
|
|
219
|
+
// b.sql opts for every retention statement built against this handle. The
|
|
220
|
+
// dialect tracks the handle (sqlite for the framework's local b.db); the
|
|
221
|
+
// validated operator identifiers are quoted by construction (quoteName)
|
|
222
|
+
// with no clusterStorage prefix rewrite on this path.
|
|
223
|
+
var SQL_OPTS = { dialect: _handleDialect(db), quoteName: true };
|
|
199
224
|
var auditOn = opts.audit !== false && opts.audit != null;
|
|
200
225
|
var auditInstance = (opts.audit && opts.audit !== true) ? opts.audit : null;
|
|
201
226
|
var rules = {};
|
|
@@ -235,16 +260,24 @@ function create(opts) {
|
|
|
235
260
|
|
|
236
261
|
function _hardDelete(table, rowId, dryRun) {
|
|
237
262
|
if (dryRun) return { wouldDelete: 1 };
|
|
238
|
-
|
|
239
|
-
|
|
263
|
+
// Operator app table — quoteName so the validated identifier emits as a
|
|
264
|
+
// quoted local name; the row id binds as a placeholder.
|
|
265
|
+
var built = sql.delete(table, SQL_OPTS)
|
|
266
|
+
.where("_id", rowId)
|
|
267
|
+
.toSql();
|
|
268
|
+
var del = db.prepare(built.sql);
|
|
269
|
+
del.run.apply(del, built.params);
|
|
240
270
|
return { deleted: 1 };
|
|
241
271
|
}
|
|
242
272
|
|
|
243
273
|
function _softDelete(table, rowId, softField, dryRun) {
|
|
244
274
|
if (dryRun) return { wouldSoftDelete: 1 };
|
|
245
|
-
var
|
|
246
|
-
|
|
247
|
-
|
|
275
|
+
var built = sql.update(table, SQL_OPTS)
|
|
276
|
+
.set(softField, Date.now())
|
|
277
|
+
.where("_id", rowId)
|
|
278
|
+
.toSql();
|
|
279
|
+
var upd = db.prepare(built.sql);
|
|
280
|
+
upd.run.apply(upd, built.params);
|
|
248
281
|
return { softDeleted: 1 };
|
|
249
282
|
}
|
|
250
283
|
|
|
@@ -261,19 +294,17 @@ function create(opts) {
|
|
|
261
294
|
return _hardDelete(table, row._id, dryRun);
|
|
262
295
|
}
|
|
263
296
|
if (dryRun) return { wouldErase: 1, sealedFieldCount: sealedFields.length };
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
var upd2 = db.prepare("UPDATE \"" + table + "\" SET " + setClauses.join(", ") + " WHERE _id = ?");
|
|
276
|
-
upd2.run.apply(upd2, values);
|
|
297
|
+
// NULL every sealed column + its derived-hash sibling. b.sql binds each
|
|
298
|
+
// null as a placeholder (the set map preserves the column ordering).
|
|
299
|
+
var eraseSet = {};
|
|
300
|
+
for (var si = 0; si < sealedFields.length; si++) eraseSet[sealedFields[si]] = null;
|
|
301
|
+
for (var hi = 0; hi < hashFields.length; hi++) eraseSet[hashFields[hi]] = null;
|
|
302
|
+
var eraseBuilt = sql.update(table, SQL_OPTS)
|
|
303
|
+
.set(eraseSet)
|
|
304
|
+
.where("_id", row._id)
|
|
305
|
+
.toSql();
|
|
306
|
+
var upd2 = db.prepare(eraseBuilt.sql);
|
|
307
|
+
upd2.run.apply(upd2, eraseBuilt.params);
|
|
277
308
|
// Per-row-key tables (declarePerRowKey): NULLing the sealed columns
|
|
278
309
|
// is not enough — WAL / replica residuals keep the old K_row cells.
|
|
279
310
|
// Destroy the row's wrapped secret so K_row is unrecoverable and the
|
|
@@ -294,15 +325,20 @@ function create(opts) {
|
|
|
294
325
|
for (var i = 0; i < rule.cascade.length; i++) {
|
|
295
326
|
var c = rule.cascade[i];
|
|
296
327
|
if (dryRun) {
|
|
297
|
-
var
|
|
298
|
-
"
|
|
299
|
-
|
|
328
|
+
var selBuilt = sql.select(c.table, SQL_OPTS)
|
|
329
|
+
.count("*", "n")
|
|
330
|
+
.where(c.foreignKey, rowId)
|
|
331
|
+
.toSql();
|
|
332
|
+
var sel = db.prepare(selBuilt.sql);
|
|
333
|
+
var n = sel.get.apply(sel, selBuilt.params);
|
|
300
334
|
cascadeSummary.push({ table: c.table, foreignKey: c.foreignKey,
|
|
301
335
|
wouldDelete: (n && typeof n.n === "number") ? n.n : 0 });
|
|
302
336
|
} else {
|
|
303
|
-
var
|
|
304
|
-
|
|
305
|
-
|
|
337
|
+
var delBuilt = sql.delete(c.table, SQL_OPTS)
|
|
338
|
+
.where(c.foreignKey, rowId)
|
|
339
|
+
.toSql();
|
|
340
|
+
var del = db.prepare(delBuilt.sql);
|
|
341
|
+
var result = del.run.apply(del, delBuilt.params);
|
|
306
342
|
cascadeSummary.push({ table: c.table, foreignKey: c.foreignKey,
|
|
307
343
|
deleted: result.changes || 0 });
|
|
308
344
|
}
|
|
@@ -392,24 +428,31 @@ function create(opts) {
|
|
|
392
428
|
while (moreRows) {
|
|
393
429
|
var rows;
|
|
394
430
|
// The candidate WHERE-clause: age + not-already-erased + not-on-legal-hold +
|
|
395
|
-
// (when soft-delete is configured) not-already-soft-deleted.
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
431
|
+
// (when soft-delete is configured) not-already-soft-deleted. Built
|
|
432
|
+
// through b.sql so the operator-supplied table / ageField / softDeleteField
|
|
433
|
+
// identifiers are quoted by construction and every value binds as a
|
|
434
|
+
// placeholder (the '' empty-string compare included — no embedded literal).
|
|
435
|
+
function _candidateBase() {
|
|
436
|
+
var qb = sql.select(rule.table, SQL_OPTS)
|
|
437
|
+
.where(rule.ageField, "<=", cutoff);
|
|
438
|
+
if (rule.softDeleteField) qb.whereNull(rule.softDeleteField);
|
|
439
|
+
return qb;
|
|
400
440
|
}
|
|
401
|
-
var sql = "SELECT * FROM \"" + rule.table + "\" " +
|
|
402
|
-
"WHERE " + whereParts.join(" AND ") + " " +
|
|
403
|
-
"AND (__erasedAt IS NULL OR __erasedAt = '') " +
|
|
404
|
-
"LIMIT ?";
|
|
405
441
|
var selStmt;
|
|
406
|
-
try {
|
|
407
|
-
|
|
442
|
+
try {
|
|
443
|
+
var built = _candidateBase()
|
|
444
|
+
.whereGroup(function (g) {
|
|
445
|
+
g.whereNull("__erasedAt").orWhereOp("__erasedAt", "=", "");
|
|
446
|
+
})
|
|
447
|
+
.limit(rule.batchSize)
|
|
448
|
+
.toSql();
|
|
449
|
+
selStmt = db.prepare(built.sql);
|
|
450
|
+
rows = selStmt.all.apply(selStmt, built.params);
|
|
451
|
+
} catch (_eA) {
|
|
408
452
|
// Fallback: tables without __erasedAt
|
|
409
|
-
var
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
rows = selPlain.all.apply(selPlain, whereArgs.concat([rule.batchSize]));
|
|
453
|
+
var plainBuilt = _candidateBase().limit(rule.batchSize).toSql();
|
|
454
|
+
var selPlain = db.prepare(plainBuilt.sql);
|
|
455
|
+
rows = selPlain.all.apply(selPlain, plainBuilt.params);
|
|
413
456
|
}
|
|
414
457
|
if (!rows || rows.length === 0) { moreRows = false; break; }
|
|
415
458
|
summary.scanned += rows.length;
|