@blamejs/core 0.7.1 → 0.7.18

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 (180) hide show
  1. package/CHANGELOG.md +423 -389
  2. package/README.md +150 -149
  3. package/bin/blamejs.js +0 -0
  4. package/index.js +308 -282
  5. package/lib/api-key.js +660 -672
  6. package/lib/api-snapshot.js +338 -338
  7. package/lib/app-shutdown.js +385 -385
  8. package/lib/app.js +365 -365
  9. package/lib/archive.js +250 -250
  10. package/lib/atomic-file.js +544 -544
  11. package/lib/audit-chain.js +177 -177
  12. package/lib/audit-sign.js +344 -344
  13. package/lib/audit-tools.js +677 -677
  14. package/lib/audit.js +766 -766
  15. package/lib/auth/jwt.js +311 -311
  16. package/lib/auth/lockout.js +436 -436
  17. package/lib/auth/oauth.js +721 -721
  18. package/lib/auth/passkey.js +181 -181
  19. package/lib/auth/password.js +594 -594
  20. package/lib/backup/bundle.js +217 -217
  21. package/lib/backup/crypto.js +176 -176
  22. package/lib/backup/index.js +515 -515
  23. package/lib/backup/manifest.js +282 -282
  24. package/lib/break-glass.js +1338 -1338
  25. package/lib/bundler.js +441 -441
  26. package/lib/cache-redis.js +256 -266
  27. package/lib/cache.js +1206 -1211
  28. package/lib/canonical-json.js +115 -115
  29. package/lib/chain-writer.js +234 -234
  30. package/lib/cli-helpers.js +206 -206
  31. package/lib/cli.js +2334 -2334
  32. package/lib/cluster-provider-db.js +317 -317
  33. package/lib/cluster-storage.js +226 -226
  34. package/lib/cluster.js +703 -703
  35. package/lib/codepoint-class.js +196 -0
  36. package/lib/config-drift.js +301 -301
  37. package/lib/consent.js +222 -222
  38. package/lib/constants.js +191 -191
  39. package/lib/cookies.js +315 -315
  40. package/lib/credential-hash.js +322 -322
  41. package/lib/crypto.js +266 -266
  42. package/lib/csv.js +275 -286
  43. package/lib/db-declare-row-policy.js +267 -267
  44. package/lib/db-declare-view.js +420 -421
  45. package/lib/db-query.js +406 -406
  46. package/lib/db-schema.js +319 -319
  47. package/lib/db.js +1288 -1288
  48. package/lib/deprecate.js +222 -222
  49. package/lib/dev.js +335 -335
  50. package/lib/dual-control.js +473 -473
  51. package/lib/error-page.js +420 -420
  52. package/lib/external-db-migrate.js +441 -441
  53. package/lib/external-db.js +1061 -1061
  54. package/lib/file-type.js +273 -273
  55. package/lib/file-upload.js +1136 -0
  56. package/lib/forms.js +422 -422
  57. package/lib/framework-error.js +293 -202
  58. package/lib/framework-schema.js +717 -717
  59. package/lib/gate-contract.js +971 -0
  60. package/lib/guard-all.js +405 -0
  61. package/lib/guard-archive.js +739 -0
  62. package/lib/guard-csv.js +816 -0
  63. package/lib/guard-email.js +744 -0
  64. package/lib/guard-filename.js +724 -0
  65. package/lib/guard-html.js +976 -0
  66. package/lib/guard-json.js +729 -0
  67. package/lib/guard-markdown.js +586 -0
  68. package/lib/guard-svg.js +976 -0
  69. package/lib/guard-xml.js +405 -0
  70. package/lib/guard-yaml.js +529 -0
  71. package/lib/handlers.js +350 -350
  72. package/lib/http-client-cookie-jar.js +508 -508
  73. package/lib/http-client.js +1195 -1195
  74. package/lib/i18n.js +878 -878
  75. package/lib/jobs.js +185 -185
  76. package/lib/log-stream-cloudwatch.js +369 -369
  77. package/lib/log-stream-local.js +146 -146
  78. package/lib/log-stream-otlp-grpc.js +410 -410
  79. package/lib/log-stream-otlp.js +286 -286
  80. package/lib/log-stream-syslog.js +302 -302
  81. package/lib/log-stream-webhook.js +199 -199
  82. package/lib/log-stream.js +330 -330
  83. package/lib/log.js +500 -500
  84. package/lib/mail-bounce.js +528 -528
  85. package/lib/mail-dkim.js +369 -362
  86. package/lib/mail.js +981 -962
  87. package/lib/metrics.js +683 -683
  88. package/lib/middleware/api-encrypt.js +936 -573
  89. package/lib/middleware/attach-user.js +157 -157
  90. package/lib/middleware/body-parser.js +1170 -1091
  91. package/lib/middleware/bot-guard.js +178 -178
  92. package/lib/middleware/compression.js +452 -452
  93. package/lib/middleware/cors.js +314 -314
  94. package/lib/middleware/csp-nonce.js +348 -348
  95. package/lib/middleware/csrf-protect.js +316 -316
  96. package/lib/middleware/db-role-for.js +264 -269
  97. package/lib/middleware/health.js +392 -392
  98. package/lib/middleware/index.js +79 -79
  99. package/lib/middleware/rate-limit.js +358 -358
  100. package/lib/middleware/request-id.js +61 -61
  101. package/lib/middleware/request-log.js +168 -168
  102. package/lib/middleware/require-auth.js +104 -104
  103. package/lib/middleware/security-headers.js +116 -116
  104. package/lib/middleware/sse.js +166 -166
  105. package/lib/migrations.js +383 -383
  106. package/lib/mtls-ca.js +518 -518
  107. package/lib/mtls-engine-default.js +481 -481
  108. package/lib/network-dns.js +632 -632
  109. package/lib/network-heartbeat.js +290 -290
  110. package/lib/network-nts.js +574 -574
  111. package/lib/network-proxy.js +265 -265
  112. package/lib/network-tls.js +328 -328
  113. package/lib/network.js +233 -233
  114. package/lib/notify.js +612 -614
  115. package/lib/ntp-check.js +229 -229
  116. package/lib/numeric-bounds.js +111 -91
  117. package/lib/object-store/azure-blob-bucket-ops.js +349 -349
  118. package/lib/object-store/azure-blob.js +488 -451
  119. package/lib/object-store/gcs-bucket-ops.js +351 -351
  120. package/lib/object-store/gcs.js +519 -479
  121. package/lib/object-store/http-put.js +153 -153
  122. package/lib/object-store/index.js +197 -197
  123. package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
  124. package/lib/object-store/sigv4.js +903 -855
  125. package/lib/observability.js +151 -151
  126. package/lib/otel-export.js +269 -269
  127. package/lib/pagination.js +464 -464
  128. package/lib/parsers/index.js +80 -80
  129. package/lib/parsers/safe-env.js +642 -642
  130. package/lib/parsers/safe-ini.js +292 -292
  131. package/lib/parsers/safe-toml.js +784 -784
  132. package/lib/parsers/safe-xml.js +390 -390
  133. package/lib/parsers/safe-yaml.js +1015 -1015
  134. package/lib/permissions.js +708 -708
  135. package/lib/pqc-agent.js +87 -87
  136. package/lib/pqc-gate.js +279 -279
  137. package/lib/protobuf-encoder.js +190 -190
  138. package/lib/protocol-dispatcher.js +161 -161
  139. package/lib/pubsub-redis.js +167 -177
  140. package/lib/pubsub.js +429 -429
  141. package/lib/queue-local.js +476 -476
  142. package/lib/queue-redis.js +745 -752
  143. package/lib/queue-sqs.js +319 -319
  144. package/lib/queue.js +695 -695
  145. package/lib/redis-client.js +519 -489
  146. package/lib/request-helpers.js +340 -336
  147. package/lib/restore-bundle.js +237 -237
  148. package/lib/restore-rollback.js +259 -259
  149. package/lib/restore.js +409 -409
  150. package/lib/retry.js +376 -376
  151. package/lib/router.js +748 -748
  152. package/lib/safe-async.js +735 -735
  153. package/lib/safe-buffer.js +237 -237
  154. package/lib/safe-json.js +541 -541
  155. package/lib/safe-schema.js +1266 -1266
  156. package/lib/safe-url.js +159 -159
  157. package/lib/scheduler.js +706 -706
  158. package/lib/security-assert.js +373 -373
  159. package/lib/seeders.js +618 -630
  160. package/lib/session.js +478 -478
  161. package/lib/slug.js +269 -269
  162. package/lib/ssrf-guard.js +401 -401
  163. package/lib/static.js +879 -114
  164. package/lib/storage.js +471 -471
  165. package/lib/subject.js +281 -281
  166. package/lib/template.js +791 -791
  167. package/lib/testing.js +798 -798
  168. package/lib/time.js +310 -310
  169. package/lib/totp.js +302 -302
  170. package/lib/tracing.js +494 -494
  171. package/lib/uuid.js +132 -132
  172. package/lib/validate-opts.js +340 -270
  173. package/lib/vault/index.js +308 -308
  174. package/lib/vault/rotate.js +784 -784
  175. package/lib/vault/wrap.js +296 -296
  176. package/lib/vendor/noble-ciphers.cjs +9 -9
  177. package/lib/webhook.js +595 -598
  178. package/lib/websocket.js +1048 -1048
  179. package/package.json +77 -77
  180. package/sbom.cyclonedx.json +7 -7
package/lib/audit.js CHANGED
@@ -1,766 +1,766 @@
1
- "use strict";
2
- /**
3
- * Audit log — tamper-evident, append-only record of every privileged action.
4
- *
5
- * audit_log table is baked into db.js's schema runner — apps cannot opt out.
6
- * Every row is hash-chained (lib/audit-chain.js); the chain is verified at
7
- * boot in db.init(); a chain break refuses-to-boot per the compliance stance.
8
- *
9
- * Action namespaces:
10
- * - Framework owns: 'auth.*', 'system.*', 'audit.*', 'consent.*', 'subject.*'
11
- * - Apps register their own via audit.registerNamespace('orders'), then
12
- * can record 'orders.created', 'orders.shipped', etc.
13
- * - Unregistered namespaces are rejected — prevents typos becoming silent
14
- * unobservable events.
15
- *
16
- * Hash chain:
17
- * - rowHash is computed over the *sealed* form of the row + the nonce.
18
- * The sealed form is what's stored on disk; verification recomputes
19
- * directly from disk without unsealing anything (faster + lets auditors
20
- * verify integrity even without the vault key).
21
- *
22
- * Public API:
23
- * audit.registerNamespace(name)
24
- * audit.record({ actor, action, resource, outcome, reason, metadata, requestId }) → row
25
- * audit.query(criteria) → rows [auto-self-logs an 'audit.read' event before returning]
26
- * audit.verify(opts?) → { ok, rowsVerified, breakAt? }
27
- * audit.beginTrace() → traceId (32 hex chars)
28
- *
29
- * Conventions for `metadata` (apps SHOULD follow these keys for cross-app
30
- * tooling and RoPA correlation; framework's own subject.* events do):
31
- * traceId — cross-request correlation; same value across linked events
32
- * parentEventId — immediate parent event in the causation chain
33
- * before — state before a change (object), for change events
34
- * after — state after the change
35
- * evidenceRef — pointer to evidence (signed PDF hash, ticket URL, etc.)
36
- * App-defined keys are also welcome; don't shadow these reserved ones.
37
- */
38
- var auditChain = require("./audit-chain");
39
- var auditSign = require("./audit-sign");
40
- var chainWriter = require("./chain-writer");
41
- var cluster = require("./cluster");
42
- var clusterStorage = require("./cluster-storage");
43
- var { generateToken } = require("./crypto");
44
- var cryptoField = require("./crypto-field");
45
- var handlers = require("./handlers");
46
- var { boot } = require("./log");
47
- var safeAsync = require("./safe-async");
48
- var C = require("./constants");
49
- var lazyRequire = require("./lazy-require");
50
- var observability = require("./observability");
51
- var { ClusterError } = require("./framework-error");
52
-
53
- var log = boot("audit");
54
-
55
- // Per-operation timeout for framework-state SQL. A misbehaving
56
- // external-db driver hanging on a query shouldn't hang audit forever.
57
- // 30s is generous for genuinely slow networks while still bounding
58
- // the worst case.
59
- var FRAMEWORK_SQL_TIMEOUT_MS = C.TIME.seconds(30);
60
-
61
- // ---- Resilience-wrapped SQL operations (audit-specific reads) ----
62
- // Chain APPEND lives in chain-writer (race-safe via mutex, retry, timeout).
63
- // The wrappers below cover audit-specific reads/writes that aren't part
64
- // of the chain append: checkpoint queries, verifyCheckpoints reads,
65
- // audit-tip cluster-row updates.
66
-
67
- async function _readLastCheckpointCounter() {
68
- return await safeAsync.withTimeout(
69
- safeAsync.asyncRetry(function () {
70
- return clusterStorage.executeOne(
71
- "SELECT atMonotonicCounter FROM audit_checkpoints " +
72
- "ORDER BY atMonotonicCounter DESC LIMIT 1"
73
- );
74
- }),
75
- FRAMEWORK_SQL_TIMEOUT_MS,
76
- { name: "audit.readLastCheckpoint" }
77
- );
78
- }
79
-
80
- async function _readAllAuditRowsAsc() {
81
- return await safeAsync.withTimeout(
82
- safeAsync.asyncRetry(function () {
83
- return clusterStorage.executeAll(
84
- 'SELECT * FROM "audit_log" ORDER BY monotonicCounter ASC'
85
- );
86
- }),
87
- FRAMEWORK_SQL_TIMEOUT_MS,
88
- { name: "audit.readAllRowsAsc" }
89
- );
90
- }
91
-
92
- async function _readAllCheckpointsAsc() {
93
- return await safeAsync.withTimeout(
94
- safeAsync.asyncRetry(function () {
95
- return clusterStorage.executeAll(
96
- "SELECT * FROM audit_checkpoints ORDER BY atMonotonicCounter ASC"
97
- );
98
- }),
99
- FRAMEWORK_SQL_TIMEOUT_MS,
100
- { name: "audit.readAllCheckpoints" }
101
- );
102
- }
103
-
104
- async function _readAuditRowHashAtCounter(counter) {
105
- return await safeAsync.withTimeout(
106
- safeAsync.asyncRetry(function () {
107
- return clusterStorage.executeOne(
108
- "SELECT rowHash FROM audit_log WHERE monotonicCounter = ?",
109
- [counter]
110
- );
111
- }),
112
- FRAMEWORK_SQL_TIMEOUT_MS,
113
- { name: "audit.readRowHashAtCounter" }
114
- );
115
- }
116
-
117
- async function _insertAuditRow(allCols, values) {
118
- // No retry — non-idempotent. Timeout only.
119
- var placeholders = allCols.map(function () { return "?"; }).join(", ");
120
- var quoted = allCols.map(function (c) { return '"' + c + '"'; }).join(", ");
121
- return await safeAsync.withTimeout(
122
- clusterStorage.execute(
123
- "INSERT INTO audit_log (" + quoted + ") VALUES (" + placeholders + ")",
124
- values
125
- ),
126
- FRAMEWORK_SQL_TIMEOUT_MS,
127
- { name: "audit.insertRow" }
128
- );
129
- }
130
-
131
- async function _insertCheckpoint(values) {
132
- return await safeAsync.withTimeout(
133
- clusterStorage.execute(
134
- "INSERT INTO audit_checkpoints (_id, createdAt, atMonotonicCounter, atRowHash, signature, publicKeyFingerprint, fencingToken) " +
135
- "VALUES (?, ?, ?, ?, ?, ?, ?)",
136
- values
137
- ),
138
- FRAMEWORK_SQL_TIMEOUT_MS,
139
- { name: "audit.insertCheckpoint" }
140
- );
141
- }
142
-
143
- async function _upsertAuditTip(counter, rowHash, signedAt, fencingToken) {
144
- // Cluster-mode only. Single atomic INSERT … ON CONFLICT … DO UPDATE
145
- // … WHERE … RETURNING. The WHERE clause is the canonical
146
- // fencing-token guard from blamejs-cluster-spec.md — it enforces
147
- // monotonic-non-decreasing fencingToken at the database level so a
148
- // partitioned old leader cannot overwrite the tip even if its
149
- // application-layer cluster.requireLeader() gate somehow allowed
150
- // the call through.
151
- //
152
- // Update accepted iff the row's stored fencingToken <= incoming one
153
- // (same-token re-write is fine; a strictly-lower token is fenced
154
- // out). On rejection RETURNING produces 0 rows — we surface that
155
- // as ClusterError(code='FENCED_OUT', permanent=true) so the
156
- // dispatching node knows it's been superseded and should step down
157
- // rather than retry.
158
- var result = await safeAsync.withTimeout(
159
- clusterStorage.execute(
160
- "INSERT INTO _blamejs_audit_tip " +
161
- " (scope, atMonotonicCounter, rowHash, signedAt, fencingToken) " +
162
- "VALUES ('audit', ?, ?, ?, ?) " +
163
- "ON CONFLICT (scope) DO UPDATE SET " +
164
- " atMonotonicCounter = EXCLUDED.atMonotonicCounter, " +
165
- " rowHash = EXCLUDED.rowHash, " +
166
- " signedAt = EXCLUDED.signedAt, " +
167
- " fencingToken = EXCLUDED.fencingToken " +
168
- "WHERE _blamejs_audit_tip.fencingToken <= EXCLUDED.fencingToken " +
169
- "RETURNING fencingToken",
170
- [counter, rowHash, signedAt, fencingToken]
171
- ),
172
- FRAMEWORK_SQL_TIMEOUT_MS,
173
- { name: "audit.upsertAuditTip" }
174
- );
175
- if (!result.rows || result.rows.length === 0) {
176
- throw new ClusterError(
177
- "FENCED_OUT",
178
- "audit-tip update rejected: incoming fencingToken=" + fencingToken +
179
- " is below the stored token (this leader has been fenced out " +
180
- "by a higher-token successor)",
181
- true
182
- );
183
- }
184
- }
185
-
186
- // Every namespace any framework primitive emits on must be listed here.
187
- // A primitive that adds a new namespace adds it here in the same patch.
188
- // Smoke walks lib/ at boot-time-equivalent (layer-0-primitives/audit-framework-namespaces.test.js)
189
- // and fails if any emitted namespace is missing from this list.
190
- //
191
- // Why this list and not auto-registration via registerNamespace() at
192
- // each primitive's create(): namespace registration must be effective
193
- // the first time ANY primitive emits, regardless of which primitive
194
- // the operator initialized first. Operators wiring just b.scheduler
195
- // without ever calling b.apiKey.create() still trip the apikey verify
196
- // path (e.g. through middleware they didn't write); the action-name
197
- // validation needs to know about apikey at boot, not at first call.
198
- var FRAMEWORK_NAMESPACES = [
199
- // Generic buckets
200
- "auth", "system", "audit", "consent", "subject",
201
- // Per-primitive namespaces — keep alphabetical
202
- "apikey", // b.apiKey
203
- "backup", // b.backup
204
- "breakglass", // b.breakGlass — column-policy / row-enforcement step-up auth (audit namespace lowercased per the validator's `namespace.verb` rule, same convention as b.apiKey → apikey.*)
205
- "cache", // b.cache
206
- "config", // b.configDrift (config.baseline.captured / config.drift.detected / config.baseline.tamper / config.baseline.unreadable)
207
- "db", // b.db / b.middleware.dbRoleFor / b.externalDb.runAs
208
- // (role-switching, RLS-shaped events)
209
- "dkim", // b.mail.dkim (DKIM-Signature generation events)
210
- "dual", // b.dualControl (dual.grant.requested / approved / denied / consumed / expired / self_approval_denied)
211
- "mail", // b.mail (b.mail-bounce uses "system.mail.*")
212
- "network", // b.middleware.networkAllowlist (network.gate.denied)
213
- "notify", // b.notify
214
- "objectstore", // b.objectStore.bucketOps (objectstore.bucket.* / objectstore.object.*)
215
- "permissions", // b.permissions
216
- "restore", // b.restore
217
- "retention", // b.retention (retention.rule.declared / sweep.started / row.processed / sweep.completed / sweep.failed)
218
- "scheduler", // b.scheduler (lifecycle: scheduler.start / scheduler.stop;
219
- // tick/task events use "system.scheduler.*")
220
- "seeders", // b.seeders
221
- "webhook", // b.webhook
222
- ];
223
- var registeredNamespaces = new Set(FRAMEWORK_NAMESPACES);
224
-
225
- // All hashable columns of audit_log (everything in the table except the chain
226
- // bookkeeping itself). This list MUST match what's actually written by INSERT
227
- // and what's read back by verify; the canonicalizer needs the same key set
228
- // at both ends or the hash will mismatch on missing-vs-null keys.
229
- var HASHABLE_COLS = [
230
- "_id", "recordedAt", "monotonicCounter",
231
- "actorUserId", "actorUserIdHash",
232
- "actorIp",
233
- "actorUserAgent", "actorSessionId",
234
- "action", "resourceKind",
235
- "resourceId", "resourceIdHash",
236
- "outcome", "reason", "metadata", "requestId",
237
- ];
238
-
239
- // Lazy db ref — avoids circular require (db -> audit -> db on init paths).
240
- var db = lazyRequire(function () { return require("./db"); });
241
-
242
- // Chain-writer instance owns the race-safe chain append: counter primer,
243
- // chain mutex, prev-tip read, hash compute, INSERT. Per the framework
244
- // rule that repeated tasks become primitives, the audit_log and
245
- // consent_log chains both consume chain-writer.
246
- var _chainWriter = chainWriter.create({
247
- table: "audit_log",
248
- hashableColumns: HASHABLE_COLS,
249
- columnsForInsert: [
250
- "_id", "recordedAt", "monotonicCounter",
251
- "actorUserId", "actorUserIdHash",
252
- "actorIp",
253
- "actorUserAgent", "actorSessionId",
254
- "action", "resourceKind",
255
- "resourceId", "resourceIdHash",
256
- "outcome", "reason", "metadata", "requestId",
257
- "prevHash", "rowHash", "nonce", "fencingToken",
258
- ],
259
- });
260
-
261
- // ---- Public API ----
262
-
263
- function registerNamespace(name) {
264
- if (typeof name !== "string" || !/^[a-z][a-z0-9_]*$/.test(name)) {
265
- throw new Error("audit namespace must match [a-z][a-z0-9_]* — got: " + name);
266
- }
267
- if (FRAMEWORK_NAMESPACES.indexOf(name) !== -1) return;
268
- registeredNamespaces.add(name);
269
- }
270
-
271
- function _validateAction(action) {
272
- if (typeof action !== "string" || !/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/.test(action)) {
273
- throw new Error(
274
- "audit action must be 'namespace.verb[.qualifier...]' (lowercase, dot-separated) — got: " + action
275
- );
276
- }
277
- var ns = action.split(".")[0];
278
- if (!registeredNamespaces.has(ns)) {
279
- throw new Error(
280
- "audit namespace '" + ns + "' is not registered. " +
281
- "Call audit.registerNamespace('" + ns + "') at app bootstrap before recording '" + action + "'."
282
- );
283
- }
284
- }
285
-
286
- async function record(event) {
287
- if (!event || typeof event !== "object") {
288
- throw new Error("audit.record requires an event object");
289
- }
290
- _validateAction(event.action);
291
- if (!event.outcome || ["success", "failure", "denied"].indexOf(event.outcome) === -1) {
292
- throw new Error("audit.record outcome must be 'success', 'failure', or 'denied'");
293
- }
294
-
295
- return observability.tap("audit.record",
296
- { action: event.action, outcome: event.outcome },
297
- async function () {
298
- // Build the audit-specific logical row; chain-writer handles _id /
299
- // recordedAt / monotonicCounter / sealing / null-fill / hashing /
300
- // insert / fencing-token / chain mutex / counter primer.
301
- var actor = event.actor || {};
302
- var resource = event.resource || {};
303
- var logical = {
304
- actorUserId: actor.userId || null,
305
- actorIp: actor.ip || null,
306
- actorUserAgent: actor.userAgent || null,
307
- actorSessionId: actor.sessionId || null,
308
- action: event.action,
309
- resourceKind: resource.kind || null,
310
- resourceId: resource.id || null,
311
- outcome: event.outcome,
312
- reason: event.reason || null,
313
- metadata: event.metadata ? JSON.stringify(event.metadata) : null,
314
- requestId: event.requestId || null,
315
- };
316
- return _chainWriter.append(logical);
317
- }
318
- );
319
- }
320
-
321
- // ---- Query ----
322
- //
323
- // Plain-field criteria translate into derived-hash equality where the column
324
- // is sealed. Returns unsealed rows for the auditor's view.
325
- //
326
- // Self-logging (PCI DSS 10.2.3): every read of audit_log is itself recorded
327
- // as an 'audit.read' event before the query runs, so an exfiltration attempt
328
- // is forensically visible. The recursion guard (_selfLogging flag) prevents
329
- // the audit.read recording from triggering its own self-log; queries
330
- // SPECIFICALLY filtering for action='audit.read' don't auto-log either
331
- // (otherwise legitimate audit auditing produces a Russell-set spiral).
332
- var _selfLogging = false;
333
-
334
- async function query(criteria) {
335
- criteria = criteria || {};
336
- if (!_selfLogging && criteria.action !== "audit.read") {
337
- _selfLogging = true;
338
- try {
339
- await record({
340
- actor: criteria.actor || {},
341
- action: "audit.read",
342
- outcome: "success",
343
- metadata: {
344
- criteria: _redactCriteria(criteria),
345
- traceId: criteria.traceId || null,
346
- },
347
- });
348
- } finally {
349
- _selfLogging = false;
350
- }
351
- }
352
-
353
- // In single-node mode the query builder gives us field-crypto unsealing
354
- // for free. In cluster mode we read raw rows from external-db and
355
- // unseal manually.
356
- if (cluster.isClusterMode()) {
357
- return await _queryCluster(criteria);
358
- }
359
-
360
- var q = db().from("audit_log");
361
-
362
- if (criteria.from) q = q.where("recordedAt", ">=", _toMs(criteria.from));
363
- if (criteria.to) q = q.where("recordedAt", "<=", _toMs(criteria.to));
364
- if (criteria.actorUserId) q = q.where({ actorUserId: criteria.actorUserId });
365
- if (criteria.resourceId) q = q.where({ resourceId: criteria.resourceId });
366
- if (criteria.action) q = q.where({ action: criteria.action });
367
- if (criteria.resourceKind) q = q.where({ resourceKind: criteria.resourceKind });
368
- if (criteria.outcome) q = q.where({ outcome: criteria.outcome });
369
-
370
- q.orderBy("monotonicCounter", "asc");
371
- if (criteria.limit != null) q.limit(criteria.limit);
372
- if (criteria.offset != null) q.offset(criteria.offset);
373
-
374
- return q.all();
375
- }
376
-
377
- async function _queryCluster(criteria) {
378
- var conds = [];
379
- var params = [];
380
- if (criteria.from) {
381
- conds.push("recordedAt >= ?");
382
- params.push(_toMs(criteria.from));
383
- }
384
- if (criteria.to) {
385
- conds.push("recordedAt <= ?");
386
- params.push(_toMs(criteria.to));
387
- }
388
- if (criteria.actorUserId) {
389
- var auh = cryptoField.lookupHash("audit_log", "actorUserId", criteria.actorUserId);
390
- if (auh) { conds.push(auh.field + " = ?"); params.push(auh.value); }
391
- }
392
- if (criteria.resourceId) {
393
- var rh = cryptoField.lookupHash("audit_log", "resourceId", criteria.resourceId);
394
- if (rh) { conds.push(rh.field + " = ?"); params.push(rh.value); }
395
- }
396
- if (criteria.action) { conds.push("action = ?"); params.push(criteria.action); }
397
- if (criteria.resourceKind) { conds.push("resourceKind = ?"); params.push(criteria.resourceKind); }
398
- if (criteria.outcome) { conds.push("outcome = ?"); params.push(criteria.outcome); }
399
-
400
- var sql = "SELECT * FROM audit_log";
401
- if (conds.length > 0) sql += " WHERE " + conds.join(" AND ");
402
- sql += " ORDER BY monotonicCounter ASC";
403
- if (criteria.limit != null) { sql += " LIMIT ?"; params.push(criteria.limit); }
404
- if (criteria.offset != null) { sql += " OFFSET ?"; params.push(criteria.offset); }
405
-
406
- var rows = await clusterStorage.executeAll(sql, params);
407
- return rows.map(function (row) { return cryptoField.unsealRow("audit_log", row); });
408
- }
409
-
410
- // Audit-readable summary of the criteria without storing raw subject IDs
411
- // in plaintext anywhere outside the sealed columns of audit_log itself.
412
- function _redactCriteria(c) {
413
- return {
414
- from: c.from || null,
415
- to: c.to || null,
416
- action: c.action || null,
417
- resourceKind: c.resourceKind || null,
418
- outcome: c.outcome || null,
419
- hasUserFilter: !!c.actorUserId,
420
- hasResourceFilter: !!c.resourceId,
421
- limit: c.limit != null ? c.limit : null,
422
- offset: c.offset != null ? c.offset : null,
423
- };
424
- }
425
-
426
- // Generate a fresh trace id apps can thread through their request handlers
427
- // and pass into audit.record() / consent.grant() / etc. via the metadata
428
- // field. Width matches the W3C traceparent trace-id format (16 random
429
- // bytes hex-encoded → 32 chars). Routed through C.BYTES so the byte
430
- // count has a single source of truth.
431
- var TRACE_ID_BYTES = C.BYTES.bytes(16);
432
- function beginTrace() {
433
- return generateToken(TRACE_ID_BYTES);
434
- }
435
-
436
- // ---- Checkpoints (tamper-proof external anchor) ----
437
-
438
- // Build the canonical bytes that get signed for a checkpoint at a given
439
- // chain tip. Keep this format stable across the framework's lifetime —
440
- // changing it invalidates every prior checkpoint signature.
441
- var CHECKPOINT_FORMAT = "blamejs-audit-checkpoint-v1";
442
- function _checkpointPayload(atMonotonicCounter, atRowHash, createdAt) {
443
- // Use a fixed multi-line layout. Avoids JSON serializer quirks; portable
444
- // to any verifier reading the same column triple from the DB.
445
- return Buffer.from(
446
- CHECKPOINT_FORMAT + "\n" +
447
- String(atMonotonicCounter) + "\n" +
448
- atRowHash + "\n" +
449
- String(createdAt),
450
- "utf8"
451
- );
452
- }
453
-
454
- // Anchor the current chain tip with a fresh ML-DSA-87 signature. Inserts
455
- // a row into audit_checkpoints. Updates <dataDir>/audit.tip for boot-time
456
- // rollback detection.
457
- //
458
- // opts:
459
- // skipIfUnchanged: bool — return null without inserting if the chain tip
460
- // hasn't advanced since the most recent checkpoint
461
- async function checkpoint(opts) {
462
- cluster.requireLeader();
463
- opts = opts || {};
464
-
465
- var tip = await safeAsync.withTimeout(
466
- safeAsync.asyncRetry(function () {
467
- return clusterStorage.executeOne(
468
- "SELECT _id, monotonicCounter, rowHash FROM audit_log " +
469
- "ORDER BY monotonicCounter DESC LIMIT 1"
470
- );
471
- }),
472
- FRAMEWORK_SQL_TIMEOUT_MS,
473
- { name: "audit.checkpoint.readTip" }
474
- );
475
-
476
- if (!tip) return null; // empty audit log; nothing to anchor
477
-
478
- if (opts.skipIfUnchanged) {
479
- var lastCkpt = await _readLastCheckpointCounter();
480
- if (lastCkpt && Number(lastCkpt.atMonotonicCounter) >= Number(tip.monotonicCounter)) {
481
- return null; // already anchored at this tip
482
- }
483
- }
484
-
485
- var createdAt = Date.now();
486
- var counter = Number(tip.monotonicCounter);
487
- var payload = _checkpointPayload(counter, tip.rowHash, createdAt);
488
- var signature = auditSign.sign(payload);
489
- var pubFp = auditSign.getPublicKeyFingerprint();
490
-
491
- var ckptId = generateToken(TRACE_ID_BYTES);
492
- var fencingToken = cluster.fencingToken();
493
- await _insertCheckpoint(
494
- [ckptId, createdAt, counter, tip.rowHash, signature, pubFp, fencingToken]
495
- );
496
-
497
- // Update rollback-detection sidecar (single-node) or audit-tip row
498
- // (cluster mode).
499
- //
500
- // Single-node sidecar is best-effort — a sidecar write failure must
501
- // not block checkpointing because the chain itself is already
502
- // committed.
503
- //
504
- // Cluster-mode audit-tip is NOT best-effort: the upsert's WHERE
505
- // clause is the fencing-token guard, and a FENCED_OUT response
506
- // means the local node has been superseded by a newer leader. The
507
- // checkpoint row was already inserted at this point but propagating
508
- // the error up makes the leadership-loss visible to the caller — it
509
- // also means the caller can audit the leader-lost transition and
510
- // step down. Other audit-tip errors (network blip, transient DB)
511
- // also surface so the operator can react.
512
- if (cluster.isClusterMode()) {
513
- await _upsertAuditTip(counter, tip.rowHash, String(createdAt), fencingToken);
514
- } else {
515
- try {
516
- db()._writeAuditTip({
517
- atMonotonicCounter: counter,
518
- atRowHash: tip.rowHash,
519
- anchoredAt: createdAt,
520
- checkpointId: ckptId,
521
- publicKeyFingerprint: pubFp,
522
- version: 1,
523
- });
524
- } catch (_e) { /* best effort */ }
525
- }
526
-
527
- return {
528
- _id: ckptId,
529
- createdAt: createdAt,
530
- atMonotonicCounter: counter,
531
- atRowHash: tip.rowHash,
532
- publicKeyFingerprint: pubFp,
533
- };
534
- }
535
-
536
- // Walk every checkpoint, verify its signature against the current public
537
- // key (or one matching the row's stored fingerprint). Also confirms the
538
- // audit_log row at atMonotonicCounter still has the recorded rowHash.
539
- //
540
- // Returns { ok, checkpointsVerified, breakAt? }.
541
- async function verifyCheckpoints() {
542
- var rows = await _readAllCheckpointsAsc();
543
-
544
- if (rows.length === 0) return { ok: true, checkpointsVerified: 0 };
545
-
546
- var currentFp = auditSign.getPublicKeyFingerprint();
547
- var currentPub = auditSign.getPublicKey();
548
-
549
- for (var i = 0; i < rows.length; i++) {
550
- var c = rows[i];
551
- // Public key check: only the current key is accepted — there is no
552
- // key-history table, so any rotation requires re-signing existing
553
- // checkpoints. A fingerprint mismatch fails verification.
554
- if (c.publicKeyFingerprint !== currentFp) {
555
- return {
556
- ok: false,
557
- checkpointsVerified: i,
558
- breakAt: i,
559
- checkpointId: c._id,
560
- reason: "public key fingerprint mismatch (key rotated without history?)",
561
- expected: currentFp,
562
- actual: c.publicKeyFingerprint,
563
- };
564
- }
565
- var payload = _checkpointPayload(Number(c.atMonotonicCounter), c.atRowHash, Number(c.createdAt));
566
- var sigBuf = Buffer.isBuffer(c.signature) ? c.signature : Buffer.from(c.signature);
567
- if (!auditSign.verify(payload, sigBuf, currentPub)) {
568
- return {
569
- ok: false,
570
- checkpointsVerified: i,
571
- breakAt: i,
572
- checkpointId: c._id,
573
- reason: "ML-DSA-87 signature failed",
574
- };
575
- }
576
- // Also confirm the audit row at atMonotonicCounter still matches the
577
- // anchored rowHash. If someone tampered with audit_log AND recomputed
578
- // hashes (requiring vault key), this catches them via the off-chain
579
- // signature anchor.
580
- var anchored = await _readAuditRowHashAtCounter(c.atMonotonicCounter);
581
- if (!anchored) {
582
- return {
583
- ok: false,
584
- checkpointsVerified: i,
585
- breakAt: i,
586
- checkpointId: c._id,
587
- reason: "anchored audit_log row missing (counter=" + c.atMonotonicCounter + ")",
588
- };
589
- }
590
- if (anchored.rowHash !== c.atRowHash) {
591
- return {
592
- ok: false,
593
- checkpointsVerified: i,
594
- breakAt: i,
595
- checkpointId: c._id,
596
- reason: "anchored rowHash mismatch — audit_log was tampered with",
597
- expected: c.atRowHash,
598
- actual: anchored.rowHash,
599
- };
600
- }
601
- }
602
- return { ok: true, checkpointsVerified: rows.length };
603
- }
604
-
605
- function _toMs(value) {
606
- if (typeof value === "number") return value;
607
- if (value instanceof Date) return value.getTime();
608
- if (typeof value === "string") {
609
- var ms = Date.parse(value);
610
- if (isNaN(ms)) throw new Error("invalid date: " + value);
611
- return ms;
612
- }
613
- throw new Error("invalid date value");
614
- }
615
-
616
- // ---- Verify ----
617
-
618
- async function verify(opts) {
619
- // verifyChain just needs an executeAll; route through the same
620
- // resilience-wrapped reader the rest of audit uses.
621
- return await auditChain.verifyChain(
622
- function (sql, params) {
623
- return safeAsync.withTimeout(
624
- safeAsync.asyncRetry(function () {
625
- return clusterStorage.executeAll(sql, params || []);
626
- }),
627
- FRAMEWORK_SQL_TIMEOUT_MS,
628
- { name: "audit.verifyChain" }
629
- );
630
- },
631
- "audit_log",
632
- opts
633
- );
634
- }
635
-
636
- // ---- Test helpers ----
637
-
638
- function _resetForTest() {
639
- registeredNamespaces = new Set(FRAMEWORK_NAMESPACES);
640
- db.reset();
641
- _chainWriter._resetForTest();
642
- // Drop pending buffered emits and cancel the age-flush timer on the
643
- // old handler before dereferencing it. Without this, the old
644
- // handler's setTimeout (scheduled when emits buffer below maxBatch)
645
- // fires AFTER the next test's db.init has opened a fresh database —
646
- // the buffered items then drain through chain-writer into the wrong
647
- // tmpDir's audit_log, breaking chain verify on the next-next test.
648
- // shutdownSync is the explicit "drop, don't drain" path because
649
- // draining to a stale or changing backing store is exactly the bug.
650
- // The handler's flush also checks ctx.isShutdown() between items, so
651
- // an in-flight drain that's mid-batch when reset fires bails out
652
- // instead of writing the rest of the batch to the new database.
653
- if (_auditHandler) {
654
- try { _auditHandler.shutdownSync("audit._resetForTest"); }
655
- catch (e) { log.debug("reset-handler-shutdown-failed: " + (e && e.message || e)); }
656
- _auditHandler = null;
657
- }
658
- }
659
-
660
- // ---- Handler-backed emit + flush ----
661
- //
662
- // emit() is the call-site API for fire-and-forget audit emission from
663
- // middleware / log-stream / external-db hooks / queue / storage / subject.
664
- // It is SYNCHRONOUS, NEVER throws, and NEVER returns a Promise — request-
665
- // path code can call it without await and without try/catch.
666
- //
667
- // Internally events queue in an AsyncHandler. flush() drains the queue
668
- // to the audit chain (single writer in-process, serialized via the
669
- // chain mutex). Tests, shutdown, and any code that needs audit-row
670
- // durability before reading audit_log calls await audit.flush().
671
- //
672
- // Why this beats fire-and-forget Promises:
673
- // - No leaked Promises across test/shutdown boundaries
674
- // - Errors go through a single onError hook (visible to operators)
675
- // - Recursive emits during flush land in the buffer for the next
676
- // drain cycle — no infinite loop in cluster-mode dispatchers
677
- // - Tests have a deterministic "audit is durable now" point
678
- var _auditHandler = null;
679
-
680
- function _ensureHandler() {
681
- if (_auditHandler) return _auditHandler;
682
- _auditHandler = handlers.create({
683
- name: "audit",
684
- flush: async function (batch, ctx) {
685
- // Drain by serially writing each event through record(). The chain
686
- // mutex inside record() further serializes vs concurrent direct
687
- // record() callers.
688
- //
689
- // Between items, check the handler's shutdown probe — if a test
690
- // (or operator) reset audit while this batch was in flight, the
691
- // remaining items would otherwise drain through the chain-writer
692
- // into a database that no longer represents the chain those
693
- // items were emitted against. Early-exit drops them; the
694
- // alternative is silent corruption of the next chain.
695
- for (var i = 0; i < batch.length; i++) {
696
- if (ctx && ctx.isShutdown && ctx.isShutdown()) return;
697
- try { await record(batch[i]); }
698
- catch (e) {
699
- // Per-item failure shouldn't drop the whole batch; log and
700
- // continue. The handler's onError gets called for batch-
701
- // wide failures only.
702
- log.error("flush dropped event: " +
703
- (e && e.message ? e.message : String(e)) +
704
- " (action=" + (batch[i] && batch[i].action) + ")");
705
- }
706
- }
707
- },
708
- });
709
- return _auditHandler;
710
- }
711
-
712
- function emit(event) {
713
- _ensureHandler().emit(event);
714
- }
715
-
716
- // safeEmit — fire-and-forget audit emit with safe defaults + try/catch.
717
- //
718
- // Most modules wrap emit() in their own _emit helper that fills in
719
- // `actor: {}`, `outcome: "success"`, etc. and catches the throw so an
720
- // audit outage doesn't crash the request handler. This is that helper,
721
- // hoisted out so each module can stop redefining it.
722
- //
723
- // Drop-silent on malformed input by design. safeEmit is called from
724
- // request hot paths where throwing on a
725
- // missing `action` would mean a malformed audit attempt crashes the
726
- // request that triggered it — strictly worse than the missing audit
727
- // row. Operators who need a hard guarantee the event landed should call
728
- // record() and await it with their own error handling. The audit chain
729
- // itself is verified at boot, so a silently dropped row shows up in the
730
- // next chain integrity sweep.
731
- function safeEmit(event) {
732
- if (!event || typeof event !== "object") return;
733
- if (typeof event.action !== "string") return; // can't emit without an action
734
- try {
735
- _ensureHandler().emit({
736
- actor: event.actor || {},
737
- action: event.action,
738
- resource: event.resource || null,
739
- outcome: event.outcome || "success",
740
- reason: event.reason || null,
741
- metadata: event.metadata || null,
742
- requestId: event.requestId || null,
743
- });
744
- } catch (_e) { /* audit best-effort — never break the caller */ }
745
- }
746
-
747
- async function flush() {
748
- if (!_auditHandler) return;
749
- await _auditHandler.drain();
750
- }
751
-
752
- module.exports = {
753
- registerNamespace: registerNamespace,
754
- record: record,
755
- emit: emit,
756
- safeEmit: safeEmit,
757
- flush: flush,
758
- query: query,
759
- verify: verify,
760
- beginTrace: beginTrace,
761
- checkpoint: checkpoint,
762
- verifyCheckpoints: verifyCheckpoints,
763
- CHECKPOINT_FORMAT: CHECKPOINT_FORMAT,
764
- FRAMEWORK_NAMESPACES: FRAMEWORK_NAMESPACES,
765
- _resetForTest: _resetForTest,
766
- };
1
+ "use strict";
2
+ /**
3
+ * Audit log — tamper-evident, append-only record of every privileged action.
4
+ *
5
+ * audit_log table is baked into db.js's schema runner — apps cannot opt out.
6
+ * Every row is hash-chained (lib/audit-chain.js); the chain is verified at
7
+ * boot in db.init(); a chain break refuses-to-boot per the compliance stance.
8
+ *
9
+ * Action namespaces:
10
+ * - Framework owns: 'auth.*', 'system.*', 'audit.*', 'consent.*', 'subject.*'
11
+ * - Apps register their own via audit.registerNamespace('orders'), then
12
+ * can record 'orders.created', 'orders.shipped', etc.
13
+ * - Unregistered namespaces are rejected — prevents typos becoming silent
14
+ * unobservable events.
15
+ *
16
+ * Hash chain:
17
+ * - rowHash is computed over the *sealed* form of the row + the nonce.
18
+ * The sealed form is what's stored on disk; verification recomputes
19
+ * directly from disk without unsealing anything (faster + lets auditors
20
+ * verify integrity even without the vault key).
21
+ *
22
+ * Public API:
23
+ * audit.registerNamespace(name)
24
+ * audit.record({ actor, action, resource, outcome, reason, metadata, requestId }) → row
25
+ * audit.query(criteria) → rows [auto-self-logs an 'audit.read' event before returning]
26
+ * audit.verify(opts?) → { ok, rowsVerified, breakAt? }
27
+ * audit.beginTrace() → traceId (32 hex chars)
28
+ *
29
+ * Conventions for `metadata` (apps SHOULD follow these keys for cross-app
30
+ * tooling and RoPA correlation; framework's own subject.* events do):
31
+ * traceId — cross-request correlation; same value across linked events
32
+ * parentEventId — immediate parent event in the causation chain
33
+ * before — state before a change (object), for change events
34
+ * after — state after the change
35
+ * evidenceRef — pointer to evidence (signed PDF hash, ticket URL, etc.)
36
+ * App-defined keys are also welcome; don't shadow these reserved ones.
37
+ */
38
+ var auditChain = require("./audit-chain");
39
+ var auditSign = require("./audit-sign");
40
+ var chainWriter = require("./chain-writer");
41
+ var cluster = require("./cluster");
42
+ var clusterStorage = require("./cluster-storage");
43
+ var { generateToken } = require("./crypto");
44
+ var cryptoField = require("./crypto-field");
45
+ var handlers = require("./handlers");
46
+ var { boot } = require("./log");
47
+ var safeAsync = require("./safe-async");
48
+ var C = require("./constants");
49
+ var lazyRequire = require("./lazy-require");
50
+ var observability = require("./observability");
51
+ var { ClusterError } = require("./framework-error");
52
+
53
+ var log = boot("audit");
54
+
55
+ // Per-operation timeout for framework-state SQL. A misbehaving
56
+ // external-db driver hanging on a query shouldn't hang audit forever.
57
+ // 30s is generous for genuinely slow networks while still bounding
58
+ // the worst case.
59
+ var FRAMEWORK_SQL_TIMEOUT_MS = C.TIME.seconds(30);
60
+
61
+ // ---- Resilience-wrapped SQL operations (audit-specific reads) ----
62
+ // Chain APPEND lives in chain-writer (race-safe via mutex, retry, timeout).
63
+ // The wrappers below cover audit-specific reads/writes that aren't part
64
+ // of the chain append: checkpoint queries, verifyCheckpoints reads,
65
+ // audit-tip cluster-row updates.
66
+
67
+ async function _readLastCheckpointCounter() {
68
+ return await safeAsync.withTimeout(
69
+ safeAsync.asyncRetry(function () {
70
+ return clusterStorage.executeOne(
71
+ "SELECT atMonotonicCounter FROM audit_checkpoints " +
72
+ "ORDER BY atMonotonicCounter DESC LIMIT 1"
73
+ );
74
+ }),
75
+ FRAMEWORK_SQL_TIMEOUT_MS,
76
+ { name: "audit.readLastCheckpoint" }
77
+ );
78
+ }
79
+
80
+ async function _readAllAuditRowsAsc() {
81
+ return await safeAsync.withTimeout(
82
+ safeAsync.asyncRetry(function () {
83
+ return clusterStorage.executeAll(
84
+ 'SELECT * FROM "audit_log" ORDER BY monotonicCounter ASC'
85
+ );
86
+ }),
87
+ FRAMEWORK_SQL_TIMEOUT_MS,
88
+ { name: "audit.readAllRowsAsc" }
89
+ );
90
+ }
91
+
92
+ async function _readAllCheckpointsAsc() {
93
+ return await safeAsync.withTimeout(
94
+ safeAsync.asyncRetry(function () {
95
+ return clusterStorage.executeAll(
96
+ "SELECT * FROM audit_checkpoints ORDER BY atMonotonicCounter ASC"
97
+ );
98
+ }),
99
+ FRAMEWORK_SQL_TIMEOUT_MS,
100
+ { name: "audit.readAllCheckpoints" }
101
+ );
102
+ }
103
+
104
+ async function _readAuditRowHashAtCounter(counter) {
105
+ return await safeAsync.withTimeout(
106
+ safeAsync.asyncRetry(function () {
107
+ return clusterStorage.executeOne(
108
+ "SELECT rowHash FROM audit_log WHERE monotonicCounter = ?",
109
+ [counter]
110
+ );
111
+ }),
112
+ FRAMEWORK_SQL_TIMEOUT_MS,
113
+ { name: "audit.readRowHashAtCounter" }
114
+ );
115
+ }
116
+
117
+ async function _insertAuditRow(allCols, values) {
118
+ // No retry — non-idempotent. Timeout only.
119
+ var placeholders = allCols.map(function () { return "?"; }).join(", ");
120
+ var quoted = allCols.map(function (c) { return '"' + c + '"'; }).join(", ");
121
+ return await safeAsync.withTimeout(
122
+ clusterStorage.execute(
123
+ "INSERT INTO audit_log (" + quoted + ") VALUES (" + placeholders + ")",
124
+ values
125
+ ),
126
+ FRAMEWORK_SQL_TIMEOUT_MS,
127
+ { name: "audit.insertRow" }
128
+ );
129
+ }
130
+
131
+ async function _insertCheckpoint(values) {
132
+ return await safeAsync.withTimeout(
133
+ clusterStorage.execute(
134
+ "INSERT INTO audit_checkpoints (_id, createdAt, atMonotonicCounter, atRowHash, signature, publicKeyFingerprint, fencingToken) " +
135
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
136
+ values
137
+ ),
138
+ FRAMEWORK_SQL_TIMEOUT_MS,
139
+ { name: "audit.insertCheckpoint" }
140
+ );
141
+ }
142
+
143
+ async function _upsertAuditTip(counter, rowHash, signedAt, fencingToken) {
144
+ // Cluster-mode only. Single atomic INSERT … ON CONFLICT … DO UPDATE
145
+ // … WHERE … RETURNING. The WHERE clause is the canonical
146
+ // fencing-token guard from blamejs-cluster-spec.md — it enforces
147
+ // monotonic-non-decreasing fencingToken at the database level so a
148
+ // partitioned old leader cannot overwrite the tip even if its
149
+ // application-layer cluster.requireLeader() gate somehow allowed
150
+ // the call through.
151
+ //
152
+ // Update accepted iff the row's stored fencingToken <= incoming one
153
+ // (same-token re-write is fine; a strictly-lower token is fenced
154
+ // out). On rejection RETURNING produces 0 rows — we surface that
155
+ // as ClusterError(code='FENCED_OUT', permanent=true) so the
156
+ // dispatching node knows it's been superseded and should step down
157
+ // rather than retry.
158
+ var result = await safeAsync.withTimeout(
159
+ clusterStorage.execute(
160
+ "INSERT INTO _blamejs_audit_tip " +
161
+ " (scope, atMonotonicCounter, rowHash, signedAt, fencingToken) " +
162
+ "VALUES ('audit', ?, ?, ?, ?) " +
163
+ "ON CONFLICT (scope) DO UPDATE SET " +
164
+ " atMonotonicCounter = EXCLUDED.atMonotonicCounter, " +
165
+ " rowHash = EXCLUDED.rowHash, " +
166
+ " signedAt = EXCLUDED.signedAt, " +
167
+ " fencingToken = EXCLUDED.fencingToken " +
168
+ "WHERE _blamejs_audit_tip.fencingToken <= EXCLUDED.fencingToken " +
169
+ "RETURNING fencingToken",
170
+ [counter, rowHash, signedAt, fencingToken]
171
+ ),
172
+ FRAMEWORK_SQL_TIMEOUT_MS,
173
+ { name: "audit.upsertAuditTip" }
174
+ );
175
+ if (!result.rows || result.rows.length === 0) {
176
+ throw new ClusterError(
177
+ "FENCED_OUT",
178
+ "audit-tip update rejected: incoming fencingToken=" + fencingToken +
179
+ " is below the stored token (this leader has been fenced out " +
180
+ "by a higher-token successor)",
181
+ true
182
+ );
183
+ }
184
+ }
185
+
186
+ // Every namespace any framework primitive emits on must be listed here.
187
+ // A primitive that adds a new namespace adds it here in the same patch.
188
+ // Smoke walks lib/ at boot-time-equivalent (layer-0-primitives/audit-framework-namespaces.test.js)
189
+ // and fails if any emitted namespace is missing from this list.
190
+ //
191
+ // Why this list and not auto-registration via registerNamespace() at
192
+ // each primitive's create(): namespace registration must be effective
193
+ // the first time ANY primitive emits, regardless of which primitive
194
+ // the operator initialized first. Operators wiring just b.scheduler
195
+ // without ever calling b.apiKey.create() still trip the apikey verify
196
+ // path (e.g. through middleware they didn't write); the action-name
197
+ // validation needs to know about apikey at boot, not at first call.
198
+ var FRAMEWORK_NAMESPACES = [
199
+ // Generic buckets
200
+ "auth", "system", "audit", "consent", "subject",
201
+ // Per-primitive namespaces — keep alphabetical
202
+ "apikey", // b.apiKey
203
+ "backup", // b.backup
204
+ "breakglass", // b.breakGlass — column-policy / row-enforcement step-up auth (audit namespace lowercased per the validator's `namespace.verb` rule, same convention as b.apiKey → apikey.*)
205
+ "cache", // b.cache
206
+ "config", // b.configDrift (config.baseline.captured / config.drift.detected / config.baseline.tamper / config.baseline.unreadable)
207
+ "db", // b.db / b.middleware.dbRoleFor / b.externalDb.runAs
208
+ // (role-switching, RLS-shaped events)
209
+ "dkim", // b.mail.dkim (DKIM-Signature generation events)
210
+ "dual", // b.dualControl (dual.grant.requested / approved / denied / consumed / expired / self_approval_denied)
211
+ "mail", // b.mail (b.mail-bounce uses "system.mail.*")
212
+ "network", // b.middleware.networkAllowlist (network.gate.denied)
213
+ "notify", // b.notify
214
+ "objectstore", // b.objectStore.bucketOps (objectstore.bucket.* / objectstore.object.*)
215
+ "permissions", // b.permissions
216
+ "restore", // b.restore
217
+ "retention", // b.retention (retention.rule.declared / sweep.started / row.processed / sweep.completed / sweep.failed)
218
+ "scheduler", // b.scheduler (lifecycle: scheduler.start / scheduler.stop;
219
+ // tick/task events use "system.scheduler.*")
220
+ "seeders", // b.seeders
221
+ "webhook", // b.webhook
222
+ ];
223
+ var registeredNamespaces = new Set(FRAMEWORK_NAMESPACES);
224
+
225
+ // All hashable columns of audit_log (everything in the table except the chain
226
+ // bookkeeping itself). This list MUST match what's actually written by INSERT
227
+ // and what's read back by verify; the canonicalizer needs the same key set
228
+ // at both ends or the hash will mismatch on missing-vs-null keys.
229
+ var HASHABLE_COLS = [
230
+ "_id", "recordedAt", "monotonicCounter",
231
+ "actorUserId", "actorUserIdHash",
232
+ "actorIp",
233
+ "actorUserAgent", "actorSessionId",
234
+ "action", "resourceKind",
235
+ "resourceId", "resourceIdHash",
236
+ "outcome", "reason", "metadata", "requestId",
237
+ ];
238
+
239
+ // Lazy db ref — avoids circular require (db -> audit -> db on init paths).
240
+ var db = lazyRequire(function () { return require("./db"); });
241
+
242
+ // Chain-writer instance owns the race-safe chain append: counter primer,
243
+ // chain mutex, prev-tip read, hash compute, INSERT. Per the framework
244
+ // rule that repeated tasks become primitives, the audit_log and
245
+ // consent_log chains both consume chain-writer.
246
+ var _chainWriter = chainWriter.create({
247
+ table: "audit_log",
248
+ hashableColumns: HASHABLE_COLS,
249
+ columnsForInsert: [
250
+ "_id", "recordedAt", "monotonicCounter",
251
+ "actorUserId", "actorUserIdHash",
252
+ "actorIp",
253
+ "actorUserAgent", "actorSessionId",
254
+ "action", "resourceKind",
255
+ "resourceId", "resourceIdHash",
256
+ "outcome", "reason", "metadata", "requestId",
257
+ "prevHash", "rowHash", "nonce", "fencingToken",
258
+ ],
259
+ });
260
+
261
+ // ---- Public API ----
262
+
263
+ function registerNamespace(name) {
264
+ if (typeof name !== "string" || !/^[a-z][a-z0-9_]*$/.test(name)) {
265
+ throw new Error("audit namespace must match [a-z][a-z0-9_]* — got: " + name);
266
+ }
267
+ if (FRAMEWORK_NAMESPACES.indexOf(name) !== -1) return;
268
+ registeredNamespaces.add(name);
269
+ }
270
+
271
+ function _validateAction(action) {
272
+ if (typeof action !== "string" || !/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/.test(action)) {
273
+ throw new Error(
274
+ "audit action must be 'namespace.verb[.qualifier...]' (lowercase, dot-separated) — got: " + action
275
+ );
276
+ }
277
+ var ns = action.split(".")[0];
278
+ if (!registeredNamespaces.has(ns)) {
279
+ throw new Error(
280
+ "audit namespace '" + ns + "' is not registered. " +
281
+ "Call audit.registerNamespace('" + ns + "') at app bootstrap before recording '" + action + "'."
282
+ );
283
+ }
284
+ }
285
+
286
+ async function record(event) {
287
+ if (!event || typeof event !== "object") {
288
+ throw new Error("audit.record requires an event object");
289
+ }
290
+ _validateAction(event.action);
291
+ if (!event.outcome || ["success", "failure", "denied"].indexOf(event.outcome) === -1) {
292
+ throw new Error("audit.record outcome must be 'success', 'failure', or 'denied'");
293
+ }
294
+
295
+ return observability.tap("audit.record",
296
+ { action: event.action, outcome: event.outcome },
297
+ async function () {
298
+ // Build the audit-specific logical row; chain-writer handles _id /
299
+ // recordedAt / monotonicCounter / sealing / null-fill / hashing /
300
+ // insert / fencing-token / chain mutex / counter primer.
301
+ var actor = event.actor || {};
302
+ var resource = event.resource || {};
303
+ var logical = {
304
+ actorUserId: actor.userId || null,
305
+ actorIp: actor.ip || null,
306
+ actorUserAgent: actor.userAgent || null,
307
+ actorSessionId: actor.sessionId || null,
308
+ action: event.action,
309
+ resourceKind: resource.kind || null,
310
+ resourceId: resource.id || null,
311
+ outcome: event.outcome,
312
+ reason: event.reason || null,
313
+ metadata: event.metadata ? JSON.stringify(event.metadata) : null,
314
+ requestId: event.requestId || null,
315
+ };
316
+ return _chainWriter.append(logical);
317
+ }
318
+ );
319
+ }
320
+
321
+ // ---- Query ----
322
+ //
323
+ // Plain-field criteria translate into derived-hash equality where the column
324
+ // is sealed. Returns unsealed rows for the auditor's view.
325
+ //
326
+ // Self-logging (PCI DSS 10.2.3): every read of audit_log is itself recorded
327
+ // as an 'audit.read' event before the query runs, so an exfiltration attempt
328
+ // is forensically visible. The recursion guard (_selfLogging flag) prevents
329
+ // the audit.read recording from triggering its own self-log; queries
330
+ // SPECIFICALLY filtering for action='audit.read' don't auto-log either
331
+ // (otherwise legitimate audit auditing produces a Russell-set spiral).
332
+ var _selfLogging = false;
333
+
334
+ async function query(criteria) {
335
+ criteria = criteria || {};
336
+ if (!_selfLogging && criteria.action !== "audit.read") {
337
+ _selfLogging = true;
338
+ try {
339
+ await record({
340
+ actor: criteria.actor || {},
341
+ action: "audit.read",
342
+ outcome: "success",
343
+ metadata: {
344
+ criteria: _redactCriteria(criteria),
345
+ traceId: criteria.traceId || null,
346
+ },
347
+ });
348
+ } finally {
349
+ _selfLogging = false;
350
+ }
351
+ }
352
+
353
+ // In single-node mode the query builder gives us field-crypto unsealing
354
+ // for free. In cluster mode we read raw rows from external-db and
355
+ // unseal manually.
356
+ if (cluster.isClusterMode()) {
357
+ return await _queryCluster(criteria);
358
+ }
359
+
360
+ var q = db().from("audit_log");
361
+
362
+ if (criteria.from) q = q.where("recordedAt", ">=", _toMs(criteria.from));
363
+ if (criteria.to) q = q.where("recordedAt", "<=", _toMs(criteria.to));
364
+ if (criteria.actorUserId) q = q.where({ actorUserId: criteria.actorUserId });
365
+ if (criteria.resourceId) q = q.where({ resourceId: criteria.resourceId });
366
+ if (criteria.action) q = q.where({ action: criteria.action });
367
+ if (criteria.resourceKind) q = q.where({ resourceKind: criteria.resourceKind });
368
+ if (criteria.outcome) q = q.where({ outcome: criteria.outcome });
369
+
370
+ q.orderBy("monotonicCounter", "asc");
371
+ if (criteria.limit != null) q.limit(criteria.limit);
372
+ if (criteria.offset != null) q.offset(criteria.offset);
373
+
374
+ return q.all();
375
+ }
376
+
377
+ async function _queryCluster(criteria) {
378
+ var conds = [];
379
+ var params = [];
380
+ if (criteria.from) {
381
+ conds.push("recordedAt >= ?");
382
+ params.push(_toMs(criteria.from));
383
+ }
384
+ if (criteria.to) {
385
+ conds.push("recordedAt <= ?");
386
+ params.push(_toMs(criteria.to));
387
+ }
388
+ if (criteria.actorUserId) {
389
+ var auh = cryptoField.lookupHash("audit_log", "actorUserId", criteria.actorUserId);
390
+ if (auh) { conds.push(auh.field + " = ?"); params.push(auh.value); }
391
+ }
392
+ if (criteria.resourceId) {
393
+ var rh = cryptoField.lookupHash("audit_log", "resourceId", criteria.resourceId);
394
+ if (rh) { conds.push(rh.field + " = ?"); params.push(rh.value); }
395
+ }
396
+ if (criteria.action) { conds.push("action = ?"); params.push(criteria.action); }
397
+ if (criteria.resourceKind) { conds.push("resourceKind = ?"); params.push(criteria.resourceKind); }
398
+ if (criteria.outcome) { conds.push("outcome = ?"); params.push(criteria.outcome); }
399
+
400
+ var sql = "SELECT * FROM audit_log";
401
+ if (conds.length > 0) sql += " WHERE " + conds.join(" AND ");
402
+ sql += " ORDER BY monotonicCounter ASC";
403
+ if (criteria.limit != null) { sql += " LIMIT ?"; params.push(criteria.limit); }
404
+ if (criteria.offset != null) { sql += " OFFSET ?"; params.push(criteria.offset); }
405
+
406
+ var rows = await clusterStorage.executeAll(sql, params);
407
+ return rows.map(function (row) { return cryptoField.unsealRow("audit_log", row); });
408
+ }
409
+
410
+ // Audit-readable summary of the criteria without storing raw subject IDs
411
+ // in plaintext anywhere outside the sealed columns of audit_log itself.
412
+ function _redactCriteria(c) {
413
+ return {
414
+ from: c.from || null,
415
+ to: c.to || null,
416
+ action: c.action || null,
417
+ resourceKind: c.resourceKind || null,
418
+ outcome: c.outcome || null,
419
+ hasUserFilter: !!c.actorUserId,
420
+ hasResourceFilter: !!c.resourceId,
421
+ limit: c.limit != null ? c.limit : null,
422
+ offset: c.offset != null ? c.offset : null,
423
+ };
424
+ }
425
+
426
+ // Generate a fresh trace id apps can thread through their request handlers
427
+ // and pass into audit.record() / consent.grant() / etc. via the metadata
428
+ // field. Width matches the W3C traceparent trace-id format (16 random
429
+ // bytes hex-encoded → 32 chars). Routed through C.BYTES so the byte
430
+ // count has a single source of truth.
431
+ var TRACE_ID_BYTES = C.BYTES.bytes(16);
432
+ function beginTrace() {
433
+ return generateToken(TRACE_ID_BYTES);
434
+ }
435
+
436
+ // ---- Checkpoints (tamper-proof external anchor) ----
437
+
438
+ // Build the canonical bytes that get signed for a checkpoint at a given
439
+ // chain tip. Keep this format stable across the framework's lifetime —
440
+ // changing it invalidates every prior checkpoint signature.
441
+ var CHECKPOINT_FORMAT = "blamejs-audit-checkpoint-v1";
442
+ function _checkpointPayload(atMonotonicCounter, atRowHash, createdAt) {
443
+ // Use a fixed multi-line layout. Avoids JSON serializer quirks; portable
444
+ // to any verifier reading the same column triple from the DB.
445
+ return Buffer.from(
446
+ CHECKPOINT_FORMAT + "\n" +
447
+ String(atMonotonicCounter) + "\n" +
448
+ atRowHash + "\n" +
449
+ String(createdAt),
450
+ "utf8"
451
+ );
452
+ }
453
+
454
+ // Anchor the current chain tip with a fresh ML-DSA-87 signature. Inserts
455
+ // a row into audit_checkpoints. Updates <dataDir>/audit.tip for boot-time
456
+ // rollback detection.
457
+ //
458
+ // opts:
459
+ // skipIfUnchanged: bool — return null without inserting if the chain tip
460
+ // hasn't advanced since the most recent checkpoint
461
+ async function checkpoint(opts) {
462
+ cluster.requireLeader();
463
+ opts = opts || {};
464
+
465
+ var tip = await safeAsync.withTimeout(
466
+ safeAsync.asyncRetry(function () {
467
+ return clusterStorage.executeOne(
468
+ "SELECT _id, monotonicCounter, rowHash FROM audit_log " +
469
+ "ORDER BY monotonicCounter DESC LIMIT 1"
470
+ );
471
+ }),
472
+ FRAMEWORK_SQL_TIMEOUT_MS,
473
+ { name: "audit.checkpoint.readTip" }
474
+ );
475
+
476
+ if (!tip) return null; // empty audit log; nothing to anchor
477
+
478
+ if (opts.skipIfUnchanged) {
479
+ var lastCkpt = await _readLastCheckpointCounter();
480
+ if (lastCkpt && Number(lastCkpt.atMonotonicCounter) >= Number(tip.monotonicCounter)) {
481
+ return null; // already anchored at this tip
482
+ }
483
+ }
484
+
485
+ var createdAt = Date.now();
486
+ var counter = Number(tip.monotonicCounter);
487
+ var payload = _checkpointPayload(counter, tip.rowHash, createdAt);
488
+ var signature = auditSign.sign(payload);
489
+ var pubFp = auditSign.getPublicKeyFingerprint();
490
+
491
+ var ckptId = generateToken(TRACE_ID_BYTES);
492
+ var fencingToken = cluster.fencingToken();
493
+ await _insertCheckpoint(
494
+ [ckptId, createdAt, counter, tip.rowHash, signature, pubFp, fencingToken]
495
+ );
496
+
497
+ // Update rollback-detection sidecar (single-node) or audit-tip row
498
+ // (cluster mode).
499
+ //
500
+ // Single-node sidecar is best-effort — a sidecar write failure must
501
+ // not block checkpointing because the chain itself is already
502
+ // committed.
503
+ //
504
+ // Cluster-mode audit-tip is NOT best-effort: the upsert's WHERE
505
+ // clause is the fencing-token guard, and a FENCED_OUT response
506
+ // means the local node has been superseded by a newer leader. The
507
+ // checkpoint row was already inserted at this point but propagating
508
+ // the error up makes the leadership-loss visible to the caller — it
509
+ // also means the caller can audit the leader-lost transition and
510
+ // step down. Other audit-tip errors (network blip, transient DB)
511
+ // also surface so the operator can react.
512
+ if (cluster.isClusterMode()) {
513
+ await _upsertAuditTip(counter, tip.rowHash, String(createdAt), fencingToken);
514
+ } else {
515
+ try {
516
+ db()._writeAuditTip({
517
+ atMonotonicCounter: counter,
518
+ atRowHash: tip.rowHash,
519
+ anchoredAt: createdAt,
520
+ checkpointId: ckptId,
521
+ publicKeyFingerprint: pubFp,
522
+ version: 1,
523
+ });
524
+ } catch (_e) { /* best effort */ }
525
+ }
526
+
527
+ return {
528
+ _id: ckptId,
529
+ createdAt: createdAt,
530
+ atMonotonicCounter: counter,
531
+ atRowHash: tip.rowHash,
532
+ publicKeyFingerprint: pubFp,
533
+ };
534
+ }
535
+
536
+ // Walk every checkpoint, verify its signature against the current public
537
+ // key (or one matching the row's stored fingerprint). Also confirms the
538
+ // audit_log row at atMonotonicCounter still has the recorded rowHash.
539
+ //
540
+ // Returns { ok, checkpointsVerified, breakAt? }.
541
+ async function verifyCheckpoints() {
542
+ var rows = await _readAllCheckpointsAsc();
543
+
544
+ if (rows.length === 0) return { ok: true, checkpointsVerified: 0 };
545
+
546
+ var currentFp = auditSign.getPublicKeyFingerprint();
547
+ var currentPub = auditSign.getPublicKey();
548
+
549
+ for (var i = 0; i < rows.length; i++) {
550
+ var c = rows[i];
551
+ // Public key check: only the current key is accepted — there is no
552
+ // key-history table, so any rotation requires re-signing existing
553
+ // checkpoints. A fingerprint mismatch fails verification.
554
+ if (c.publicKeyFingerprint !== currentFp) {
555
+ return {
556
+ ok: false,
557
+ checkpointsVerified: i,
558
+ breakAt: i,
559
+ checkpointId: c._id,
560
+ reason: "public key fingerprint mismatch (key rotated without history?)",
561
+ expected: currentFp,
562
+ actual: c.publicKeyFingerprint,
563
+ };
564
+ }
565
+ var payload = _checkpointPayload(Number(c.atMonotonicCounter), c.atRowHash, Number(c.createdAt));
566
+ var sigBuf = Buffer.isBuffer(c.signature) ? c.signature : Buffer.from(c.signature);
567
+ if (!auditSign.verify(payload, sigBuf, currentPub)) {
568
+ return {
569
+ ok: false,
570
+ checkpointsVerified: i,
571
+ breakAt: i,
572
+ checkpointId: c._id,
573
+ reason: "ML-DSA-87 signature failed",
574
+ };
575
+ }
576
+ // Also confirm the audit row at atMonotonicCounter still matches the
577
+ // anchored rowHash. If someone tampered with audit_log AND recomputed
578
+ // hashes (requiring vault key), this catches them via the off-chain
579
+ // signature anchor.
580
+ var anchored = await _readAuditRowHashAtCounter(c.atMonotonicCounter);
581
+ if (!anchored) {
582
+ return {
583
+ ok: false,
584
+ checkpointsVerified: i,
585
+ breakAt: i,
586
+ checkpointId: c._id,
587
+ reason: "anchored audit_log row missing (counter=" + c.atMonotonicCounter + ")",
588
+ };
589
+ }
590
+ if (anchored.rowHash !== c.atRowHash) {
591
+ return {
592
+ ok: false,
593
+ checkpointsVerified: i,
594
+ breakAt: i,
595
+ checkpointId: c._id,
596
+ reason: "anchored rowHash mismatch — audit_log was tampered with",
597
+ expected: c.atRowHash,
598
+ actual: anchored.rowHash,
599
+ };
600
+ }
601
+ }
602
+ return { ok: true, checkpointsVerified: rows.length };
603
+ }
604
+
605
+ function _toMs(value) {
606
+ if (typeof value === "number") return value;
607
+ if (value instanceof Date) return value.getTime();
608
+ if (typeof value === "string") {
609
+ var ms = Date.parse(value);
610
+ if (isNaN(ms)) throw new Error("invalid date: " + value);
611
+ return ms;
612
+ }
613
+ throw new Error("invalid date value");
614
+ }
615
+
616
+ // ---- Verify ----
617
+
618
+ async function verify(opts) {
619
+ // verifyChain just needs an executeAll; route through the same
620
+ // resilience-wrapped reader the rest of audit uses.
621
+ return await auditChain.verifyChain(
622
+ function (sql, params) {
623
+ return safeAsync.withTimeout(
624
+ safeAsync.asyncRetry(function () {
625
+ return clusterStorage.executeAll(sql, params || []);
626
+ }),
627
+ FRAMEWORK_SQL_TIMEOUT_MS,
628
+ { name: "audit.verifyChain" }
629
+ );
630
+ },
631
+ "audit_log",
632
+ opts
633
+ );
634
+ }
635
+
636
+ // ---- Test helpers ----
637
+
638
+ function _resetForTest() {
639
+ registeredNamespaces = new Set(FRAMEWORK_NAMESPACES);
640
+ db.reset();
641
+ _chainWriter._resetForTest();
642
+ // Drop pending buffered emits and cancel the age-flush timer on the
643
+ // old handler before dereferencing it. Without this, the old
644
+ // handler's setTimeout (scheduled when emits buffer below maxBatch)
645
+ // fires AFTER the next test's db.init has opened a fresh database —
646
+ // the buffered items then drain through chain-writer into the wrong
647
+ // tmpDir's audit_log, breaking chain verify on the next-next test.
648
+ // shutdownSync is the explicit "drop, don't drain" path because
649
+ // draining to a stale or changing backing store is exactly the bug.
650
+ // The handler's flush also checks ctx.isShutdown() between items, so
651
+ // an in-flight drain that's mid-batch when reset fires bails out
652
+ // instead of writing the rest of the batch to the new database.
653
+ if (_auditHandler) {
654
+ try { _auditHandler.shutdownSync("audit._resetForTest"); }
655
+ catch (e) { log.debug("reset-handler-shutdown-failed: " + (e && e.message || e)); }
656
+ _auditHandler = null;
657
+ }
658
+ }
659
+
660
+ // ---- Handler-backed emit + flush ----
661
+ //
662
+ // emit() is the call-site API for fire-and-forget audit emission from
663
+ // middleware / log-stream / external-db hooks / queue / storage / subject.
664
+ // It is SYNCHRONOUS, NEVER throws, and NEVER returns a Promise — request-
665
+ // path code can call it without await and without try/catch.
666
+ //
667
+ // Internally events queue in an AsyncHandler. flush() drains the queue
668
+ // to the audit chain (single writer in-process, serialized via the
669
+ // chain mutex). Tests, shutdown, and any code that needs audit-row
670
+ // durability before reading audit_log calls await audit.flush().
671
+ //
672
+ // Why this beats fire-and-forget Promises:
673
+ // - No leaked Promises across test/shutdown boundaries
674
+ // - Errors go through a single onError hook (visible to operators)
675
+ // - Recursive emits during flush land in the buffer for the next
676
+ // drain cycle — no infinite loop in cluster-mode dispatchers
677
+ // - Tests have a deterministic "audit is durable now" point
678
+ var _auditHandler = null;
679
+
680
+ function _ensureHandler() {
681
+ if (_auditHandler) return _auditHandler;
682
+ _auditHandler = handlers.create({
683
+ name: "audit",
684
+ flush: async function (batch, ctx) {
685
+ // Drain by serially writing each event through record(). The chain
686
+ // mutex inside record() further serializes vs concurrent direct
687
+ // record() callers.
688
+ //
689
+ // Between items, check the handler's shutdown probe — if a test
690
+ // (or operator) reset audit while this batch was in flight, the
691
+ // remaining items would otherwise drain through the chain-writer
692
+ // into a database that no longer represents the chain those
693
+ // items were emitted against. Early-exit drops them; the
694
+ // alternative is silent corruption of the next chain.
695
+ for (var i = 0; i < batch.length; i++) {
696
+ if (ctx && ctx.isShutdown && ctx.isShutdown()) return;
697
+ try { await record(batch[i]); }
698
+ catch (e) {
699
+ // Per-item failure shouldn't drop the whole batch; log and
700
+ // continue. The handler's onError gets called for batch-
701
+ // wide failures only.
702
+ log.error("flush dropped event: " +
703
+ (e && e.message ? e.message : String(e)) +
704
+ " (action=" + (batch[i] && batch[i].action) + ")");
705
+ }
706
+ }
707
+ },
708
+ });
709
+ return _auditHandler;
710
+ }
711
+
712
+ function emit(event) {
713
+ _ensureHandler().emit(event);
714
+ }
715
+
716
+ // safeEmit — fire-and-forget audit emit with safe defaults + try/catch.
717
+ //
718
+ // Most modules wrap emit() in their own _emit helper that fills in
719
+ // `actor: {}`, `outcome: "success"`, etc. and catches the throw so an
720
+ // audit outage doesn't crash the request handler. This is that helper,
721
+ // hoisted out so each module can stop redefining it.
722
+ //
723
+ // Drop-silent on malformed input by design. safeEmit is called from
724
+ // request hot paths where throwing on a
725
+ // missing `action` would mean a malformed audit attempt crashes the
726
+ // request that triggered it — strictly worse than the missing audit
727
+ // row. Operators who need a hard guarantee the event landed should call
728
+ // record() and await it with their own error handling. The audit chain
729
+ // itself is verified at boot, so a silently dropped row shows up in the
730
+ // next chain integrity sweep.
731
+ function safeEmit(event) {
732
+ if (!event || typeof event !== "object") return;
733
+ if (typeof event.action !== "string") return; // can't emit without an action
734
+ try {
735
+ _ensureHandler().emit({
736
+ actor: event.actor || {},
737
+ action: event.action,
738
+ resource: event.resource || null,
739
+ outcome: event.outcome || "success",
740
+ reason: event.reason || null,
741
+ metadata: event.metadata || null,
742
+ requestId: event.requestId || null,
743
+ });
744
+ } catch (_e) { /* audit best-effort — never break the caller */ }
745
+ }
746
+
747
+ async function flush() {
748
+ if (!_auditHandler) return;
749
+ await _auditHandler.drain();
750
+ }
751
+
752
+ module.exports = {
753
+ registerNamespace: registerNamespace,
754
+ record: record,
755
+ emit: emit,
756
+ safeEmit: safeEmit,
757
+ flush: flush,
758
+ query: query,
759
+ verify: verify,
760
+ beginTrace: beginTrace,
761
+ checkpoint: checkpoint,
762
+ verifyCheckpoints: verifyCheckpoints,
763
+ CHECKPOINT_FORMAT: CHECKPOINT_FORMAT,
764
+ FRAMEWORK_NAMESPACES: FRAMEWORK_NAMESPACES,
765
+ _resetForTest: _resetForTest,
766
+ };