@blamejs/core 0.8.43 → 0.8.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/README.md +10 -10
  3. package/index.js +52 -0
  4. package/lib/a2a.js +159 -34
  5. package/lib/acme.js +762 -0
  6. package/lib/ai-pref.js +166 -43
  7. package/lib/api-key.js +108 -47
  8. package/lib/api-snapshot.js +157 -40
  9. package/lib/app-shutdown.js +113 -77
  10. package/lib/archive.js +337 -40
  11. package/lib/arg-parser.js +697 -0
  12. package/lib/asyncapi.js +99 -55
  13. package/lib/atomic-file.js +465 -104
  14. package/lib/audit-chain.js +123 -34
  15. package/lib/audit-daily-review.js +389 -0
  16. package/lib/audit-sign.js +302 -56
  17. package/lib/audit-tools.js +412 -63
  18. package/lib/audit.js +656 -35
  19. package/lib/auth/jwt-external.js +17 -0
  20. package/lib/auth/oauth.js +7 -0
  21. package/lib/auth-bot-challenge.js +505 -0
  22. package/lib/auth-header.js +92 -25
  23. package/lib/backup/bundle.js +26 -0
  24. package/lib/backup/index.js +512 -89
  25. package/lib/backup/manifest.js +168 -7
  26. package/lib/break-glass.js +415 -39
  27. package/lib/budr.js +103 -30
  28. package/lib/bundler.js +86 -66
  29. package/lib/cache.js +192 -72
  30. package/lib/chain-writer.js +65 -40
  31. package/lib/circuit-breaker.js +56 -33
  32. package/lib/cli-helpers.js +106 -75
  33. package/lib/cli.js +6 -30
  34. package/lib/cloud-events.js +99 -32
  35. package/lib/cluster-storage.js +162 -37
  36. package/lib/cluster.js +340 -49
  37. package/lib/codepoint-class.js +66 -0
  38. package/lib/compliance.js +424 -24
  39. package/lib/config-drift.js +111 -46
  40. package/lib/config.js +94 -40
  41. package/lib/consent.js +165 -18
  42. package/lib/constants.js +1 -0
  43. package/lib/content-credentials.js +153 -48
  44. package/lib/cookies.js +154 -62
  45. package/lib/credential-hash.js +133 -61
  46. package/lib/crypto-field.js +702 -18
  47. package/lib/crypto-hpke.js +256 -0
  48. package/lib/crypto.js +744 -22
  49. package/lib/csv.js +178 -35
  50. package/lib/daemon.js +456 -0
  51. package/lib/dark-patterns.js +186 -55
  52. package/lib/db-query.js +79 -2
  53. package/lib/db.js +1431 -60
  54. package/lib/ddl-change-control.js +523 -0
  55. package/lib/deprecate.js +195 -40
  56. package/lib/dev.js +82 -39
  57. package/lib/dora.js +67 -48
  58. package/lib/dr-runbook.js +368 -0
  59. package/lib/dsr.js +142 -11
  60. package/lib/dual-control.js +91 -56
  61. package/lib/events.js +120 -41
  62. package/lib/external-db-migrate.js +192 -2
  63. package/lib/external-db.js +795 -50
  64. package/lib/fapi2.js +122 -1
  65. package/lib/fda-21cfr11.js +395 -0
  66. package/lib/fdx.js +132 -2
  67. package/lib/file-type.js +87 -0
  68. package/lib/file-upload.js +93 -0
  69. package/lib/flag.js +82 -20
  70. package/lib/forms.js +132 -29
  71. package/lib/framework-error.js +169 -0
  72. package/lib/framework-schema.js +163 -35
  73. package/lib/gate-contract.js +849 -175
  74. package/lib/graphql-federation.js +68 -7
  75. package/lib/guard-all.js +172 -55
  76. package/lib/guard-archive.js +286 -124
  77. package/lib/guard-auth.js +194 -21
  78. package/lib/guard-cidr.js +190 -28
  79. package/lib/guard-csv.js +397 -51
  80. package/lib/guard-domain.js +213 -91
  81. package/lib/guard-email.js +236 -29
  82. package/lib/guard-filename.js +307 -75
  83. package/lib/guard-graphql.js +263 -30
  84. package/lib/guard-html.js +310 -116
  85. package/lib/guard-image.js +243 -30
  86. package/lib/guard-json.js +260 -54
  87. package/lib/guard-jsonpath.js +235 -23
  88. package/lib/guard-jwt.js +284 -30
  89. package/lib/guard-markdown.js +204 -22
  90. package/lib/guard-mime.js +190 -26
  91. package/lib/guard-oauth.js +277 -28
  92. package/lib/guard-pdf.js +251 -27
  93. package/lib/guard-regex.js +226 -18
  94. package/lib/guard-shell.js +229 -26
  95. package/lib/guard-svg.js +177 -10
  96. package/lib/guard-template.js +232 -21
  97. package/lib/guard-time.js +195 -29
  98. package/lib/guard-uuid.js +189 -30
  99. package/lib/guard-xml.js +259 -36
  100. package/lib/guard-yaml.js +241 -44
  101. package/lib/honeytoken.js +63 -27
  102. package/lib/html-balance.js +83 -0
  103. package/lib/http-client.js +486 -59
  104. package/lib/http-message-signature.js +582 -0
  105. package/lib/i18n.js +102 -49
  106. package/lib/iab-mspa.js +112 -32
  107. package/lib/iab-tcf.js +107 -2
  108. package/lib/inbox.js +90 -52
  109. package/lib/keychain.js +865 -0
  110. package/lib/legal-hold.js +374 -0
  111. package/lib/local-db-thin.js +320 -0
  112. package/lib/log-stream.js +281 -51
  113. package/lib/log.js +184 -86
  114. package/lib/mail-bounce.js +107 -62
  115. package/lib/mail.js +295 -58
  116. package/lib/mcp.js +108 -27
  117. package/lib/metrics.js +98 -89
  118. package/lib/middleware/age-gate.js +36 -0
  119. package/lib/middleware/ai-act-disclosure.js +37 -0
  120. package/lib/middleware/api-encrypt.js +45 -0
  121. package/lib/middleware/assetlinks.js +40 -0
  122. package/lib/middleware/asyncapi-serve.js +35 -0
  123. package/lib/middleware/attach-user.js +40 -0
  124. package/lib/middleware/bearer-auth.js +40 -0
  125. package/lib/middleware/body-parser.js +230 -0
  126. package/lib/middleware/bot-disclose.js +34 -0
  127. package/lib/middleware/bot-guard.js +39 -0
  128. package/lib/middleware/compression.js +37 -0
  129. package/lib/middleware/cookies.js +32 -0
  130. package/lib/middleware/cors.js +40 -0
  131. package/lib/middleware/csp-nonce.js +40 -0
  132. package/lib/middleware/csp-report.js +34 -0
  133. package/lib/middleware/csrf-protect.js +43 -0
  134. package/lib/middleware/daily-byte-quota.js +53 -85
  135. package/lib/middleware/db-role-for.js +40 -0
  136. package/lib/middleware/dpop.js +40 -0
  137. package/lib/middleware/error-handler.js +37 -14
  138. package/lib/middleware/fetch-metadata.js +39 -0
  139. package/lib/middleware/flag-context.js +34 -0
  140. package/lib/middleware/gpc.js +33 -0
  141. package/lib/middleware/headers.js +35 -0
  142. package/lib/middleware/health.js +46 -0
  143. package/lib/middleware/host-allowlist.js +30 -0
  144. package/lib/middleware/network-allowlist.js +38 -0
  145. package/lib/middleware/openapi-serve.js +34 -0
  146. package/lib/middleware/rate-limit.js +160 -18
  147. package/lib/middleware/request-id.js +36 -18
  148. package/lib/middleware/request-log.js +37 -0
  149. package/lib/middleware/require-aal.js +29 -0
  150. package/lib/middleware/require-auth.js +32 -0
  151. package/lib/middleware/require-bound-key.js +41 -0
  152. package/lib/middleware/require-content-type.js +32 -0
  153. package/lib/middleware/require-methods.js +27 -0
  154. package/lib/middleware/require-mtls.js +33 -0
  155. package/lib/middleware/require-step-up.js +37 -0
  156. package/lib/middleware/security-headers.js +44 -0
  157. package/lib/middleware/security-txt.js +38 -0
  158. package/lib/middleware/span-http-server.js +37 -0
  159. package/lib/middleware/sse.js +36 -0
  160. package/lib/middleware/trace-log-correlation.js +33 -0
  161. package/lib/middleware/trace-propagate.js +32 -0
  162. package/lib/middleware/tus-upload.js +90 -0
  163. package/lib/middleware/web-app-manifest.js +53 -0
  164. package/lib/mtls-ca.js +100 -70
  165. package/lib/network-byte-quota.js +308 -0
  166. package/lib/network-heartbeat.js +135 -0
  167. package/lib/network-tls.js +534 -4
  168. package/lib/network.js +103 -0
  169. package/lib/notify.js +114 -43
  170. package/lib/ntp-check.js +192 -51
  171. package/lib/observability.js +145 -47
  172. package/lib/openapi.js +90 -44
  173. package/lib/outbox.js +99 -1
  174. package/lib/pagination.js +168 -86
  175. package/lib/parsers/index.js +16 -5
  176. package/lib/permissions.js +93 -40
  177. package/lib/pqc-agent.js +84 -8
  178. package/lib/pqc-software.js +94 -60
  179. package/lib/process-spawn.js +95 -21
  180. package/lib/pubsub.js +96 -66
  181. package/lib/queue.js +375 -54
  182. package/lib/redact.js +793 -21
  183. package/lib/render.js +139 -47
  184. package/lib/request-helpers.js +485 -121
  185. package/lib/restore-bundle.js +142 -39
  186. package/lib/restore-rollback.js +136 -45
  187. package/lib/retention.js +178 -50
  188. package/lib/retry.js +116 -33
  189. package/lib/router.js +475 -23
  190. package/lib/safe-async.js +543 -94
  191. package/lib/safe-buffer.js +337 -41
  192. package/lib/safe-json.js +467 -62
  193. package/lib/safe-jsonpath.js +285 -0
  194. package/lib/safe-schema.js +631 -87
  195. package/lib/safe-sql.js +221 -59
  196. package/lib/safe-url.js +278 -46
  197. package/lib/sandbox-worker.js +135 -0
  198. package/lib/sandbox.js +358 -0
  199. package/lib/scheduler.js +135 -70
  200. package/lib/self-update.js +647 -0
  201. package/lib/session-device-binding.js +431 -0
  202. package/lib/session.js +259 -49
  203. package/lib/slug.js +138 -26
  204. package/lib/ssrf-guard.js +316 -56
  205. package/lib/storage.js +433 -70
  206. package/lib/subject.js +405 -23
  207. package/lib/template.js +148 -8
  208. package/lib/tenant-quota.js +545 -0
  209. package/lib/testing.js +440 -53
  210. package/lib/time.js +291 -23
  211. package/lib/tls-exporter.js +239 -0
  212. package/lib/tracing.js +90 -74
  213. package/lib/uuid.js +97 -22
  214. package/lib/vault/index.js +284 -22
  215. package/lib/vault/seal-pem-file.js +66 -0
  216. package/lib/watcher.js +368 -0
  217. package/lib/webhook.js +196 -63
  218. package/lib/websocket.js +393 -68
  219. package/lib/wiki-concepts.js +338 -0
  220. package/lib/worker-pool.js +464 -0
  221. package/package.json +3 -3
  222. package/sbom.cyclonedx.json +7 -7
@@ -1,29 +1,162 @@
1
1
  "use strict";
2
2
  /**
3
- * Field-level crypto engine.
3
+ * @module b.cryptoField
4
+ * @nav Crypto
5
+ * @title Field-Level Crypto
4
6
  *
5
- * Wraps vault.seal/unseal at the row boundary so apps can declare which
6
- * columns hold PHI/PCI/personal data and the framework auto-protects them
7
- * on every write/read. Apps register their schema via db.init({ schema }) —
8
- * this module then operates on (table, row) pairs.
7
+ * @intro
8
+ * Per-column field-level encryption with AAD-bound envelopes. Apps
9
+ * declare which columns hold PHI / PCI / personal data via
10
+ * `b.db.init({ schema })`; the framework then auto-protects those
11
+ * columns on every write (`sealRow`) and reverses on every read
12
+ * (`unsealRow`). Sealed values are produced by `b.vault.seal`, which
13
+ * wraps an XChaCha20-Poly1305 ciphertext under the framework's PQC
14
+ * envelope (ML-KEM + ECDH hybrid) — every encryption uses a fresh
15
+ * random nonce, so two seals of the same plaintext never collide.
9
16
  *
10
- * Per-column field types:
11
- * - sealedFields: vault.seal() on write, vault.unseal() on read
12
- * - derivedHashes: computed from a source field on write, indexed lookup
13
- * enabled via where({ derivedField: hashFor(...) })
17
+ * Per-row key (K_row) derivation is opt-in via `declarePerRowKey`.
18
+ * Tables that opt in get a fresh K_row per INSERT, stored sealed in
19
+ * `_blamejs_per_row_keys`. AAD on the K_row binds (table, rowId,
20
+ * info-label) copying a wrapped K_row from one row to another
21
+ * fails Poly1305 verification, so a DB-write attacker cannot move
22
+ * ciphertext between rows to bypass row-scoped erasure. This is the
23
+ * crypto-shred substrate for `b.subject.eraseHard`: deleting the
24
+ * K_row entry leaves WAL / replica residual ciphertext mathematically
25
+ * undecryptable.
14
26
  *
15
- * Apps that need a one-way hash field (e.g. an opaque IP block list) build
16
- * the derived hash themselves with a custom namespace via db.hashFor().
27
+ * Derived hashes (`derivedHashes`) provide indexed lookup for sealed
28
+ * columns: a normalized SHA3 of the plaintext, salted by the vault's
29
+ * per-deployment salt + a per-field namespace, so dictionary /
30
+ * rainbow attacks across fields and across deployments fail. Sealed
31
+ * columns without a derived hash are unindexable — queries on them
32
+ * silently return zero rows.
17
33
  *
18
- * No mutation of the input row — every operation returns a new object.
34
+ * Per-column residency (`declareColumnResidency`) declares EU / US /
35
+ * global tags; the storage-write gate (`assertColumnResidency`)
36
+ * refuses writes to a backend whose tag doesn't satisfy the column
37
+ * under gdpr / dpdp / pipl-cn / uk-gdpr postures.
38
+ *
39
+ * No mutation of the input row — every operation returns a new
40
+ * object, suitable for direct insertion into the audit chain.
41
+ *
42
+ * @card
43
+ * Per-column field-level encryption with AAD-bound envelopes.
19
44
  */
45
+ var lazyRequire = require("./lazy-require");
20
46
  var vault = require("./vault");
21
- var { sha3Hash } = require("./crypto");
47
+ var { sha3Hash, kdf } = require("./crypto");
22
48
  var { HASH_PREFIX, VAULT_PREFIX, TIME } = require("./constants");
23
49
 
50
+ var complianceMod = lazyRequire(function () { return require("./compliance"); });
51
+ var dbMod = lazyRequire(function () { return require("./db"); });
52
+ var auditMod = lazyRequire(function () { return require("./audit"); });
53
+
54
+ // F-POSTURE-1 cascade hook + F-RTBF-2 integration. Recording the
55
+ // posture lets eraseRow call b.db.vacuumAfterErase({ mode: "full" })
56
+ // automatically under postures whose POSTURE_DEFAULTS sets
57
+ // requireVacuumAfterErase: true (gdpr / dpdp / pipl-cn / lgpd-br /
58
+ // hipaa). Without the vacuum, freed B-tree index pages keep sealed-
59
+ // column ciphertext readable from a forensic disk image — defeats the
60
+ // "right to erasure" the regulatory regime guarantees.
61
+ var _activePosture = null;
62
+
63
+ /**
64
+ * @primitive b.cryptoField.applyPosture
65
+ * @signature b.cryptoField.applyPosture(posture)
66
+ * @since 0.7.27
67
+ * @compliance gdpr, hipaa
68
+ * @related b.cryptoField.getActivePosture, b.cryptoField.eraseRow
69
+ *
70
+ * Records the active compliance posture so `eraseRow` can cascade into
71
+ * `b.db.vacuumAfterErase({ mode: "full" })` under regimes whose
72
+ * `POSTURE_DEFAULTS` sets `requireVacuumAfterErase: true` (gdpr / dpdp /
73
+ * pipl-cn / lgpd-br / hipaa). Without the vacuum, freed B-tree index
74
+ * pages keep sealed-column ciphertext readable from a forensic disk
75
+ * image — defeating the "right to erasure" the regime guarantees.
76
+ * Returns null when posture is empty/non-string; otherwise returns
77
+ * `{ posture, requireVacuumAfterErase }`.
78
+ *
79
+ * @example
80
+ * var info = b.cryptoField.applyPosture("gdpr");
81
+ * info.posture; // → "gdpr"
82
+ * info.requireVacuumAfterErase; // → true
83
+ *
84
+ * b.cryptoField.applyPosture(""); // → null (no-op)
85
+ */
86
+ function applyPosture(posture) {
87
+ if (typeof posture !== "string" || posture.length === 0) return null;
88
+ _activePosture = posture;
89
+ var requireVacuum = false;
90
+ try {
91
+ requireVacuum = complianceMod().postureDefault(posture, "requireVacuumAfterErase") === true;
92
+ } catch (_e) { /* compliance not loaded — record posture only */ }
93
+ return { posture: posture, requireVacuumAfterErase: requireVacuum };
94
+ }
95
+
96
+ /**
97
+ * @primitive b.cryptoField.getActivePosture
98
+ * @signature b.cryptoField.getActivePosture()
99
+ * @since 0.7.27
100
+ * @related b.cryptoField.applyPosture
101
+ *
102
+ * Returns the posture string most recently recorded via `applyPosture`,
103
+ * or null when no posture has been applied. Read-only — does not
104
+ * mutate state. Used by storage backends to gate cross-border writes.
105
+ *
106
+ * @example
107
+ * b.cryptoField.applyPosture("hipaa");
108
+ * b.cryptoField.getActivePosture(); // → "hipaa"
109
+ */
110
+ function getActivePosture() { return _activePosture; }
111
+
24
112
  // Per-table registry, populated by db.init()
25
113
  var schemas = Object.create(null);
26
114
 
115
+ // F-CBT-1 — per-COLUMN data residency registry. Real GDPR / DPDP
116
+ // deployments have row-level mixed residency: a `users.name` column
117
+ // may be global, but `users.addressLine1` must stay in EU storage.
118
+ // db.init({ schema }) carries the operator's residency declaration
119
+ // per table; this registry stores it for cross-region check at the
120
+ // storage-write boundary.
121
+ //
122
+ // { tableName: { columnName: "eu" | "us" | "global" | <tag> } }
123
+ var columnResidency = Object.create(null);
124
+
125
+ // F-RTBF-3 — per-row key declaration registry. For tables that opt
126
+ // into per-row keying, b.subject.eraseHard deletes the wrapped K_row
127
+ // from _blamejs_per_row_keys, leaving WAL/replica residual ciphertext
128
+ // undecryptable.
129
+ //
130
+ // { tableName: { keySize, info, residencyTag } }
131
+ var perRowKeyTables = Object.create(null);
132
+
133
+ /**
134
+ * @primitive b.cryptoField.registerTable
135
+ * @signature b.cryptoField.registerTable(name, opts)
136
+ * @since 0.4.0
137
+ * @related b.cryptoField.getSchema, b.cryptoField.sealRow
138
+ *
139
+ * Registers a table's sealed-column declaration. Called from
140
+ * `b.db.init({ schema })` at boot — operators rarely call directly.
141
+ * Stores the per-table list of sealed fields, the derived-hash specs
142
+ * (mapping `derivedField -> { from, normalize }`), and any per-field
143
+ * hash namespaces. Subsequent `sealRow` / `unsealRow` / `eraseRow`
144
+ * calls dispatch through this registry.
145
+ *
146
+ * @opts
147
+ * sealedFields: string[], // column names sealed via vault.seal
148
+ * derivedHashes: { [hashCol]: { from: string, normalize?: fn } },
149
+ * hashNamespaces: { [field]: string }, // override default rainbow-defense ns
150
+ *
151
+ * @example
152
+ * b.cryptoField.registerTable("patients", {
153
+ * sealedFields: ["ssn", "diagnosis"],
154
+ * derivedHashes: {
155
+ * ssnHash: { from: "ssn", normalize: function (s) { return String(s).replace(/-/g, ""); } }
156
+ * }
157
+ * });
158
+ * b.cryptoField.getSealedFields("patients"); // → ["ssn", "diagnosis"]
159
+ */
27
160
  function registerTable(name, opts) {
28
161
  schemas[name] = {
29
162
  sealedFields: Array.isArray(opts.sealedFields) ? opts.sealedFields.slice() : [],
@@ -32,15 +165,68 @@ function registerTable(name, opts) {
32
165
  };
33
166
  }
34
167
 
168
+ /**
169
+ * @primitive b.cryptoField.getSchema
170
+ * @signature b.cryptoField.getSchema(table)
171
+ * @since 0.4.0
172
+ * @related b.cryptoField.registerTable, b.cryptoField.getSealedFields
173
+ *
174
+ * Returns the registered schema record for `table` — `{ sealedFields,
175
+ * derivedHashes, hashNamespaces }` — or null when the table was never
176
+ * registered. Read-only; mutations to the returned object do not
177
+ * affect future calls (the inner arrays/objects are shared, so
178
+ * operators should treat the result as read-only).
179
+ *
180
+ * @example
181
+ * b.cryptoField.registerTable("patients", { sealedFields: ["ssn"] });
182
+ * var schema = b.cryptoField.getSchema("patients");
183
+ * schema.sealedFields; // → ["ssn"]
184
+ *
185
+ * b.cryptoField.getSchema("unknown"); // → null
186
+ */
35
187
  function getSchema(table) {
36
188
  return schemas[table] || null;
37
189
  }
38
190
 
191
+ /**
192
+ * @primitive b.cryptoField.getSealedFields
193
+ * @signature b.cryptoField.getSealedFields(table)
194
+ * @since 0.4.0
195
+ * @related b.cryptoField.getSchema, b.cryptoField.sealRow
196
+ *
197
+ * Returns the array of sealed column names for `table`, or an empty
198
+ * array when the table is unregistered. Convenience accessor used by
199
+ * storage backends to know which columns to wrap in `vault.seal` on
200
+ * write and `vault.unseal` on read.
201
+ *
202
+ * @example
203
+ * b.cryptoField.registerTable("patients", { sealedFields: ["ssn", "diagnosis"] });
204
+ * b.cryptoField.getSealedFields("patients"); // → ["ssn", "diagnosis"]
205
+ * b.cryptoField.getSealedFields("public"); // → []
206
+ */
39
207
  function getSealedFields(table) {
40
208
  var s = schemas[table];
41
209
  return s ? s.sealedFields : [];
42
210
  }
43
211
 
212
+ /**
213
+ * @primitive b.cryptoField.clearForTest
214
+ * @signature b.cryptoField.clearForTest()
215
+ * @since 0.4.0
216
+ * @status experimental
217
+ * @related b.cryptoField.registerTable
218
+ *
219
+ * Test-only helper. Drops every entry from the per-table schema
220
+ * registry so a test fixture can re-register tables under different
221
+ * sealed-field declarations between cases. Operator code never calls
222
+ * this — production schemas come from `b.db.init({ schema })` once at
223
+ * boot.
224
+ *
225
+ * @example
226
+ * b.cryptoField.registerTable("patients", { sealedFields: ["ssn"] });
227
+ * b.cryptoField.clearForTest();
228
+ * b.cryptoField.getSchema("patients"); // → null
229
+ */
44
230
  function clearForTest() {
45
231
  for (var k in schemas) delete schemas[k];
46
232
  }
@@ -57,6 +243,31 @@ function namespaceFor(table, field, registered) {
57
243
  return "bj-" + table + "-" + field + ":";
58
244
  }
59
245
 
246
+ /**
247
+ * @primitive b.cryptoField.computeDerived
248
+ * @signature b.cryptoField.computeDerived(table, sourceField, sourceValue)
249
+ * @since 0.4.0
250
+ * @related b.cryptoField.lookupHash, b.cryptoField.sealRow
251
+ *
252
+ * Computes the derived hash for a (table, sourceField) pair when the
253
+ * schema declares a derived-hash mirror of that source. Returns
254
+ * `{ field, value }` naming the derived column and its hash, or null
255
+ * when no derived hash is declared. Hashes are SHA3 of
256
+ * `vaultSalt + namespace + normalizedValue`, where the per-deployment
257
+ * vault salt prevents cross-deployment correlation and the per-field
258
+ * namespace prevents cross-field rainbow attacks.
259
+ *
260
+ * @example
261
+ * b.cryptoField.registerTable("users", {
262
+ * sealedFields: ["email"],
263
+ * derivedHashes: { emailHash: { from: "email" } }
264
+ * });
265
+ * var d = b.cryptoField.computeDerived("users", "email", "alice@example.com");
266
+ * d.field; // → "emailHash"
267
+ * typeof d.value; // → "string"
268
+ *
269
+ * b.cryptoField.computeDerived("users", "email", null); // → null
270
+ */
60
271
  function computeDerived(table, sourceField, sourceValue) {
61
272
  if (sourceValue === undefined || sourceValue === null) return null;
62
273
  var s = schemas[table];
@@ -76,6 +287,32 @@ function computeDerived(table, sourceField, sourceValue) {
76
287
 
77
288
  // ---- Row sealing / unsealing ----
78
289
 
290
+ /**
291
+ * @primitive b.cryptoField.sealRow
292
+ * @signature b.cryptoField.sealRow(table, row)
293
+ * @since 0.4.0
294
+ * @compliance hipaa, gdpr, pci-dss
295
+ * @related b.cryptoField.unsealRow, b.cryptoField.eraseRow, b.vault.seal
296
+ *
297
+ * Returns a copy of `row` with every sealed column wrapped in
298
+ * `vault.seal()` and every derived-hash mirror computed from the
299
+ * pre-seal plaintext. The input row is never mutated. `vault.seal` is
300
+ * idempotent — already-sealed values pass through unchanged so
301
+ * round-trips through the storage layer are safe. Derived hashes are
302
+ * computed BEFORE sealing the source so the indexed lookup column
303
+ * captures the plaintext digest.
304
+ *
305
+ * @example
306
+ * b.cryptoField.registerTable("patients", {
307
+ * sealedFields: ["ssn"],
308
+ * derivedHashes: { ssnHash: { from: "ssn" } }
309
+ * });
310
+ * var row = { id: 1, name: "Alice", ssn: "123-45-6789" };
311
+ * var sealed = b.cryptoField.sealRow("patients", row);
312
+ * String(sealed.ssn).startsWith("vault:"); // → true
313
+ * typeof sealed.ssnHash; // → "string"
314
+ * row.ssn; // → "123-45-6789" (input untouched)
315
+ */
79
316
  function sealRow(table, row) {
80
317
  if (!row) return row;
81
318
  var s = schemas[table];
@@ -109,6 +346,28 @@ function sealRow(table, row) {
109
346
  return out;
110
347
  }
111
348
 
349
+ /**
350
+ * @primitive b.cryptoField.unsealRow
351
+ * @signature b.cryptoField.unsealRow(table, row)
352
+ * @since 0.4.0
353
+ * @compliance hipaa, gdpr, pci-dss
354
+ * @related b.cryptoField.sealRow, b.vault.unseal
355
+ *
356
+ * Returns a copy of `row` with every sealed column unwrapped via
357
+ * `vault.unseal()`. Round-trips with `sealRow`. When `vault.unseal`
358
+ * throws (DB-write attacker forging a `vault:<crafted>` payload to
359
+ * force ML-KEM decapsulation on attacker-controlled bytes), the
360
+ * failure is recorded on the audit chain as
361
+ * `system.crypto.unseal_failed` and the field is replaced with null
362
+ * so downstream code sees "no value" instead of crashing the request.
363
+ * The input row is never mutated.
364
+ *
365
+ * @example
366
+ * b.cryptoField.registerTable("patients", { sealedFields: ["ssn"] });
367
+ * var sealed = b.cryptoField.sealRow("patients", { id: 1, ssn: "123-45-6789" });
368
+ * var clear = b.cryptoField.unsealRow("patients", sealed);
369
+ * clear.ssn; // → "123-45-6789"
370
+ */
112
371
  function unsealRow(table, row) {
113
372
  if (!row) return row;
114
373
  var s = schemas[table];
@@ -161,6 +420,38 @@ function unsealRow(table, row) {
161
420
  // Callers that need the row removed entirely should DELETE; eraseRow
162
421
  // is for the case where downstream FKs / audit references make
163
422
  // outright deletion infeasible.
423
+
424
+ /**
425
+ * @primitive b.cryptoField.eraseRow
426
+ * @signature b.cryptoField.eraseRow(table, row)
427
+ * @since 0.7.10
428
+ * @compliance gdpr, hipaa
429
+ * @related b.cryptoField.sealRow, b.subject.eraseHard, b.db.vacuumAfterErase
430
+ *
431
+ * Returns a tombstoned copy of `row`: every sealed column NULLed,
432
+ * every derived-hash mirror NULLed, and `__erasedAt` set to a
433
+ * 1-day-bucketed UTC ms timestamp (sub-day timing is intentionally
434
+ * fuzzed to defeat audit-log exfiltration + cross-tenant correlation
435
+ * attacks like "this row was erased 2.3s before that one"). Under
436
+ * regulatory postures whose `POSTURE_DEFAULTS` sets
437
+ * `requireVacuumAfterErase: true` (gdpr / dpdp / pipl-cn / lgpd-br /
438
+ * hipaa), automatically schedules `b.db.vacuumAfterErase({ mode:
439
+ * "full" })` so freed B-tree pages don't linger with sealed-column
440
+ * ciphertext readable from a forensic disk image. The row stays in
441
+ * the table for referential integrity; outright DELETE remains the
442
+ * caller's choice when FKs allow.
443
+ *
444
+ * @example
445
+ * b.cryptoField.registerTable("patients", {
446
+ * sealedFields: ["ssn"],
447
+ * derivedHashes: { ssnHash: { from: "ssn" } }
448
+ * });
449
+ * var sealed = b.cryptoField.sealRow("patients", { id: 1, ssn: "123-45-6789" });
450
+ * var erased = b.cryptoField.eraseRow("patients", sealed);
451
+ * erased.ssn; // → null
452
+ * erased.ssnHash; // → null
453
+ * typeof erased.__erasedAt; // → "number"
454
+ */
164
455
  function eraseRow(table, row) {
165
456
  if (!row) return row;
166
457
  var s = schemas[table];
@@ -190,16 +481,77 @@ function eraseRow(table, row) {
190
481
  // (which is itself sealed under the audit-sign keypair).
191
482
  var dayMs = TIME.days(1);
192
483
  out.__erasedAt = Math.floor(Date.now() / dayMs) * dayMs;
484
+
485
+ // F-RTBF-2 — under regulatory postures whose POSTURE_DEFAULTS sets
486
+ // requireVacuumAfterErase: true (gdpr / dpdp / pipl-cn / lgpd-br /
487
+ // hipaa), the B-tree index pages freed by the upcoming UPDATE/DELETE
488
+ // would otherwise linger with sealed-column ciphertext readable
489
+ // from a forensic disk image. The cascade-installed posture (set by
490
+ // b.compliance.set) drives an automatic VACUUM after the in-memory
491
+ // tombstone — the actual write happens at the operator's call site,
492
+ // and the framework only schedules the vacuum AFTER the next write.
493
+ // Each erase emits cryptofield.erase.row + (when vacuum runs)
494
+ // db.vacuum_after_erase so the audit trail covers both halves.
495
+ if (_activePosture) {
496
+ var requireVacuum = false;
497
+ try {
498
+ requireVacuum = complianceMod().postureDefault(
499
+ _activePosture, "requireVacuumAfterErase") === true;
500
+ } catch (_e) { /* compliance lookup best-effort */ }
501
+ if (requireVacuum) {
502
+ try {
503
+ var db = dbMod();
504
+ if (db && typeof db.vacuumAfterErase === "function") {
505
+ db.vacuumAfterErase({ mode: "full" });
506
+ }
507
+ } catch (_vacErr) {
508
+ // VACUUM is best-effort at the eraseRow seam — DB might not be
509
+ // initialized yet (cluster mode, test fixture). The cascade row
510
+ // captures the skip; operators on regulated postures wire the
511
+ // sweep through b.retention which gates erasure on db.init().
512
+ try {
513
+ auditMod().safeEmit({
514
+ action: "cryptofield.vacuum.skipped",
515
+ outcome: "failure",
516
+ metadata: {
517
+ posture: _activePosture,
518
+ reason: (_vacErr && _vacErr.message) ? _vacErr.message : String(_vacErr),
519
+ },
520
+ });
521
+ } catch (_ae) { /* audit best-effort */ }
522
+ }
523
+ }
524
+ }
193
525
  return out;
194
526
  }
195
527
 
196
528
  // ---- Lookup translation ----
197
529
 
198
- // where({ email: 'x' }) → where({ emailHash: hash(...) }).
199
- // If the field is sealed and has no derived hash, lookup is impossible
200
- // (sealed values use random nonces — every encryption is unique). Caller
201
- // is expected to declare a derived hash for every sealed field they want
202
- // to query; otherwise queries on sealed fields silently return zero rows.
530
+ /**
531
+ * @primitive b.cryptoField.lookupHash
532
+ * @signature b.cryptoField.lookupHash(table, field, value)
533
+ * @since 0.4.0
534
+ * @related b.cryptoField.computeDerived, b.cryptoField.sealRow
535
+ *
536
+ * Translates a plaintext-keyed lookup (e.g. `where({ email: "..." })`)
537
+ * into the derived-hash form (`where({ emailHash: hash(...) })`).
538
+ * Returns `{ field, value }` naming the derived column and its hash,
539
+ * or null when no derived hash is declared for that source field.
540
+ * Sealed columns without a declared derived hash are unindexable —
541
+ * every encryption uses a fresh random nonce, so the ciphertext alone
542
+ * cannot anchor a query.
543
+ *
544
+ * @example
545
+ * b.cryptoField.registerTable("users", {
546
+ * sealedFields: ["email"],
547
+ * derivedHashes: { emailHash: { from: "email" } }
548
+ * });
549
+ * var lookup = b.cryptoField.lookupHash("users", "email", "alice@example.com");
550
+ * lookup.field; // → "emailHash"
551
+ * typeof lookup.value; // → "string"
552
+ *
553
+ * b.cryptoField.lookupHash("users", "name", "Alice"); // → null (no derived hash)
554
+ */
203
555
  function lookupHash(table, field, value) {
204
556
  var s = schemas[table];
205
557
  if (!s || !s.derivedHashes) return null;
@@ -215,6 +567,328 @@ function lookupHash(table, field, value) {
215
567
  return null;
216
568
  }
217
569
 
570
+ /**
571
+ * @primitive b.cryptoField.declareColumnResidency
572
+ * @signature b.cryptoField.declareColumnResidency(table, opts)
573
+ * @since 0.7.27
574
+ * @compliance gdpr
575
+ * @related b.cryptoField.assertColumnResidency, b.cryptoField.getColumnResidency
576
+ *
577
+ * Declares per-column data residency for `table`. Real GDPR / DPDP /
578
+ * pipl-cn deployments have row-level mixed residency: a `users.name`
579
+ * column may be globally replicable, but `users.addressLine1` must
580
+ * stay in EU storage. At write time
581
+ * (`b.db.set` / `b.db.from(...).insert` / `.update`), the framework
582
+ * consults this registry; if the storage backend's tag doesn't satisfy
583
+ * the column's tag, the write is refused under gdpr / dpdp / pipl-cn /
584
+ * uk-gdpr postures. Throws on bad input (config-time fail-loud).
585
+ *
586
+ * @opts
587
+ * columnResidency: { [columnName]: "eu" | "us" | "global" | <tag> },
588
+ *
589
+ * @example
590
+ * b.cryptoField.declareColumnResidency("users", {
591
+ * columnResidency: {
592
+ * name: "global",
593
+ * addressLine1: "eu",
594
+ * addressLine2: "eu"
595
+ * }
596
+ * });
597
+ * var got = b.cryptoField.getColumnResidency("users");
598
+ * got.addressLine1; // → "eu"
599
+ */
600
+ function declareColumnResidency(table, opts) {
601
+ if (typeof table !== "string" || table.length === 0) {
602
+ throw new Error("declareColumnResidency: table must be a non-empty string");
603
+ }
604
+ if (opts === null || opts === undefined || typeof opts !== "object" || Array.isArray(opts)) {
605
+ throw new Error("declareColumnResidency: opts must be a plain object");
606
+ }
607
+ var map = opts.columnResidency;
608
+ if (!map || typeof map !== "object" || Array.isArray(map)) {
609
+ throw new Error("declareColumnResidency: opts.columnResidency must be an object");
610
+ }
611
+ var entry = Object.create(null);
612
+ for (var col in map) {
613
+ if (!Object.prototype.hasOwnProperty.call(map, col)) continue;
614
+ var tag = map[col];
615
+ if (typeof tag !== "string" || tag.length === 0) {
616
+ throw new Error("declareColumnResidency: column '" + col +
617
+ "' residency tag must be a non-empty string");
618
+ }
619
+ entry[col] = tag;
620
+ }
621
+ columnResidency[table] = entry;
622
+ return { table: table, columnResidency: Object.assign({}, entry) };
623
+ }
624
+
625
+ /**
626
+ * @primitive b.cryptoField.getColumnResidency
627
+ * @signature b.cryptoField.getColumnResidency(table)
628
+ * @since 0.7.27
629
+ * @related b.cryptoField.declareColumnResidency
630
+ *
631
+ * Returns the residency map declared for `table`, or null when the
632
+ * table has no residency declaration. Read-only — does not mutate
633
+ * state. Storage backends use this to inspect residency at the
634
+ * write boundary.
635
+ *
636
+ * @example
637
+ * b.cryptoField.declareColumnResidency("users", {
638
+ * columnResidency: { addressLine1: "eu" }
639
+ * });
640
+ * b.cryptoField.getColumnResidency("users"); // → { addressLine1: "eu" }
641
+ * b.cryptoField.getColumnResidency("unknown"); // → null
642
+ */
643
+ function getColumnResidency(table) {
644
+ return columnResidency[table] || null;
645
+ }
646
+
647
+ /**
648
+ * @primitive b.cryptoField.assertColumnResidency
649
+ * @signature b.cryptoField.assertColumnResidency(table, row, args)
650
+ * @since 0.7.27
651
+ * @compliance gdpr
652
+ * @related b.cryptoField.declareColumnResidency
653
+ *
654
+ * Storage-write gate. Storage backends call this with the proposed
655
+ * row before the SQL hits the wire; refusal under regulated postures
656
+ * surfaces a config-time error rather than a silent cross-border leak.
657
+ * Returns null on pass; returns
658
+ * `{ error, table, column, want, got }` on refusal so the storage
659
+ * backend can wrap it in its own error class. Columns tagged "global"
660
+ * or "unrestricted" pass any backend; columns tagged with a region
661
+ * (e.g. "eu") refuse mismatched backends.
662
+ *
663
+ * @opts
664
+ * backendTag: string, // tag of the storage backend ("eu" | "us" | "unrestricted")
665
+ *
666
+ * @example
667
+ * b.cryptoField.declareColumnResidency("users", {
668
+ * columnResidency: { addressLine1: "eu" }
669
+ * });
670
+ * var refusal = b.cryptoField.assertColumnResidency(
671
+ * "users",
672
+ * { id: 1, addressLine1: "10 Rue de Rivoli" },
673
+ * { backendTag: "us" }
674
+ * );
675
+ * refusal.error; // → "column-residency-mismatch"
676
+ * refusal.column; // → "addressLine1"
677
+ * refusal.want; // → "eu"
678
+ * refusal.got; // → "us"
679
+ *
680
+ * b.cryptoField.assertColumnResidency(
681
+ * "users",
682
+ * { id: 1, addressLine1: "10 Rue de Rivoli" },
683
+ * { backendTag: "eu" }
684
+ * ); // → null (pass)
685
+ */
686
+ function assertColumnResidency(table, row, args) {
687
+ var entry = columnResidency[table];
688
+ if (!entry || !row || !args) return null;
689
+ var backendTag = args.backendTag || "unrestricted";
690
+ for (var col in entry) {
691
+ var want = entry[col];
692
+ if (row[col] === undefined || row[col] === null) continue;
693
+ if (want === "global" || want === "unrestricted") continue;
694
+ if (backendTag === "unrestricted") continue;
695
+ if (backendTag !== want) {
696
+ return {
697
+ error: "column-residency-mismatch",
698
+ table: table,
699
+ column: col,
700
+ want: want,
701
+ got: backendTag,
702
+ };
703
+ }
704
+ }
705
+ return null;
706
+ }
707
+
708
+ /**
709
+ * @primitive b.cryptoField.declarePerRowKey
710
+ * @signature b.cryptoField.declarePerRowKey(table, opts)
711
+ * @since 0.7.27
712
+ * @compliance gdpr, hipaa
713
+ * @related b.cryptoField.materializePerRowKey, b.cryptoField.destroyPerRowKey, b.subject.eraseHard
714
+ *
715
+ * Opts a table into per-row keying (K_row crypto-shred substrate).
716
+ * After registration, every INSERT generates a fresh K_row and stores
717
+ * it sealed in `_blamejs_per_row_keys (table, rowId, wrapped)`. AAD on
718
+ * the K_row binds (table, rowId, info-label) — copy-row attacks fail
719
+ * Poly1305 verification. `b.subject.eraseHard(subjectId)` deletes the
720
+ * per-row key entries for the subject's rows; WAL / replica residual
721
+ * ciphertext becomes mathematically undecryptable because K_row is
722
+ * gone everywhere it ever lived. Throws on bad input (config-time
723
+ * fail-loud).
724
+ *
725
+ * @opts
726
+ * keySize: number, // bytes; default 32 (XChaCha20-Poly1305 key length); minimum 16
727
+ * info: string, // HKDF info label; default "blamejs-per-row-key:<table>"
728
+ *
729
+ * @example
730
+ * var spec = b.cryptoField.declarePerRowKey("orders", {
731
+ * keySize: 32,
732
+ * info: "blamejs-per-row-key:orders"
733
+ * });
734
+ * spec.keySize; // → 32
735
+ * b.cryptoField.hasPerRowKey("orders"); // → true
736
+ */
737
+ function declarePerRowKey(table, opts) {
738
+ if (typeof table !== "string" || table.length === 0) {
739
+ throw new Error("declarePerRowKey: table must be a non-empty string");
740
+ }
741
+ opts = opts || {};
742
+ var keySize = opts.keySize === undefined ? 32 : opts.keySize; // allow:raw-byte-literal — XChaCha20-Poly1305 key length in bytes
743
+ if (typeof keySize !== "number" || !isFinite(keySize) ||
744
+ keySize < 16 || Math.floor(keySize) !== keySize) { // allow:raw-byte-literal — minimum AES-128 key length in bytes
745
+ throw new Error("declarePerRowKey: opts.keySize must be an integer >= 16 (bytes)");
746
+ }
747
+ var info = opts.info || ("blamejs-per-row-key:" + table);
748
+ if (typeof info !== "string" || info.length === 0) {
749
+ throw new Error("declarePerRowKey: opts.info must be a non-empty string");
750
+ }
751
+ perRowKeyTables[table] = { keySize: keySize, info: info };
752
+ return { table: table, keySize: keySize, info: info };
753
+ }
754
+
755
+ /**
756
+ * @primitive b.cryptoField.hasPerRowKey
757
+ * @signature b.cryptoField.hasPerRowKey(table)
758
+ * @since 0.7.27
759
+ * @related b.cryptoField.declarePerRowKey
760
+ *
761
+ * Returns `true` when `table` has been registered for per-row keying
762
+ * via `declarePerRowKey`, `false` otherwise. Storage backends gate
763
+ * the K_row materialize/destroy paths through this check.
764
+ *
765
+ * @example
766
+ * b.cryptoField.hasPerRowKey("orders"); // → false
767
+ * b.cryptoField.declarePerRowKey("orders", { keySize: 32 });
768
+ * b.cryptoField.hasPerRowKey("orders"); // → true
769
+ */
770
+ function hasPerRowKey(table) {
771
+ return !!perRowKeyTables[table];
772
+ }
773
+
774
+ /**
775
+ * @primitive b.cryptoField.materializePerRowKey
776
+ * @signature b.cryptoField.materializePerRowKey(table, rowId, dbHandle)
777
+ * @since 0.7.27
778
+ * @compliance gdpr, hipaa
779
+ * @related b.cryptoField.declarePerRowKey, b.cryptoField.destroyPerRowKey
780
+ *
781
+ * Derive-and-store: called by the storage backend on INSERT. Generates
782
+ * `K_row = SHAKE256(vaultSalt + table + rowId + info, keySize)`, seals
783
+ * it via `vault.seal`, and inserts into `_blamejs_per_row_keys`.
784
+ * Returns the unwrapped K_row Buffer for the caller to use to encrypt
785
+ * sealed columns under the row-scoped key. Idempotent on UPSERT — if
786
+ * a K_row already exists for (table, rowId), returns the unwrapped
787
+ * existing key. The AAD-bound envelope rejects copy-row attacks: a
788
+ * wrapped K_row pasted under a different rowId fails Poly1305
789
+ * verification at unseal time.
790
+ *
791
+ * @example
792
+ * b.cryptoField.declarePerRowKey("orders", { keySize: 32 });
793
+ * var dbHandle = b.db.handle();
794
+ * var kRow = b.cryptoField.materializePerRowKey("orders", "ord-42", dbHandle);
795
+ * Buffer.isBuffer(kRow); // → true
796
+ * kRow.length; // → 32
797
+ *
798
+ * // Idempotent — second call returns the same key.
799
+ * var kRowAgain = b.cryptoField.materializePerRowKey("orders", "ord-42", dbHandle);
800
+ * kRow.equals(kRowAgain); // → true
801
+ */
802
+ function materializePerRowKey(table, rowId, dbHandle) {
803
+ var spec = perRowKeyTables[table];
804
+ if (!spec) return null;
805
+ if (!dbHandle || typeof dbHandle.prepare !== "function") {
806
+ throw new Error("materializePerRowKey: dbHandle (b.db) is required");
807
+ }
808
+ // Existing key? Re-use to support idempotent UPSERTs.
809
+ var existing = dbHandle.prepare(
810
+ 'SELECT wrappedKey FROM "_blamejs_per_row_keys" WHERE tableName = ? AND rowId = ?'
811
+ ).get(table, rowId);
812
+ if (existing) {
813
+ return vault.unseal(existing.wrappedKey);
814
+ }
815
+ // Derive K_row from the table-level vault key salt + rowId via
816
+ // SHAKE256 expand. This is a one-shot derivation (HKDF-shaped) that
817
+ // matches the framework's PQC-first kdf — no HMAC-SHA3 dependency.
818
+ var saltHex = vault.getDerivedHashSalt().toString("hex");
819
+ var ikm = Buffer.from(saltHex + ":" + table + ":" + rowId + ":" + spec.info, "utf8");
820
+ var kRow = kdf(ikm, spec.keySize);
821
+ var sealed = vault.seal(kRow.toString("base64"));
822
+ dbHandle.prepare(
823
+ 'INSERT INTO "_blamejs_per_row_keys" (tableName, rowId, wrappedKey, createdAt) ' +
824
+ 'VALUES (?, ?, ?, ?)'
825
+ ).run(table, rowId, sealed, Date.now());
826
+ return kRow;
827
+ }
828
+
829
+ /**
830
+ * @primitive b.cryptoField.destroyPerRowKey
831
+ * @signature b.cryptoField.destroyPerRowKey(table, rowId, dbHandle)
832
+ * @since 0.7.27
833
+ * @compliance gdpr, hipaa
834
+ * @related b.cryptoField.materializePerRowKey, b.subject.eraseHard
835
+ *
836
+ * Crypto-shred: drops the per-row K_row entry from
837
+ * `_blamejs_per_row_keys`. Called by `b.subject.eraseHard` for each
838
+ * row mapped to the erased subject. Returns
839
+ * `{ destroyed: <rowsAffected> }`. After destruction, any WAL /
840
+ * replica residual ciphertext for the row is mathematically
841
+ * undecryptable — even with the vault root key — because K_row is
842
+ * gone everywhere it ever lived. No-op when the table is not
843
+ * registered for per-row keying.
844
+ *
845
+ * @example
846
+ * b.cryptoField.declarePerRowKey("orders", { keySize: 32 });
847
+ * var dbHandle = b.db.handle();
848
+ * b.cryptoField.materializePerRowKey("orders", "ord-42", dbHandle);
849
+ *
850
+ * var result = b.cryptoField.destroyPerRowKey("orders", "ord-42", dbHandle);
851
+ * result.destroyed; // → 1
852
+ *
853
+ * // Subsequent destroy is a no-op.
854
+ * b.cryptoField.destroyPerRowKey("orders", "ord-42", dbHandle).destroyed; // → 0
855
+ */
856
+ function destroyPerRowKey(table, rowId, dbHandle) {
857
+ if (!perRowKeyTables[table]) return { destroyed: 0 };
858
+ if (!dbHandle || typeof dbHandle.prepare !== "function") {
859
+ throw new Error("destroyPerRowKey: dbHandle (b.db) is required");
860
+ }
861
+ var result = dbHandle.prepare(
862
+ 'DELETE FROM "_blamejs_per_row_keys" WHERE tableName = ? AND rowId = ?'
863
+ ).run(table, rowId);
864
+ return { destroyed: (result && result.changes) || 0 };
865
+ }
866
+
867
+ /**
868
+ * @primitive b.cryptoField.clearResidencyForTest
869
+ * @signature b.cryptoField.clearResidencyForTest()
870
+ * @since 0.7.27
871
+ * @status experimental
872
+ * @related b.cryptoField.declareColumnResidency, b.cryptoField.declarePerRowKey
873
+ *
874
+ * Test-only helper. Drops every entry from the per-column residency
875
+ * registry AND the per-row-key registry so a test fixture can
876
+ * re-declare both between cases. Operator code never calls this —
877
+ * production declarations come from `b.db.init({ schema })` once at
878
+ * boot.
879
+ *
880
+ * @example
881
+ * b.cryptoField.declareColumnResidency("users", {
882
+ * columnResidency: { addressLine1: "eu" }
883
+ * });
884
+ * b.cryptoField.clearResidencyForTest();
885
+ * b.cryptoField.getColumnResidency("users"); // → null
886
+ */
887
+ function clearResidencyForTest() {
888
+ for (var t in columnResidency) delete columnResidency[t];
889
+ for (var u in perRowKeyTables) delete perRowKeyTables[u];
890
+ }
891
+
218
892
  module.exports = {
219
893
  registerTable: registerTable,
220
894
  getSchema: getSchema,
@@ -222,7 +896,17 @@ module.exports = {
222
896
  sealRow: sealRow,
223
897
  unsealRow: unsealRow,
224
898
  eraseRow: eraseRow,
899
+ applyPosture: applyPosture,
900
+ getActivePosture: getActivePosture,
225
901
  computeDerived: computeDerived,
226
902
  lookupHash: lookupHash,
227
903
  clearForTest: clearForTest,
904
+ declareColumnResidency: declareColumnResidency,
905
+ getColumnResidency: getColumnResidency,
906
+ assertColumnResidency: assertColumnResidency,
907
+ declarePerRowKey: declarePerRowKey,
908
+ hasPerRowKey: hasPerRowKey,
909
+ materializePerRowKey: materializePerRowKey,
910
+ destroyPerRowKey: destroyPerRowKey,
911
+ clearResidencyForTest: clearResidencyForTest,
228
912
  };