@blamejs/core 0.8.43 → 0.8.49

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.
Files changed (222) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/README.md +10 -10
  3. package/index.js +52 -0
  4. package/lib/a2a.js +159 -34
  5. package/lib/acme.js +762 -0
  6. package/lib/ai-pref.js +166 -43
  7. package/lib/api-key.js +108 -47
  8. package/lib/api-snapshot.js +157 -40
  9. package/lib/app-shutdown.js +113 -77
  10. package/lib/archive.js +337 -40
  11. package/lib/arg-parser.js +697 -0
  12. package/lib/asyncapi.js +99 -55
  13. package/lib/atomic-file.js +465 -104
  14. package/lib/audit-chain.js +123 -34
  15. package/lib/audit-daily-review.js +389 -0
  16. package/lib/audit-sign.js +302 -56
  17. package/lib/audit-tools.js +412 -63
  18. package/lib/audit.js +656 -35
  19. package/lib/auth/jwt-external.js +17 -0
  20. package/lib/auth/oauth.js +7 -0
  21. package/lib/auth-bot-challenge.js +505 -0
  22. package/lib/auth-header.js +92 -25
  23. package/lib/backup/bundle.js +26 -0
  24. package/lib/backup/index.js +512 -89
  25. package/lib/backup/manifest.js +168 -7
  26. package/lib/break-glass.js +415 -39
  27. package/lib/budr.js +103 -30
  28. package/lib/bundler.js +86 -66
  29. package/lib/cache.js +192 -72
  30. package/lib/chain-writer.js +65 -40
  31. package/lib/circuit-breaker.js +56 -33
  32. package/lib/cli-helpers.js +106 -75
  33. package/lib/cli.js +6 -30
  34. package/lib/cloud-events.js +99 -32
  35. package/lib/cluster-storage.js +162 -37
  36. package/lib/cluster.js +340 -49
  37. package/lib/codepoint-class.js +66 -0
  38. package/lib/compliance.js +424 -24
  39. package/lib/config-drift.js +111 -46
  40. package/lib/config.js +94 -40
  41. package/lib/consent.js +165 -18
  42. package/lib/constants.js +1 -0
  43. package/lib/content-credentials.js +153 -48
  44. package/lib/cookies.js +154 -62
  45. package/lib/credential-hash.js +133 -61
  46. package/lib/crypto-field.js +702 -18
  47. package/lib/crypto-hpke.js +256 -0
  48. package/lib/crypto.js +744 -22
  49. package/lib/csv.js +178 -35
  50. package/lib/daemon.js +456 -0
  51. package/lib/dark-patterns.js +186 -55
  52. package/lib/db-query.js +79 -2
  53. package/lib/db.js +1431 -60
  54. package/lib/ddl-change-control.js +523 -0
  55. package/lib/deprecate.js +195 -40
  56. package/lib/dev.js +82 -39
  57. package/lib/dora.js +67 -48
  58. package/lib/dr-runbook.js +368 -0
  59. package/lib/dsr.js +142 -11
  60. package/lib/dual-control.js +91 -56
  61. package/lib/events.js +120 -41
  62. package/lib/external-db-migrate.js +192 -2
  63. package/lib/external-db.js +795 -50
  64. package/lib/fapi2.js +122 -1
  65. package/lib/fda-21cfr11.js +395 -0
  66. package/lib/fdx.js +132 -2
  67. package/lib/file-type.js +87 -0
  68. package/lib/file-upload.js +93 -0
  69. package/lib/flag.js +82 -20
  70. package/lib/forms.js +132 -29
  71. package/lib/framework-error.js +169 -0
  72. package/lib/framework-schema.js +163 -35
  73. package/lib/gate-contract.js +849 -175
  74. package/lib/graphql-federation.js +68 -7
  75. package/lib/guard-all.js +172 -55
  76. package/lib/guard-archive.js +286 -124
  77. package/lib/guard-auth.js +194 -21
  78. package/lib/guard-cidr.js +190 -28
  79. package/lib/guard-csv.js +397 -51
  80. package/lib/guard-domain.js +213 -91
  81. package/lib/guard-email.js +236 -29
  82. package/lib/guard-filename.js +307 -75
  83. package/lib/guard-graphql.js +263 -30
  84. package/lib/guard-html.js +310 -116
  85. package/lib/guard-image.js +243 -30
  86. package/lib/guard-json.js +260 -54
  87. package/lib/guard-jsonpath.js +235 -23
  88. package/lib/guard-jwt.js +284 -30
  89. package/lib/guard-markdown.js +204 -22
  90. package/lib/guard-mime.js +190 -26
  91. package/lib/guard-oauth.js +277 -28
  92. package/lib/guard-pdf.js +251 -27
  93. package/lib/guard-regex.js +226 -18
  94. package/lib/guard-shell.js +229 -26
  95. package/lib/guard-svg.js +177 -10
  96. package/lib/guard-template.js +232 -21
  97. package/lib/guard-time.js +195 -29
  98. package/lib/guard-uuid.js +189 -30
  99. package/lib/guard-xml.js +259 -36
  100. package/lib/guard-yaml.js +241 -44
  101. package/lib/honeytoken.js +63 -27
  102. package/lib/html-balance.js +83 -0
  103. package/lib/http-client.js +486 -59
  104. package/lib/http-message-signature.js +582 -0
  105. package/lib/i18n.js +102 -49
  106. package/lib/iab-mspa.js +112 -32
  107. package/lib/iab-tcf.js +107 -2
  108. package/lib/inbox.js +90 -52
  109. package/lib/keychain.js +865 -0
  110. package/lib/legal-hold.js +374 -0
  111. package/lib/local-db-thin.js +320 -0
  112. package/lib/log-stream.js +281 -51
  113. package/lib/log.js +184 -86
  114. package/lib/mail-bounce.js +107 -62
  115. package/lib/mail.js +295 -58
  116. package/lib/mcp.js +108 -27
  117. package/lib/metrics.js +98 -89
  118. package/lib/middleware/age-gate.js +36 -0
  119. package/lib/middleware/ai-act-disclosure.js +37 -0
  120. package/lib/middleware/api-encrypt.js +45 -0
  121. package/lib/middleware/assetlinks.js +40 -0
  122. package/lib/middleware/asyncapi-serve.js +35 -0
  123. package/lib/middleware/attach-user.js +40 -0
  124. package/lib/middleware/bearer-auth.js +40 -0
  125. package/lib/middleware/body-parser.js +230 -0
  126. package/lib/middleware/bot-disclose.js +34 -0
  127. package/lib/middleware/bot-guard.js +39 -0
  128. package/lib/middleware/compression.js +37 -0
  129. package/lib/middleware/cookies.js +32 -0
  130. package/lib/middleware/cors.js +40 -0
  131. package/lib/middleware/csp-nonce.js +40 -0
  132. package/lib/middleware/csp-report.js +34 -0
  133. package/lib/middleware/csrf-protect.js +43 -0
  134. package/lib/middleware/daily-byte-quota.js +53 -85
  135. package/lib/middleware/db-role-for.js +40 -0
  136. package/lib/middleware/dpop.js +40 -0
  137. package/lib/middleware/error-handler.js +37 -14
  138. package/lib/middleware/fetch-metadata.js +39 -0
  139. package/lib/middleware/flag-context.js +34 -0
  140. package/lib/middleware/gpc.js +33 -0
  141. package/lib/middleware/headers.js +35 -0
  142. package/lib/middleware/health.js +46 -0
  143. package/lib/middleware/host-allowlist.js +30 -0
  144. package/lib/middleware/network-allowlist.js +38 -0
  145. package/lib/middleware/openapi-serve.js +34 -0
  146. package/lib/middleware/rate-limit.js +160 -18
  147. package/lib/middleware/request-id.js +36 -18
  148. package/lib/middleware/request-log.js +37 -0
  149. package/lib/middleware/require-aal.js +29 -0
  150. package/lib/middleware/require-auth.js +32 -0
  151. package/lib/middleware/require-bound-key.js +41 -0
  152. package/lib/middleware/require-content-type.js +32 -0
  153. package/lib/middleware/require-methods.js +27 -0
  154. package/lib/middleware/require-mtls.js +33 -0
  155. package/lib/middleware/require-step-up.js +37 -0
  156. package/lib/middleware/security-headers.js +44 -0
  157. package/lib/middleware/security-txt.js +38 -0
  158. package/lib/middleware/span-http-server.js +37 -0
  159. package/lib/middleware/sse.js +36 -0
  160. package/lib/middleware/trace-log-correlation.js +33 -0
  161. package/lib/middleware/trace-propagate.js +32 -0
  162. package/lib/middleware/tus-upload.js +90 -0
  163. package/lib/middleware/web-app-manifest.js +53 -0
  164. package/lib/mtls-ca.js +100 -70
  165. package/lib/network-byte-quota.js +308 -0
  166. package/lib/network-heartbeat.js +135 -0
  167. package/lib/network-tls.js +534 -4
  168. package/lib/network.js +103 -0
  169. package/lib/notify.js +114 -43
  170. package/lib/ntp-check.js +192 -51
  171. package/lib/observability.js +145 -47
  172. package/lib/openapi.js +90 -44
  173. package/lib/outbox.js +99 -1
  174. package/lib/pagination.js +168 -86
  175. package/lib/parsers/index.js +16 -5
  176. package/lib/permissions.js +93 -40
  177. package/lib/pqc-agent.js +84 -8
  178. package/lib/pqc-software.js +94 -60
  179. package/lib/process-spawn.js +95 -21
  180. package/lib/pubsub.js +96 -66
  181. package/lib/queue.js +375 -54
  182. package/lib/redact.js +793 -21
  183. package/lib/render.js +139 -47
  184. package/lib/request-helpers.js +485 -121
  185. package/lib/restore-bundle.js +142 -39
  186. package/lib/restore-rollback.js +136 -45
  187. package/lib/retention.js +178 -50
  188. package/lib/retry.js +116 -33
  189. package/lib/router.js +475 -23
  190. package/lib/safe-async.js +543 -94
  191. package/lib/safe-buffer.js +337 -41
  192. package/lib/safe-json.js +467 -62
  193. package/lib/safe-jsonpath.js +285 -0
  194. package/lib/safe-schema.js +631 -87
  195. package/lib/safe-sql.js +221 -59
  196. package/lib/safe-url.js +278 -46
  197. package/lib/sandbox-worker.js +135 -0
  198. package/lib/sandbox.js +358 -0
  199. package/lib/scheduler.js +135 -70
  200. package/lib/self-update.js +647 -0
  201. package/lib/session-device-binding.js +431 -0
  202. package/lib/session.js +259 -49
  203. package/lib/slug.js +138 -26
  204. package/lib/ssrf-guard.js +316 -56
  205. package/lib/storage.js +433 -70
  206. package/lib/subject.js +405 -23
  207. package/lib/template.js +148 -8
  208. package/lib/tenant-quota.js +545 -0
  209. package/lib/testing.js +440 -53
  210. package/lib/time.js +291 -23
  211. package/lib/tls-exporter.js +239 -0
  212. package/lib/tracing.js +90 -74
  213. package/lib/uuid.js +97 -22
  214. package/lib/vault/index.js +284 -22
  215. package/lib/vault/seal-pem-file.js +66 -0
  216. package/lib/watcher.js +368 -0
  217. package/lib/webhook.js +196 -63
  218. package/lib/websocket.js +393 -68
  219. package/lib/wiki-concepts.js +338 -0
  220. package/lib/worker-pool.js +464 -0
  221. package/package.json +3 -3
  222. package/sbom.cyclonedx.json +7 -7
@@ -1,47 +1,48 @@
1
1
  "use strict";
2
2
  /**
3
- * restore-bundle — extract an encrypted backup bundle to a staging dir.
3
+ * @module b.restoreBundle
4
+ * @nav Production
5
+ * @title Restore Bundle
4
6
  *
5
- * The mirror of backup-bundle. Reads manifest.json from a bundle
6
- * directory, decrypts each per-file blob via backup-crypto, verifies
7
- * each plaintext's sha3-512 checksum matches the manifest, and writes
8
- * the recovered files to a staging directory the caller then atomically
9
- * swaps into place. The bundle directory itself is read-only throughout.
7
+ * @intro
8
+ * Backup-bundle reader verify the manifest signature, list bundle
9
+ * contents without decrypting, and cherry-pick a restore subset to a
10
+ * staging directory the caller atomically swaps into place.
10
11
  *
11
- * var r = await b.restoreBundle.extract({
12
- * bundleDir: "./backups/2026-04-27.bundle",
13
- * stagingDir: "./data.staging", // must NOT exist
14
- * passphrase: Buffer.from("operator passphrase"),
15
- * filter: function (entry) { return true; }, // optional
16
- * progressCallback: function (event) { ... },
17
- * });
18
- * // { manifest, vaultKeyJson, fileCount, totalBytes,
19
- * // stagingDir, durationMs }
12
+ * The mirror of `b.backupBundle`. `b.restoreBundle.inspect` reads
13
+ * `manifest.json` and returns the parsed object — useful for
14
+ * dashboards and pre-flight UI that want to list files, sizes,
15
+ * timestamps, and kinds before prompting the operator for the
16
+ * passphrase. `b.restoreBundle.extract` decrypts each per-file blob
17
+ * via `b.backup/crypto`, verifies the SHA3-512 plaintext checksum
18
+ * against the manifest, and writes the recovered files into a
19
+ * fresh `stagingDir`. The bundle directory itself stays read-only
20
+ * throughout.
20
21
  *
21
- * vaultKeyJson is the decrypted vault keypair JSON the bundle carried
22
- * in manifest.vaultKeyEnc. The caller decides what to do with it:
23
- * write to stagingDir/vault.key for a fresh framework boot, hand to
24
- * vault.init for an in-process load, etc. restore-bundle's job ends
25
- * at recovery; vault-key placement is operator policy.
22
+ * `extract` always recovers the wrapped vault key (decrypted JSON
23
+ * returned on `vaultKeyJson`) so the operator can unseal columns
24
+ * from a partial restore. The `filter` predicate lets the caller
25
+ * pull a subset only the DB, only TLS keys, only the consent
26
+ * log without producing every blob.
26
27
  *
27
- * filter: optional predicate that lets a caller pull a subset (only
28
- * the DB, only the TLS keys, etc.). The vault key is always recovered
29
- * regardless of filter so the operator can read sealed values from a
30
- * partial restore.
28
+ * Defense surface:
31
29
  *
32
- * Defense:
33
- * - Wrong passphrase AEAD tag check fails on first blob →
34
- * restore-bundle/decrypt-failed (no plaintext leaked, no staging
30
+ * - Wrong passphrase / tampered blob → AEAD tag failure →
31
+ * `restore-bundle/decrypt-failed` (no plaintext leak, no staging
35
32
  * left behind)
36
- * - Tampered blob (single byte flip in ciphertext) same path
37
- * - encryptedSize mismatch → restore-bundle/size-mismatch (cheap
38
- * pre-decrypt check)
39
- * - Plaintext sha3-512 != manifest.checksum → restore-bundle/
40
- * checksum-mismatch (post-decrypt integrity guard)
41
- * - Missing blob file → restore-bundle/missing-blob (manifest
42
- * references a path the bundle dir doesn't have)
43
- * - On any failure, the partially-built stagingDir is removed so a
44
- * subsequent retry isn't blocked by a stale dir
33
+ * - Pre-decrypt `encryptedSize` mismatch`restore-bundle/
34
+ * size-mismatch`
35
+ * - Post-decrypt SHA3-512 ≠ manifest checksum →
36
+ * `restore-bundle/checksum-mismatch`
37
+ * - Missing blob file → `restore-bundle/missing-blob`
38
+ * - Bad manifest signature`restore-bundle/bad-signature`;
39
+ * `requireSignature: true` upgrades a missing signature to
40
+ * `restore-bundle/missing-signature`
41
+ * - On any failure the partially-built `stagingDir` is removed so a
42
+ * subsequent retry is not blocked by a stale directory
43
+ *
44
+ * @card
45
+ * Backup-bundle reader — verify the manifest signature, list bundle contents without decrypting, and cherry-pick a restore subset to a staging directory the caller atomically swaps into place.
45
46
  */
46
47
 
47
48
  var fs = require("fs");
@@ -68,6 +69,57 @@ function _cleanupStaging(stagingDir) {
68
69
  catch (_e) { /* best-effort */ }
69
70
  }
70
71
 
72
+ /**
73
+ * @primitive b.restoreBundle.extract
74
+ * @signature b.restoreBundle.extract(opts)
75
+ * @since 0.5.0
76
+ * @status stable
77
+ * @related b.restoreBundle.inspect, b.backupBundle.create, b.vault.init
78
+ *
79
+ * Decrypt every blob the manifest references (or the subset
80
+ * `opts.filter` accepts), verify each plaintext's checksum, and write
81
+ * the recovered files into `opts.stagingDir`. Returns
82
+ * `{ manifest, vaultKeyJson, fileCount, totalBytes, stagingDir,
83
+ * durationMs }`.
84
+ *
85
+ * `stagingDir` MUST NOT exist — extract refuses to merge into an
86
+ * existing directory so a half-finished prior restore can never get
87
+ * silently overlaid. On any failure the partial `stagingDir` is
88
+ * removed.
89
+ *
90
+ * Signature handling: when the manifest carries a signature it is
91
+ * verified with `b.backup/manifest`'s public-key check. Pass
92
+ * `verifySignature: false` for cold restores from an org whose
93
+ * audit-sign keypair the framework cannot reach; pass
94
+ * `requireSignature: true` to fail-closed on bundles missing a
95
+ * signature; pass `expectedFingerprint` to pin a specific signing
96
+ * key.
97
+ *
98
+ * @opts
99
+ * bundleDir: string, // read-only bundle dir (required)
100
+ * stagingDir: string, // fresh output dir (required, must not exist)
101
+ * passphrase: Buffer | string, // unwrap key (required)
102
+ * filter: function (entry): boolean,// subset predicate
103
+ * progressCallback: function (ev): void, // phase events: read_manifest / decrypt / done
104
+ * verifySignature: boolean, // default: true
105
+ * requireSignature: boolean, // fail-closed on missing signature
106
+ * expectedFingerprint: string, // pin specific signing key
107
+ *
108
+ * @example
109
+ * try {
110
+ * var report = await b.restoreBundle.extract({
111
+ * bundleDir: "/srv/backups/2026-04-27.bundle",
112
+ * stagingDir: "/srv/restore/data.staging",
113
+ * passphrase: Buffer.from("operator-passphrase"),
114
+ * requireSignature: true,
115
+ * filter: function (entry) { return entry.kind === "db"; },
116
+ * });
117
+ * report.fileCount; // → 1
118
+ * typeof report.vaultKeyJson; // → "string"
119
+ * } catch (e) {
120
+ * e.code; // → "restore-bundle/decrypt-failed"
121
+ * }
122
+ */
71
123
  async function extract(opts) {
72
124
  var t0 = Date.now();
73
125
  opts = opts || {};
@@ -107,6 +159,27 @@ async function extract(opts) {
107
159
  "extract: manifest could not be parsed: " + ((e && e.message) || String(e)));
108
160
  }
109
161
 
162
+ // Verify the manifest signature when present. Operators can pass
163
+ // `requireSignature: true` to fail-closed on missing signatures
164
+ // (HIPAA/PCI-DSS), `expectedFingerprint` to pin a specific signing
165
+ // key, or pass `verifySignature: false` to skip verification when
166
+ // the signing key is genuinely unavailable (cold-restore from a
167
+ // separate org with their own audit-sign keypair the framework
168
+ // can't reach).
169
+ var verifySig = opts.verifySignature !== false;
170
+ if (verifySig && manifest.signature) {
171
+ var sigResult = backupManifest.verifySignature(manifest, {
172
+ expectedFingerprint: opts.expectedFingerprint || undefined,
173
+ });
174
+ if (!sigResult.ok) {
175
+ throw new RestoreBundleError("restore-bundle/bad-signature",
176
+ "extract: manifest signature invalid: " + sigResult.reason);
177
+ }
178
+ } else if (opts.requireSignature === true && !manifest.signature) {
179
+ throw new RestoreBundleError("restore-bundle/missing-signature",
180
+ "extract: manifest has no signature but opts.requireSignature=true");
181
+ }
182
+
110
183
  // 2. Recover the vault key (always, regardless of filter — the
111
184
  // operator may need it to unseal post-restore even on partial
112
185
  // restores)
@@ -213,9 +286,39 @@ async function extract(opts) {
213
286
  };
214
287
  }
215
288
 
216
- // Inspect a bundle without decrypting — read the manifest and return
217
- // it. Useful for dashboards and pre-flight UI: list files, sizes,
218
- // timestamps, kinds without prompting for the passphrase.
289
+ /**
290
+ * @primitive b.restoreBundle.inspect
291
+ * @signature b.restoreBundle.inspect(opts)
292
+ * @since 0.5.0
293
+ * @status stable
294
+ * @related b.restoreBundle.extract, b.backupBundle.create
295
+ *
296
+ * Read `manifest.json` from `opts.bundleDir` and return the parsed
297
+ * object — files, sizes, timestamps, kinds, signature presence —
298
+ * without prompting for the passphrase or decrypting anything. Useful
299
+ * for dashboards, pre-flight UI, and "what's in this bundle?" checks
300
+ * before kicking off a long extract.
301
+ *
302
+ * Throws `RestoreBundleError("restore-bundle/no-bundle")` when
303
+ * `bundleDir` is missing, and
304
+ * `RestoreBundleError("restore-bundle/missing-manifest")` when the
305
+ * directory exists but has no `manifest.json` (the bundle is
306
+ * incomplete or not a blamejs bundle).
307
+ *
308
+ * @opts
309
+ * bundleDir: string, // bundle directory (required, must exist)
310
+ *
311
+ * @example
312
+ * try {
313
+ * var manifest = b.restoreBundle.inspect({
314
+ * bundleDir: "/srv/backups/2026-04-27.bundle",
315
+ * });
316
+ * manifest.files.length; // → 12
317
+ * typeof manifest.signature; // → "string"
318
+ * } catch (e) {
319
+ * e.code; // → "restore-bundle/missing-manifest"
320
+ * }
321
+ */
219
322
  function inspect(opts) {
220
323
  opts = opts || {};
221
324
  if (typeof opts.bundleDir !== "string" || !fs.existsSync(opts.bundleDir)) {
@@ -1,56 +1,45 @@
1
1
  "use strict";
2
2
  /**
3
- * restore-rollback — atomic dataDir swap with a versioned rollback path.
3
+ * @module b.restoreRollback
4
+ * @nav Other
5
+ * @title Restore Rollback
4
6
  *
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.
7
+ * @intro
8
+ * Backup-restore safety net atomic dataDir swap with a versioned
9
+ * rollback path. The primitive `b.restore` calls to put a
10
+ * freshly-decrypted bundle into place: filesystem rename is atomic
11
+ * on POSIX (and on Windows when nothing has the dir open), so the
12
+ * swap either fully completes or the previous `dataDir` is
13
+ * recoverable through `rollback`.
9
14
  *
10
- * var rb = b.restoreRollback;
15
+ * Three steps frame every restore: pre-restore snapshot (the
16
+ * existing `dataDir` is renamed into `<root>/<timestamp>/` before
17
+ * the new bundle moves in), post-restore verify (operator runs
18
+ * integrity / audit-chain checks against the live framework), and
19
+ * rollback on failure (a single `rollback({ rollbackPath })` call
20
+ * reverses the swap). A marker JSON file carries operator-supplied
21
+ * metadata (`bundleId`, `reason`, timestamps) so `list` and `purge`
22
+ * are informative without rifling through directory contents.
11
23
  *
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] }
24
+ * Layout after a successful swap:
29
25
  *
30
- * Layout after a successful swap:
26
+ * ./data <- freshly-restored bundle
27
+ * ./data.rollbacks/
28
+ * 2026-04-27T17-46-36-075Z/ <- previous dataDir
29
+ * 2026-04-27T17-46-36-075Z.marker.json
31
30
  *
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
31
+ * Stop-framework-first contract: this primitive does NOT close the
32
+ * framework's open file handles. On Linux a directory rename
33
+ * succeeds with handles open, but the running process keeps reading
34
+ * stale data. Operators run restore as `stop framework -> swap ->
35
+ * start framework`, same shape as a database restore. Concurrency
36
+ * guard: `swap` refuses if another rollback for the same
37
+ * millisecond timestamp already exists — collisions are
38
+ * vanishingly rare but the check keeps a double-fire from
39
+ * corrupting state.
37
40
  *
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.
41
+ * @card
42
+ * Backup-restore safety net atomic dataDir swap with a versioned rollback path.
54
43
  */
55
44
 
56
45
  var fs = require("fs");
@@ -76,6 +65,33 @@ function _resolveRollbackRoot(opts) {
76
65
  }
77
66
 
78
67
 
68
+ /**
69
+ * @primitive b.restoreRollback.swap
70
+ * @signature b.restoreRollback.swap(opts)
71
+ * @since 0.1.89
72
+ * @status stable
73
+ * @related b.restoreRollback.rollback, b.restoreRollback.list, b.restore.applyBundle
74
+ *
75
+ * Pre-restore snapshot + atomic swap. Renames the existing `dataDir`
76
+ * into `<rollbackRoot>/<timestamp>/`, then renames `stagingDir` into
77
+ * `dataDir`. If step two fails, step one is undone so the operator's
78
+ * dataDir is intact. Writes a `<timestamp>.marker.json` carrying
79
+ * operator metadata for later `list` / `rollback` discovery.
80
+ *
81
+ * @opts
82
+ * stagingDir: string, // pre-decrypted bundle, must exist
83
+ * dataDir: string, // live data dir to replace
84
+ * rollbackRoot: string, // optional; defaults to "<dataDir>.rollbacks"
85
+ * marker: object, // operator metadata for the marker file
86
+ *
87
+ * @example
88
+ * var r = b.restoreRollback.swap({
89
+ * stagingDir: "./data.staging",
90
+ * dataDir: "./data",
91
+ * marker: { bundleId: "bk-2026-05-09", reason: "scheduled-restore" },
92
+ * });
93
+ * // → { rollbackPath: "./data.rollbacks/2026-05-09T...", markerPath, swappedAt, marker }
94
+ */
79
95
  function swap(opts) {
80
96
  opts = opts || {};
81
97
  if (typeof opts.stagingDir !== "string" || !fs.existsSync(opts.stagingDir)) {
@@ -143,6 +159,34 @@ function swap(opts) {
143
159
  };
144
160
  }
145
161
 
162
+ /**
163
+ * @primitive b.restoreRollback.rollback
164
+ * @signature b.restoreRollback.rollback(opts)
165
+ * @since 0.1.89
166
+ * @status stable
167
+ * @related b.restoreRollback.swap, b.restoreRollback.list
168
+ *
169
+ * Reverse a prior swap. Moves the current `dataDir` aside as
170
+ * `discarded-<timestamp>/` (so the rename target is empty), then
171
+ * renames the named `rollbackPath` back into `dataDir`. The marker
172
+ * JSON is removed best-effort. Operator must have stopped the
173
+ * framework first — open file handles on the live dataDir on Windows
174
+ * cause the rename to fail.
175
+ *
176
+ * @opts
177
+ * dataDir: string, // live dataDir to replace
178
+ * rollbackPath: string, // must exist; from swap() return
179
+ * rollbackRoot: string, // optional; defaults to "<dataDir>.rollbacks"
180
+ *
181
+ * @example
182
+ * var r = b.restoreRollback.swap({
183
+ * stagingDir: "./data.staging", dataDir: "./data",
184
+ * marker: { reason: "test" },
185
+ * });
186
+ * // post-restore verify failed:
187
+ * await b.restoreRollback.rollback({ dataDir: "./data", rollbackPath: r.rollbackPath });
188
+ * // → { restoredFrom: "./data.rollbacks/2026-05-09T...", discardedAt: "..." }
189
+ */
146
190
  async function rollback(opts) {
147
191
  opts = opts || {};
148
192
  if (typeof opts.dataDir !== "string" || opts.dataDir.length === 0) {
@@ -188,6 +232,29 @@ async function rollback(opts) {
188
232
  };
189
233
  }
190
234
 
235
+ /**
236
+ * @primitive b.restoreRollback.list
237
+ * @signature b.restoreRollback.list(opts)
238
+ * @since 0.1.89
239
+ * @status stable
240
+ * @related b.restoreRollback.swap, b.restoreRollback.purge
241
+ *
242
+ * Enumerate available rollback points, newest first. Reads each
243
+ * marker file (capped at 64 KiB via `b.safeJson` to bound a
244
+ * tampered-marker DoS). Skips `discarded-*` directories — those are
245
+ * sweep-only and never restore points.
246
+ *
247
+ * @opts
248
+ * dataDir: string, // optional, used to derive rollbackRoot
249
+ * rollbackRoot: string, // optional; defaults to "<dataDir>.rollbacks"
250
+ *
251
+ * @example
252
+ * var points = b.restoreRollback.list({ dataDir: "./data" });
253
+ * points.forEach(function (p) {
254
+ * console.log(p.swappedAt, p.marker && p.marker.operator);
255
+ * });
256
+ * // → [{ rollbackPath, swappedAt, marker }, ...]
257
+ */
191
258
  function list(opts) {
192
259
  opts = opts || {};
193
260
  var rollbackRoot = _resolveRollbackRoot(opts);
@@ -218,6 +285,30 @@ function list(opts) {
218
285
  return out;
219
286
  }
220
287
 
288
+ /**
289
+ * @primitive b.restoreRollback.purge
290
+ * @signature b.restoreRollback.purge(opts)
291
+ * @since 0.1.89
292
+ * @status stable
293
+ * @related b.restoreRollback.list, b.restoreRollback.swap
294
+ *
295
+ * Sweep stale rollback directories. Always removes every directory
296
+ * named `discarded-<timestamp>` (those are never restore points),
297
+ * then keeps the newest `keep` rollback points and removes the rest
298
+ * along with their marker files. `opts.keep` defaults to 0; pass a
299
+ * positive integer to retain a sliding window. Best-effort: a
300
+ * per-path unlink failure is logged via the deleted-list omission
301
+ * rather than thrown.
302
+ *
303
+ * @opts
304
+ * dataDir: string,
305
+ * rollbackRoot: string,
306
+ * keep: number, // non-negative integer, default 0
307
+ *
308
+ * @example
309
+ * var r = b.restoreRollback.purge({ dataDir: "./data", keep: 3 });
310
+ * // → { kept: 3, deleted: ["./data.rollbacks/2026-04-...", ...] }
311
+ */
221
312
  function purge(opts) {
222
313
  opts = opts || {};
223
314
  nb.requireNonNegativeFiniteIntIfPresent(opts.keep,