@blamejs/core 0.8.43 → 0.8.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/README.md +10 -10
  3. package/index.js +52 -0
  4. package/lib/a2a.js +159 -34
  5. package/lib/acme.js +762 -0
  6. package/lib/ai-pref.js +166 -43
  7. package/lib/api-key.js +108 -47
  8. package/lib/api-snapshot.js +157 -40
  9. package/lib/app-shutdown.js +113 -77
  10. package/lib/archive.js +337 -40
  11. package/lib/arg-parser.js +697 -0
  12. package/lib/asyncapi.js +99 -55
  13. package/lib/atomic-file.js +465 -104
  14. package/lib/audit-chain.js +123 -34
  15. package/lib/audit-daily-review.js +389 -0
  16. package/lib/audit-sign.js +302 -56
  17. package/lib/audit-tools.js +412 -63
  18. package/lib/audit.js +656 -35
  19. package/lib/auth/jwt-external.js +17 -0
  20. package/lib/auth/oauth.js +7 -0
  21. package/lib/auth-bot-challenge.js +505 -0
  22. package/lib/auth-header.js +92 -25
  23. package/lib/backup/bundle.js +26 -0
  24. package/lib/backup/index.js +512 -89
  25. package/lib/backup/manifest.js +168 -7
  26. package/lib/break-glass.js +415 -39
  27. package/lib/budr.js +103 -30
  28. package/lib/bundler.js +86 -66
  29. package/lib/cache.js +192 -72
  30. package/lib/chain-writer.js +65 -40
  31. package/lib/circuit-breaker.js +56 -33
  32. package/lib/cli-helpers.js +106 -75
  33. package/lib/cli.js +6 -30
  34. package/lib/cloud-events.js +99 -32
  35. package/lib/cluster-storage.js +162 -37
  36. package/lib/cluster.js +340 -49
  37. package/lib/codepoint-class.js +66 -0
  38. package/lib/compliance.js +424 -24
  39. package/lib/config-drift.js +111 -46
  40. package/lib/config.js +94 -40
  41. package/lib/consent.js +165 -18
  42. package/lib/constants.js +1 -0
  43. package/lib/content-credentials.js +153 -48
  44. package/lib/cookies.js +154 -62
  45. package/lib/credential-hash.js +133 -61
  46. package/lib/crypto-field.js +702 -18
  47. package/lib/crypto-hpke.js +256 -0
  48. package/lib/crypto.js +744 -22
  49. package/lib/csv.js +178 -35
  50. package/lib/daemon.js +456 -0
  51. package/lib/dark-patterns.js +186 -55
  52. package/lib/db-query.js +79 -2
  53. package/lib/db.js +1431 -60
  54. package/lib/ddl-change-control.js +523 -0
  55. package/lib/deprecate.js +195 -40
  56. package/lib/dev.js +82 -39
  57. package/lib/dora.js +67 -48
  58. package/lib/dr-runbook.js +368 -0
  59. package/lib/dsr.js +142 -11
  60. package/lib/dual-control.js +91 -56
  61. package/lib/events.js +120 -41
  62. package/lib/external-db-migrate.js +192 -2
  63. package/lib/external-db.js +795 -50
  64. package/lib/fapi2.js +122 -1
  65. package/lib/fda-21cfr11.js +395 -0
  66. package/lib/fdx.js +132 -2
  67. package/lib/file-type.js +87 -0
  68. package/lib/file-upload.js +93 -0
  69. package/lib/flag.js +82 -20
  70. package/lib/forms.js +132 -29
  71. package/lib/framework-error.js +169 -0
  72. package/lib/framework-schema.js +163 -35
  73. package/lib/gate-contract.js +849 -175
  74. package/lib/graphql-federation.js +68 -7
  75. package/lib/guard-all.js +172 -55
  76. package/lib/guard-archive.js +286 -124
  77. package/lib/guard-auth.js +194 -21
  78. package/lib/guard-cidr.js +190 -28
  79. package/lib/guard-csv.js +397 -51
  80. package/lib/guard-domain.js +213 -91
  81. package/lib/guard-email.js +236 -29
  82. package/lib/guard-filename.js +307 -75
  83. package/lib/guard-graphql.js +263 -30
  84. package/lib/guard-html.js +310 -116
  85. package/lib/guard-image.js +243 -30
  86. package/lib/guard-json.js +260 -54
  87. package/lib/guard-jsonpath.js +235 -23
  88. package/lib/guard-jwt.js +284 -30
  89. package/lib/guard-markdown.js +204 -22
  90. package/lib/guard-mime.js +190 -26
  91. package/lib/guard-oauth.js +277 -28
  92. package/lib/guard-pdf.js +251 -27
  93. package/lib/guard-regex.js +226 -18
  94. package/lib/guard-shell.js +229 -26
  95. package/lib/guard-svg.js +177 -10
  96. package/lib/guard-template.js +232 -21
  97. package/lib/guard-time.js +195 -29
  98. package/lib/guard-uuid.js +189 -30
  99. package/lib/guard-xml.js +259 -36
  100. package/lib/guard-yaml.js +241 -44
  101. package/lib/honeytoken.js +63 -27
  102. package/lib/html-balance.js +83 -0
  103. package/lib/http-client.js +486 -59
  104. package/lib/http-message-signature.js +582 -0
  105. package/lib/i18n.js +102 -49
  106. package/lib/iab-mspa.js +112 -32
  107. package/lib/iab-tcf.js +107 -2
  108. package/lib/inbox.js +90 -52
  109. package/lib/keychain.js +865 -0
  110. package/lib/legal-hold.js +374 -0
  111. package/lib/local-db-thin.js +320 -0
  112. package/lib/log-stream.js +281 -51
  113. package/lib/log.js +184 -86
  114. package/lib/mail-bounce.js +107 -62
  115. package/lib/mail.js +295 -58
  116. package/lib/mcp.js +108 -27
  117. package/lib/metrics.js +98 -89
  118. package/lib/middleware/age-gate.js +36 -0
  119. package/lib/middleware/ai-act-disclosure.js +37 -0
  120. package/lib/middleware/api-encrypt.js +45 -0
  121. package/lib/middleware/assetlinks.js +40 -0
  122. package/lib/middleware/asyncapi-serve.js +35 -0
  123. package/lib/middleware/attach-user.js +40 -0
  124. package/lib/middleware/bearer-auth.js +40 -0
  125. package/lib/middleware/body-parser.js +230 -0
  126. package/lib/middleware/bot-disclose.js +34 -0
  127. package/lib/middleware/bot-guard.js +39 -0
  128. package/lib/middleware/compression.js +37 -0
  129. package/lib/middleware/cookies.js +32 -0
  130. package/lib/middleware/cors.js +40 -0
  131. package/lib/middleware/csp-nonce.js +40 -0
  132. package/lib/middleware/csp-report.js +34 -0
  133. package/lib/middleware/csrf-protect.js +43 -0
  134. package/lib/middleware/daily-byte-quota.js +53 -85
  135. package/lib/middleware/db-role-for.js +40 -0
  136. package/lib/middleware/dpop.js +40 -0
  137. package/lib/middleware/error-handler.js +37 -14
  138. package/lib/middleware/fetch-metadata.js +39 -0
  139. package/lib/middleware/flag-context.js +34 -0
  140. package/lib/middleware/gpc.js +33 -0
  141. package/lib/middleware/headers.js +35 -0
  142. package/lib/middleware/health.js +46 -0
  143. package/lib/middleware/host-allowlist.js +30 -0
  144. package/lib/middleware/network-allowlist.js +38 -0
  145. package/lib/middleware/openapi-serve.js +34 -0
  146. package/lib/middleware/rate-limit.js +160 -18
  147. package/lib/middleware/request-id.js +36 -18
  148. package/lib/middleware/request-log.js +37 -0
  149. package/lib/middleware/require-aal.js +29 -0
  150. package/lib/middleware/require-auth.js +32 -0
  151. package/lib/middleware/require-bound-key.js +41 -0
  152. package/lib/middleware/require-content-type.js +32 -0
  153. package/lib/middleware/require-methods.js +27 -0
  154. package/lib/middleware/require-mtls.js +33 -0
  155. package/lib/middleware/require-step-up.js +37 -0
  156. package/lib/middleware/security-headers.js +44 -0
  157. package/lib/middleware/security-txt.js +38 -0
  158. package/lib/middleware/span-http-server.js +37 -0
  159. package/lib/middleware/sse.js +36 -0
  160. package/lib/middleware/trace-log-correlation.js +33 -0
  161. package/lib/middleware/trace-propagate.js +32 -0
  162. package/lib/middleware/tus-upload.js +90 -0
  163. package/lib/middleware/web-app-manifest.js +53 -0
  164. package/lib/mtls-ca.js +100 -70
  165. package/lib/network-byte-quota.js +308 -0
  166. package/lib/network-heartbeat.js +135 -0
  167. package/lib/network-tls.js +534 -4
  168. package/lib/network.js +103 -0
  169. package/lib/notify.js +114 -43
  170. package/lib/ntp-check.js +192 -51
  171. package/lib/observability.js +145 -47
  172. package/lib/openapi.js +90 -44
  173. package/lib/outbox.js +99 -1
  174. package/lib/pagination.js +168 -86
  175. package/lib/parsers/index.js +16 -5
  176. package/lib/permissions.js +93 -40
  177. package/lib/pqc-agent.js +84 -8
  178. package/lib/pqc-software.js +94 -60
  179. package/lib/process-spawn.js +95 -21
  180. package/lib/pubsub.js +96 -66
  181. package/lib/queue.js +375 -54
  182. package/lib/redact.js +793 -21
  183. package/lib/render.js +139 -47
  184. package/lib/request-helpers.js +485 -121
  185. package/lib/restore-bundle.js +142 -39
  186. package/lib/restore-rollback.js +136 -45
  187. package/lib/retention.js +178 -50
  188. package/lib/retry.js +116 -33
  189. package/lib/router.js +475 -23
  190. package/lib/safe-async.js +543 -94
  191. package/lib/safe-buffer.js +337 -41
  192. package/lib/safe-json.js +467 -62
  193. package/lib/safe-jsonpath.js +285 -0
  194. package/lib/safe-schema.js +631 -87
  195. package/lib/safe-sql.js +221 -59
  196. package/lib/safe-url.js +278 -46
  197. package/lib/sandbox-worker.js +135 -0
  198. package/lib/sandbox.js +358 -0
  199. package/lib/scheduler.js +135 -70
  200. package/lib/self-update.js +647 -0
  201. package/lib/session-device-binding.js +431 -0
  202. package/lib/session.js +259 -49
  203. package/lib/slug.js +138 -26
  204. package/lib/ssrf-guard.js +316 -56
  205. package/lib/storage.js +433 -70
  206. package/lib/subject.js +405 -23
  207. package/lib/template.js +148 -8
  208. package/lib/tenant-quota.js +545 -0
  209. package/lib/testing.js +440 -53
  210. package/lib/time.js +291 -23
  211. package/lib/tls-exporter.js +239 -0
  212. package/lib/tracing.js +90 -74
  213. package/lib/uuid.js +97 -22
  214. package/lib/vault/index.js +284 -22
  215. package/lib/vault/seal-pem-file.js +66 -0
  216. package/lib/watcher.js +368 -0
  217. package/lib/webhook.js +196 -63
  218. package/lib/websocket.js +393 -68
  219. package/lib/wiki-concepts.js +338 -0
  220. package/lib/worker-pool.js +464 -0
  221. package/package.json +3 -3
  222. package/sbom.cyclonedx.json +7 -7
@@ -0,0 +1,374 @@
1
+ "use strict";
2
+ /**
3
+ * legal-hold — subject-level litigation/regulatory hold registry.
4
+ *
5
+ * A legal hold (a.k.a. litigation hold, preservation order, regulatory
6
+ * hold) freezes a named subject's data so retention sweeps, RTBF
7
+ * erasures, and routine purges cannot remove records pending the
8
+ * resolution of an investigation, lawsuit, audit, or regulatory
9
+ * inquiry. Per FRCP Rule 26 / Rule 37(e) (US Federal Rules of Civil
10
+ * Procedure), GDPR Art. 17(3)(e) (right-to-erasure exception for
11
+ * legal claims), SEC Rule 17a-4 (broker-dealer record preservation),
12
+ * and HIPAA §164.530(j)(2) (six-year retention of HIPAA records),
13
+ * once an organization knows or should reasonably know that records
14
+ * are relevant to anticipated litigation or regulatory action, it
15
+ * must suspend routine destruction.
16
+ *
17
+ * Per-rule `legalHoldField` in b.retention already lets an operator
18
+ * gate retention skips on a column value. This module is the central
19
+ * registry that:
20
+ *
21
+ * 1. Records each placement / release as a hash-chained audit row
22
+ * (`legalhold.placed` / `legalhold.released` events).
23
+ * 2. Persists subject-id-keyed entries in `_blamejs_legal_hold` so
24
+ * the framework can answer `isHeld(subjectId)` in O(1) without
25
+ * the operator having to plumb a flag column into every table.
26
+ * 3. Wires into b.subject.erase + b.retention so a placed hold
27
+ * refuses erasure even when the operator-supplied
28
+ * acknowledgements would otherwise pass.
29
+ *
30
+ * var holds = b.legalHold.create({
31
+ * db: b.db,
32
+ * audit: b.audit,
33
+ * signWith: b.auditSign, // optional — sign every event
34
+ * });
35
+ *
36
+ * await holds.place("user-42", {
37
+ * reason: "SEC subpoena 2026-03-12 case 24-cv-01933",
38
+ * custodian: "legal@example.com",
39
+ * citation: "FRCP-26",
40
+ * retainUntil: Date.now() + C.TIME.days(365), // optional sunset
41
+ * });
42
+ *
43
+ * await holds.release("user-42", {
44
+ * reason: "case dismissed; preservation duty ended",
45
+ * approver: "legal@example.com",
46
+ * });
47
+ *
48
+ * holds.isHeld("user-42"); // → boolean (sync read)
49
+ * holds.list(); // → [{ subjectId, ... }]
50
+ * holds.history("user-42"); // → [{ at, action, ... }]
51
+ *
52
+ * Storage shape: `_blamejs_legal_hold` is the active registry (one
53
+ * row per held subject); placement + release history is preserved in
54
+ * the framework audit_log via `legalhold.placed` / `.released`
55
+ * events. Operators wanting a flat history table re-derive it from
56
+ * the audit chain.
57
+ *
58
+ * Validation tier: throw at config-time on bad opts; throw on
59
+ * missing/garbage subjectId at the API; emit + return shaped error
60
+ * on policy denials (already-held / not-held / invalid-citation).
61
+ */
62
+ var crypto = require("./crypto");
63
+ var lazyRequire = require("./lazy-require");
64
+ var safeJson = require("./safe-json");
65
+ var validateOpts = require("./validate-opts");
66
+ var { defineClass } = require("./framework-error");
67
+
68
+ var audit = lazyRequire(function () { return require("./audit"); });
69
+
70
+ var LegalHoldError = defineClass("LegalHoldError", { alwaysPermanent: true });
71
+ var _err = LegalHoldError.factory;
72
+
73
+ // Per FRCP Rule 26(f) / 37(e), GDPR Art. 17(3)(e), SEC Rule 17a-4(b),
74
+ // HIPAA §164.530(j)(2), 21 CFR Part 11 §11.10(c). Operator-supplied
75
+ // citation should match one of these or be a free-form reference; the
76
+ // framework only sanity-checks shape, not value.
77
+ var KNOWN_CITATIONS = Object.freeze([
78
+ "FRCP-26", "FRCP-37(e)",
79
+ "GDPR-Art-17-3-e",
80
+ "SEC-Rule-17a-4", "SEC-Rule-17a-4(f)",
81
+ "FINRA-4511",
82
+ "HIPAA-164.530(j)(2)",
83
+ "21-CFR-Part-11", "21-CFR-Part-11-11.10(c)",
84
+ "operator-defined",
85
+ ]);
86
+
87
+ function _subjectIdString(subjectId) {
88
+ if (subjectId === null || subjectId === undefined) {
89
+ throw _err("BAD_ARG", "subjectId must be a non-empty string");
90
+ }
91
+ var s = String(subjectId);
92
+ if (s.length === 0) {
93
+ throw _err("BAD_ARG", "subjectId must be a non-empty string");
94
+ }
95
+ return s;
96
+ }
97
+
98
+ function _hashSubject(subjectId) {
99
+ return crypto.sha3Hash("bj-legal-hold:" + subjectId);
100
+ }
101
+
102
+ function create(opts) {
103
+ opts = opts || {};
104
+ validateOpts(opts, ["db", "audit", "signWith"], "legalHold");
105
+ if (!opts.db || typeof opts.db.prepare !== "function") {
106
+ throw _err("BAD_OPT", "create: opts.db is required (a b.db handle)");
107
+ }
108
+ var db = opts.db;
109
+ var auditOn = opts.audit !== false && opts.audit != null;
110
+ var auditInstance = (opts.audit && opts.audit !== true) ? opts.audit : null;
111
+ // signWith is reserved for future per-event detached signatures;
112
+ // currently the audit chain itself is hash-linked + the sidecar
113
+ // signing on audit_checkpoints covers tamper detection. Validate
114
+ // shape so a caller passing it gets a typo-surfacing throw, but
115
+ // don't require it.
116
+ validateOpts.optionalObjectWithMethod(opts.signWith, "sign",
117
+ "create: opts.signWith", LegalHoldError, "BAD_OPT", "b.auditSign-shaped object");
118
+
119
+ function _emit(action, info, outcome) {
120
+ if (!auditOn) return;
121
+ var sink = auditInstance || audit();
122
+ try {
123
+ sink.safeEmit({
124
+ action: action,
125
+ outcome: outcome,
126
+ resource: { kind: "legal-hold", id: info && info.subjectId },
127
+ reason: (info && info.reason) || null,
128
+ metadata: info || {},
129
+ });
130
+ } catch (_e) { /* best-effort */ }
131
+ }
132
+
133
+ function _ensureSchema() {
134
+ // Idempotent migration. The framework SQLite path installs the
135
+ // table via FRAMEWORK_SCHEMA at boot; this guard is for the
136
+ // external-db / cluster path where schema migrations are operator-
137
+ // driven. Either way the IF NOT EXISTS shape is safe to re-run.
138
+ var fn = db.runSql || db.execRaw;
139
+ if (typeof fn === "function") {
140
+ fn(
141
+ 'CREATE TABLE IF NOT EXISTS "_blamejs_legal_hold" (' +
142
+ '"subjectIdHash" TEXT PRIMARY KEY,' +
143
+ '"placedAt" INTEGER NOT NULL,' +
144
+ '"placedBy" TEXT,' +
145
+ '"reason" TEXT NOT NULL,' +
146
+ '"custodian" TEXT,' +
147
+ '"citation" TEXT,' +
148
+ '"retainUntil" INTEGER' +
149
+ ')'
150
+ );
151
+ }
152
+ }
153
+
154
+ function place(subjectId, args) {
155
+ var sid = _subjectIdString(subjectId);
156
+ args = args || {};
157
+ if (typeof args.reason !== "string" || args.reason.length === 0) {
158
+ throw _err("BAD_ARG", "place: args.reason is required (non-empty string)");
159
+ }
160
+ if (args.citation !== undefined && args.citation !== null) {
161
+ if (typeof args.citation !== "string" || args.citation.length === 0) {
162
+ throw _err("BAD_ARG", "place: args.citation must be a non-empty string");
163
+ }
164
+ }
165
+ if (args.retainUntil !== undefined && args.retainUntil !== null) {
166
+ if (typeof args.retainUntil !== "number" ||
167
+ !isFinite(args.retainUntil) ||
168
+ args.retainUntil <= 0) {
169
+ throw _err("BAD_ARG", "place: args.retainUntil must be a positive finite ms-epoch");
170
+ }
171
+ }
172
+ _ensureSchema();
173
+ var hash = _hashSubject(sid);
174
+ var existing = db.prepare(
175
+ 'SELECT placedAt FROM "_blamejs_legal_hold" WHERE subjectIdHash = ?'
176
+ ).get(hash);
177
+ if (existing) {
178
+ _emit("legalhold.place_rejected",
179
+ { subjectId: sid, reason: "already-held",
180
+ existingSince: existing.placedAt },
181
+ "denied");
182
+ return { error: "already-held", placedAt: existing.placedAt };
183
+ }
184
+ var nowMs = Date.now();
185
+ db.prepare(
186
+ 'INSERT INTO "_blamejs_legal_hold" ' +
187
+ '(subjectIdHash, placedAt, placedBy, reason, custodian, citation, retainUntil) ' +
188
+ 'VALUES (?, ?, ?, ?, ?, ?, ?)'
189
+ ).run(
190
+ hash, nowMs,
191
+ args.placedBy || null,
192
+ args.reason,
193
+ args.custodian || null,
194
+ args.citation || null,
195
+ args.retainUntil || null
196
+ );
197
+ _emit("legalhold.placed",
198
+ { subjectId: sid, reason: args.reason,
199
+ custodian: args.custodian || null,
200
+ citation: args.citation || null,
201
+ retainUntil: args.retainUntil || null,
202
+ placedBy: args.placedBy || null,
203
+ knownCitation: args.citation && KNOWN_CITATIONS.indexOf(args.citation) !== -1 },
204
+ "success");
205
+ return { placed: true, placedAt: nowMs };
206
+ }
207
+
208
+ function release(subjectId, args) {
209
+ var sid = _subjectIdString(subjectId);
210
+ args = args || {};
211
+ if (typeof args.reason !== "string" || args.reason.length === 0) {
212
+ throw _err("BAD_ARG", "release: args.reason is required (non-empty string)");
213
+ }
214
+ if (typeof args.approver !== "string" || args.approver.length === 0) {
215
+ throw _err("BAD_ARG", "release: args.approver is required (non-empty string)");
216
+ }
217
+ _ensureSchema();
218
+ var hash = _hashSubject(sid);
219
+ var existing = db.prepare(
220
+ 'SELECT placedAt, reason FROM "_blamejs_legal_hold" WHERE subjectIdHash = ?'
221
+ ).get(hash);
222
+ if (!existing) {
223
+ _emit("legalhold.release_rejected",
224
+ { subjectId: sid, reason: "not-held" },
225
+ "denied");
226
+ return { error: "not-held" };
227
+ }
228
+ db.prepare(
229
+ 'DELETE FROM "_blamejs_legal_hold" WHERE subjectIdHash = ?'
230
+ ).run(hash);
231
+ _emit("legalhold.released",
232
+ { subjectId: sid, reason: args.reason,
233
+ approver: args.approver,
234
+ originalReason: existing.reason,
235
+ heldSince: existing.placedAt },
236
+ "success");
237
+ return { released: true, heldSince: existing.placedAt };
238
+ }
239
+
240
+ function isHeld(subjectId) {
241
+ var sid = _subjectIdString(subjectId);
242
+ _ensureSchema();
243
+ var hash = _hashSubject(sid);
244
+ var row = db.prepare(
245
+ 'SELECT retainUntil FROM "_blamejs_legal_hold" WHERE subjectIdHash = ?'
246
+ ).get(hash);
247
+ if (!row) return false;
248
+ // retainUntil expiry — when the operator pinned a sunset and it
249
+ // has passed, the hold has lapsed and isHeld returns false. The
250
+ // row stays so audit/history reads can see when the sunset hit;
251
+ // operators wanting the row gone call release() explicitly.
252
+ if (row.retainUntil && row.retainUntil < Date.now()) return false;
253
+ return true;
254
+ }
255
+
256
+ function get(subjectId) {
257
+ var sid = _subjectIdString(subjectId);
258
+ _ensureSchema();
259
+ var hash = _hashSubject(sid);
260
+ var row = db.prepare(
261
+ 'SELECT subjectIdHash, placedAt, placedBy, reason, custodian, citation, retainUntil ' +
262
+ 'FROM "_blamejs_legal_hold" WHERE subjectIdHash = ?'
263
+ ).get(hash);
264
+ if (!row) return null;
265
+ return {
266
+ subjectId: sid,
267
+ placedAt: row.placedAt,
268
+ placedBy: row.placedBy,
269
+ reason: row.reason,
270
+ custodian: row.custodian,
271
+ citation: row.citation,
272
+ retainUntil: row.retainUntil,
273
+ lapsed: !!(row.retainUntil && row.retainUntil < Date.now()),
274
+ };
275
+ }
276
+
277
+ function list() {
278
+ _ensureSchema();
279
+ var rows = db.prepare(
280
+ 'SELECT subjectIdHash, placedAt, placedBy, reason, custodian, citation, retainUntil ' +
281
+ 'FROM "_blamejs_legal_hold" ORDER BY placedAt'
282
+ ).all();
283
+ var nowMs = Date.now();
284
+ return rows.map(function (r) {
285
+ return {
286
+ subjectIdHash: r.subjectIdHash,
287
+ placedAt: r.placedAt,
288
+ placedBy: r.placedBy,
289
+ reason: r.reason,
290
+ custodian: r.custodian,
291
+ citation: r.citation,
292
+ retainUntil: r.retainUntil,
293
+ lapsed: !!(r.retainUntil && r.retainUntil < nowMs),
294
+ };
295
+ });
296
+ }
297
+
298
+ function history(subjectId) {
299
+ // Re-derive the placement/release history from the audit chain.
300
+ // We don't store a separate history table — the audit_log is
301
+ // already tamper-evident and chain-verified.
302
+ var sid = _subjectIdString(subjectId);
303
+ var rows = [];
304
+ try {
305
+ var auditQuery = db.prepare(
306
+ 'SELECT recordedAt, action, metadata, outcome ' +
307
+ 'FROM audit_log ' +
308
+ 'WHERE action LIKE ? AND resourceKind = ? ' +
309
+ 'ORDER BY recordedAt'
310
+ );
311
+ // resourceId is sealed, so match on resourceKind + post-filter
312
+ // by parsed metadata.
313
+ var raw = auditQuery.all("legalhold.%", "legal-hold");
314
+ for (var i = 0; i < raw.length; i++) {
315
+ var meta = null;
316
+ try { meta = safeJson.parse(raw[i].metadata || "{}"); } catch (_e) { meta = null; }
317
+ if (meta && meta.subjectId === sid) {
318
+ rows.push({
319
+ at: raw[i].recordedAt,
320
+ action: raw[i].action,
321
+ outcome: raw[i].outcome,
322
+ metadata: meta,
323
+ });
324
+ }
325
+ }
326
+ } catch (_e) { /* drop-silent: audit_log may be sealed-metadata in cluster mode */ }
327
+ return rows;
328
+ }
329
+
330
+ var instance = {
331
+ place: place,
332
+ release: release,
333
+ isHeld: isHeld,
334
+ get: get,
335
+ list: list,
336
+ history: history,
337
+ KNOWN_CITATIONS: KNOWN_CITATIONS,
338
+ };
339
+ // Auto-register the most recent instance as the framework singleton
340
+ // so b.subject.erase / b.retention can consult b.legalHold.isHeld
341
+ // without each operator threading the instance through. Operators
342
+ // building multiple registries (multi-tenant, test isolation) call
343
+ // create() once per tenant; the last create() wins for the global
344
+ // gate, and per-tenant code holds its own instance reference.
345
+ _registerSingleton(instance);
346
+ return instance;
347
+ }
348
+
349
+ // Singleton convenience — many primitives (b.subject.erase,
350
+ // b.retention) need to consult the registry without each operator
351
+ // passing a holds instance through. The first create() under a
352
+ // db handle wins; subsequent calls return the active singleton.
353
+ var _singleton = null;
354
+
355
+ function _registerSingleton(instance) {
356
+ _singleton = instance;
357
+ }
358
+
359
+ function _getSingleton() {
360
+ return _singleton;
361
+ }
362
+
363
+ function _resetForTest() {
364
+ _singleton = null;
365
+ }
366
+
367
+ module.exports = {
368
+ create: create,
369
+ KNOWN_CITATIONS: KNOWN_CITATIONS,
370
+ LegalHoldError: LegalHoldError,
371
+ _registerSingleton: _registerSingleton,
372
+ _getSingleton: _getSingleton,
373
+ _resetForTest: _resetForTest,
374
+ };
@@ -0,0 +1,320 @@
1
+ "use strict";
2
+ /**
3
+ * b.localDb.thin — lightweight node:sqlite wrapper for desktop-daemon-
4
+ * shaped local state.
5
+ *
6
+ * The framework's full b.db is the right tool when the workload needs
7
+ * vault-sealed columns, the audit chain, sealed-by-default schema
8
+ * governance, framework-schema bootstrap, derived-hash lookup, sealed-
9
+ * fields rotation, and the cross-cutting "encrypted at rest" envelope.
10
+ * That stack costs vault keys, a tmpfs sealed-file dance, schema
11
+ * declarations, and a startup audit chain — overkill for a daemon
12
+ * keeping a hundred-row local registry on the operator's laptop.
13
+ *
14
+ * b.localDb.thin keeps the parts a daemon does need:
15
+ *
16
+ * - A node:sqlite handle opened in WAL mode with sane busy-timeout.
17
+ * - Boot-time `PRAGMA integrity_check` (cheap + closes the partial-
18
+ * write data-loss class).
19
+ * - Optional corrupt-rename-and-recreate recovery: if the file fails
20
+ * integrity_check or open() raises SQLITE_CORRUPT, rename it to
21
+ * `<file>.corrupt-<unix-ms>` and start fresh against the operator-
22
+ * supplied schema.
23
+ * - LRU-bounded prepared-statement cache (matches b.db's shape so
24
+ * long-running daemons with diverse query shapes don't leak
25
+ * statement handles).
26
+ * - Audit hooks on open / recover / close so operators can wire
27
+ * the same incident review pipeline they already have for b.db.
28
+ *
29
+ * What it deliberately does NOT do:
30
+ *
31
+ * - No vault, no field encryption, no per-row keys, no SHA3 derived
32
+ * hashes — operators with PHI / PCI go straight to b.db.
33
+ * - No audit chain — there is no server-side compliance posture for
34
+ * a desktop daemon's sqlite cache.
35
+ * - No schema governance — `schemaSql` is the operator's `CREATE
36
+ * TABLE IF NOT EXISTS ...` script, run verbatim at open.
37
+ * - No background flush / no encrypted-at-rest tmpfs — the file is
38
+ * opened in place. Operators wanting at-rest encryption use full
39
+ * b.db.
40
+ *
41
+ * Public surface:
42
+ * localDbThin.thin({
43
+ * file: string, // required absolute path
44
+ * schemaSql: string, // required CREATE TABLE / INDEX script
45
+ * recovery: "refuse" | "rename-and-recreate", // default: "refuse"
46
+ * pragmas: object, // optional extra PRAGMA overrides
47
+ * audit: boolean, // default: true
48
+ * }) -> { db, prepare, run, query, close, file }
49
+ *
50
+ * Audit emits:
51
+ * localdb.thin.opened { file }
52
+ * localdb.thin.recovered { file, renamedTo }
53
+ * localdb.thin.closed { file }
54
+ */
55
+
56
+ var fs = require("fs");
57
+ var path = require("path");
58
+ var lazyRequire = require("./lazy-require");
59
+ var validateOpts = require("./validate-opts");
60
+ var safeSql = require("./safe-sql");
61
+ var { LocalDbThinError } = require("./framework-error");
62
+
63
+ var audit = lazyRequire(function () { return require("./audit"); });
64
+
65
+ // LRU prepared-statement cache cap — same magnitude as lib/db.js's full
66
+ // variant. Daemons issuing more than this many distinct SQL strings
67
+ // likely have a string-concat bug rather than a legitimate need.
68
+ var PREPARE_CACHE_MAX = 256; // allow:raw-byte-literal — distinct-statement cache cap
69
+
70
+ var ALLOWED_RECOVERY = ["refuse", "rename-and-recreate"];
71
+
72
+ // Bare NUL byte, expressed via String.fromCharCode so the source file
73
+ // itself stays pure ASCII (the codebase-patterns gate also guarantees
74
+ // this won't be confused with a literal embedded NUL during diffs).
75
+ var NUL_BYTE = String.fromCharCode(0);
76
+
77
+ function _validateOpts(opts) {
78
+ validateOpts.requireObject(opts, "localDb.thin", LocalDbThinError, "localdb-thin/bad-opts");
79
+ validateOpts.requireNonEmptyString(opts.file, "file", LocalDbThinError, "localdb-thin/bad-file");
80
+ // `file` is operator-supplied (daemon's chosen storage path), not
81
+ // request-driven input. Reject NUL bytes defensively — Node's path
82
+ // routines silently truncate at the first NUL, which would let a
83
+ // typo open a different file than the operator intended.
84
+ if (opts.file.indexOf(NUL_BYTE) !== -1) {
85
+ throw new LocalDbThinError("localdb-thin/bad-file",
86
+ "localDb.thin: file path must not contain NUL bytes");
87
+ }
88
+ validateOpts.requireNonEmptyString(opts.schemaSql, "schemaSql",
89
+ LocalDbThinError, "localdb-thin/bad-schema-sql");
90
+ var recovery = opts.recovery || "refuse";
91
+ if (ALLOWED_RECOVERY.indexOf(recovery) === -1) {
92
+ throw new LocalDbThinError("localdb-thin/bad-recovery",
93
+ "localDb.thin: recovery must be one of " + ALLOWED_RECOVERY.join(", ") +
94
+ " (got '" + recovery + "')");
95
+ }
96
+ if (opts.pragmas !== undefined &&
97
+ (typeof opts.pragmas !== "object" || Array.isArray(opts.pragmas))) {
98
+ throw new LocalDbThinError("localdb-thin/bad-pragmas",
99
+ "localDb.thin: pragmas must be an object mapping pragma name -> value");
100
+ }
101
+ }
102
+
103
+ function _runPragmas(database, extra) {
104
+ database.exec("PRAGMA journal_mode=WAL");
105
+ database.exec("PRAGMA synchronous=NORMAL");
106
+ database.exec("PRAGMA busy_timeout=5000");
107
+ database.exec("PRAGMA foreign_keys=ON");
108
+ database.exec("PRAGMA secure_delete=ON");
109
+ try { database.exec("PRAGMA trusted_schema=OFF"); } catch (_e) { /* sqlite < 3.31 */ }
110
+ try { database.exec("PRAGMA cell_size_check=ON"); } catch (_e) { /* sqlite < 3.26 */ }
111
+ if (extra && typeof extra === "object") {
112
+ var keys = Object.keys(extra);
113
+ for (var i = 0; i < keys.length; i += 1) {
114
+ var name = keys[i];
115
+ // PRAGMA names are operator-supplied keys; reject anything that
116
+ // isn't a bare SQL identifier so this never becomes a SQL-injection
117
+ // vector even at config time. Composes the same identifier shape
118
+ // safeSql.validateIdentifier enforces elsewhere.
119
+ if (!safeSql.DEFAULT_IDENTIFIER_RE.test(name) ||
120
+ name.length > safeSql.MAX_IDENTIFIER_LENGTH) {
121
+ throw new LocalDbThinError("localdb-thin/bad-pragma-name",
122
+ "localDb.thin: pragma name '" + name + "' must be a bare identifier");
123
+ }
124
+ var value = extra[name];
125
+ if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
126
+ throw new LocalDbThinError("localdb-thin/bad-pragma-value",
127
+ "localDb.thin: pragma '" + name + "' value must be string|number|boolean");
128
+ }
129
+ database.exec("PRAGMA " + name + "=" + String(value));
130
+ }
131
+ }
132
+ }
133
+
134
+ function _integrityOk(database) {
135
+ try {
136
+ var rows = database.prepare("PRAGMA integrity_check").all();
137
+ return rows.length === 1 && rows[0] && rows[0].integrity_check === "ok";
138
+ } catch (_e) {
139
+ return false;
140
+ }
141
+ }
142
+
143
+ function thin(opts) {
144
+ _validateOpts(opts);
145
+
146
+ var auditOn = opts.audit !== false;
147
+ // opts.file is operator-config (daemon-author chosen storage path),
148
+ // not request-driven input. Validation above already rejected non-
149
+ // strings and NUL bytes. The operator picks the file location; the
150
+ // wrapper opens it as-is.
151
+ var file = opts.file;
152
+ var recovery = opts.recovery || "refuse";
153
+
154
+ function _safeEmitAudit(action, metadata) {
155
+ if (!auditOn) return;
156
+ try { audit().safeEmit({ action: action, outcome: "success", metadata: metadata || {} }); }
157
+ catch (_e) { /* drop-silent — audit best-effort */ }
158
+ }
159
+
160
+ // node:sqlite is required lazily so a process never importing localDb
161
+ // doesn't pay the cost of resolving it at module load.
162
+ var nodeSqlite = require("node:sqlite");
163
+ var DatabaseSync = nodeSqlite.DatabaseSync;
164
+ if (typeof DatabaseSync !== "function") {
165
+ throw new LocalDbThinError("localdb-thin/sqlite-missing",
166
+ "localDb.thin: node:sqlite is unavailable on this Node build (requires Node 24.14+)");
167
+ }
168
+
169
+ // Ensure parent directory exists — operators commonly point this at
170
+ // an OS app-data path that may not exist on first daemon launch.
171
+ try { fs.mkdirSync(path.dirname(file), { recursive: true }); } catch (_e) { /* best-effort */ }
172
+
173
+ var database = null;
174
+ var renamedTo = null;
175
+
176
+ function _attemptOpen() {
177
+ var db = new DatabaseSync(file);
178
+ _runPragmas(db, opts.pragmas);
179
+ if (!_integrityOk(db)) {
180
+ try { db.close(); } catch (_e) { /* best-effort */ }
181
+ throw new LocalDbThinError("localdb-thin/corrupt",
182
+ "localDb.thin: PRAGMA integrity_check failed for '" + file + "'");
183
+ }
184
+ db.exec(opts.schemaSql);
185
+ return db;
186
+ }
187
+
188
+ try {
189
+ database = _attemptOpen();
190
+ } catch (e) {
191
+ var corrupt = (e && e.code === "localdb-thin/corrupt") ||
192
+ (e && typeof e.message === "string" &&
193
+ /SQLITE_CORRUPT|malformed|not a database/i.test(e.message));
194
+ if (corrupt && recovery === "rename-and-recreate") {
195
+ var stamp = String(Date.now());
196
+ renamedTo = file + ".corrupt-" + stamp;
197
+ // Bounded rename retry — Windows holds a file lock briefly after
198
+ // DatabaseSync.close() returns. Linux/macOS land on the first
199
+ // attempt. Capped at ~250ms total so a genuinely-stuck handle
200
+ // still surfaces as recovery-failed rather than hanging.
201
+ var renamed = false;
202
+ var lastRenameErr = null;
203
+ for (var attempt = 0; attempt < 5 && !renamed; attempt += 1) {
204
+ try {
205
+ if (fs.existsSync(file)) fs.renameSync(file, renamedTo);
206
+ renamed = true;
207
+ } catch (re) {
208
+ lastRenameErr = re;
209
+ if (re && (re.code === "EBUSY" || re.code === "EPERM")) {
210
+ // Synchronous spin — don't reach for setTimeout in a
211
+ // boot-time path. 50ms × 5 = 250ms upper bound.
212
+ var until = Date.now() + 50;
213
+ while (Date.now() < until) { /* spin */ }
214
+ continue;
215
+ }
216
+ throw new LocalDbThinError("localdb-thin/recovery-failed",
217
+ "localDb.thin: rename of corrupt file failed: " + ((re && re.message) || String(re)));
218
+ }
219
+ }
220
+ if (!renamed) {
221
+ throw new LocalDbThinError("localdb-thin/recovery-failed",
222
+ "localDb.thin: rename of corrupt file failed: " +
223
+ ((lastRenameErr && lastRenameErr.message) || "unknown"));
224
+ }
225
+ // Also move WAL/SHM siblings if present so the fresh DB doesn't
226
+ // re-attach a half-open journal.
227
+ ["-wal", "-shm"].forEach(function (suffix) {
228
+ var sibling = file + suffix;
229
+ if (fs.existsSync(sibling)) {
230
+ try { fs.renameSync(sibling, sibling + ".corrupt-" + stamp); }
231
+ catch (_se) { /* best-effort */ }
232
+ }
233
+ });
234
+ database = _attemptOpen();
235
+ _safeEmitAudit("localdb.thin.recovered", { file: file, renamedTo: renamedTo });
236
+ } else if (corrupt) {
237
+ throw new LocalDbThinError("localdb-thin/corrupt",
238
+ "localDb.thin: file '" + file + "' is corrupt; pass recovery: 'rename-and-recreate' to auto-recover");
239
+ } else if (e && e.isLocalDbThinError) {
240
+ // Bad-pragma / bad-shape errors bubble up from _runPragmas with
241
+ // their own typed code already attached — re-throw verbatim
242
+ // rather than re-wrapping behind open-failed.
243
+ throw e;
244
+ } else {
245
+ throw new LocalDbThinError("localdb-thin/open-failed",
246
+ "localDb.thin: open of '" + file + "' failed: " + ((e && e.message) || String(e)));
247
+ }
248
+ }
249
+
250
+ _safeEmitAudit("localdb.thin.opened", { file: file });
251
+
252
+ // ---- Prepared-statement cache ----
253
+ var prepareCache = new Map();
254
+ var closed = false;
255
+
256
+ function _ensureOpen() {
257
+ if (closed) {
258
+ throw new LocalDbThinError("localdb-thin/closed",
259
+ "localDb.thin: handle is closed");
260
+ }
261
+ }
262
+
263
+ function prepare(sql) {
264
+ _ensureOpen();
265
+ validateOpts.requireNonEmptyString(sql, "sql",
266
+ LocalDbThinError, "localdb-thin/bad-sql");
267
+ if (prepareCache.has(sql)) {
268
+ // Touch for LRU
269
+ var hit = prepareCache.get(sql);
270
+ prepareCache.delete(sql);
271
+ prepareCache.set(sql, hit);
272
+ return hit;
273
+ }
274
+ var stmt = database.prepare(sql);
275
+ prepareCache.set(sql, stmt);
276
+ if (prepareCache.size > PREPARE_CACHE_MAX) {
277
+ var oldest = prepareCache.keys().next().value;
278
+ prepareCache.delete(oldest);
279
+ }
280
+ return stmt;
281
+ }
282
+
283
+ function run(sql /* , ...params */) {
284
+ _ensureOpen();
285
+ var params = Array.prototype.slice.call(arguments, 1);
286
+ var stmt = prepare(sql);
287
+ return stmt.run.apply(stmt, params);
288
+ }
289
+
290
+ function query(sql /* , ...params */) {
291
+ _ensureOpen();
292
+ var params = Array.prototype.slice.call(arguments, 1);
293
+ var stmt = prepare(sql);
294
+ return stmt.all.apply(stmt, params);
295
+ }
296
+
297
+ function close() {
298
+ if (closed) return;
299
+ closed = true;
300
+ prepareCache.clear();
301
+ try { database.close(); } catch (_e) { /* best-effort */ }
302
+ _safeEmitAudit("localdb.thin.closed", { file: file });
303
+ }
304
+
305
+ return {
306
+ db: database,
307
+ prepare: prepare,
308
+ run: run,
309
+ query: query,
310
+ close: close,
311
+ file: file,
312
+ recovered: !!renamedTo,
313
+ recoveredTo: renamedTo,
314
+ };
315
+ }
316
+
317
+ module.exports = {
318
+ thin: thin,
319
+ LocalDbThinError: LocalDbThinError,
320
+ };