Backup
PQC-encrypted backup bundles — sealed columns + audit chain + keyring. SLH-DSA signature on every bundle, kid pinning, restore validates signature against operator-pinned public key.
The namespace wires b.backupBundle.create (encrypt + emit a bundle directory) to a pluggable storage backend, plus retention policy + audit emission. Ships with a local-filesystem backend (b.backup.diskStorage); S3 or any custom backend drops in through the same interface.
Storage backend contract:
{ async writeBundle(bundleId, sourceDir), async readBundle(bundleId, destDir), async listBundles(), // → [{ bundleId, createdAt, size }] async deleteBundle(bundleId), async hasBundle(bundleId), canReadBundle, // optional; false when this host cannot read back async verifyStoredContent(bundleId), // optional; { ok, reason? } }
listBundles and hasBundle answer two different questions, and a backend has to answer both. listBundles reports a bundle only when it holds what a restore needs: a manifest.json that parses, and every encrypted file that manifest declares present as a regular file at the declared size. Retention keeps the newest bundles a backend lists, so a bundle listed but unrestorable takes a keep slot from one that could have been restored. hasBundle reports whether the bundle is stored at all, whatever state it is in, so that a restore aimed at a damaged bundle can say what is wrong with it rather than that no such bundle exists. run() calls readBundle on each bundle it writes and compares the copy with what it wrote before retention deletes anything. A backend that writes to a recipient whose private key this host does not hold sets canReadBundle: false and offers verifyStoredContent(bundleId), which compares the stored bytes with what was written without decrypting them. The result's verifiedBy says which check ran: "readback", "stored-content", or "presence". Retention does not run after a "presence" check, since a stored key proves nothing about the bytes under it; the result carries retentionSkipped saying so.
vaultKeyJson can be a string (the operator has the JSON in hand) or a function returning a string (or async returning a string) — the framework calls it each backup so a long-running app doesn't pin the vault key in memory between runs.
Bundle IDs are filesystem-safe timestamps with millisecond precision plus a 4-byte random suffix: 2026-04-27T14-00-00-123Z-a8f30b21. Colons + dots in standard ISO-8601 are replaced with dashes so the id works as a directory name on every platform (Windows reserves : for drive letters). String sort still gives chronological order.
Posture-enforced encryption: HIPAA / PCI-DSS postures refuse a pipeline created with encrypt: false. Posture-enforced residency: gdpr / uk-gdpr / dpdp / pipl-cn / lgpd-br / appi-jp / pdpa-sg refuse a destination tag that doesn't match the live DB residency unless the operator passes allowCrossBorder: true with a documented legalBasis.
b.backup.diskStorage(opts) #
{
root: string, // required; directory under which bundle dirs land
}
Local-filesystem storage backend implementing the { writeBundle, readBundle, listBundles, deleteBundle, hasBundle } contract. Bundles land as directories named by bundle id under opts.root. Newest-first ordering is enforced by reverse lexicographic sort on the timestamp-prefixed bundle id.
A directory is reported by listBundles and hasBundle only when it holds a manifest.json and every encrypted file that manifest names is present at the size it records, so a copy interrupted partway through takes no retention slot from a bundle that restores. A manifest this release cannot parse is reported on the strength of its presence alone, since it says nothing about which files to expect.
Operators pointing at S3 / GCS / Azure Blob / a tape gateway pass a custom backend matching the same shape; the engine never touches the filesystem directly.
var fs = require("node:fs");
var path = require("node:path");
var os = require("node:os");
var root = fs.mkdtempSync(path.join(os.tmpdir(), "backup-root-"));
var storage = b.backup.diskStorage({ root: root });
storage.name; // → "local"
typeof storage.writeBundle; // → "function"
typeof storage.listBundles; // → "function"
b.backup.create(opts) #
{
dataDir: string, // required; must exist on disk
storage: StorageBackend, // required; diskStorage() or custom
passphrase: Buffer | string, // required; one Argon2id bundle key, HKDF-SHA3-512 subkey per file
files: Array<{ relativePath, kind, required }>,
vaultKeyJson: string | () => string | Promise,
retention: { keep: number }, // optional; keep is a positive integer; sweep older bundles after run()
audit: boolean, // default true
scheduler: b.scheduler, // required for schedule() / scheduleTest()
flushBeforeBackup: false | () => void | Promise,
requireFlush: boolean, // default false
encrypt: boolean, // default true; refused under hipaa / pci-dss
residencyTag: string | null, // e.g. "EU"; checked against b.db.getDataResidency()
allowCrossBorder: boolean, // explicit override for residency mismatch
legalBasis: string, // recorded in audit chain when allowCrossBorder
}
Build a backup engine bound to a data directory, a storage backend, the operator's passphrase, and an include list. Returns an object with run / list / delete / read / purgeOlder / schedule / scheduleTest plus the wired storage reference.
Each run() produces a fresh bundle id (), stages encryption to a process-private tmpdir, writes through storage.writeBundle, and reads the bundle back through storage.readBundle. When the copy read back differs from the bundle written, run() deletes that bundle from storage, skips retention and rejects with backup/verify-after-write-failed. Otherwise it applies retention: keep counts the complete bundles whose id sorts at or before the one this run wrote, and deletes the rest of those. Audit events backup.success / backup.failure / backup.retention.swept land on b.audit when opts.audit !== false.
A storage backend's listBundles and hasBundle report a bundle only once it is complete: diskStorage copies into .partial- and renames it into place, and every listing requires manifest.json. A manifest larger than b.backupManifest.MAX_MANIFEST_BYTES (64 MiB) is refused with backup-manifest/too-large before it reaches storage.
Posture gates fire at create() time, not run() time — so a misconfigured pipeline refuses to construct rather than producing one good bundle and then failing the next.
create() throws backup/no-datadir when dataDir is missing or does not exist, backup/no-passphrase when passphrase is neither a Buffer nor a string, backup/no-files for an empty include list, backup/no-vault-key-json when vaultKeyJson is absent, backup/bad-residency-tag for a residencyTag that is neither a non-empty string nor null, and backup/bad-retention for a retention that is not { keep: .
Under a posture that mandates encrypted backups it throws backup/encryption-required for encrypt: false, and under a residency-regulated posture whose region disagrees with residencyTag it throws backup/residency-mismatch unless allowCrossBorder is set. allowCrossBorder and requireFlush must each be a boolean, refused as backup/bad-opt and backup/bad-opts respectively, so neither a cross-border transfer nor a backup of unflushed state proceeds on a value that was only read for its truthiness. A storage that does not expose the backend contract throws backup/bad-storage.
var fs = require("node:fs");
var path = require("node:path");
var os = require("node:os");
var dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "backup-data-"));
var root = fs.mkdtempSync(path.join(os.tmpdir(), "backup-root-"));
fs.writeFileSync(path.join(dataDir, "db.enc"), Buffer.from([1, 2, 3]));
fs.writeFileSync(path.join(dataDir, "db.key.enc"), Buffer.from([4, 5, 6]));
var engine = b.backup.create({
dataDir: dataDir,
storage: b.backup.diskStorage({ root: root }),
passphrase: Buffer.from("operator backup passphrase"),
files: [
{ relativePath: "db.enc", kind: "raw", required: true },
{ relativePath: "db.key.enc", kind: "raw", required: true },
],
vaultKeyJson: '{"version":1,"kid":"k1"}',
retention: { keep: 7 },
});
typeof engine.run; // → "function"
typeof engine.list; // → "function"
typeof engine.purgeOlder; // → "function"
b.backup.verifyManifestSignature(target, opts) #
{
expectedFingerprint: string, // trust only the key with this SHA3-512 fingerprint
}
Read the manifest from a restored bundle directory (or accept a pre-parsed manifest object) and verify its SLH-DSA audit-sign signature. Operator-facing wrapper around b.backupManifest.verifySignature that handles the on-disk fetch + JCS parse, so a regulator-facing restore drill is a single call.
Returns { ok, fingerprint?, reason? }. Throws BackupError only for missing / unreadable / unparseable manifests — a bad signature returns { ok: false, reason } so the caller can branch on the verdict without a try/catch.
The signing key must match opts.expectedFingerprint, or be the active or a rotated b.auditSign key. A signature that verifies under any other key returns { ok: false }. A process that has not run b.auditSign.init() passes expectedFingerprint.
var fs = require("node:fs");
var path = require("node:path");
var os = require("node:os");
var bundleDir = fs.mkdtempSync(path.join(os.tmpdir(), "verify-bundle-"));
try {
b.backup.verifyManifestSignature(bundleDir);
} catch (e) {
e.code; // → "backup/no-manifest"
}
b.backup.recommendedFiles(opts) #
{
atRest: "plain" | "encrypted", // default "encrypted"
vaultMode: "plaintext" | "wrapped", // default "wrapped"
dbName: string, // default "blamejs.db"
additionalSealed: Array, // operator-supplied sealed-file paths
}
Return the framework-default include list for a given DB at-rest mode + vault wrap mode. Operators with the standard layout pass the result straight to b.backup.create({ files }); operators with custom data files (additional sealed keys, OIDC provider material, application-specific keystores) append their own entries.
The list adapts to mode: - plain DB → the live SQLite file (default name blamejs.db) - encrypted DB → db.enc + db.key.enc (envelope + sealed DEK) - plaintext vault → vault.key - wrapped vault → vault.key.sealed
The audit-signing key is always included (sealed in wrapped mode) so a restored deployment can verify its own audit chain.
var files = b.backup.recommendedFiles({
atRest: "encrypted",
vaultMode: "wrapped",
additionalSealed: ["ca.key.sealed", "tls/privkey.pem.sealed"],
});
files[0].relativePath; // → "db.enc"
files[1].relativePath; // → "db.key.enc"
files[2].relativePath; // → "vault.key.sealed"
b.backup.runInWorker(opts) #
{
workerScript: string, // required; absolute path to the worker module
args: object, // optional; passed as workerData to the worker
timeoutMs: number, // optional; positive finite int, terminates worker on miss
shareArgon2Gate: boolean, // optional; default false. Pass the shared Argon2id gate
// handle to the worker and reclaim its permits on exit
}
Execute a backup or restore inside a node:worker_threads worker so the heavy-CPU Argon2id + XChaCha20-Poly1305 + SHA3-512 walk doesn't block the request loop. Returns a Promise resolving with the worker's posted message, or rejecting with the worker's error, a non-zero exit, or the operator's timeoutMs.
The worker script is supplied by the operator — responsibility for thread-safe storage adapters stays with the operator; this helper is the dispatch + lifecycle glue. The framework rejects with backup/no-worker-threads when node:worker_threads is unavailable (sandboxed runtimes, stripped Node builds).
A malformed call rejects rather than throwing, so one .catch covers both it and a worker failure: backup/no-worker-script when workerScript is absent or empty, backup/bad-timeout when timeoutMs is supplied as anything but a positive integer, and backup/bad-opt when shareArgon2Gate is supplied as anything but a boolean.
The run itself rejects rather than throwing: a worker that exits non-zero rejects with backup/worker-nonzero-exit naming the exit code, and one still running at timeoutMs is terminated and rejects with backup/worker-timeout.
shareArgon2Gate: true puts the worker under the same Argon2id bound as this thread. It adds the handle from b.auth.password.gateHandle() to workerData as argon2GateHandle, for the worker to adopt with b.auth.password.gate(n, { handle: workerData.argon2GateHandle }), and reclaims that worker's permits from its exit event. Without it a worker terminated at timeoutMs mid-derivation keeps the permit it held for the life of the process. It rejects with backup/no-shared-gate when this thread holds its own limit, since there is no shared gate to join.
var path = require("node:path");
b.backup.runInWorker({
workerScript: path.resolve("/does/not/exist/worker.js"),
args: { mode: "full" },
timeoutMs: 60000,
}).catch(function (err) {
// worker failed to load — error surfaces as a rejected promise
typeof err.message; // → "string"
});
b.backup.bundleAdapterStorage(opts) #
{
adapter: { writeFile, readFile, listKeys, deleteKey, hasKey },
format: string, // "tar" (default) | "tar.gz" | "directory"
cryptoStrategy: string, // "none" (default) | "recipient" | "passphrase"
recipient: object, // required by "recipient": { publicKey, ecPublicKey } or { peerCertDer, peerKemPubkey }
passphrase: Buffer | string, // required by "passphrase"
passphraseMinEntropyBits: number, // default 80; an encryption-mandating posture raises it to 128
posture: string, // overrides b.compliance.current() for the encryption gate
maxBundleBytes: number, // default 8 GiB; bounds the built archive, tar framing included
}
Adapter-driven storage backend. Wraps the bundle directory's file tree into per-file key-value pairs routed through an operator- supplied byte-store adapter so backup bundles can land anywhere that exposes the contract: local fs (the default), tar / tar.gz folding, and S3 / MinIO / Azure / GCS objectStore adapters.
adapter is required and has no default: building the backend without one raises backup/bad-adapter. Two factories ship: b.backup.bundleAdapterStorage.fsAdapter({ root }) for a local filesystem, and .objectStoreAdapter(client) for an object store. An operator supplies the methods below only for a store neither covers. b.backup.diskStorage is the separate filesystem-backed backend that takes no adapter at all.
The adapter contract:
adapter.writeFile(key, bytes): Promise
Keys are . Operators pointing at an objectStore implementation pass an adapter that routes keys to S3 paths; pointing at an HTTP-backed store, ditto.
maxBundleBytes bounds the archive this storage writes and reads back (8 GiB by default), the tar framing included: each entry costs a 512-byte header and padding to the next 512-byte boundary, so a bundle of many small files spends more on framing than on payload. writeBundle refuses backup/bundle-too-large for the built archive, not only for the sum of the file sizes, so a bundle this storage accepted is one it can read.
Building the backend throws backup/bad-adapter when adapter is missing any of the five methods, backup/bad-format and backup/bad-crypto-strategy for a value outside the sets listed below, and backup/bad-arg when passphraseMinEntropyBits is not a finite non-negative number or maxBundleBytes is not a positive integer. NaN and Infinity are refused here rather than later so the HIPAA / PCI-DSS 128-bit floor cannot be bypassed.
cryptoStrategy: "recipient" without recipient throws backup/no-recipient, and cryptoStrategy: "passphrase" without a string or Buffer passphrase throws backup/no-passphrase. Either strategy combined with format: "directory" throws backup/recipient-strategy-needs-bundled-format or backup/passphrase-strategy-needs-bundled-format, because the wrap layer composes only with a tar or tar.gz bundle. Under a posture that mandates encryption at rest, cryptoStrategy: "none" throws backup/posture-requires-encryption, and the passphrase floor rises to 128 bits.
var storage = b.backup.bundleAdapterStorage({
adapter: b.backup.bundleAdapterStorage.fsAdapter({ root: "/var/backups" }),
});
storage.name; // → "adapter"
typeof storage.writeBundle; // → "function"
b.backup.bundleAdapterStorage.objectStoreAdapter(client, opts?) #
{
prefix: string, // namespace every key under this prefix in the bucket
list: { maxResults: number }, // forwarded to client.list opts
}
Wraps a b.objectStore-shaped client into the { writeFile, readFile, listKeys, deleteKey, hasKey } adapter contract that bundleAdapterStorage consumes. The client must expose put(key, body) → Promise<{ size }>, get(key) → Promise, head(key) → Promise<{ size, ... }>, delete(key) → Promise, and list(prefix, opts?) → Promise<{ items: [{ key, size, ... }], truncated }> — the shape produced by b.objectStore.buildBackend({ protocol: ... }) for the local / SigV4 / GCS / Azure-Blob backends.
opts.prefix namespaces every key under a fixed root inside the bucket — operators sharing a bucket across multiple deployments pass distinct prefixes so listings stay scoped.
opts.list is the operator-tunable { maxResults, ... } pass- through forwarded to the underlying client.list call (defaults to whatever the backend's list defaults to — typically 1000).
Closes the v0.12.10 deferral: "S3 / MinIO / Azure / GCS-backed backups" promised since v0.11.2 JSDoc.
var client = b.objectStore.buildBackend({
protocol: "local",
rootDir: "/var/backups",
});
var storage = b.backup.bundleAdapterStorage({
adapter: b.backup.bundleAdapterStorage.objectStoreAdapter(client),
format: "tar.gz",
cryptoStrategy: "recipient",
recipient: pair,
});
// bundle bytes hit the object-store backend's put(); restore
// path composes through unwrap + read.gz + read.tar.
b.backup.migrate(opts) #
{
from: bundleAdapterStorage with format: "directory",
to: bundleAdapterStorage with format: "tar",
bundleId: string (single-bundle migrate; omit to migrate all),
deleteSourceOnSuccess: boolean (default false; explicit opt-in),
}
One-shot helper that walks an operator's directory-tree-format bundle (v0.12.7 layout) and writes the same content as a tar-format bundle via the v0.12.8 bundleAdapterStorage. Idempotent: re- running on an already-migrated bundle is a no-op. Source stays in place by default; operators with explicit transition windows opt into the inline replace via deleteSourceOnSuccess: true.
var from = b.backup.bundleAdapterStorage({
adapter: b.backup.bundleAdapterStorage.fsAdapter({ root: "/var/backups-v7" }),
format: "directory",
});
var to = b.backup.bundleAdapterStorage({
adapter: b.backup.bundleAdapterStorage.fsAdapter({ root: "/var/backups-v8" }),
format: "tar",
});
await b.backup.migrate({ from: from, to: to });