@blamejs/core 0.8.42 → 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 +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,65 +1,34 @@
1
1
  "use strict";
2
2
  /**
3
- * dual-control — two-person-rule primitive for destructive operations.
3
+ * @module b.dualControl
4
+ * @nav Identity
5
+ * @title Dual Control
4
6
  *
5
- * b.breakGlass already gates a single-actor step-up (TOTP / passkey
6
- * proof from the SAME actor performing the unseal). dual-control
7
- * raises the bar to "two distinct named actors must approve before
8
- * the operation runs" the standard control for destructive actions
9
- * in compliance-sensitive domains (HIPAA admin actions, PCI key
10
- * rotation, financial close, T+1 settlement, etc.).
7
+ * @intro
8
+ * M-of-N approval workflow for destructive operations (eraseHard,
9
+ * key rotation, etc.). b.breakGlass gates a single-actor step-up
10
+ * (TOTP / passkey proof from the SAME actor performing the
11
+ * unseal); dual-control raises the bar to "two distinct named
12
+ * actors must approve before the operation runs" — the standard
13
+ * control for HIPAA admin actions, PCI key rotation, financial
14
+ * close, T+1 settlement, and similar compliance-sensitive flows.
11
15
  *
12
- * var approvals = b.dualControl.create({
13
- * namespace: "wiki.destructive",
14
- * audit: b.audit,
15
- * ttlMs: C.TIME.minutes(15), // grant expires after this; default 15m
16
- * minApprovers: 2, // dual = 2; quorum can be larger
17
- * forbidSelfApprove: true, // requester cannot also approve; default true
18
- * });
19
- *
20
- * // Step 1: requester opens the request
21
- * var req1 = await approvals.request({
22
- * action: "<your-domain>.<verb>", // e.g. operator picks a stable name
23
- * resource: { kind: "user.bulk", id: "older-than-30d" },
24
- * requestedBy: actor1, // operator-shaped { id, email, ... }
25
- * reason: "GDPR sweep; quarter-close",
26
- * req: req, // for actor-context capture
27
- * });
28
- * // → { grantId, status: "pending", needs: 2, approvedBy: [actor1.id], expiresAt: ... }
29
- *
30
- * // Step 2: a DIFFERENT actor approves
31
- * var req2 = await approvals.approve({
32
- * grantId: req1.grantId,
33
- * approver: actor2,
34
- * reason: "verified ticket #4421",
35
- * req: req,
36
- * });
37
- * // → { grantId, status: "approved", approvedBy: [actor1.id, actor2.id] }
38
- *
39
- * // Step 3: code that performs the destructive op consumes the grant
40
- * var grant = await approvals.consume(req1.grantId, { req });
41
- * if (!grant.ready) throw new Error("not approved or already consumed");
42
- * // ... perform users.purge ...
43
- *
44
- * Audit posture:
45
- * - Every state transition emits to b.audit:
46
- * dual.grant.requested (status pending)
47
- * dual.grant.approved (each approval; metadata.approverCount)
48
- * dual.grant.denied (operator-callable revoke())
49
- * dual.grant.consumed (the destructive op ran)
50
- * dual.grant.expired (TTL hit before approve+consume)
51
- * - Each event carries the grant ID + the actor 5 W's so a compliance
52
- * reviewer can reconstruct the chain.
16
+ * Every state transition emits an audit row:
17
+ * `dual.grant.requested` / `dual.grant.approved` /
18
+ * `dual.grant.denied` / `dual.grant.consumed` /
19
+ * `dual.grant.expired` / `dual.grant.cancelled`. Each event
20
+ * carries the grant ID and the actor 5 W's so a compliance
21
+ * reviewer can reconstruct the chain.
53
22
  *
54
- * Storage: the grants live in a b.cache instance the operator passes
55
- * in (memory backend → per-process; cluster backend → shared across
56
- * nodes). The cache TTL bounds grant freshness automatically.
23
+ * Grants live in a b.cache instance the operator wires in (memory
24
+ * backend → per-process; cluster backend → shared across nodes).
25
+ * The cache TTL bounds grant freshness automatically. Optional
26
+ * cooling-off lock (`consumeLockMs`) defends against rapid-burst
27
+ * compromise of requester+approver. Optional `approverRoles`
28
+ * restricts approval to actors carrying a named role.
57
29
  *
58
- * Validation:
59
- * - create() opts: throw at boot on bad shape
60
- * - request() / approve() / consume() / revoke(): throw on missing
61
- * required args, return { error } on policy denials (already
62
- * consumed, expired, self-approval, etc.)
30
+ * @card
31
+ * M-of-N approval workflow for destructive operations (eraseHard, key rotation, etc.).
63
32
  */
64
33
  var lazyRequire = require("./lazy-require");
65
34
  var crypto = require("./crypto");
@@ -107,6 +76,72 @@ function _actorIdOf(actor) {
107
76
  return null;
108
77
  }
109
78
 
79
+ /**
80
+ * @primitive b.dualControl.create
81
+ * @signature b.dualControl.create(opts)
82
+ * @since 0.8.41
83
+ * @status stable
84
+ * @compliance hipaa, pci-dss, sox-404, dora, soc2
85
+ * @related b.breakGlass.policy.set, b.audit, b.cache.create
86
+ *
87
+ * Build a dual-control approval workflow bound to a `namespace`.
88
+ * Returns a handle exposing async `request(args)` (open a pending
89
+ * grant), `approve(args)` (a different actor approves; quorum
90
+ * detection is automatic), `cancel(args)` (the requester withdraws),
91
+ * `revoke(args)` (an admin denies), `consume(grantId, args)` (the
92
+ * destructive code path single-uses the grant), and `status(grantId)`
93
+ * for inspection. Every transition audits with the actor 5 W's.
94
+ * Grant lifecycle is bounded by `ttlMs`; cluster-shared grants need
95
+ * a cluster-backed cache.
96
+ *
97
+ * @opts
98
+ * namespace: string, // grant key namespace (required, non-empty)
99
+ * cache: b.cache, // b.cache instance — memory or cluster (required)
100
+ * audit: b.audit, // optional audit sink (defaults to global b.audit)
101
+ * ttlMs: number, // grant lifetime (default 15 minutes)
102
+ * minApprovers: number, // approvals required to reach quorum (default 2, ≥2)
103
+ * forbidSelfApprove: boolean, // requester cannot approve own grant (default true)
104
+ * consumeLockMs: number, // cooling-off ms after final approval (default 0)
105
+ * minReasonLength: number, // minimum chars on reason fields (default 0 → off)
106
+ * approverRoles: Array<string>, // restrict approve() to actors with one of these roles (default null)
107
+ * notify: function, // (event) → void; fired on every transition
108
+ *
109
+ * @example
110
+ * var approvals = b.dualControl.create({
111
+ * namespace: "users.eraseHard",
112
+ * cache: b.cache.create({ namespace: "dc", backend: "memory" }),
113
+ * audit: b.audit,
114
+ * ttlMs: b.constants.TIME.minutes(15),
115
+ * minApprovers: 2,
116
+ * consumeLockMs: b.constants.TIME.minutes(2),
117
+ * minReasonLength: 12,
118
+ * approverRoles: ["security:officer", "compliance:officer"],
119
+ * });
120
+ *
121
+ * // Step 1: requester opens the grant.
122
+ * var opened = await approvals.request({
123
+ * action: "users.eraseHard",
124
+ * resource: { kind: "user", id: "user-42" },
125
+ * requestedBy: { id: "alice", roles: ["engineer"] },
126
+ * reason: "GDPR Art. 17 erasure request, ticket SUP-4421",
127
+ * req: req,
128
+ * });
129
+ * // → { grantId: "dc-<hex>", status: "pending", needs: 2, expiresAt: ... }
130
+ *
131
+ * // Step 2: a different actor approves.
132
+ * var approved = await approvals.approve({
133
+ * grantId: opened.grantId,
134
+ * approver: { id: "bob", roles: ["security:officer"] },
135
+ * reason: "verified ticket SUP-4421 against subject identity",
136
+ * req: req,
137
+ * });
138
+ * // → { grantId, status: "approved", approvedBy: ["alice"... wait, no: ["bob"], ... }
139
+ *
140
+ * // Step 3: the destructive code path consumes the grant once.
141
+ * var grant = await approvals.consume(opened.grantId, { req: req });
142
+ * if (!grant.ready) throw new Error("dual-control: " + grant.reason);
143
+ * await b.db.eraseHard("users", { id: "user-42" });
144
+ */
110
145
  function create(opts) {
111
146
  opts = opts || {};
112
147
  validateOpts(opts, [
package/lib/events.js CHANGED
@@ -1,45 +1,36 @@
1
1
  "use strict";
2
2
  /**
3
- * events — framework-wide cross-cutting signal bus.
3
+ * @module b.events
4
+ * @nav Other
5
+ * @title Events
4
6
  *
5
- * A thin wrapper around Node's EventEmitter, exposed as `b.events`,
6
- * dedicated to framework-emitted breach-detection and integrity signals.
7
- * Operators wire listeners to PagerDuty / Opsgenie / a Slack webhook /
8
- * a notification queue and the framework fires them on the specific
9
- * high-signal conditions documented in `EVENTS` below.
7
+ * @intro
8
+ * In-process event emitter namespaced channels, drop-silent on
9
+ * unknown listeners, audit on registration. A thin wrapper around
10
+ * Node's `EventEmitter` dedicated to framework-emitted breach-
11
+ * detection and integrity signals. Operators wire listeners to
12
+ * PagerDuty / Opsgenie / a Slack webhook / a notification queue and
13
+ * the framework fires them on the specific high-signal conditions
14
+ * exported on `EVENTS` (audit chain break, audit checkpoint break,
15
+ * audit rollback detected, NTP drift, api-encrypt failure).
10
16
  *
11
- * Why a separate emitter (not Node's built-in EventEmitter directly):
12
- * - One bus the framework controls operators don't have to chase
13
- * emit calls across modules to know what's available.
14
- * - Best-effort emit semantics: a listener throwing must NOT break
15
- * the framework's refuse-to-boot fail-fast. emit() catches and
16
- * swallows listener errors.
17
- * - Stable event names exported as constants operators reference
18
- * b.events.EVENTS.AUDIT_CHAIN_BREAK rather than typing the string.
19
- * - _resetForTest() clears listeners between tests without touching
20
- * consumer state.
17
+ * Best-effort emit semantics: a listener throwing must NOT break
18
+ * the framework's refuse-to-boot fail-fast. `emit()` catches and
19
+ * swallows listener errors per-listener so the rest still fire.
20
+ * Stable event names live on `b.events.EVENTS` so operators
21
+ * reference `b.events.EVENTS.AUDIT_CHAIN_BREAK` rather than typing
22
+ * the raw `"audit:chain-break"` string. The default
23
+ * 10-listener cap is removed operators routinely wire several
24
+ * notify / structured-log / file-flag listeners on the same event.
21
25
  *
22
- * Public API:
23
- * b.events.on(name, fn) register listener
24
- * b.events.off(name, fn) unregister listener
25
- * b.events.once(name, fn) register single-fire listener
26
- * b.events.emit(name, payload) fire (best-effort; listener
27
- * errors logged + swallowed)
28
- * b.events.listenerCount(name) diagnostic
29
- * b.events.EVENTS stable name constants
26
+ * Listener handlers should keep work synchronous and short. Several
27
+ * framework signals lead to `process.exit`, so blocking network
28
+ * calls inside a listener would delay the fail-fast path; hand off
29
+ * to a queue or write a sync flag-file from the listener and let an
30
+ * external watcher do the network call.
30
31
  *
31
- * Example operator wiring (sync — synchronous I/O only inside the handler
32
- * since process.exit may follow on certain framework signals):
33
- *
34
- * b.events.on(b.events.EVENTS.AUDIT_CHAIN_BREAK, function (info) {
35
- * fs.writeFileSync("/var/run/blamejs-chain-break.flag",
36
- * JSON.stringify({ at: Date.now(), info: info }));
37
- * });
38
- *
39
- * For async fan-out (PagerDuty webhook, etc.) the listener should hand
40
- * off to a queue — synchronous I/O in the listener is safe but blocking
41
- * network calls would delay the framework's fail-fast path on signals
42
- * that lead to process.exit.
32
+ * @card
33
+ * In-process event emitter namespaced channels, drop-silent on unknown listeners, audit on registration.
43
34
  */
44
35
 
45
36
  var nodeEvents = require("node:events");
@@ -78,26 +69,99 @@ var _emitter = new nodeEvents.EventEmitter();
78
69
  // sidecar, file flag writer, etc.) and the warning isn't useful here.
79
70
  _emitter.setMaxListeners(0);
80
71
 
72
+ /**
73
+ * @primitive b.events.on
74
+ * @signature b.events.on(name, fn)
75
+ * @since 0.4.0
76
+ * @related b.events.off, b.events.once, b.events.emit
77
+ *
78
+ * Register a listener for one of the stable framework event names on
79
+ * `b.events.EVENTS`. The listener fires every time the framework
80
+ * emits the named event. Listeners may throw — `emit()` swallows the
81
+ * throw and logs it so the emit path stays best-effort, but operators
82
+ * should keep handlers synchronous and short (several framework
83
+ * signals lead to `process.exit`).
84
+ *
85
+ * @example
86
+ * b.events.on(b.events.EVENTS.AUDIT_CHAIN_BREAK, function (info) {
87
+ * // Sync I/O only — exit may follow.
88
+ * require("node:fs").writeFileSync(
89
+ * "/var/run/blamejs-chain-break.flag",
90
+ * JSON.stringify({ at: Date.now(), info: info })
91
+ * );
92
+ * });
93
+ */
81
94
  function on(name, fn) {
82
95
  _emitter.on(name, fn);
83
96
  return _emitter;
84
97
  }
85
98
 
99
+ /**
100
+ * @primitive b.events.off
101
+ * @signature b.events.off(name, fn)
102
+ * @since 0.4.0
103
+ * @related b.events.on, b.events.once
104
+ *
105
+ * Remove a previously-registered listener. The function reference
106
+ * must be the same object passed to `on()` / `once()` — otherwise
107
+ * Node's emitter silently keeps the listener registered.
108
+ *
109
+ * @example
110
+ * function onBreak(info) { console.error("audit chain break", info); }
111
+ * b.events.on(b.events.EVENTS.AUDIT_CHAIN_BREAK, onBreak);
112
+ * // ... later, during teardown:
113
+ * b.events.off(b.events.EVENTS.AUDIT_CHAIN_BREAK, onBreak);
114
+ * b.events.listenerCount(b.events.EVENTS.AUDIT_CHAIN_BREAK); // → 0
115
+ */
86
116
  function off(name, fn) {
87
117
  _emitter.off(name, fn);
88
118
  return _emitter;
89
119
  }
90
120
 
121
+ /**
122
+ * @primitive b.events.once
123
+ * @signature b.events.once(name, fn)
124
+ * @since 0.4.0
125
+ * @related b.events.on, b.events.emit
126
+ *
127
+ * Register a single-fire listener — fires once and auto-removes. The
128
+ * auto-removal is preserved through `b.events.emit` even though emit
129
+ * iterates raw listeners directly to keep the best-effort contract.
130
+ *
131
+ * @example
132
+ * b.events.once(b.events.EVENTS.NTP_DRIFT, function (payload) {
133
+ * console.warn("NTP drift detected (first occurrence): " + payload.driftMs + "ms");
134
+ * });
135
+ */
91
136
  function once(name, fn) {
92
137
  _emitter.once(name, fn);
93
138
  return _emitter;
94
139
  }
95
140
 
96
- // Best-effort emit. A listener throwing must not propagate — framework
97
- // callers (e.g. db.init's chain-verify FATAL path) emit immediately
98
- // before process.exit and can't tolerate a listener crashing the exit
99
- // path. We log+swallow per-listener errors so the rest of the listeners
100
- // still fire.
141
+ /**
142
+ * @primitive b.events.emit
143
+ * @signature b.events.emit(name, payload)
144
+ * @since 0.4.0
145
+ * @related b.events.on, b.events.once
146
+ *
147
+ * Best-effort fire — invokes every registered listener for `name`,
148
+ * passing `payload`. Listener throws are logged and swallowed
149
+ * per-listener so the rest of the chain still fires; framework
150
+ * callers (e.g. `db.init`'s chain-verify FATAL path) emit immediately
151
+ * before `process.exit` and can't tolerate a listener crashing the
152
+ * exit path. Returns `true` when at least one listener was registered.
153
+ *
154
+ * Operator code rarely emits onto `b.events` directly — the bus is
155
+ * for framework-emitted signals. Calling `emit()` from operator code
156
+ * is supported for tests that want to exercise listener wiring.
157
+ *
158
+ * @example
159
+ * var fired = false;
160
+ * b.events.on(b.events.EVENTS.AUDIT_CHAIN_BREAK, function () { fired = true; });
161
+ * var hadListener = b.events.emit(b.events.EVENTS.AUDIT_CHAIN_BREAK, { at: 1 });
162
+ * hadListener; // → true
163
+ * fired; // → true
164
+ */
101
165
  function emit(name, payload) {
102
166
  // rawListeners returns the wrapper functions including the auto-
103
167
  // removing wrapper that once() registers. Calling the wrapper triggers
@@ -116,6 +180,21 @@ function emit(name, payload) {
116
180
  return listeners.length > 0;
117
181
  }
118
182
 
183
+ /**
184
+ * @primitive b.events.listenerCount
185
+ * @signature b.events.listenerCount(name)
186
+ * @since 0.4.0
187
+ * @related b.events.on, b.events.off
188
+ *
189
+ * Diagnostic — returns the number of listeners registered for `name`.
190
+ * Useful in tests and during teardown to confirm `off()` removed the
191
+ * intended listener.
192
+ *
193
+ * @example
194
+ * b.events.on(b.events.EVENTS.NTP_DRIFT, function () {});
195
+ * b.events.on(b.events.EVENTS.NTP_DRIFT, function () {});
196
+ * b.events.listenerCount(b.events.EVENTS.NTP_DRIFT); // → 2
197
+ */
119
198
  function listenerCount(name) {
120
199
  return _emitter.listenerCount(name);
121
200
  }
@@ -50,12 +50,16 @@
50
50
  */
51
51
  var path = require("path");
52
52
  var atomicFile = require("./atomic-file");
53
+ var canonicalJson = require("./canonical-json");
54
+ var { sha3Hash } = require("./crypto");
53
55
  var lazyRequire = require("./lazy-require");
54
56
  var migrationFiles = require("./migration-files");
55
57
  var numericBounds = require("./numeric-bounds");
56
58
  var validateOpts = require("./validate-opts");
57
59
  var { defineClass } = require("./framework-error");
58
60
 
61
+ var auditSign = lazyRequire(function () { return require("./audit-sign"); });
62
+
59
63
  var ExternalDbMigrateError = defineClass("ExternalDbMigrateError", { alwaysPermanent: true });
60
64
 
61
65
  // Lazy require — external-db imports back into this module via its
@@ -64,10 +68,47 @@ var externalDbModule = lazyRequire(function () { return require("./external-db")
64
68
 
65
69
  var TRACKING_TABLE = "_blamejs_externaldb_migrations";
66
70
  var LOCK_TABLE = "_blamejs_externaldb_migrations_lock";
71
+ var HISTORY_TABLE = "_blamejs_schema_version_history";
67
72
  // Identifiers wrapped in `"..."` per project convention so a reserved-word
68
73
  // or whitespace-bearing name resolves correctly.
69
74
  var Q_TRACKING = '"' + TRACKING_TABLE + '"';
70
75
  var Q_LOCK = '"' + LOCK_TABLE + '"';
76
+ var Q_HISTORY = '"' + HISTORY_TABLE + '"';
77
+
78
+ // Bytes that get signed for one history row. Stable forever — changing
79
+ // it invalidates every prior signature.
80
+ var HISTORY_SIGNATURE_FORMAT = "blamejs-schema-history-v1";
81
+
82
+ function _historyPayload(row) {
83
+ // Canonical JSON keeps the byte stream deterministic across Node
84
+ // versions / property-insertion order. Order-independent verifiers
85
+ // recompute the same bytes.
86
+ var payload =
87
+ HISTORY_SIGNATURE_FORMAT + "\n" +
88
+ canonicalJson.stringify({
89
+ version: row.version,
90
+ ranAt: row.ranAt,
91
+ ranBy: row.ranBy,
92
+ schemaIntrospectionHash: row.schemaIntrospectionHash,
93
+ });
94
+ return Buffer.from(payload, "utf8");
95
+ }
96
+
97
+ // Hash the current schema introspection — operators wiring an opts.
98
+ // schemaIntrospect that returns deterministic bytes get the strict
99
+ // guarantee that a tampered table after-the-fact will not verify. The
100
+ // default introspect just returns the migration name list as a JSON
101
+ // array, which is enough to detect "someone manually altered the
102
+ // migrations table."
103
+ async function _defaultSchemaIntrospect(xdb) {
104
+ var res = await xdb.query(
105
+ "SELECT name, appliedAt FROM " + Q_TRACKING +
106
+ " ORDER BY appliedAt ASC, name ASC",
107
+ []
108
+ );
109
+ var rows = (res && res.rows) || [];
110
+ return sha3Hash(Buffer.from(canonicalJson.stringify(rows), "utf8"));
111
+ }
71
112
 
72
113
  // Filename grammar lives in lib/migration-files (shared with the local
73
114
  // migrations.js + seeders.js runners). Length capped before the regex
@@ -114,6 +155,42 @@ async function _ensureTrackingTable(xdb) {
114
155
  );
115
156
  }
116
157
 
158
+ async function _ensureHistoryTable(xdb) {
159
+ // Schema-version history table: append-only record of every migrate.up
160
+ // wave + signature over (version, ranAt, ranBy, schemaIntrospectionHash).
161
+ // Signature uses ML-DSA-87 / SLH-DSA-SHAKE-256f via b.auditSign — an
162
+ // attacker tampering with rows after-the-fact cannot forge a matching
163
+ // signature without the audit-signing private key.
164
+ await xdb.query(
165
+ "CREATE TABLE IF NOT EXISTS " + Q_HISTORY + " (" +
166
+ " version TEXT NOT NULL," +
167
+ " ranAt TEXT NOT NULL," +
168
+ " ranBy TEXT NOT NULL," +
169
+ " schemaIntrospectionHash TEXT NOT NULL," +
170
+ " signature TEXT," +
171
+ " publicKeyFingerprint TEXT," +
172
+ " PRIMARY KEY (version, ranAt)" +
173
+ ")",
174
+ []
175
+ );
176
+ }
177
+
178
+ async function _writeHistoryRow(xdb, row) {
179
+ await xdb.query(
180
+ "INSERT INTO " + Q_HISTORY +
181
+ " (version, ranAt, ranBy, schemaIntrospectionHash, signature, publicKeyFingerprint) " +
182
+ " VALUES ($1, $2, $3, $4, $5, $6)",
183
+ [
184
+ row.version,
185
+ row.ranAt,
186
+ row.ranBy,
187
+ row.schemaIntrospectionHash,
188
+ row.signature,
189
+ row.publicKeyFingerprint,
190
+ ]
191
+ );
192
+ }
193
+
117
194
  async function _ensureLockTable(xdb) {
118
195
  await xdb.query(
119
196
  "CREATE TABLE IF NOT EXISTS " + Q_LOCK + " (" +
@@ -263,12 +340,23 @@ function _resolveBackendName(opts) {
263
340
 
264
341
  function create(opts) {
265
342
  opts = opts || {};
266
- validateOpts(opts, ["dir", "backend", "audit", "staleAfterMs"], "b.externalDb.migrate");
343
+ validateOpts(opts, [
344
+ "dir", "backend", "audit", "staleAfterMs",
345
+ "schemaIntrospect", "ranBy", "signHistory",
346
+ ], "b.externalDb.migrate");
267
347
  validateOpts.requireNonEmptyString(opts.dir, "externalDb.migrate.create: opts.dir (path to migrations directory)", ExternalDbMigrateError, "externaldb-migrate/no-dir");
268
348
  validateOpts.optionalFiniteNonNegative(opts.staleAfterMs, "externalDb.migrate: staleAfterMs", ExternalDbMigrateError, "externaldb-migrate/bad-stale");
269
349
  validateOpts.auditShape(opts.audit, "externalDb.migrate", ExternalDbMigrateError, "externaldb-migrate/bad-audit");
350
+ validateOpts.optionalFunction(opts.schemaIntrospect,
351
+ "externalDb.migrate: schemaIntrospect", ExternalDbMigrateError,
352
+ "externaldb-migrate/bad-introspect");
270
353
  var dir = opts.dir;
271
354
  var audit = opts.audit || null;
355
+ var schemaIntrospect = typeof opts.schemaIntrospect === "function"
356
+ ? opts.schemaIntrospect : _defaultSchemaIntrospect;
357
+ var ranBy = typeof opts.ranBy === "string" && opts.ranBy.length > 0
358
+ ? opts.ranBy : _lockHolderId();
359
+ var signHistory = opts.signHistory !== false;
272
360
 
273
361
  function _ctx(backendName) {
274
362
  return {
@@ -306,6 +394,7 @@ function create(opts) {
306
394
  return await externalDbModule().transaction(async function (xdb) {
307
395
  await _ensureTrackingTable(xdb);
308
396
  await _ensureLockTable(xdb);
397
+ await _ensureHistoryTable(xdb);
309
398
  }, { backend: backendName }).then(async function () {
310
399
  // Acquire the lock OUTSIDE the per-migration transaction so the
311
400
  // lock survives across migration boundaries. We use a separate
@@ -345,11 +434,43 @@ function create(opts) {
345
434
  try {
346
435
  await externalDbModule().transaction(async function (xdb) {
347
436
  await mod.up(xdb, ctx);
437
+ var ranAt = new Date().toISOString();
348
438
  await xdb.query(
349
439
  "INSERT INTO " + Q_TRACKING +
350
440
  " (name, description, appliedAt) VALUES ($1, $2, $3)",
351
- [file, mod.description || "", new Date().toISOString()]
441
+ [file, mod.description || "", ranAt]
352
442
  );
443
+ // Schema-version history with signature. Sign post-INSERT
444
+ // so the introspection hash reflects the row that just
445
+ // landed. Sign-failure is non-fatal for the migration but
446
+ // emits a failure audit so the operator chases it down.
447
+ var historyRow = {
448
+ version: file,
449
+ ranAt: ranAt,
450
+ ranBy: ranBy,
451
+ schemaIntrospectionHash: await schemaIntrospect(xdb),
452
+ signature: null,
453
+ publicKeyFingerprint: null,
454
+ };
455
+ if (signHistory) {
456
+ try {
457
+ var payload = _historyPayload(historyRow);
458
+ var sigBuf = auditSign().sign(payload);
459
+ historyRow.signature = sigBuf.toString("base64");
460
+ historyRow.publicKeyFingerprint = auditSign().getPublicKeyFingerprint();
461
+ } catch (sigErr) {
462
+ _emit(audit, "migrations.history.sign_failed", "failure",
463
+ { migration: file, backend: backendName },
464
+ (sigErr && sigErr.message) || String(sigErr));
465
+ }
466
+ }
467
+ await _writeHistoryRow(xdb, historyRow);
468
+ _emit(audit, "migrations.history.appended", "success", {
469
+ migration: file,
470
+ schemaIntrospectionHash: historyRow.schemaIntrospectionHash,
471
+ signed: historyRow.signature !== null,
472
+ backend: backendName,
473
+ }, null);
353
474
  }, { backend: backendName });
354
475
  _emit(audit, "externaldb.migrate.up", "success",
355
476
  { migration: file, durationMs: Date.now() - t0, backend: backendName }, null);
@@ -452,10 +573,77 @@ function create(opts) {
452
573
  }
453
574
  }
454
575
 
576
+ // history(opts?) — list every schema-version-history row + verify the
577
+ // signature on each. Returns:
578
+ // [{ version, ranAt, ranBy, schemaIntrospectionHash, signature,
579
+ // publicKeyFingerprint, verified: bool, verifyReason: string|null }]
580
+ //
581
+ // verify is `true` when the signature decodes + auditSign.verify
582
+ // returns true against the row's payload; `false` with reason
583
+ // otherwise (unsigned row / verify-failed / signing key absent /
584
+ // public-key-fingerprint mismatch).
585
+ async function history(historyOpts) {
586
+ historyOpts = historyOpts || {};
587
+ var backendName = _resolveBackendName(opts);
588
+ return await externalDbModule().transaction(async function (xdb) {
589
+ await _ensureHistoryTable(xdb);
590
+ var res = await xdb.query(
591
+ "SELECT version, ranAt, ranBy, schemaIntrospectionHash, signature, publicKeyFingerprint " +
592
+ "FROM " + Q_HISTORY + " ORDER BY ranAt ASC, version ASC",
593
+ []
594
+ );
595
+ var out = [];
596
+ var rows = (res && res.rows) || [];
597
+ for (var i = 0; i < rows.length; i++) {
598
+ var row = rows[i];
599
+ var verified = false;
600
+ var verifyReason = null;
601
+ if (!row.signature) {
602
+ verifyReason = "row-unsigned";
603
+ } else {
604
+ try {
605
+ var payload = _historyPayload(row);
606
+ var sigBuf = Buffer.from(row.signature, "base64");
607
+ var currentFp = auditSign().getPublicKeyFingerprint();
608
+ if (row.publicKeyFingerprint && row.publicKeyFingerprint !== currentFp) {
609
+ verifyReason = "public-key-fingerprint-mismatch";
610
+ } else {
611
+ verified = !!auditSign().verify(payload, sigBuf);
612
+ if (!verified) verifyReason = "signature-verify-failed";
613
+ }
614
+ } catch (e) {
615
+ verifyReason = "verify-threw: " + ((e && e.message) || String(e));
616
+ }
617
+ }
618
+ out.push({
619
+ version: row.version,
620
+ ranAt: row.ranAt,
621
+ ranBy: row.ranBy,
622
+ schemaIntrospectionHash: row.schemaIntrospectionHash,
623
+ signature: row.signature,
624
+ publicKeyFingerprint: row.publicKeyFingerprint,
625
+ verified: verified,
626
+ verifyReason: verifyReason,
627
+ });
628
+ if (!verified && row.signature) {
629
+ _emit(audit, "migrations.history.tamper_detected", "denied", {
630
+ version: row.version, ranAt: row.ranAt, reason: verifyReason,
631
+ backend: backendName,
632
+ }, null);
633
+ }
634
+ }
635
+ _emit(audit, "migrations.history.verified", "success", {
636
+ rowsVerified: out.length, backend: backendName,
637
+ }, null);
638
+ return out;
639
+ }, { backend: backendName });
640
+ }
641
+
455
642
  return {
456
643
  up: up,
457
644
  down: down,
458
645
  status: status,
646
+ history: history,
459
647
  };
460
648
  }
461
649
 
@@ -463,4 +651,6 @@ module.exports = {
463
651
  create: create,
464
652
  ExternalDbMigrateError: ExternalDbMigrateError,
465
653
  TRACKING_TABLE: TRACKING_TABLE,
654
+ HISTORY_TABLE: HISTORY_TABLE,
655
+ HISTORY_SIGNATURE_FORMAT: HISTORY_SIGNATURE_FORMAT,
466
656
  };