@blamejs/core 0.4.1
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 +230 -0
- package/LICENSE +201 -0
- package/LTS-CALENDAR.md +29 -0
- package/MIGRATING.md +7 -0
- package/NOTICE +59 -0
- package/README.md +100 -0
- package/bin/blamejs.js +13 -0
- package/index.js +253 -0
- package/lib/api-key.js +705 -0
- package/lib/api-snapshot.js +335 -0
- package/lib/app-shutdown.js +381 -0
- package/lib/app.js +364 -0
- package/lib/atomic-file.js +525 -0
- package/lib/audit-chain.js +168 -0
- package/lib/audit-sign.js +319 -0
- package/lib/audit-tools.js +682 -0
- package/lib/audit.js +753 -0
- package/lib/auth/jwt.js +280 -0
- package/lib/auth/oauth.js +691 -0
- package/lib/auth/passkey.js +185 -0
- package/lib/auth/password.js +139 -0
- package/lib/auth/totp.js +17 -0
- package/lib/auth-header.js +81 -0
- package/lib/backup/bundle.js +219 -0
- package/lib/backup/crypto.js +174 -0
- package/lib/backup/index.js +490 -0
- package/lib/backup/manifest.js +275 -0
- package/lib/bundler.js +295 -0
- package/lib/cache.js +819 -0
- package/lib/chain-writer.js +234 -0
- package/lib/cli-helpers.js +201 -0
- package/lib/cli.js +1377 -0
- package/lib/cluster-provider-db.js +245 -0
- package/lib/cluster-storage.js +166 -0
- package/lib/cluster.js +691 -0
- package/lib/consent.js +222 -0
- package/lib/constants.js +186 -0
- package/lib/cookies.js +293 -0
- package/lib/credential-hash.js +303 -0
- package/lib/crypto-field.js +159 -0
- package/lib/crypto.js +250 -0
- package/lib/db-query.js +297 -0
- package/lib/db-schema.js +250 -0
- package/lib/db.js +1054 -0
- package/lib/deprecate.js +226 -0
- package/lib/dev.js +324 -0
- package/lib/error-page.js +424 -0
- package/lib/events.js +135 -0
- package/lib/external-db.js +422 -0
- package/lib/forms.js +378 -0
- package/lib/framework-error.js +189 -0
- package/lib/framework-schema.js +604 -0
- package/lib/handlers.js +350 -0
- package/lib/html-balance.js +227 -0
- package/lib/http-client.js +615 -0
- package/lib/i18n.js +780 -0
- package/lib/jobs.js +181 -0
- package/lib/lazy-require.js +48 -0
- package/lib/log-stream-local.js +137 -0
- package/lib/log-stream-webhook.js +170 -0
- package/lib/log-stream.js +211 -0
- package/lib/log.js +355 -0
- package/lib/mail-bounce.js +507 -0
- package/lib/mail.js +701 -0
- package/lib/metrics.js +647 -0
- package/lib/middleware/api-encrypt.js +553 -0
- package/lib/middleware/attach-user.js +156 -0
- package/lib/middleware/body-parser.js +883 -0
- package/lib/middleware/bot-guard.js +148 -0
- package/lib/middleware/compression.js +436 -0
- package/lib/middleware/cors.js +236 -0
- package/lib/middleware/csp-nonce.js +332 -0
- package/lib/middleware/csrf-protect.js +275 -0
- package/lib/middleware/error-handler.js +46 -0
- package/lib/middleware/health.js +358 -0
- package/lib/middleware/index.js +52 -0
- package/lib/middleware/rate-limit.js +319 -0
- package/lib/middleware/request-id.js +53 -0
- package/lib/middleware/require-auth.js +95 -0
- package/lib/middleware/security-headers.js +91 -0
- package/lib/migrations.js +353 -0
- package/lib/mtls-ca.js +333 -0
- package/lib/mtls-engine-default.js +285 -0
- package/lib/nonce-store.js +177 -0
- package/lib/notify.js +643 -0
- package/lib/ntp-check.js +178 -0
- package/lib/object-store/azure-blob.js +467 -0
- package/lib/object-store/gcs.js +469 -0
- package/lib/object-store/http-put.js +153 -0
- package/lib/object-store/index.js +140 -0
- package/lib/object-store/local.js +163 -0
- package/lib/object-store/retry.js +15 -0
- package/lib/object-store/sigv4.js +535 -0
- package/lib/observability.js +114 -0
- package/lib/pagination.js +371 -0
- package/lib/parsers/index.js +64 -0
- package/lib/parsers/safe-csv.js +224 -0
- package/lib/parsers/safe-env.js +614 -0
- package/lib/parsers/safe-toml.js +745 -0
- package/lib/parsers/safe-xml.js +379 -0
- package/lib/parsers/safe-yaml.js +977 -0
- package/lib/permissions.js +430 -0
- package/lib/pqc-agent.js +85 -0
- package/lib/pqc-gate.js +266 -0
- package/lib/protocol-dispatcher.js +144 -0
- package/lib/queue-local.js +327 -0
- package/lib/queue.js +430 -0
- package/lib/redact.js +192 -0
- package/lib/render.js +193 -0
- package/lib/request-helpers.js +178 -0
- package/lib/restore-bundle.js +239 -0
- package/lib/restore-rollback.js +254 -0
- package/lib/restore.js +301 -0
- package/lib/retry.js +329 -0
- package/lib/router.js +437 -0
- package/lib/safe-async.js +520 -0
- package/lib/safe-buffer.js +162 -0
- package/lib/safe-json.js +532 -0
- package/lib/safe-schema.js +1176 -0
- package/lib/safe-sql.js +157 -0
- package/lib/safe-url.js +109 -0
- package/lib/scheduler.js +680 -0
- package/lib/seeders.js +622 -0
- package/lib/session.js +304 -0
- package/lib/slug.js +243 -0
- package/lib/static.js +268 -0
- package/lib/storage.js +470 -0
- package/lib/subject.js +281 -0
- package/lib/template.js +781 -0
- package/lib/testing.js +621 -0
- package/lib/totp.js +285 -0
- package/lib/tracing.js +484 -0
- package/lib/validate-opts.js +56 -0
- package/lib/vault/index.js +299 -0
- package/lib/vault/passphrase-ops.js +311 -0
- package/lib/vault/passphrase-source.js +198 -0
- package/lib/vault/rotate.js +761 -0
- package/lib/vault/wrap.js +289 -0
- package/lib/vendor/MANIFEST.json +84 -0
- package/lib/vendor/argon2/argon2.cjs +466 -0
- package/lib/vendor/argon2/argon2.d.cts +62 -0
- package/lib/vendor/argon2/package.json +1 -0
- package/lib/vendor/argon2/prebuilds/darwin-arm64/argon2.armv8.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/darwin-x64/argon2.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/freebsd-arm64/argon2.armv8.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/freebsd-x64/argon2.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-arm/argon2.armv7.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-arm/argon2.armv7.musl.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-arm64/argon2.armv8.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-arm64/argon2.armv8.musl.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-x64/argon2.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-x64/argon2.musl.node +0 -0
- package/lib/vendor/argon2/prebuilds/win32-x64/argon2.glibc.node +0 -0
- package/lib/vendor/noble-ciphers.cjs +9 -0
- package/lib/vendor/pki.cjs +181 -0
- package/lib/vendor/simplewebauthn-server.cjs +328 -0
- package/lib/webhook.js +632 -0
- package/lib/websocket-channels.js +413 -0
- package/lib/websocket.js +833 -0
- package/package.json +39 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* restore-rollback — atomic dataDir swap with a versioned rollback path.
|
|
4
|
+
*
|
|
5
|
+
* The primitive used by lib/restore to put a freshly-decrypted bundle
|
|
6
|
+
* into place. Filesystem-level directory rename is atomic on POSIX
|
|
7
|
+
* (and on Windows when nothing has the dir open) — the swap either
|
|
8
|
+
* fully completes or the previous dataDir is recoverable.
|
|
9
|
+
*
|
|
10
|
+
* var rb = b.restoreRollback;
|
|
11
|
+
*
|
|
12
|
+
* var r = rb.swap({
|
|
13
|
+
* stagingDir: "./data.staging",
|
|
14
|
+
* dataDir: "./data",
|
|
15
|
+
* rollbackRoot: "./data.rollbacks", // optional; defaults to <dataDir>.rollbacks
|
|
16
|
+
* marker: { bundleId: "...", reason: "scheduled-restore" },
|
|
17
|
+
* });
|
|
18
|
+
* // → { rollbackPath, markerPath, swappedAt }
|
|
19
|
+
*
|
|
20
|
+
* // Reverse the most recent swap (or a specific one by path)
|
|
21
|
+
* await rb.rollback({ dataDir: "./data", rollbackPath: r.rollbackPath });
|
|
22
|
+
* // → { restoredFrom, discardedAt }
|
|
23
|
+
*
|
|
24
|
+
* rb.list({ rollbackRoot: "./data.rollbacks" });
|
|
25
|
+
* // → [{ rollbackPath, swappedAt, marker }] (newest first)
|
|
26
|
+
*
|
|
27
|
+
* rb.purge({ rollbackRoot: "./data.rollbacks", keep: 3 });
|
|
28
|
+
* // → { kept, deleted: [paths] }
|
|
29
|
+
*
|
|
30
|
+
* Layout after a successful swap:
|
|
31
|
+
*
|
|
32
|
+
* ./data ← the freshly-restored bundle
|
|
33
|
+
* ./data.rollbacks/
|
|
34
|
+
* 2026-04-27T17-46-36-075Z/ ← previous dataDir, renamed atomically
|
|
35
|
+
* (whatever was in dataDir at the time of swap)
|
|
36
|
+
* 2026-04-27T17-46-36-075Z.marker.json
|
|
37
|
+
*
|
|
38
|
+
* The marker file carries operator-supplied metadata (which bundle
|
|
39
|
+
* triggered the swap, what reason was given, when) so a list / audit
|
|
40
|
+
* over rollback dirs is informative without rifling through their
|
|
41
|
+
* contents.
|
|
42
|
+
*
|
|
43
|
+
* Concurrency: swap() refuses to operate if another rollback dir for
|
|
44
|
+
* the same timestamp already exists — collisions are vanishingly rare
|
|
45
|
+
* because the timestamp has millisecond precision plus the framework
|
|
46
|
+
* never runs two restores in parallel on the same dataDir, but the
|
|
47
|
+
* check makes a corrupted state impossible if an operator fires twice.
|
|
48
|
+
*
|
|
49
|
+
* Operator stop-framework-first contract: this primitive does NOT
|
|
50
|
+
* close the framework's open file handles. On Linux a directory
|
|
51
|
+
* rename succeeds even with handles open, but the running framework
|
|
52
|
+
* process will see stale data. Operators run restore as: stop
|
|
53
|
+
* framework → swap → start framework. Same as a database restore.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
var fs = require("fs");
|
|
57
|
+
var path = require("path");
|
|
58
|
+
var atomicFile = require("./atomic-file");
|
|
59
|
+
var { defineClass } = require("./framework-error");
|
|
60
|
+
|
|
61
|
+
var RestoreRollbackError = defineClass("RestoreRollbackError", { alwaysPermanent: true });
|
|
62
|
+
|
|
63
|
+
function _resolveRollbackRoot(opts) {
|
|
64
|
+
if (typeof opts.rollbackRoot === "string" && opts.rollbackRoot.length > 0) {
|
|
65
|
+
return opts.rollbackRoot;
|
|
66
|
+
}
|
|
67
|
+
// Default: sibling of dataDir named <dataDir>.rollbacks
|
|
68
|
+
if (typeof opts.dataDir !== "string" || opts.dataDir.length === 0) {
|
|
69
|
+
throw new RestoreRollbackError("restore-rollback/no-rollback-root",
|
|
70
|
+
"rollbackRoot must be supplied or derivable from opts.dataDir");
|
|
71
|
+
}
|
|
72
|
+
return opts.dataDir + ".rollbacks";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
function swap(opts) {
|
|
77
|
+
opts = opts || {};
|
|
78
|
+
if (typeof opts.stagingDir !== "string" || !fs.existsSync(opts.stagingDir)) {
|
|
79
|
+
throw new RestoreRollbackError("restore-rollback/no-staging",
|
|
80
|
+
"swap: opts.stagingDir is required and must exist");
|
|
81
|
+
}
|
|
82
|
+
if (typeof opts.dataDir !== "string" || opts.dataDir.length === 0) {
|
|
83
|
+
throw new RestoreRollbackError("restore-rollback/no-datadir",
|
|
84
|
+
"swap: opts.dataDir is required");
|
|
85
|
+
}
|
|
86
|
+
var rollbackRoot = _resolveRollbackRoot(opts);
|
|
87
|
+
atomicFile.ensureDir(rollbackRoot);
|
|
88
|
+
|
|
89
|
+
var swappedAt = atomicFile.pathTimestamp();
|
|
90
|
+
var rollbackPath = path.join(rollbackRoot, swappedAt);
|
|
91
|
+
var markerPath = path.join(rollbackRoot, swappedAt + ".marker.json");
|
|
92
|
+
|
|
93
|
+
if (fs.existsSync(rollbackPath) || fs.existsSync(markerPath)) {
|
|
94
|
+
throw new RestoreRollbackError("restore-rollback/collision",
|
|
95
|
+
"swap: a rollback at " + rollbackPath + " already exists — refusing to overwrite");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
var hadDataDir = fs.existsSync(opts.dataDir);
|
|
99
|
+
|
|
100
|
+
// Step 1: rename current dataDir → rollback path. Skipped on first
|
|
101
|
+
// restore (no existing dataDir).
|
|
102
|
+
if (hadDataDir) {
|
|
103
|
+
try { fs.renameSync(opts.dataDir, rollbackPath); }
|
|
104
|
+
catch (e) {
|
|
105
|
+
throw new RestoreRollbackError("restore-rollback/rename-existing-failed",
|
|
106
|
+
"swap: could not move existing dataDir to rollback: " + ((e && e.message) || String(e)));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Step 2: rename staging → dataDir
|
|
111
|
+
try { fs.renameSync(opts.stagingDir, opts.dataDir); }
|
|
112
|
+
catch (e) {
|
|
113
|
+
// Step 2 failed — try to undo step 1 so the operator's dataDir is back
|
|
114
|
+
if (hadDataDir) {
|
|
115
|
+
try { fs.renameSync(rollbackPath, opts.dataDir); }
|
|
116
|
+
catch (_e) { /* dataDir is now in rollbackPath; operator must recover manually */ }
|
|
117
|
+
}
|
|
118
|
+
throw new RestoreRollbackError("restore-rollback/rename-staging-failed",
|
|
119
|
+
"swap: could not move staging to dataDir: " + ((e && e.message) || String(e)) +
|
|
120
|
+
(hadDataDir ? " (attempted to undo previous rename — verify dataDir state)" : ""));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Step 3: write the marker (best-effort; missing marker is recoverable
|
|
124
|
+
// from the rollback dir's mtime, but operators want it for audit)
|
|
125
|
+
var marker = {
|
|
126
|
+
swappedAt: new Date().toISOString(),
|
|
127
|
+
rollbackPath: rollbackPath,
|
|
128
|
+
dataDir: opts.dataDir,
|
|
129
|
+
operator: opts.marker || null,
|
|
130
|
+
};
|
|
131
|
+
try {
|
|
132
|
+
fs.writeFileSync(markerPath, JSON.stringify(marker, null, 2) + "\n", { mode: 0o600 });
|
|
133
|
+
} catch (_e) { /* marker write is best-effort */ }
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
rollbackPath: hadDataDir ? rollbackPath : null,
|
|
137
|
+
markerPath: markerPath,
|
|
138
|
+
swappedAt: swappedAt,
|
|
139
|
+
marker: marker,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function rollback(opts) {
|
|
144
|
+
opts = opts || {};
|
|
145
|
+
if (typeof opts.dataDir !== "string" || opts.dataDir.length === 0) {
|
|
146
|
+
throw new RestoreRollbackError("restore-rollback/no-datadir",
|
|
147
|
+
"rollback: opts.dataDir is required");
|
|
148
|
+
}
|
|
149
|
+
if (typeof opts.rollbackPath !== "string" || !fs.existsSync(opts.rollbackPath)) {
|
|
150
|
+
throw new RestoreRollbackError("restore-rollback/no-rollback",
|
|
151
|
+
"rollback: opts.rollbackPath is required and must exist");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Move the current dataDir aside (so the rollback's rename target is empty)
|
|
155
|
+
var discardedAt = null;
|
|
156
|
+
if (fs.existsSync(opts.dataDir)) {
|
|
157
|
+
var rollbackRoot = _resolveRollbackRoot(opts);
|
|
158
|
+
atomicFile.ensureDir(rollbackRoot);
|
|
159
|
+
discardedAt = atomicFile.pathTimestamp();
|
|
160
|
+
var discardedPath = path.join(rollbackRoot, "discarded-" + discardedAt);
|
|
161
|
+
try { fs.renameSync(opts.dataDir, discardedPath); }
|
|
162
|
+
catch (e) {
|
|
163
|
+
throw new RestoreRollbackError("restore-rollback/rename-existing-failed",
|
|
164
|
+
"rollback: could not move current dataDir aside: " + ((e && e.message) || String(e)));
|
|
165
|
+
}
|
|
166
|
+
discardedAt = discardedPath;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Rename the rollback dir back into dataDir's place
|
|
170
|
+
try { fs.renameSync(opts.rollbackPath, opts.dataDir); }
|
|
171
|
+
catch (e) {
|
|
172
|
+
throw new RestoreRollbackError("restore-rollback/rollback-rename-failed",
|
|
173
|
+
"rollback: could not move rollback into dataDir: " + ((e && e.message) || String(e)) +
|
|
174
|
+
" (current dataDir, if any, was moved to " + discardedAt + ")");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Best-effort: clean up the marker file alongside the rollback path
|
|
178
|
+
var markerPath = opts.rollbackPath + ".marker.json";
|
|
179
|
+
try { if (fs.existsSync(markerPath)) fs.unlinkSync(markerPath); }
|
|
180
|
+
catch (_e) { /* marker cleanup is best-effort */ }
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
restoredFrom: opts.rollbackPath,
|
|
184
|
+
discardedAt: discardedAt,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function list(opts) {
|
|
189
|
+
opts = opts || {};
|
|
190
|
+
var rollbackRoot = _resolveRollbackRoot(opts);
|
|
191
|
+
if (!fs.existsSync(rollbackRoot)) return [];
|
|
192
|
+
var entries = fs.readdirSync(rollbackRoot, { withFileTypes: true });
|
|
193
|
+
var out = [];
|
|
194
|
+
for (var i = 0; i < entries.length; i++) {
|
|
195
|
+
if (!entries[i].isDirectory()) continue;
|
|
196
|
+
var name = entries[i].name;
|
|
197
|
+
if (name.indexOf("discarded-") === 0) continue; // discarded dirs aren't restore points
|
|
198
|
+
var p = path.join(rollbackRoot, name);
|
|
199
|
+
var markerPath = p + ".marker.json";
|
|
200
|
+
var marker = null;
|
|
201
|
+
if (fs.existsSync(markerPath)) {
|
|
202
|
+
try { marker = JSON.parse(fs.readFileSync(markerPath, "utf8")); }
|
|
203
|
+
catch (_e) { marker = null; }
|
|
204
|
+
}
|
|
205
|
+
var stat;
|
|
206
|
+
try { stat = fs.statSync(p); } catch (_e) { continue; }
|
|
207
|
+
out.push({
|
|
208
|
+
rollbackPath: p,
|
|
209
|
+
swappedAt: (marker && marker.swappedAt) || stat.mtime.toISOString(),
|
|
210
|
+
marker: marker,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
// Newest first
|
|
214
|
+
out.sort(function (a, b) { return a.swappedAt < b.swappedAt ? 1 : -1; });
|
|
215
|
+
return out;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function purge(opts) {
|
|
219
|
+
opts = opts || {};
|
|
220
|
+
var keep = typeof opts.keep === "number" && opts.keep >= 0 ? Math.floor(opts.keep) : 0;
|
|
221
|
+
var rollbackRoot = _resolveRollbackRoot(opts);
|
|
222
|
+
if (!fs.existsSync(rollbackRoot)) return { kept: keep, deleted: [] };
|
|
223
|
+
// Always sweep "discarded-*" dirs — they're never restore points
|
|
224
|
+
var entries = fs.readdirSync(rollbackRoot, { withFileTypes: true });
|
|
225
|
+
var deleted = [];
|
|
226
|
+
for (var i = 0; i < entries.length; i++) {
|
|
227
|
+
if (entries[i].isDirectory() && entries[i].name.indexOf("discarded-") === 0) {
|
|
228
|
+
var p = path.join(rollbackRoot, entries[i].name);
|
|
229
|
+
try { fs.rmSync(p, { recursive: true, force: true }); deleted.push(p); }
|
|
230
|
+
catch (_e) { /* best-effort */ }
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Keep newest `keep`, delete the rest (and their marker files)
|
|
235
|
+
var rb = list(opts);
|
|
236
|
+
var toDelete = rb.slice(keep);
|
|
237
|
+
for (var j = 0; j < toDelete.length; j++) {
|
|
238
|
+
var rbPath = toDelete[j].rollbackPath;
|
|
239
|
+
var mkPath = rbPath + ".marker.json";
|
|
240
|
+
try { fs.rmSync(rbPath, { recursive: true, force: true }); deleted.push(rbPath); }
|
|
241
|
+
catch (_e) { /* best-effort */ }
|
|
242
|
+
try { if (fs.existsSync(mkPath)) fs.unlinkSync(mkPath); }
|
|
243
|
+
catch (_e) { /* best-effort */ }
|
|
244
|
+
}
|
|
245
|
+
return { kept: keep, deleted: deleted };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
module.exports = {
|
|
249
|
+
swap: swap,
|
|
250
|
+
rollback: rollback,
|
|
251
|
+
list: list,
|
|
252
|
+
purge: purge,
|
|
253
|
+
RestoreRollbackError: RestoreRollbackError,
|
|
254
|
+
};
|
package/lib/restore.js
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* restore — operator-facing restore from a backup bundle in storage.
|
|
4
|
+
*
|
|
5
|
+
* Mirror of lib/backup. Pulls a bundle from a storage backend,
|
|
6
|
+
* decrypts it via lib/restore-bundle into a staging directory, then
|
|
7
|
+
* swaps that staging into place as the new dataDir via
|
|
8
|
+
* lib/restore-rollback (saving the previous dataDir as a versioned
|
|
9
|
+
* rollback point). Operators stop the framework first, run restore,
|
|
10
|
+
* then start the framework — same workflow as a database restore.
|
|
11
|
+
*
|
|
12
|
+
* var restore = b.restore.create({
|
|
13
|
+
* dataDir: "./data",
|
|
14
|
+
* storage: b.backup.localStorage({ root: "./backups" }),
|
|
15
|
+
* passphrase: Buffer.from("operator backup passphrase"),
|
|
16
|
+
* rollbackRoot: "./data.rollbacks", // optional; default <dataDir>.rollbacks
|
|
17
|
+
* audit: true,
|
|
18
|
+
* });
|
|
19
|
+
*
|
|
20
|
+
* await restore.list(); // → [{ bundleId, createdAt, size }]
|
|
21
|
+
* await restore.inspect(bundleId); // → manifest (no decrypt; no passphrase
|
|
22
|
+
* // needed for inspect)
|
|
23
|
+
*
|
|
24
|
+
* await restore.run({
|
|
25
|
+
* bundleId,
|
|
26
|
+
* filter, // optional file filter
|
|
27
|
+
* marker: { reason: "incident-2026-04-27" },
|
|
28
|
+
* });
|
|
29
|
+
* // → { bundleId, fileCount, totalBytes, rollbackPath, vaultKeyJson,
|
|
30
|
+
* // durationMs }
|
|
31
|
+
*
|
|
32
|
+
* await restore.rollback(); // → reverts the most recent restore
|
|
33
|
+
* await restore.listRollbacks(); // → [{ rollbackPath, swappedAt, marker }]
|
|
34
|
+
* await restore.purgeRollbacks({ keep });
|
|
35
|
+
*
|
|
36
|
+
* vaultKeyJson: the manifest's vaultKeyEnc decrypted via the passphrase,
|
|
37
|
+
* returned to the caller as a string. Returned but NOT auto-installed —
|
|
38
|
+
* the caller decides what to do with it (the swap may have already put
|
|
39
|
+
* the bundle's vault.key file into place if the bundle included one;
|
|
40
|
+
* operators with vault-passphrase-wrapped setups handle the placement
|
|
41
|
+
* themselves before re-starting the framework).
|
|
42
|
+
*
|
|
43
|
+
* Failure modes (each cleans up the tmp pull dir + staging dir):
|
|
44
|
+
* - bundle not in storage → restore/bundle-not-found
|
|
45
|
+
* - manifest absent / bad → restore/missing-manifest
|
|
46
|
+
* - wrong passphrase → restore/decrypt-failed
|
|
47
|
+
* - tampered blob → restore/decrypt-failed (AEAD tag check)
|
|
48
|
+
* - checksum mismatch → restore/checksum-mismatch
|
|
49
|
+
* - swap fails after extract → restore/swap-failed (staging is
|
|
50
|
+
* preserved at the path returned in error.stagingDir for operator
|
|
51
|
+
* manual recovery)
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
var fs = require("fs");
|
|
55
|
+
var nodeCrypto = require("node:crypto");
|
|
56
|
+
var os = require("os");
|
|
57
|
+
var path = require("path");
|
|
58
|
+
var restoreBundle = require("./restore-bundle");
|
|
59
|
+
var restoreRollback = require("./restore-rollback");
|
|
60
|
+
var lazyRequire = require("./lazy-require");
|
|
61
|
+
var validateOpts = require("./validate-opts");
|
|
62
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
63
|
+
var { FrameworkError } = require("./framework-error");
|
|
64
|
+
|
|
65
|
+
class RestoreError extends FrameworkError {
|
|
66
|
+
constructor(code, message, permanent) {
|
|
67
|
+
super(message, code);
|
|
68
|
+
this.name = "RestoreError";
|
|
69
|
+
this.permanent = !!permanent;
|
|
70
|
+
this.isRestoreError = true;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function _validateStorage(storage) {
|
|
75
|
+
if (!storage || typeof storage !== "object") {
|
|
76
|
+
throw new RestoreError("restore/bad-storage",
|
|
77
|
+
"storage backend is required (use b.backup.localStorage or pass a custom one)");
|
|
78
|
+
}
|
|
79
|
+
var required = ["readBundle", "listBundles", "hasBundle"];
|
|
80
|
+
for (var i = 0; i < required.length; i++) {
|
|
81
|
+
if (typeof storage[required[i]] !== "function") {
|
|
82
|
+
throw new RestoreError("restore/bad-storage",
|
|
83
|
+
"storage backend missing method '" + required[i] + "'");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function create(opts) {
|
|
89
|
+
opts = opts || {};
|
|
90
|
+
validateOpts(opts, [
|
|
91
|
+
"dataDir", "storage", "passphrase", "rollbackRoot", "audit",
|
|
92
|
+
], "restore");
|
|
93
|
+
if (typeof opts.dataDir !== "string" || opts.dataDir.length === 0) {
|
|
94
|
+
throw new RestoreError("restore/no-datadir",
|
|
95
|
+
"create: opts.dataDir is required");
|
|
96
|
+
}
|
|
97
|
+
_validateStorage(opts.storage);
|
|
98
|
+
if (!Buffer.isBuffer(opts.passphrase) && typeof opts.passphrase !== "string") {
|
|
99
|
+
throw new RestoreError("restore/no-passphrase",
|
|
100
|
+
"create: opts.passphrase is required (Buffer or string)");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
var dataDir = opts.dataDir;
|
|
104
|
+
var storage = opts.storage;
|
|
105
|
+
var passphrase = opts.passphrase;
|
|
106
|
+
var rollbackRoot = opts.rollbackRoot || (dataDir + ".rollbacks");
|
|
107
|
+
var auditOn = opts.audit !== false;
|
|
108
|
+
|
|
109
|
+
function _emitAudit(action, info, outcome) {
|
|
110
|
+
if (!auditOn) return;
|
|
111
|
+
audit().safeEmit({
|
|
112
|
+
action: action,
|
|
113
|
+
outcome: outcome,
|
|
114
|
+
metadata: info || {},
|
|
115
|
+
reason: info && info.reason ? info.reason : null,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function list() { return await storage.listBundles(); }
|
|
120
|
+
|
|
121
|
+
async function inspect(bundleId) {
|
|
122
|
+
if (typeof bundleId !== "string" || bundleId.length === 0) {
|
|
123
|
+
throw new RestoreError("restore/bad-bundle-id", "inspect: bundleId is required");
|
|
124
|
+
}
|
|
125
|
+
var has = await storage.hasBundle(bundleId);
|
|
126
|
+
if (!has) {
|
|
127
|
+
throw new RestoreError("restore/bundle-not-found",
|
|
128
|
+
"inspect: bundle '" + bundleId + "' not in storage");
|
|
129
|
+
}
|
|
130
|
+
var pullDir = path.join(os.tmpdir(),
|
|
131
|
+
"blamejs-restore-inspect-" + nodeCrypto.randomBytes(4).toString("hex"));
|
|
132
|
+
try {
|
|
133
|
+
await storage.readBundle(bundleId, pullDir);
|
|
134
|
+
return restoreBundle.inspect({ bundleDir: pullDir });
|
|
135
|
+
} finally {
|
|
136
|
+
try { fs.rmSync(pullDir, { recursive: true, force: true }); } catch (_e) {}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function run(runOpts) {
|
|
141
|
+
runOpts = runOpts || {};
|
|
142
|
+
var t0 = Date.now();
|
|
143
|
+
var bundleId = runOpts.bundleId;
|
|
144
|
+
if (typeof bundleId !== "string" || bundleId.length === 0) {
|
|
145
|
+
throw new RestoreError("restore/bad-bundle-id", "run: opts.bundleId is required");
|
|
146
|
+
}
|
|
147
|
+
var has = await storage.hasBundle(bundleId);
|
|
148
|
+
if (!has) {
|
|
149
|
+
throw new RestoreError("restore/bundle-not-found",
|
|
150
|
+
"run: bundle '" + bundleId + "' not in storage");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
var pullId = nodeCrypto.randomBytes(4).toString("hex");
|
|
154
|
+
var pullDir = path.join(os.tmpdir(), "blamejs-restore-pull-" + pullId);
|
|
155
|
+
var stagingDir = path.join(os.tmpdir(), "blamejs-restore-staging-" + pullId);
|
|
156
|
+
|
|
157
|
+
function _cleanupTmp() {
|
|
158
|
+
try { fs.rmSync(pullDir, { recursive: true, force: true }); } catch (_e) {}
|
|
159
|
+
try { fs.rmSync(stagingDir, { recursive: true, force: true }); } catch (_e) {}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// 1. Pull bundle out of storage
|
|
163
|
+
try {
|
|
164
|
+
await storage.readBundle(bundleId, pullDir);
|
|
165
|
+
} catch (e) {
|
|
166
|
+
_cleanupTmp();
|
|
167
|
+
_emitAudit("restore.failure",
|
|
168
|
+
{ bundleId: bundleId, reason: "storage.readBundle: " + ((e && e.message) || String(e)) },
|
|
169
|
+
"failure");
|
|
170
|
+
throw new RestoreError("restore/storage-read-failed",
|
|
171
|
+
"pulling bundle from storage failed: " + ((e && e.message) || String(e)));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// 2. Decrypt + verify into stagingDir
|
|
175
|
+
var extracted;
|
|
176
|
+
try {
|
|
177
|
+
extracted = await restoreBundle.extract({
|
|
178
|
+
bundleDir: pullDir,
|
|
179
|
+
stagingDir: stagingDir,
|
|
180
|
+
passphrase: passphrase,
|
|
181
|
+
filter: runOpts.filter,
|
|
182
|
+
progressCallback: runOpts.progressCallback,
|
|
183
|
+
});
|
|
184
|
+
} catch (e) {
|
|
185
|
+
_cleanupTmp();
|
|
186
|
+
// Map restore-bundle error codes to restore/* domain so consumer
|
|
187
|
+
// code sees a single error namespace
|
|
188
|
+
var code = e && e.code;
|
|
189
|
+
var mappedCode = "restore/extract-failed";
|
|
190
|
+
if (code === "restore-bundle/decrypt-failed") mappedCode = "restore/decrypt-failed";
|
|
191
|
+
else if (code === "restore-bundle/checksum-mismatch") mappedCode = "restore/checksum-mismatch";
|
|
192
|
+
else if (code === "restore-bundle/missing-manifest") mappedCode = "restore/missing-manifest";
|
|
193
|
+
else if (code === "restore-bundle/missing-blob") mappedCode = "restore/missing-blob";
|
|
194
|
+
else if (code === "restore-bundle/size-mismatch") mappedCode = "restore/size-mismatch";
|
|
195
|
+
_emitAudit("restore.failure",
|
|
196
|
+
{ bundleId: bundleId, reason: (e && e.message) || String(e) }, "failure");
|
|
197
|
+
throw new RestoreError(mappedCode,
|
|
198
|
+
"extract failed: " + ((e && e.message) || String(e)));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 3. Atomic swap. On swap failure, the stagingDir is preserved so
|
|
202
|
+
// an operator can recover manually — we do NOT delete it here.
|
|
203
|
+
var swapResult;
|
|
204
|
+
try {
|
|
205
|
+
swapResult = restoreRollback.swap({
|
|
206
|
+
stagingDir: stagingDir,
|
|
207
|
+
dataDir: dataDir,
|
|
208
|
+
rollbackRoot: rollbackRoot,
|
|
209
|
+
marker: Object.assign({ bundleId: bundleId }, runOpts.marker || {}),
|
|
210
|
+
});
|
|
211
|
+
} catch (e) {
|
|
212
|
+
// Pull dir is safe to clean (the source bundle is in storage);
|
|
213
|
+
// staging stays for manual recovery.
|
|
214
|
+
try { fs.rmSync(pullDir, { recursive: true, force: true }); } catch (_e) {}
|
|
215
|
+
_emitAudit("restore.failure",
|
|
216
|
+
{ bundleId: bundleId, reason: "swap: " + ((e && e.message) || String(e)) },
|
|
217
|
+
"failure");
|
|
218
|
+
var err = new RestoreError("restore/swap-failed",
|
|
219
|
+
"atomic swap failed after successful extract — staging preserved at " +
|
|
220
|
+
stagingDir + ": " + ((e && e.message) || String(e)));
|
|
221
|
+
err.stagingDir = stagingDir;
|
|
222
|
+
throw err;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// 4. Clean up the pull dir (source bundle still in storage)
|
|
226
|
+
try { fs.rmSync(pullDir, { recursive: true, force: true }); } catch (_e) {}
|
|
227
|
+
|
|
228
|
+
var summary = {
|
|
229
|
+
bundleId: bundleId,
|
|
230
|
+
fileCount: extracted.fileCount,
|
|
231
|
+
totalBytes: extracted.totalBytes,
|
|
232
|
+
rollbackPath: swapResult.rollbackPath,
|
|
233
|
+
vaultKeyJson: extracted.vaultKeyJson,
|
|
234
|
+
durationMs: Date.now() - t0,
|
|
235
|
+
};
|
|
236
|
+
_emitAudit("restore.success", {
|
|
237
|
+
bundleId: bundleId,
|
|
238
|
+
fileCount: extracted.fileCount,
|
|
239
|
+
totalBytes: extracted.totalBytes,
|
|
240
|
+
rollbackPath: swapResult.rollbackPath,
|
|
241
|
+
durationMs: summary.durationMs,
|
|
242
|
+
});
|
|
243
|
+
return summary;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function rollback(rollbackOpts) {
|
|
247
|
+
rollbackOpts = rollbackOpts || {};
|
|
248
|
+
// Either an explicit rollbackPath OR pull the most-recent one
|
|
249
|
+
var target = rollbackOpts.rollbackPath;
|
|
250
|
+
if (!target) {
|
|
251
|
+
var bundles = restoreRollback.list({ rollbackRoot: rollbackRoot });
|
|
252
|
+
if (bundles.length === 0) {
|
|
253
|
+
throw new RestoreError("restore/no-rollbacks",
|
|
254
|
+
"rollback: no rollback points found at " + rollbackRoot);
|
|
255
|
+
}
|
|
256
|
+
target = bundles[0].rollbackPath;
|
|
257
|
+
}
|
|
258
|
+
var r;
|
|
259
|
+
try {
|
|
260
|
+
r = await restoreRollback.rollback({
|
|
261
|
+
dataDir: dataDir,
|
|
262
|
+
rollbackPath: target,
|
|
263
|
+
rollbackRoot: rollbackRoot,
|
|
264
|
+
});
|
|
265
|
+
} catch (e) {
|
|
266
|
+
_emitAudit("restore.rollback.failure",
|
|
267
|
+
{ rollbackPath: target, reason: (e && e.message) || String(e) }, "failure");
|
|
268
|
+
throw new RestoreError("restore/rollback-failed",
|
|
269
|
+
"rollback failed: " + ((e && e.message) || String(e)));
|
|
270
|
+
}
|
|
271
|
+
_emitAudit("restore.rollback.success",
|
|
272
|
+
{ rollbackPath: target, discardedAt: r.discardedAt });
|
|
273
|
+
return r;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function listRollbacks() {
|
|
277
|
+
return restoreRollback.list({ rollbackRoot: rollbackRoot });
|
|
278
|
+
}
|
|
279
|
+
function purgeRollbacks(purgeOpts) {
|
|
280
|
+
return restoreRollback.purge({
|
|
281
|
+
rollbackRoot: rollbackRoot,
|
|
282
|
+
keep: (purgeOpts && purgeOpts.keep) || 0,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
list: list,
|
|
288
|
+
inspect: inspect,
|
|
289
|
+
run: run,
|
|
290
|
+
rollback: rollback,
|
|
291
|
+
listRollbacks: listRollbacks,
|
|
292
|
+
purgeRollbacks: purgeRollbacks,
|
|
293
|
+
storage: storage,
|
|
294
|
+
rollbackRoot: rollbackRoot,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
module.exports = {
|
|
299
|
+
create: create,
|
|
300
|
+
RestoreError: RestoreError,
|
|
301
|
+
};
|