@blamejs/core 0.8.43 → 0.8.50

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 +93 -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,62 +1,51 @@
1
1
  "use strict";
2
2
  /**
3
- * backup — operator-facing backup orchestration.
3
+ * @module b.backup
4
+ * @featured true
5
+ * @nav Production
6
+ * @title Backup
4
7
  *
5
- * Wires lib/backup-bundle (encrypt + emit a bundle directory) to a
6
- * pluggable storage backend, plus retention policy + audit emission.
7
- * Ships with a local-filesystem backend (b.backup.localStorage); S3
8
- * or any custom backend drops in through the same interface.
8
+ * @intro
9
+ * PQC-encrypted backup bundles sealed columns + audit chain +
10
+ * keyring. SLH-DSA signature on every bundle, kid pinning, restore
11
+ * validates signature against operator-pinned public key.
9
12
  *
10
- * var backup = b.backup.create({
11
- * dataDir: "./data",
12
- * storage: b.backup.localStorage({ root: "./backups" }),
13
- * passphrase: Buffer.from("operator backup passphrase"),
14
- * files: [
15
- * { relativePath: "db.enc", kind: "raw", required: true },
16
- * { relativePath: "db.key.enc", kind: "raw", required: true },
17
- * { relativePath: "vault.key", kind: "raw", required: false },
18
- * { relativePath: "ca.key.sealed",kind: "vault-sealed", required: false },
19
- * ],
20
- * vaultKeyJson: function () { return fs.readFileSync('./data/vault.key','utf8'); },
21
- * retention: { keep: 7 }, // keep latest 7; older purged after run
22
- * audit: true,
23
- * scheduler: b.scheduler, // optional; needed for backup.schedule()
24
- * });
13
+ * The namespace wires `b.backupBundle.create` (encrypt + emit a bundle
14
+ * directory) to a pluggable storage backend, plus retention policy +
15
+ * audit emission. Ships with a local-filesystem backend
16
+ * (`b.backup.localStorage`); S3 or any custom backend drops in through
17
+ * the same interface.
25
18
  *
26
- * await backup.run({ metadata: { reason: "daily" } });
27
- * // → { bundleId, bundleSize, fileCount, durationMs }
28
- * await backup.list();
29
- * // → [{ bundleId, createdAt, size, fileCount }]
30
- * await backup.delete(bundleId);
31
- * await backup.purgeOlder({ keep: 7 });
32
- * await backup.read(bundleId, destDir); // pull a bundle back to disk
33
- * // (without decrypting — that's
34
- * // restore-bundle's job)
35
- *
36
- * backup.schedule({ cron: "0 2 * * *", timezone: "America/New_York" });
37
- * // returns a scheduler task name; wires through b.scheduler
38
- *
39
- * Storage backend contract:
40
- *
41
- * {
42
- * async writeBundle(bundleId, sourceDir) copy sourceDir contents under bundleId
43
- * async readBundle(bundleId, destDir) copy bundle out to destDir
44
- * async listBundles() → [{ bundleId, createdAt, size }]
45
- * async deleteBundle(bundleId)
46
- * async hasBundle(bundleId) → boolean
47
- * }
19
+ * Storage backend contract:
20
+ *
21
+ * {
22
+ * async writeBundle(bundleId, sourceDir),
23
+ * async readBundle(bundleId, destDir),
24
+ * async listBundles(), // → [{ bundleId, createdAt, size }]
25
+ * async deleteBundle(bundleId),
26
+ * async hasBundle(bundleId),
27
+ * }
28
+ *
29
+ * `vaultKeyJson` can be a string (the operator has the JSON in hand)
30
+ * or a function returning a string (or async returning a string) — the
31
+ * framework calls it each backup so a long-running app doesn't pin
32
+ * the vault key in memory between runs.
48
33
  *
49
- * vaultKeyJson can be either:
50
- * - A string (the operator already has the JSON in hand)
51
- * - A function returning a string (or async returning a string) the
52
- * framework calls this each backup so a long-running app doesn't pin
53
- * the vault key in memory between runs
54
- *
55
- * Bundle IDs are filesystem-safe timestamps with millisecond precision
56
- * plus a 4-byte random suffix: "2026-04-27T14-00-00-123Z-a8f30b21".
57
- * Colons + dots in standard ISO-8601 are replaced with dashes so the
58
- * id works as a directory name on every platform (Windows reserves ':'
59
- * for drive letters). String sort still gives chronological order.
34
+ * Bundle IDs are filesystem-safe timestamps with millisecond precision
35
+ * plus a 4-byte random suffix: `2026-04-27T14-00-00-123Z-a8f30b21`.
36
+ * Colons + dots in standard ISO-8601 are replaced with dashes so the
37
+ * id works as a directory name on every platform (Windows reserves
38
+ * `:` for drive letters). String sort still gives chronological order.
39
+ *
40
+ * Posture-enforced encryption: HIPAA / PCI-DSS postures refuse a
41
+ * pipeline created with `encrypt: false`. Posture-enforced residency:
42
+ * gdpr / uk-gdpr / dpdp / pipl-cn / lgpd-br / appi-jp / pdpa-sg refuse
43
+ * a destination tag that doesn't match the live DB residency unless
44
+ * the operator passes `allowCrossBorder: true` with a documented
45
+ * `legalBasis`.
46
+ *
47
+ * @card
48
+ * PQC-encrypted backup bundles — sealed columns + audit chain + keyring.
60
49
  */
61
50
 
62
51
  var fs = require("fs");
@@ -65,10 +54,12 @@ var path = require("path");
65
54
  var crypto = require("../crypto");
66
55
  var atomicFile = require("../atomic-file");
67
56
  var backupBundle = require("./bundle");
57
+ var backupManifest = require("./manifest");
68
58
  var lazyRequire = require("../lazy-require");
69
59
  var validateOpts = require("../validate-opts");
70
60
  var numericBounds = require("../numeric-bounds");
71
61
  var audit = lazyRequire(function () { return require("../audit"); });
62
+ var compliance = lazyRequire(function () { return require("../compliance"); });
72
63
  // lazyRequire ../db so backup stays a leaf module operators can use
73
64
  // without the rest of the framework's DB chain loaded in the same
74
65
  // module graph (CLI tools, stand-alone backup runners). The db()
@@ -78,6 +69,15 @@ var { defineClass } = require("../framework-error");
78
69
 
79
70
  var BackupError = defineClass("BackupError");
80
71
 
72
+ // Postures whose published controls require backup encryption. PCI
73
+ // DSS 4.0.1 Req 9.4.1.b ("backups are protected with strong cryptography
74
+ // and encrypted") and HIPAA §164.310(d)(2)(iv) ("create a retrievable,
75
+ // exact copy of ePHI" — encryption strongly implied by §164.312(a)(2)
76
+ // (iv) addressable encryption standard).
77
+ var BACKUP_ENCRYPTION_REQUIRED_POSTURES = Object.freeze([
78
+ "hipaa", "pci-dss",
79
+ ]);
80
+
81
81
  // "2026-04-27T14-00-00-123Z-a8f30b21" — atomicFile.pathTimestamp() form
82
82
  // (ISO with ':'+'.' replaced by '-') plus a random suffix.
83
83
  var BUNDLE_ID_RE = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z-[0-9a-f]{8}$/;
@@ -109,6 +109,37 @@ function _dirSize(p) {
109
109
 
110
110
  // ---- Local filesystem storage backend (the default) ----
111
111
 
112
+ /**
113
+ * @primitive b.backup.localStorage
114
+ * @signature b.backup.localStorage(opts)
115
+ * @since 0.4.0
116
+ * @status stable
117
+ * @related b.backup.create
118
+ *
119
+ * Local-filesystem storage backend implementing the
120
+ * `{ writeBundle, readBundle, listBundles, deleteBundle, hasBundle }`
121
+ * contract. Bundles land as directories named by bundle id under
122
+ * `opts.root`. Newest-first ordering is enforced by reverse
123
+ * lexicographic sort on the timestamp-prefixed bundle id.
124
+ *
125
+ * Operators pointing at S3 / GCS / Azure Blob / a tape gateway pass a
126
+ * custom backend matching the same shape; the engine never touches the
127
+ * filesystem directly.
128
+ *
129
+ * @opts
130
+ * root: string, // required; directory under which bundle dirs land
131
+ *
132
+ * @example
133
+ * var fs = require("node:fs");
134
+ * var path = require("node:path");
135
+ * var os = require("node:os");
136
+ * var root = fs.mkdtempSync(path.join(os.tmpdir(), "backup-root-"));
137
+ *
138
+ * var storage = b.backup.localStorage({ root: root });
139
+ * storage.name; // → "local"
140
+ * typeof storage.writeBundle; // → "function"
141
+ * typeof storage.listBundles; // → "function"
142
+ */
112
143
  function localStorage(opts) {
113
144
  opts = opts || {};
114
145
  validateOpts.requireNonEmptyString(opts.root, "localStorage: opts.root", BackupError, "backup/no-storage-root");
@@ -210,6 +241,71 @@ async function _resolveVaultKeyJson(vaultKeyJsonOpt) {
210
241
  "opts.vaultKeyJson is required (string or function returning a string)");
211
242
  }
212
243
 
244
+ /**
245
+ * @primitive b.backup.create
246
+ * @signature b.backup.create(opts)
247
+ * @since 0.4.0
248
+ * @status stable
249
+ * @compliance hipaa, pci-dss, gdpr, soc2, dora
250
+ * @related b.backup.localStorage, b.backup.recommendedFiles, b.backup.verifyManifestSignature, b.backupBundle.create
251
+ *
252
+ * Build a backup engine bound to a data directory, a storage backend,
253
+ * the operator's passphrase, and an include list. Returns an object
254
+ * with `run` / `list` / `delete` / `read` / `purgeOlder` / `schedule` /
255
+ * `scheduleTest` plus the wired `storage` reference.
256
+ *
257
+ * Each `run()` produces a fresh bundle id (`<iso-timestamp>-<8 hex>`),
258
+ * stages encryption to a process-private tmpdir, writes through
259
+ * `storage.writeBundle`, sweeps tmpdir, then applies retention. Audit
260
+ * events `backup.success` / `backup.failure` / `backup.retention.swept`
261
+ * land on `b.audit` when `opts.audit !== false`.
262
+ *
263
+ * Posture gates fire at `create()` time, not `run()` time — so a
264
+ * misconfigured pipeline refuses to construct rather than producing
265
+ * one good bundle and then failing the next.
266
+ *
267
+ * @opts
268
+ * dataDir: string, // required; must exist on disk
269
+ * storage: StorageBackend, // required; localStorage() or custom
270
+ * passphrase: Buffer | string, // required; KEK for per-file Argon2id wrap
271
+ * files: Array<{ relativePath, kind, required }>,
272
+ * vaultKeyJson: string | () => string | Promise<string>,
273
+ * retention: { keep: number }, // optional; sweep older bundles after run()
274
+ * audit: boolean, // default true
275
+ * scheduler: b.scheduler, // required for schedule() / scheduleTest()
276
+ * flushBeforeBackup: false | () => void | Promise<void>,
277
+ * requireFlush: boolean, // default false
278
+ * encrypt: boolean, // default true; refused under hipaa / pci-dss
279
+ * residencyTag: string | null, // e.g. "EU"; checked against b.db.getDataResidency()
280
+ * allowCrossBorder: boolean, // explicit override for residency mismatch
281
+ * legalBasis: string, // recorded in audit chain when allowCrossBorder
282
+ *
283
+ * @example
284
+ * var fs = require("node:fs");
285
+ * var path = require("node:path");
286
+ * var os = require("node:os");
287
+ *
288
+ * var dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "backup-data-"));
289
+ * var root = fs.mkdtempSync(path.join(os.tmpdir(), "backup-root-"));
290
+ * fs.writeFileSync(path.join(dataDir, "db.enc"), Buffer.from([1, 2, 3]));
291
+ * fs.writeFileSync(path.join(dataDir, "db.key.enc"), Buffer.from([4, 5, 6]));
292
+ *
293
+ * var engine = b.backup.create({
294
+ * dataDir: dataDir,
295
+ * storage: b.backup.localStorage({ root: root }),
296
+ * passphrase: Buffer.from("operator backup passphrase"),
297
+ * files: [
298
+ * { relativePath: "db.enc", kind: "raw", required: true },
299
+ * { relativePath: "db.key.enc", kind: "raw", required: true },
300
+ * ],
301
+ * vaultKeyJson: '{"version":1,"kid":"k1"}',
302
+ * retention: { keep: 7 },
303
+ * });
304
+ *
305
+ * typeof engine.run; // → "function"
306
+ * typeof engine.list; // → "function"
307
+ * typeof engine.purgeOlder; // → "function"
308
+ */
213
309
  function create(opts) {
214
310
  opts = opts || {};
215
311
  if (typeof opts.dataDir !== "string" || !fs.existsSync(opts.dataDir)) {
@@ -230,6 +326,85 @@ function create(opts) {
230
326
  "create: opts.vaultKeyJson is required (string or function returning string)");
231
327
  }
232
328
 
329
+ // Posture-enforced backup encryption (F-BUDR-4). HIPAA / PCI-DSS
330
+ // operators MUST keep encryption on. The framework's backup pipeline
331
+ // is encrypted-by-default — passphrase + per-file XChaCha20-Poly1305
332
+ // — but operators in third-party storage backends sometimes pass
333
+ // `encrypt: false` on bespoke backends. Refuse boot under regulated
334
+ // postures. Permits explicit `opts.allowUnencrypted: true` only when
335
+ // a documented compensating control is present (offline tape vault
336
+ // with physical custody, separate KMS-encrypted bucket, etc.) — and
337
+ // even then the framework refuses unless paired with the operator's
338
+ // posture explicitly acknowledging the deviation.
339
+ var posture = null;
340
+ try { posture = compliance().current(); }
341
+ catch (_e) { /* compliance optional at backup-create time */ }
342
+ if (posture && BACKUP_ENCRYPTION_REQUIRED_POSTURES.indexOf(posture) !== -1) {
343
+ if (opts.encrypt === false) {
344
+ throw new BackupError("backup/encryption-required",
345
+ "backup.create: posture='" + posture + "' requires backup encryption " +
346
+ "(HIPAA §164.310(d)(2)(iv) / PCI DSS 4.0.1 Req 9.4.1.b). " +
347
+ "Refusing to create an unencrypted backup pipeline.");
348
+ }
349
+ }
350
+
351
+ // F-CBT-3 — backup destination residency posture. EU-tagged primary
352
+ // backing up to a US-region destination is a GDPR Article 46
353
+ // cross-border transfer; without an explicit operator opt-in the
354
+ // framework refuses to create the pipeline under gdpr / dpdp /
355
+ // pipl-cn / uk-gdpr / lgpd-br / appi-jp / pdpa-sg postures.
356
+ //
357
+ // b.backup.create({
358
+ // ...,
359
+ // residencyTag: "EU", // matches your DB residency
360
+ // allowCrossBorder: true, // explicit override
361
+ // legalBasis: "EU SCCs 2021/914", // recorded in audit chain
362
+ // });
363
+ var BACKUP_RESIDENCY_REGULATED_POSTURES = ["gdpr", "uk-gdpr", "dpdp", "pipl-cn",
364
+ "lgpd-br", "appi-jp", "pdpa-sg"];
365
+ var backupResidencyTag = opts.residencyTag || null;
366
+ if (opts.residencyTag !== undefined && opts.residencyTag !== null &&
367
+ (typeof opts.residencyTag !== "string" || opts.residencyTag.length === 0)) {
368
+ throw new BackupError("backup/bad-residency-tag",
369
+ "backup.create: opts.residencyTag must be a non-empty string or null");
370
+ }
371
+ if (posture && BACKUP_RESIDENCY_REGULATED_POSTURES.indexOf(posture) !== -1) {
372
+ var dbResidency = null;
373
+ try {
374
+ var dbModuleR = dbModuleLazy();
375
+ dbResidency = (dbModuleR && typeof dbModuleR.getDataResidency === "function")
376
+ ? dbModuleR.getDataResidency() : null;
377
+ } catch (_e) { dbResidency = null; }
378
+ var dbTag = (dbResidency && dbResidency.region) || null;
379
+ if (dbTag && backupResidencyTag &&
380
+ dbTag !== backupResidencyTag &&
381
+ backupResidencyTag !== "unrestricted" &&
382
+ dbTag !== "unrestricted") {
383
+ if (!opts.allowCrossBorder) {
384
+ throw new BackupError("backup/residency-mismatch",
385
+ "backup.create: db residency '" + dbTag +
386
+ "' but backup destination residencyTag '" + backupResidencyTag +
387
+ "' under '" + posture + "' posture. This is a cross-border data " +
388
+ "transfer (GDPR Art 46 / DPDP / PIPL category). Pass " +
389
+ "allowCrossBorder: true with a documented legalBasis to suppress.");
390
+ }
391
+ }
392
+ if (!backupResidencyTag) {
393
+ // Under regulated posture an undeclared backup residency is a
394
+ // smell — emit warning, don't refuse (operators with single-
395
+ // region S3 buckets that match the DB region are the common
396
+ // case and shouldn't be blocked).
397
+ try {
398
+ audit().safeEmit({
399
+ action: "backup.residency_undeclared",
400
+ outcome: "success",
401
+ metadata: { severity: "warning", posture: posture, dbResidency: dbTag,
402
+ recommendation: "declare opts.residencyTag matching the DB residency tag" },
403
+ });
404
+ } catch (_e) { /* drop-silent */ }
405
+ }
406
+ }
407
+
233
408
  var dataDir = opts.dataDir;
234
409
  var storage = opts.storage;
235
410
  var passphrase = opts.passphrase;
@@ -440,6 +615,140 @@ function create(opts) {
440
615
  return { name: name, instance: schedInstance };
441
616
  }
442
617
 
618
+ // scheduleTest — periodic restore-and-verify drill required by HIPAA
619
+ // §164.308(a)(7)(ii)(D) ("testing and revision procedures"). The
620
+ // framework picks the latest backup, restores it to the operator-
621
+ // supplied directory, runs the operator's verify callback, and emits
622
+ // backup.test.passed / backup.test.failed in the audit chain.
623
+ //
624
+ // await b.backup.scheduleTest({
625
+ // cron: "0 3 * * 0", // weekly at 03:00 Sunday
626
+ // restoreTo: "/var/backup-test/staging",
627
+ // verify: async function ({ outDir, manifest }) {
628
+ // // operator confirms key files restored, returns truthy on
629
+ // // success or throws on failure.
630
+ // },
631
+ // notify: async function ({ outcome, reason, manifest }) { /* page operator */ },
632
+ // posture: "hipaa",
633
+ // });
634
+ function scheduleTest(testOpts) {
635
+ if (!scheduler || typeof scheduler.create !== "function") {
636
+ throw new BackupError("backup/no-scheduler",
637
+ "scheduleTest: opts.scheduler must be wired at create() to use scheduleTest()");
638
+ }
639
+ testOpts = testOpts || {};
640
+ if (typeof testOpts.cron !== "string" || testOpts.cron.length === 0) {
641
+ throw new BackupError("backup/bad-test-schedule",
642
+ "scheduleTest: opts.cron is required");
643
+ }
644
+ if (typeof testOpts.restoreTo !== "string" || testOpts.restoreTo.length === 0) {
645
+ throw new BackupError("backup/bad-test-restore-to",
646
+ "scheduleTest: opts.restoreTo is required (operator-controlled staging dir)");
647
+ }
648
+ if (typeof testOpts.verify !== "function") {
649
+ throw new BackupError("backup/bad-test-verify",
650
+ "scheduleTest: opts.verify must be an async function — operator " +
651
+ "supplies the per-deployment verification (file exists, schema " +
652
+ "matches, audit chain verifies, etc.)");
653
+ }
654
+ var name = testOpts.name || "blamejs.backup.test";
655
+ var schedInstance = scheduler.create({ audit: auditOn });
656
+ schedInstance.schedule({
657
+ name: name,
658
+ cron: testOpts.cron,
659
+ timezone: testOpts.timezone,
660
+ run: async function () {
661
+ var startedAt = Date.now();
662
+ var bundles = [];
663
+ try { bundles = await storage.listBundles(); }
664
+ catch (e) {
665
+ _emitAudit("backup.test.failed",
666
+ { reason: "listBundles failed: " + ((e && e.message) || String(e)) },
667
+ "failure");
668
+ return;
669
+ }
670
+ if (!bundles || bundles.length === 0) {
671
+ _emitAudit("backup.test.failed",
672
+ { reason: "no bundles in storage to test against" },
673
+ "failure");
674
+ return;
675
+ }
676
+ // Newest bundle (storage.listBundles returns newest first).
677
+ var bundleId = bundles[0].bundleId;
678
+ var stagingDir = path.join(testOpts.restoreTo,
679
+ "test-" + bundleId.replace(/[:.]/g, "-"));
680
+ // Refuse to overwrite an existing dir — operators get a fresh
681
+ // restore every drill.
682
+ if (fs.existsSync(stagingDir)) {
683
+ _emitAudit("backup.test.failed",
684
+ { bundleId: bundleId, reason: "stagingDir already exists: " + stagingDir },
685
+ "failure");
686
+ return;
687
+ }
688
+ var manifestPath, manifest, sigVerification;
689
+ try {
690
+ await storage.readBundle(bundleId, stagingDir);
691
+ manifestPath = path.join(stagingDir, "manifest.json");
692
+ if (!fs.existsSync(manifestPath)) {
693
+ throw new BackupError("backup/test-no-manifest",
694
+ "manifest.json missing under restored bundle " + bundleId);
695
+ }
696
+ manifest = backupManifest.parse(fs.readFileSync(manifestPath, "utf8"));
697
+ // Verify the manifest signature so a tampered backup test
698
+ // surfaces here, not as a regulator finding later.
699
+ sigVerification = backupManifest.verifySignature(manifest, {
700
+ expectedFingerprint: testOpts.expectedFingerprint || undefined,
701
+ });
702
+ if (!sigVerification.ok) {
703
+ throw new BackupError("backup/test-bad-signature",
704
+ "manifest signature invalid: " + sigVerification.reason);
705
+ }
706
+ // Hand off to operator verify hook
707
+ await testOpts.verify({
708
+ outDir: stagingDir,
709
+ manifest: manifest,
710
+ bundleId: bundleId,
711
+ sigFingerprint: sigVerification.fingerprint,
712
+ });
713
+ _emitAudit("backup.test.passed", {
714
+ bundleId: bundleId,
715
+ posture: testOpts.posture || posture || null,
716
+ fingerprint: sigVerification.fingerprint,
717
+ durationMs: Date.now() - startedAt,
718
+ }, "success");
719
+ if (typeof testOpts.notify === "function") {
720
+ try { await testOpts.notify({ outcome: "success", bundleId: bundleId, manifest: manifest }); }
721
+ catch (_e) { /* notify hook is best-effort */ }
722
+ }
723
+ } catch (e) {
724
+ _emitAudit("backup.test.failed", {
725
+ bundleId: bundleId,
726
+ posture: testOpts.posture || posture || null,
727
+ reason: (e && e.message) || String(e),
728
+ durationMs: Date.now() - startedAt,
729
+ }, "failure");
730
+ if (typeof testOpts.notify === "function") {
731
+ try {
732
+ await testOpts.notify({
733
+ outcome: "failure",
734
+ bundleId: bundleId,
735
+ reason: (e && e.message) || String(e),
736
+ });
737
+ } catch (_e) { /* notify hook is best-effort */ }
738
+ }
739
+ } finally {
740
+ // Best-effort cleanup so the staging dir doesn't accumulate
741
+ // across drills.
742
+ if (testOpts.cleanup !== false) {
743
+ try { fs.rmSync(stagingDir, { recursive: true, force: true }); }
744
+ catch (_e) { /* tmpdir cleanup best-effort */ }
745
+ }
746
+ }
747
+ },
748
+ });
749
+ return { name: name, instance: schedInstance };
750
+ }
751
+
443
752
  return {
444
753
  run: run,
445
754
  list: list,
@@ -447,26 +756,116 @@ function create(opts) {
447
756
  read: read,
448
757
  purgeOlder: purgeOlder,
449
758
  schedule: schedule,
759
+ scheduleTest: scheduleTest,
450
760
  storage: storage,
451
761
  };
452
762
  }
453
763
 
454
- // recommendedFiles — return the framework-default include list for
455
- // a given DB at-rest mode + vault wrap mode. Operators with custom
456
- // data files extend the result; operators with the standard layout
457
- // can use it as-is.
458
- //
459
- // var files = b.backup.recommendedFiles({
460
- // atRest: b.db.getMode(), // 'plain' | 'encrypted'
461
- // vaultMode: b.vault.getMode(), // 'plaintext' | 'wrapped'
462
- // additionalSealed: ["ca.key.sealed", "tls/privkey.pem.sealed"],
463
- // });
464
- //
465
- // The list adapts to mode:
466
- // plain DB → blamejs.db (the live SQLite file)
467
- // encrypted DB → db.enc + db.key.enc (the at-rest envelope + sealed key)
468
- // plaintext vault→ vault.key
469
- // wrapped vault → vault.key.sealed
764
+ /**
765
+ * @primitive b.backup.verifyManifestSignature
766
+ * @signature b.backup.verifyManifestSignature(target, opts)
767
+ * @since 0.7.30
768
+ * @status stable
769
+ * @compliance hipaa, pci-dss, soc2
770
+ * @related b.backup.create, b.backupManifest.verifySignature
771
+ *
772
+ * Read the manifest from a restored bundle directory (or accept a
773
+ * pre-parsed manifest object) and verify its SLH-DSA audit-sign
774
+ * signature. Operator-facing wrapper around
775
+ * `b.backupManifest.verifySignature` that handles the on-disk fetch
776
+ * + JCS parse, so a regulator-facing restore drill is a single call.
777
+ *
778
+ * Returns `{ ok, fingerprint?, reason? }`. Throws `BackupError` only
779
+ * for missing / unreadable / unparseable manifests — a bad signature
780
+ * returns `{ ok: false, reason }` so the caller can branch on the
781
+ * verdict without a try/catch.
782
+ *
783
+ * Pass `opts.expectedFingerprint` to pin the signing key; the
784
+ * verification rejects any signature that validates against a
785
+ * different key, even if the math checks out. That's the kid-pinning
786
+ * the restore drill leans on.
787
+ *
788
+ * @opts
789
+ * expectedFingerprint: string, // optional; SHA3-512 fingerprint to pin
790
+ *
791
+ * @example
792
+ * var fs = require("node:fs");
793
+ * var path = require("node:path");
794
+ * var os = require("node:os");
795
+ *
796
+ * var bundleDir = fs.mkdtempSync(path.join(os.tmpdir(), "verify-bundle-"));
797
+ * try {
798
+ * b.backup.verifyManifestSignature(bundleDir);
799
+ * } catch (e) {
800
+ * e.code; // → "backup/no-manifest"
801
+ * }
802
+ */
803
+ function verifyManifestSignature(target, opts) {
804
+ opts = opts || {};
805
+ var manifest;
806
+ if (typeof target === "string") {
807
+ var manifestPath = path.join(target, "manifest.json");
808
+ if (!fs.existsSync(manifestPath)) {
809
+ throw new BackupError("backup/no-manifest",
810
+ "verifyManifestSignature: manifest.json missing at " + manifestPath);
811
+ }
812
+ try { manifest = backupManifest.parse(fs.readFileSync(manifestPath, "utf8")); }
813
+ catch (e) {
814
+ throw new BackupError("backup/bad-manifest",
815
+ "verifyManifestSignature: parse failed: " + ((e && e.message) || String(e)));
816
+ }
817
+ } else if (target && typeof target === "object" && target.manifest) {
818
+ manifest = target.manifest;
819
+ } else if (target && typeof target === "object" &&
820
+ typeof target.version === "number") {
821
+ manifest = target;
822
+ } else {
823
+ throw new BackupError("backup/bad-target",
824
+ "verifyManifestSignature: target must be a bundle dir path, " +
825
+ "{ manifest } object, or a parsed manifest object");
826
+ }
827
+ return backupManifest.verifySignature(manifest, opts);
828
+ }
829
+
830
+ /**
831
+ * @primitive b.backup.recommendedFiles
832
+ * @signature b.backup.recommendedFiles(opts)
833
+ * @since 0.4.0
834
+ * @status stable
835
+ * @related b.backup.create, b.db.getMode, b.vault.getMode
836
+ *
837
+ * Return the framework-default include list for a given DB at-rest
838
+ * mode + vault wrap mode. Operators with the standard layout pass the
839
+ * result straight to `b.backup.create({ files })`; operators with
840
+ * custom data files (additional sealed keys, OIDC provider material,
841
+ * application-specific keystores) append their own entries.
842
+ *
843
+ * The list adapts to mode:
844
+ * - plain DB → the live SQLite file (default name `blamejs.db`)
845
+ * - encrypted DB → `db.enc` + `db.key.enc` (envelope + sealed DEK)
846
+ * - plaintext vault → `vault.key`
847
+ * - wrapped vault → `vault.key.sealed`
848
+ *
849
+ * The audit-signing key is always included (sealed in `wrapped` mode)
850
+ * so a restored deployment can verify its own audit chain.
851
+ *
852
+ * @opts
853
+ * atRest: "plain" | "encrypted", // default "encrypted"
854
+ * vaultMode: "plaintext" | "wrapped", // default "wrapped"
855
+ * dbName: string, // default "blamejs.db"
856
+ * additionalSealed: Array<string>, // operator-supplied sealed-file paths
857
+ *
858
+ * @example
859
+ * var files = b.backup.recommendedFiles({
860
+ * atRest: "encrypted",
861
+ * vaultMode: "wrapped",
862
+ * additionalSealed: ["ca.key.sealed", "tls/privkey.pem.sealed"],
863
+ * });
864
+ *
865
+ * files[0].relativePath; // → "db.enc"
866
+ * files[1].relativePath; // → "db.key.enc"
867
+ * files[2].relativePath; // → "vault.key.sealed"
868
+ */
470
869
  function recommendedFiles(opts) {
471
870
  opts = opts || {};
472
871
  var atRest = opts.atRest || "encrypted";
@@ -507,20 +906,42 @@ function recommendedFiles(opts) {
507
906
  return files;
508
907
  }
509
908
 
510
- // runInWorker — execute the backup/restore against a worker_thread so
511
- // the heavy-CPU encryption + checksum walk doesn't block the request
512
- // loop. Returns a Promise that resolves with the worker's result, or
513
- // rejects with the worker's error. The worker module is supplied by
514
- // the operator (responsibility for thread-safe storage adapters
515
- // stays with the operator); this helper is the dispatch glue. Falls
516
- // back to in-process execution when worker_threads is unavailable
517
- // (older Node, sandboxed runtime).
518
- //
519
- // var result = await b.backup.runInWorker({
520
- // workerScript: path.join(__dirname, "backup-worker.js"),
521
- // args: { mode: "full", out: "/data/backups", passphrase: ... },
522
- // timeoutMs: C.TIME.minutes(30),
523
- // });
909
+ /**
910
+ * @primitive b.backup.runInWorker
911
+ * @signature b.backup.runInWorker(opts)
912
+ * @since 0.8.41
913
+ * @status stable
914
+ * @related b.backup.create
915
+ *
916
+ * Execute a backup or restore inside a `node:worker_threads` worker
917
+ * so the heavy-CPU Argon2id + XChaCha20-Poly1305 + SHA3-512 walk
918
+ * doesn't block the request loop. Returns a Promise resolving with
919
+ * the worker's posted message, or rejecting with the worker's error,
920
+ * a non-zero exit, or the operator's `timeoutMs`.
921
+ *
922
+ * The worker script is supplied by the operator — responsibility for
923
+ * thread-safe storage adapters stays with the operator; this helper
924
+ * is the dispatch + lifecycle glue. The framework rejects with
925
+ * `backup/no-worker-threads` when `node:worker_threads` is
926
+ * unavailable (sandboxed runtimes, stripped Node builds).
927
+ *
928
+ * @opts
929
+ * workerScript: string, // required; absolute path to the worker module
930
+ * args: object, // optional; passed as workerData to the worker
931
+ * timeoutMs: number, // optional; positive finite int, terminates worker on miss
932
+ *
933
+ * @example
934
+ * var path = require("node:path");
935
+ *
936
+ * b.backup.runInWorker({
937
+ * workerScript: path.resolve("/does/not/exist/worker.js"),
938
+ * args: { mode: "full" },
939
+ * timeoutMs: 60000,
940
+ * }).catch(function (err) {
941
+ * // worker failed to load — error surfaces as a rejected promise
942
+ * typeof err.message; // → "string"
943
+ * });
944
+ */
524
945
  function runInWorker(opts) {
525
946
  opts = opts || {};
526
947
  try {
@@ -569,10 +990,12 @@ function runInWorker(opts) {
569
990
  }
570
991
 
571
992
  module.exports = {
572
- create: create,
573
- localStorage: localStorage,
574
- recommendedFiles: recommendedFiles,
575
- runInWorker: runInWorker,
576
- BackupError: BackupError,
577
- BUNDLE_ID_RE: BUNDLE_ID_RE,
993
+ create: create,
994
+ localStorage: localStorage,
995
+ recommendedFiles: recommendedFiles,
996
+ runInWorker: runInWorker,
997
+ verifyManifestSignature: verifyManifestSignature,
998
+ BACKUP_ENCRYPTION_REQUIRED_POSTURES: BACKUP_ENCRYPTION_REQUIRED_POSTURES,
999
+ BackupError: BackupError,
1000
+ BUNDLE_ID_RE: BUNDLE_ID_RE,
578
1001
  };