@blamejs/core 0.6.13 → 0.6.20
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 +7 -0
- package/NOTICE +16 -0
- package/README.md +9 -8
- package/index.js +12 -0
- package/lib/audit.js +4 -0
- package/lib/auth/password.js +449 -4
- package/lib/cli.js +598 -4
- package/lib/config-drift.js +309 -0
- package/lib/crypto-field.js +37 -0
- package/lib/crypto.js +8 -0
- package/lib/db.js +17 -2
- package/lib/dual-control.js +475 -0
- package/lib/file-type.js +265 -0
- package/lib/http-client.js +77 -0
- package/lib/internal-sha1-hibp.js +34 -0
- package/lib/middleware/csp-nonce.js +7 -4
- package/lib/middleware/index.js +2 -0
- package/lib/middleware/network-allowlist.js +199 -0
- package/lib/network-dns.js +469 -0
- package/lib/network-heartbeat.js +290 -0
- package/lib/network-nts.js +552 -0
- package/lib/network-proxy.js +246 -0
- package/lib/network-tls.js +326 -0
- package/lib/network.js +233 -0
- package/lib/ntp-check.js +50 -4
- package/lib/object-store/azure-blob.js +16 -42
- package/lib/permissions.js +223 -9
- package/lib/pqc-agent.js +4 -4
- package/lib/retention.js +439 -0
- package/lib/security-assert.js +368 -0
- package/lib/session.js +138 -8
- package/lib/ssrf-guard.js +9 -0
- package/lib/vendor/MANIFEST.json +12 -0
- package/lib/vendor/common-passwords-top-10000.txt +10000 -0
- package/package.json +3 -2
- package/sbom.cyclonedx.json +61 -0
package/lib/pqc-agent.js
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
var https = require("node:https");
|
|
32
32
|
var http = require("node:http");
|
|
33
33
|
var C = require("./constants");
|
|
34
|
+
var networkTls = require("./network-tls");
|
|
34
35
|
|
|
35
36
|
// Defaults for connection pooling. These ARE overridable via opts —
|
|
36
37
|
// only the cryptographic posture (ecdhCurve / minVersion) is locked.
|
|
@@ -45,12 +46,11 @@ var DEFAULT_OPTS = {
|
|
|
45
46
|
function _buildAgentOpts(opts) {
|
|
46
47
|
opts = opts || {};
|
|
47
48
|
var merged = Object.assign({}, DEFAULT_OPTS, opts);
|
|
48
|
-
// Cryptographic posture cannot be relaxed via opts. Even if the
|
|
49
|
-
// operator passes ecdhCurve: 'P-256' or minVersion: 'TLSv1.2', the
|
|
50
|
-
// framework defaults win. This is deliberate: the primitive's whole
|
|
51
|
-
// value is that you can't accidentally ship a downgraded agent.
|
|
52
49
|
merged.ecdhCurve = C.TLS_GROUP_CURVE_STR;
|
|
53
50
|
merged.minVersion = "TLSv1.3";
|
|
51
|
+
if (networkTls && typeof networkTls.applyToContext === "function") {
|
|
52
|
+
merged = networkTls.applyToContext({ base: merged });
|
|
53
|
+
}
|
|
54
54
|
return merged;
|
|
55
55
|
}
|
|
56
56
|
|
package/lib/retention.js
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* retention — operator-declared data retention rules with periodic sweep.
|
|
4
|
+
*
|
|
5
|
+
* GDPR / HIPAA / PCI / industry-specific compliance regimes all share
|
|
6
|
+
* the shape: "data of class X stored beyond TTL Y must be either
|
|
7
|
+
* deleted or anonymized". The framework provides the building blocks
|
|
8
|
+
* (b.cryptoField.eraseRow for crypto-erasure of sealed columns,
|
|
9
|
+
* b.scheduler for the wake cadence, b.audit for the chain). retention
|
|
10
|
+
* ties them into one operator-facing primitive.
|
|
11
|
+
*
|
|
12
|
+
* var rules = b.retention.create({
|
|
13
|
+
* db: b.db,
|
|
14
|
+
* audit: b.audit,
|
|
15
|
+
* });
|
|
16
|
+
*
|
|
17
|
+
* rules.declare({
|
|
18
|
+
* name: "users.notes-ttl",
|
|
19
|
+
* table: "users",
|
|
20
|
+
* ageField: "createdAt", // milliseconds-since-epoch column
|
|
21
|
+
* ttlMs: C.TIME.days(90),
|
|
22
|
+
* action: "erase", // "erase" (b.cryptoField.eraseRow) | "delete"
|
|
23
|
+
* batchSize: 500, // rows-per-sweep iteration; default 500
|
|
24
|
+
* });
|
|
25
|
+
*
|
|
26
|
+
* // Operator wires the sweep cadence:
|
|
27
|
+
* scheduler.schedule({
|
|
28
|
+
* name: "retention.sweep",
|
|
29
|
+
* every: C.TIME.hours(1),
|
|
30
|
+
* run: function () { return rules.runAll(); },
|
|
31
|
+
* });
|
|
32
|
+
*
|
|
33
|
+
* // Or run on demand (operator CLI / one-shot):
|
|
34
|
+
* var summary = await rules.run("users.notes-ttl");
|
|
35
|
+
* // → { name, scanned, processed, action, durationMs, errors: [] }
|
|
36
|
+
*
|
|
37
|
+
* Audit posture (audit namespace "retention"):
|
|
38
|
+
* - retention.rule.declared — once per declare() call
|
|
39
|
+
* - retention.sweep.started — at the top of each runAll()/run()
|
|
40
|
+
* - retention.row.processed — per row, with metadata.action
|
|
41
|
+
* - retention.sweep.completed — at the end with row counts
|
|
42
|
+
* - retention.sweep.failed — when the rule's SQL throws
|
|
43
|
+
*
|
|
44
|
+
* Erase vs delete:
|
|
45
|
+
* - "erase" (default): sealed columns + derived hashes go to NULL,
|
|
46
|
+
* `__erasedAt` is set. Row stays for FK / audit reference. Per
|
|
47
|
+
* GDPR Art. 17 the cleartext is unrecoverable even with a vault
|
|
48
|
+
* key (no ciphertext to decrypt).
|
|
49
|
+
* - "delete": full row DELETE. Use when no FK / audit reference
|
|
50
|
+
* blocks the row from going.
|
|
51
|
+
*
|
|
52
|
+
* Operators with COMPLEX retention (multi-table joins, conditional
|
|
53
|
+
* rules) use action: function(row) async — the framework calls back
|
|
54
|
+
* with each candidate row and the operator's function performs the
|
|
55
|
+
* write. This is the escape hatch; the table+ageField+ttlMs shape
|
|
56
|
+
* covers the common case.
|
|
57
|
+
*/
|
|
58
|
+
var lazyRequire = require("./lazy-require");
|
|
59
|
+
var validateOpts = require("./validate-opts");
|
|
60
|
+
var { defineClass } = require("./framework-error");
|
|
61
|
+
|
|
62
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
63
|
+
var cryptoField = require("./crypto-field");
|
|
64
|
+
|
|
65
|
+
var RetentionError = defineClass("RetentionError", { alwaysPermanent: true });
|
|
66
|
+
var _err = RetentionError.factory;
|
|
67
|
+
|
|
68
|
+
function _validateRule(rule) {
|
|
69
|
+
if (!rule || typeof rule !== "object") {
|
|
70
|
+
throw _err("BAD_RULE", "rule must be an object");
|
|
71
|
+
}
|
|
72
|
+
if (typeof rule.name !== "string" || rule.name.length === 0) {
|
|
73
|
+
throw _err("BAD_RULE", "rule.name (string) is required");
|
|
74
|
+
}
|
|
75
|
+
if (typeof rule.table !== "string" || rule.table.length === 0) {
|
|
76
|
+
throw _err("BAD_RULE", "rule.table (string) is required");
|
|
77
|
+
}
|
|
78
|
+
if (typeof rule.ageField !== "string" || rule.ageField.length === 0) {
|
|
79
|
+
throw _err("BAD_RULE", "rule.ageField (string) is required");
|
|
80
|
+
}
|
|
81
|
+
if (typeof rule.ttlMs !== "number" || !isFinite(rule.ttlMs) || rule.ttlMs <= 0) {
|
|
82
|
+
throw _err("BAD_RULE", "rule.ttlMs must be a positive finite number");
|
|
83
|
+
}
|
|
84
|
+
var action = rule.action;
|
|
85
|
+
if (typeof action !== "string" && typeof action !== "function") {
|
|
86
|
+
throw _err("BAD_RULE", "rule.action must be 'erase' / 'delete' / 'soft-delete' or a function(row)");
|
|
87
|
+
}
|
|
88
|
+
if (typeof action === "string" && ["erase", "delete", "soft-delete"].indexOf(action) === -1) {
|
|
89
|
+
throw _err("BAD_RULE",
|
|
90
|
+
"rule.action string must be 'erase' / 'delete' / 'soft-delete', got " + JSON.stringify(action));
|
|
91
|
+
}
|
|
92
|
+
if (rule.batchSize !== undefined &&
|
|
93
|
+
(typeof rule.batchSize !== "number" || !isFinite(rule.batchSize) ||
|
|
94
|
+
rule.batchSize <= 0 || Math.floor(rule.batchSize) !== rule.batchSize)) {
|
|
95
|
+
throw _err("BAD_RULE", "rule.batchSize must be a positive integer");
|
|
96
|
+
}
|
|
97
|
+
if (rule.softDeleteField !== undefined &&
|
|
98
|
+
(typeof rule.softDeleteField !== "string" || rule.softDeleteField.length === 0)) {
|
|
99
|
+
throw _err("BAD_RULE", "rule.softDeleteField must be a non-empty string");
|
|
100
|
+
}
|
|
101
|
+
if (rule.legalHoldField !== undefined &&
|
|
102
|
+
(typeof rule.legalHoldField !== "string" || rule.legalHoldField.length === 0)) {
|
|
103
|
+
throw _err("BAD_RULE", "rule.legalHoldField must be a non-empty string");
|
|
104
|
+
}
|
|
105
|
+
if (rule.cascade !== undefined) {
|
|
106
|
+
if (!Array.isArray(rule.cascade) || rule.cascade.length === 0) {
|
|
107
|
+
throw _err("BAD_RULE", "rule.cascade must be a non-empty array of { table, foreignKey } entries");
|
|
108
|
+
}
|
|
109
|
+
for (var ci = 0; ci < rule.cascade.length; ci++) {
|
|
110
|
+
var c = rule.cascade[ci];
|
|
111
|
+
if (!c || typeof c.table !== "string" || c.table.length === 0 ||
|
|
112
|
+
typeof c.foreignKey !== "string" || c.foreignKey.length === 0) {
|
|
113
|
+
throw _err("BAD_RULE", "rule.cascade[" + ci + "] must be { table: string, foreignKey: string }");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (rule.stages !== undefined) {
|
|
118
|
+
if (!Array.isArray(rule.stages) || rule.stages.length === 0) {
|
|
119
|
+
throw _err("BAD_RULE", "rule.stages must be a non-empty array of { atMs, action } entries");
|
|
120
|
+
}
|
|
121
|
+
for (var si = 0; si < rule.stages.length; si++) {
|
|
122
|
+
var stage = rule.stages[si];
|
|
123
|
+
if (!stage || typeof stage.atMs !== "number" || !isFinite(stage.atMs) || stage.atMs <= 0) {
|
|
124
|
+
throw _err("BAD_RULE", "rule.stages[" + si + "].atMs must be a positive finite number");
|
|
125
|
+
}
|
|
126
|
+
if (typeof stage.action !== "string" && typeof stage.action !== "function") {
|
|
127
|
+
throw _err("BAD_RULE",
|
|
128
|
+
"rule.stages[" + si + "].action must be 'erase' / 'delete' / 'soft-delete' / 'warn' or a function(row)");
|
|
129
|
+
}
|
|
130
|
+
if (typeof stage.action === "string" &&
|
|
131
|
+
["erase", "delete", "soft-delete", "warn"].indexOf(stage.action) === -1) {
|
|
132
|
+
throw _err("BAD_RULE",
|
|
133
|
+
"rule.stages[" + si + "].action string must be one of erase / delete / soft-delete / warn");
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function create(opts) {
|
|
140
|
+
opts = opts || {};
|
|
141
|
+
validateOpts(opts, ["db", "audit"], "retention");
|
|
142
|
+
if (!opts.db || typeof opts.db.prepare !== "function") {
|
|
143
|
+
throw _err("BAD_OPT", "create: opts.db is required (a b.db handle with .prepare(sql))");
|
|
144
|
+
}
|
|
145
|
+
var db = opts.db;
|
|
146
|
+
var auditOn = opts.audit !== false && opts.audit != null;
|
|
147
|
+
var auditInstance = (opts.audit && opts.audit !== true) ? opts.audit : null;
|
|
148
|
+
var rules = {};
|
|
149
|
+
|
|
150
|
+
function _emit(action, info, outcome) {
|
|
151
|
+
if (!auditOn) return;
|
|
152
|
+
var sink = auditInstance || audit();
|
|
153
|
+
try {
|
|
154
|
+
sink.safeEmit({
|
|
155
|
+
action: action,
|
|
156
|
+
outcome: outcome,
|
|
157
|
+
metadata: info || {},
|
|
158
|
+
});
|
|
159
|
+
} catch (_e) { /* best-effort */ }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Per-rule "running" lock so a slow sweep can't be re-entered by
|
|
163
|
+
// the next scheduler tick. Operators with overlap risk wire a
|
|
164
|
+
// longer scheduler.every and let the lock catch the rare overrun.
|
|
165
|
+
var running = {};
|
|
166
|
+
|
|
167
|
+
function declare(rule) {
|
|
168
|
+
_validateRule(rule);
|
|
169
|
+
if (rules[rule.name]) {
|
|
170
|
+
throw _err("DUPLICATE_RULE", "rule '" + rule.name + "' is already declared");
|
|
171
|
+
}
|
|
172
|
+
rules[rule.name] = Object.assign({ batchSize: 500 }, rule);
|
|
173
|
+
_emit("retention.rule.declared",
|
|
174
|
+
{ name: rule.name, table: rule.table, ageField: rule.ageField,
|
|
175
|
+
ttlMs: rule.ttlMs, action: typeof rule.action === "function" ? "<custom>" : rule.action,
|
|
176
|
+
hasStages: Array.isArray(rule.stages) && rule.stages.length > 0,
|
|
177
|
+
hasCascade: Array.isArray(rule.cascade) && rule.cascade.length > 0,
|
|
178
|
+
legalHoldField: rule.legalHoldField || null,
|
|
179
|
+
softDeleteField: rule.softDeleteField || null },
|
|
180
|
+
"success");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function _hardDelete(table, rowId, dryRun) {
|
|
184
|
+
if (dryRun) return { wouldDelete: 1 };
|
|
185
|
+
var del = db.prepare("DELETE FROM \"" + table + "\" WHERE _id = ?");
|
|
186
|
+
del.run(rowId);
|
|
187
|
+
return { deleted: 1 };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function _softDelete(table, rowId, softField, dryRun) {
|
|
191
|
+
if (dryRun) return { wouldSoftDelete: 1 };
|
|
192
|
+
var upd = db.prepare(
|
|
193
|
+
"UPDATE \"" + table + "\" SET \"" + softField + "\" = ? WHERE _id = ?");
|
|
194
|
+
upd.run(Date.now(), rowId);
|
|
195
|
+
return { softDeleted: 1 };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function _erase(table, row, dryRun) {
|
|
199
|
+
var erased = cryptoField.eraseRow(table, row);
|
|
200
|
+
var sealedFields = cryptoField.getSealedFields(table) || [];
|
|
201
|
+
var hashFields = [];
|
|
202
|
+
var schema = cryptoField.getSchema(table);
|
|
203
|
+
if (schema && schema.derivedHashes) {
|
|
204
|
+
for (var k in schema.derivedHashes) hashFields.push(k);
|
|
205
|
+
}
|
|
206
|
+
if (sealedFields.length === 0 && hashFields.length === 0) {
|
|
207
|
+
// Table has no sealed columns to erase — fall back to delete.
|
|
208
|
+
return _hardDelete(table, row._id, dryRun);
|
|
209
|
+
}
|
|
210
|
+
if (dryRun) return { wouldErase: 1, sealedFieldCount: sealedFields.length };
|
|
211
|
+
var setClauses = [];
|
|
212
|
+
var values = [];
|
|
213
|
+
for (var si = 0; si < sealedFields.length; si++) {
|
|
214
|
+
setClauses.push('"' + sealedFields[si] + '" = ?');
|
|
215
|
+
values.push(null);
|
|
216
|
+
}
|
|
217
|
+
for (var hi = 0; hi < hashFields.length; hi++) {
|
|
218
|
+
setClauses.push('"' + hashFields[hi] + '" = ?');
|
|
219
|
+
values.push(null);
|
|
220
|
+
}
|
|
221
|
+
values.push(row._id);
|
|
222
|
+
var upd2 = db.prepare("UPDATE \"" + table + "\" SET " + setClauses.join(", ") + " WHERE _id = ?");
|
|
223
|
+
upd2.run.apply(upd2, values);
|
|
224
|
+
void erased;
|
|
225
|
+
return { erased: 1, sealedFieldCount: sealedFields.length };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function _cascade(rule, rowId, dryRun) {
|
|
229
|
+
if (!Array.isArray(rule.cascade) || rule.cascade.length === 0) return null;
|
|
230
|
+
var cascadeSummary = [];
|
|
231
|
+
for (var i = 0; i < rule.cascade.length; i++) {
|
|
232
|
+
var c = rule.cascade[i];
|
|
233
|
+
if (dryRun) {
|
|
234
|
+
var sel = db.prepare(
|
|
235
|
+
"SELECT COUNT(*) AS n FROM \"" + c.table + "\" WHERE \"" + c.foreignKey + "\" = ?");
|
|
236
|
+
var n = sel.get(rowId);
|
|
237
|
+
cascadeSummary.push({ table: c.table, foreignKey: c.foreignKey,
|
|
238
|
+
wouldDelete: (n && typeof n.n === "number") ? n.n : 0 });
|
|
239
|
+
} else {
|
|
240
|
+
var del = db.prepare(
|
|
241
|
+
"DELETE FROM \"" + c.table + "\" WHERE \"" + c.foreignKey + "\" = ?");
|
|
242
|
+
var result = del.run(rowId);
|
|
243
|
+
cascadeSummary.push({ table: c.table, foreignKey: c.foreignKey,
|
|
244
|
+
deleted: result.changes || 0 });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return cascadeSummary;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function _runAction(rule, action, row, dryRun) {
|
|
251
|
+
if (typeof action === "function") {
|
|
252
|
+
if (dryRun) return { wouldCustomAction: 1 };
|
|
253
|
+
var ret = await action(row);
|
|
254
|
+
return ret || { customAction: 1 };
|
|
255
|
+
}
|
|
256
|
+
if (action === "warn") {
|
|
257
|
+
// Multi-stage "warn" entry — emit an audit event but DON'T touch the row.
|
|
258
|
+
_emit("retention.row.warned",
|
|
259
|
+
{ table: rule.table, name: rule.name, rowId: row._id, ageMs: Date.now() - Number(row[rule.ageField]) },
|
|
260
|
+
"warning");
|
|
261
|
+
return { warned: 1 };
|
|
262
|
+
}
|
|
263
|
+
if (action === "soft-delete") {
|
|
264
|
+
if (!rule.softDeleteField) {
|
|
265
|
+
throw _err("BAD_RULE",
|
|
266
|
+
"soft-delete action requires rule.softDeleteField (column to write deletion timestamp into)");
|
|
267
|
+
}
|
|
268
|
+
return _softDelete(rule.table, row._id, rule.softDeleteField, dryRun);
|
|
269
|
+
}
|
|
270
|
+
if (action === "delete") {
|
|
271
|
+
var hardRes = _hardDelete(rule.table, row._id, dryRun);
|
|
272
|
+
var hardCasc = _cascade(rule, row._id, dryRun);
|
|
273
|
+
if (hardCasc) hardRes.cascade = hardCasc;
|
|
274
|
+
return hardRes;
|
|
275
|
+
}
|
|
276
|
+
// erase
|
|
277
|
+
var eraseRes = _erase(rule.table, row, dryRun);
|
|
278
|
+
var eraseCasc = _cascade(rule, row._id, dryRun);
|
|
279
|
+
if (eraseCasc) eraseRes.cascade = eraseCasc;
|
|
280
|
+
return eraseRes;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function _stageForRow(rule, row, nowMs) {
|
|
284
|
+
// Multi-stage routing: pick the most-aggressive stage whose atMs
|
|
285
|
+
// threshold the row has crossed. Ordered descending so erase
|
|
286
|
+
// wins over warn when both are due.
|
|
287
|
+
if (!Array.isArray(rule.stages) || rule.stages.length === 0) return rule.action;
|
|
288
|
+
var ageMs = nowMs - Number(row[rule.ageField]);
|
|
289
|
+
var sorted = rule.stages.slice().sort(function (a, b) { return b.atMs - a.atMs; });
|
|
290
|
+
for (var i = 0; i < sorted.length; i++) {
|
|
291
|
+
if (ageMs >= sorted[i].atMs) return sorted[i].action;
|
|
292
|
+
}
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function run(name, runOpts) {
|
|
297
|
+
var rule = rules[name];
|
|
298
|
+
if (!rule) throw _err("NO_SUCH_RULE", "rule '" + name + "' not declared");
|
|
299
|
+
runOpts = runOpts || {};
|
|
300
|
+
var dryRun = runOpts.dryRun === true;
|
|
301
|
+
if (!dryRun && running[name]) {
|
|
302
|
+
_emit("retention.sweep.skipped_concurrent",
|
|
303
|
+
{ name: name, reason: "previous sweep still running" }, "warning");
|
|
304
|
+
return { name: name, skipped: true, reason: "concurrent-sweep-in-progress" };
|
|
305
|
+
}
|
|
306
|
+
if (!dryRun) running[name] = true;
|
|
307
|
+
var startedAt = Date.now();
|
|
308
|
+
// For multi-stage rules the cutoff is the EARLIEST stage atMs;
|
|
309
|
+
// single-stage rules use ttlMs.
|
|
310
|
+
var earliestAtMs = rule.ttlMs;
|
|
311
|
+
if (Array.isArray(rule.stages) && rule.stages.length > 0) {
|
|
312
|
+
for (var sx = 0; sx < rule.stages.length; sx++) {
|
|
313
|
+
if (rule.stages[sx].atMs < earliestAtMs) earliestAtMs = rule.stages[sx].atMs;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
var cutoff = startedAt - earliestAtMs;
|
|
317
|
+
_emit("retention.sweep.started",
|
|
318
|
+
{ name: name, table: rule.table, cutoff: cutoff, dryRun: dryRun,
|
|
319
|
+
hasStages: Array.isArray(rule.stages) && rule.stages.length > 0 },
|
|
320
|
+
"success");
|
|
321
|
+
|
|
322
|
+
var summary = { name: name, scanned: 0, processed: 0, skipped: 0,
|
|
323
|
+
legalHoldsHonored: 0, dryRun: dryRun,
|
|
324
|
+
action: typeof rule.action === "function" ? "custom" : rule.action,
|
|
325
|
+
errors: [], stageBreakdown: {} };
|
|
326
|
+
|
|
327
|
+
try {
|
|
328
|
+
var moreRows = true;
|
|
329
|
+
while (moreRows) {
|
|
330
|
+
var rows;
|
|
331
|
+
// The candidate WHERE-clause: age + not-already-erased + not-on-legal-hold +
|
|
332
|
+
// (when soft-delete is configured) not-already-soft-deleted.
|
|
333
|
+
var whereParts = ['"' + rule.ageField + '" <= ?'];
|
|
334
|
+
var whereArgs = [cutoff];
|
|
335
|
+
if (rule.softDeleteField) {
|
|
336
|
+
whereParts.push('("' + rule.softDeleteField + '" IS NULL)');
|
|
337
|
+
}
|
|
338
|
+
var sql = "SELECT * FROM \"" + rule.table + "\" " +
|
|
339
|
+
"WHERE " + whereParts.join(" AND ") + " " +
|
|
340
|
+
"AND (__erasedAt IS NULL OR __erasedAt = '') " +
|
|
341
|
+
"LIMIT ?";
|
|
342
|
+
var selStmt;
|
|
343
|
+
try { selStmt = db.prepare(sql); rows = selStmt.all.apply(selStmt, whereArgs.concat([rule.batchSize])); }
|
|
344
|
+
catch (_eA) {
|
|
345
|
+
// Fallback: tables without __erasedAt
|
|
346
|
+
var sqlPlain = "SELECT * FROM \"" + rule.table + "\" " +
|
|
347
|
+
"WHERE " + whereParts.join(" AND ") + " LIMIT ?";
|
|
348
|
+
var selPlain = db.prepare(sqlPlain);
|
|
349
|
+
rows = selPlain.all.apply(selPlain, whereArgs.concat([rule.batchSize]));
|
|
350
|
+
}
|
|
351
|
+
if (!rows || rows.length === 0) { moreRows = false; break; }
|
|
352
|
+
summary.scanned += rows.length;
|
|
353
|
+
for (var i = 0; i < rows.length; i++) {
|
|
354
|
+
var row = rows[i];
|
|
355
|
+
// Legal-hold honour: per-row exemption skips ALL retention
|
|
356
|
+
// actions until the operator clears the field.
|
|
357
|
+
if (rule.legalHoldField && row[rule.legalHoldField]) {
|
|
358
|
+
summary.legalHoldsHonored++;
|
|
359
|
+
_emit("retention.row.legal_hold_skipped",
|
|
360
|
+
{ name: name, table: rule.table, rowId: row._id }, "warning");
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
var action = _stageForRow(rule, row, startedAt);
|
|
364
|
+
if (!action) { summary.skipped++; continue; }
|
|
365
|
+
var actionLabel = typeof action === "function" ? "custom" : action;
|
|
366
|
+
summary.stageBreakdown[actionLabel] = (summary.stageBreakdown[actionLabel] || 0) + 1;
|
|
367
|
+
try {
|
|
368
|
+
var result = await _runAction(rule, action, row, dryRun);
|
|
369
|
+
summary.processed++;
|
|
370
|
+
_emit("retention.row.processed",
|
|
371
|
+
{ name: name, table: rule.table, rowId: row._id, action: actionLabel,
|
|
372
|
+
dryRun: dryRun, result: result },
|
|
373
|
+
"success");
|
|
374
|
+
} catch (e) {
|
|
375
|
+
summary.errors.push({ rowId: row._id,
|
|
376
|
+
reason: (e && e.message) || String(e) });
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
if (rows.length < rule.batchSize) moreRows = false;
|
|
380
|
+
}
|
|
381
|
+
} catch (e) {
|
|
382
|
+
_emit("retention.sweep.failed",
|
|
383
|
+
{ name: name, table: rule.table, reason: (e && e.message) || String(e) },
|
|
384
|
+
"failure");
|
|
385
|
+
if (!dryRun) delete running[name];
|
|
386
|
+
throw _err("SWEEP_FAILED",
|
|
387
|
+
"rule '" + name + "' sweep failed: " + ((e && e.message) || String(e)));
|
|
388
|
+
}
|
|
389
|
+
if (!dryRun) delete running[name];
|
|
390
|
+
summary.durationMs = Date.now() - startedAt;
|
|
391
|
+
_emit("retention.sweep.completed",
|
|
392
|
+
{ name: name, table: rule.table, scanned: summary.scanned,
|
|
393
|
+
processed: summary.processed, skipped: summary.skipped,
|
|
394
|
+
legalHoldsHonored: summary.legalHoldsHonored,
|
|
395
|
+
errorCount: summary.errors.length, dryRun: dryRun,
|
|
396
|
+
durationMs: summary.durationMs, stageBreakdown: summary.stageBreakdown },
|
|
397
|
+
summary.errors.length > 0 ? "warning" : "success");
|
|
398
|
+
return summary;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async function runAll(runOpts) {
|
|
402
|
+
var summaries = [];
|
|
403
|
+
var names = Object.keys(rules);
|
|
404
|
+
for (var i = 0; i < names.length; i++) {
|
|
405
|
+
try { summaries.push(await run(names[i], runOpts)); }
|
|
406
|
+
catch (e) {
|
|
407
|
+
summaries.push({ name: names[i], error: (e && e.message) || String(e) });
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return summaries;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Operator-callable preview — runs `run(name, { dryRun: true })`
|
|
414
|
+
// without consuming the concurrency lock and returns the count of
|
|
415
|
+
// rows that WOULD be touched. Useful for ops dashboards that want
|
|
416
|
+
// to surface "the next sweep will erase N rows" before the operator
|
|
417
|
+
// promotes a rule's TTL.
|
|
418
|
+
async function preview(name) {
|
|
419
|
+
return await run(name, { dryRun: true });
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function list() {
|
|
423
|
+
return Object.keys(rules).map(function (n) {
|
|
424
|
+
var r = rules[n];
|
|
425
|
+
return {
|
|
426
|
+
name: r.name, table: r.table, ageField: r.ageField, ttlMs: r.ttlMs,
|
|
427
|
+
action: typeof r.action === "function" ? "<custom>" : r.action,
|
|
428
|
+
batchSize: r.batchSize,
|
|
429
|
+
};
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return { declare: declare, run: run, runAll: runAll, preview: preview, list: list };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
module.exports = {
|
|
437
|
+
create: create,
|
|
438
|
+
RetentionError: RetentionError,
|
|
439
|
+
};
|