@noy-db/hub 0.1.0-pre.8 → 0.2.0-pre.1

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 (253) hide show
  1. package/dist/aggregate/index.cjs +91 -36
  2. package/dist/aggregate/index.cjs.map +1 -1
  3. package/dist/aggregate/index.d.cts +2 -2
  4. package/dist/aggregate/index.d.ts +2 -2
  5. package/dist/aggregate/index.js +16 -9
  6. package/dist/aggregate/index.js.map +1 -1
  7. package/dist/blobs/index.cjs.map +1 -1
  8. package/dist/blobs/index.d.cts +6 -6
  9. package/dist/blobs/index.d.ts +6 -6
  10. package/dist/blobs/index.js +4 -4
  11. package/dist/bundle/index.cjs +298 -7
  12. package/dist/bundle/index.cjs.map +1 -1
  13. package/dist/bundle/index.d.cts +6 -6
  14. package/dist/bundle/index.d.ts +6 -6
  15. package/dist/bundle/index.js +15 -4
  16. package/dist/{chunk-GOUT6DND.js → chunk-23TTQXVO.js} +173 -91
  17. package/dist/chunk-23TTQXVO.js.map +1 -0
  18. package/dist/{chunk-CIMZBAZB.js → chunk-2AXFIYHT.js} +1 -1
  19. package/dist/chunk-2AXFIYHT.js.map +1 -0
  20. package/dist/chunk-34YSDCDP.js +73 -0
  21. package/dist/chunk-34YSDCDP.js.map +1 -0
  22. package/dist/{chunk-HC7Z5EQZ.js → chunk-4TFSM22V.js} +4 -4
  23. package/dist/{chunk-7XBQS42M.js → chunk-537VFZTR.js} +4 -4
  24. package/dist/{chunk-M62XNWRA.js → chunk-5DWL3JBF.js} +2 -2
  25. package/dist/{chunk-RSPLI376.js → chunk-5SCJ5UEF.js} +3 -3
  26. package/dist/chunk-5ZGZ6HIZ.js +100 -0
  27. package/dist/chunk-5ZGZ6HIZ.js.map +1 -0
  28. package/dist/chunk-6HPZY4ON.js +291 -0
  29. package/dist/chunk-6HPZY4ON.js.map +1 -0
  30. package/dist/{chunk-WN6UK7PM.js → chunk-7H6DOO3E.js} +239 -11
  31. package/dist/chunk-7H6DOO3E.js.map +1 -0
  32. package/dist/{chunk-ACLDOTNQ.js → chunk-ADQ5MQ54.js} +275 -3
  33. package/dist/chunk-ADQ5MQ54.js.map +1 -0
  34. package/dist/chunk-CBAHB2BF.js +893 -0
  35. package/dist/chunk-CBAHB2BF.js.map +1 -0
  36. package/dist/chunk-DPMFBCV6.js +296 -0
  37. package/dist/chunk-DPMFBCV6.js.map +1 -0
  38. package/dist/chunk-DYBQG5PQ.js +34 -0
  39. package/dist/chunk-DYBQG5PQ.js.map +1 -0
  40. package/dist/{chunk-ZFKD4QMV.js → chunk-DYECX3IX.js} +3 -3
  41. package/dist/chunk-EGQYGYIU.js +51 -0
  42. package/dist/chunk-EGQYGYIU.js.map +1 -0
  43. package/dist/chunk-FCXOFQAJ.js +79 -0
  44. package/dist/chunk-FCXOFQAJ.js.map +1 -0
  45. package/dist/chunk-HB3Z2GCR.js +124 -0
  46. package/dist/chunk-HB3Z2GCR.js.map +1 -0
  47. package/dist/{chunk-SCZXXXU4.js → chunk-I6MX32UC.js} +7 -32
  48. package/dist/chunk-I6MX32UC.js.map +1 -0
  49. package/dist/{chunk-VQBTTTUN.js → chunk-KESP7GOK.js} +4 -4
  50. package/dist/{chunk-VQBTTTUN.js.map → chunk-KESP7GOK.js.map} +1 -1
  51. package/dist/{chunk-NXFEYLVG.js → chunk-MIQHZESA.js} +4 -3
  52. package/dist/{chunk-NXFEYLVG.js.map → chunk-MIQHZESA.js.map} +1 -1
  53. package/dist/chunk-MKSA2V7A.js +19 -0
  54. package/dist/chunk-MKSA2V7A.js.map +1 -0
  55. package/dist/{chunk-M5INGEFC.js → chunk-MRIBLZL3.js} +3 -1
  56. package/dist/chunk-MRIBLZL3.js.map +1 -0
  57. package/dist/{chunk-2WGMYBYS.js → chunk-NIOHFJPJ.js} +6 -6
  58. package/dist/chunk-OMLIZL2P.js +61 -0
  59. package/dist/chunk-OMLIZL2P.js.map +1 -0
  60. package/dist/{chunk-USKYUS74.js → chunk-P7EQ2S5O.js} +2 -2
  61. package/dist/{chunk-YVFTBQHL.js → chunk-PA6R5ZCI.js} +217 -10
  62. package/dist/chunk-PA6R5ZCI.js.map +1 -0
  63. package/dist/chunk-PEULZC6M.js +118 -0
  64. package/dist/chunk-PEULZC6M.js.map +1 -0
  65. package/dist/chunk-RD5LYKD6.js +82 -0
  66. package/dist/chunk-RD5LYKD6.js.map +1 -0
  67. package/dist/chunk-SIZWEV2Y.js +145 -0
  68. package/dist/chunk-SIZWEV2Y.js.map +1 -0
  69. package/dist/{chunk-Y4CMTMUW.js → chunk-UA4RI7OT.js} +12 -6
  70. package/dist/chunk-UA4RI7OT.js.map +1 -0
  71. package/dist/chunk-UMLVJTYV.js +20 -0
  72. package/dist/chunk-UMLVJTYV.js.map +1 -0
  73. package/dist/chunk-UZXLQCHP.js +53 -0
  74. package/dist/chunk-UZXLQCHP.js.map +1 -0
  75. package/dist/{chunk-R2ZTGEVP.js → chunk-VMIO4IXG.js} +5 -5
  76. package/dist/{chunk-MR4424N3.js → chunk-WCA2NROQ.js} +2 -2
  77. package/dist/{chunk-TDR6T5CJ.js → chunk-XGSOTWYX.js} +91 -132
  78. package/dist/chunk-XGSOTWYX.js.map +1 -0
  79. package/dist/{chunk-NPC4LFV5.js → chunk-YMYK7US4.js} +2 -2
  80. package/dist/{chunk-PJK6IOBC.js → chunk-YS3POABP.js} +1 -1
  81. package/dist/chunk-YS3POABP.js.map +1 -0
  82. package/dist/chunk-Z72JH4KG.js +209 -0
  83. package/dist/chunk-Z72JH4KG.js.map +1 -0
  84. package/dist/{chunk-R36SIKES.js → chunk-ZNOEIM6Y.js} +2 -2
  85. package/dist/consent/index.cjs.map +1 -1
  86. package/dist/consent/index.d.cts +6 -6
  87. package/dist/consent/index.d.ts +6 -6
  88. package/dist/consent/index.js +3 -3
  89. package/dist/{crypto-IVKU7YTT.js → crypto-A7FRXYHC.js} +3 -3
  90. package/dist/{delegation-2DBS2EOH.js → delegation-YBA4X4JN.js} +5 -4
  91. package/dist/derivations/index.cjs +351 -0
  92. package/dist/derivations/index.cjs.map +1 -0
  93. package/dist/derivations/index.d.cts +71 -0
  94. package/dist/derivations/index.d.ts +71 -0
  95. package/dist/derivations/index.js +27 -0
  96. package/dist/{dev-unlock-BygpnIWe.d.ts → dev-unlock-D9s-loPr.d.ts} +1 -1
  97. package/dist/{dev-unlock-BZKx666y.d.cts → dev-unlock-DRwVSy2S.d.cts} +1 -1
  98. package/dist/executor-7E3VFGW7.js +11 -0
  99. package/dist/executor-CEWX2FQI.js +8 -0
  100. package/dist/executor-CEWX2FQI.js.map +1 -0
  101. package/dist/executor-X4SQ3ZLC.js +8 -0
  102. package/dist/executor-X4SQ3ZLC.js.map +1 -0
  103. package/dist/fanout-sidecar-VJ52RIEY.js +51 -0
  104. package/dist/fanout-sidecar-VJ52RIEY.js.map +1 -0
  105. package/dist/guards/index.cjs +315 -0
  106. package/dist/guards/index.cjs.map +1 -0
  107. package/dist/guards/index.d.cts +30 -0
  108. package/dist/guards/index.d.ts +30 -0
  109. package/dist/guards/index.js +29 -0
  110. package/dist/guards/index.js.map +1 -0
  111. package/dist/{hash-B0eU2Qv9.d.ts → hash-DXXXusyk.d.ts} +1 -1
  112. package/dist/{hash-CIyfmKsg.d.cts → hash-DtRih9MQ.d.cts} +1 -1
  113. package/dist/history/index.cjs +8 -1
  114. package/dist/history/index.cjs.map +1 -1
  115. package/dist/history/index.d.cts +7 -7
  116. package/dist/history/index.d.ts +7 -7
  117. package/dist/history/index.js +6 -6
  118. package/dist/i18n/index.cjs +81 -0
  119. package/dist/i18n/index.cjs.map +1 -1
  120. package/dist/i18n/index.d.cts +6 -6
  121. package/dist/i18n/index.d.ts +6 -6
  122. package/dist/i18n/index.js +19 -6
  123. package/dist/i18n/index.js.map +1 -1
  124. package/dist/{index-Dp4tKCjX.d.ts → index-4agOpzqd.d.ts} +174 -3
  125. package/dist/{index-6xNpPsxR.d.cts → index-CNwA-B6-.d.ts} +303 -5
  126. package/dist/{index-DJTf9yxn.d.ts → index-CmVgTkqk.d.cts} +303 -5
  127. package/dist/{index-DsVbTDZI.d.cts → index-hdFvZkBP.d.cts} +174 -3
  128. package/dist/index.cjs +5929 -1089
  129. package/dist/index.cjs.map +1 -1
  130. package/dist/index.d.cts +207 -16
  131. package/dist/index.d.ts +207 -16
  132. package/dist/index.js +2402 -672
  133. package/dist/index.js.map +1 -1
  134. package/dist/indexing/index.cjs +2 -0
  135. package/dist/indexing/index.cjs.map +1 -1
  136. package/dist/indexing/index.d.cts +3 -3
  137. package/dist/indexing/index.d.ts +3 -3
  138. package/dist/indexing/index.js +4 -4
  139. package/dist/{lazy-builder-CZVLKh0Z.d.cts → lazy-builder-C-rPfWG0.d.cts} +1 -1
  140. package/dist/{lazy-builder-BwEoBQZ9.d.ts → lazy-builder-Rpd-V3jP.d.ts} +1 -1
  141. package/dist/{ledger-UQIMMKO5.js → ledger-3TXNP47J.js} +6 -6
  142. package/dist/ledger-3TXNP47J.js.map +1 -0
  143. package/dist/materialized-views/index.cjs +837 -0
  144. package/dist/materialized-views/index.cjs.map +1 -0
  145. package/dist/materialized-views/index.d.cts +183 -0
  146. package/dist/materialized-views/index.d.ts +183 -0
  147. package/dist/materialized-views/index.js +45 -0
  148. package/dist/materialized-views/index.js.map +1 -0
  149. package/dist/overlay-views/index.cjs +359 -0
  150. package/dist/overlay-views/index.cjs.map +1 -0
  151. package/dist/overlay-views/index.d.cts +81 -0
  152. package/dist/overlay-views/index.d.ts +81 -0
  153. package/dist/overlay-views/index.js +23 -0
  154. package/dist/overlay-views/index.js.map +1 -0
  155. package/dist/periods/index.cjs +7 -1
  156. package/dist/periods/index.cjs.map +1 -1
  157. package/dist/periods/index.d.cts +6 -6
  158. package/dist/periods/index.d.ts +6 -6
  159. package/dist/periods/index.js +6 -6
  160. package/dist/{predicate-SBHmi6D0.d.cts → predicate-Dnu81tsS.d.cts} +25 -1
  161. package/dist/{predicate-SBHmi6D0.d.ts → predicate-Dnu81tsS.d.ts} +25 -1
  162. package/dist/{public-envelope-3QTQADDW.js → public-envelope-PY6NKFLI.js} +4 -4
  163. package/dist/public-envelope-PY6NKFLI.js.map +1 -0
  164. package/dist/query/index.cjs +302 -124
  165. package/dist/query/index.cjs.map +1 -1
  166. package/dist/query/index.d.cts +3 -3
  167. package/dist/query/index.d.ts +3 -3
  168. package/dist/query/index.js +26 -11
  169. package/dist/read-only-facade-ITU6L7BL.js +7 -0
  170. package/dist/read-only-facade-ITU6L7BL.js.map +1 -0
  171. package/dist/registry-3L3N3PTG.js +10 -0
  172. package/dist/registry-3L3N3PTG.js.map +1 -0
  173. package/dist/registry-O47PUPSY.js +8 -0
  174. package/dist/registry-O47PUPSY.js.map +1 -0
  175. package/dist/registry-RFGGMVNJ.js +7 -0
  176. package/dist/registry-RFGGMVNJ.js.map +1 -0
  177. package/dist/registry-WLLMODKN.js +8 -0
  178. package/dist/registry-WLLMODKN.js.map +1 -0
  179. package/dist/session/index.cjs +7 -1
  180. package/dist/session/index.cjs.map +1 -1
  181. package/dist/session/index.d.cts +7 -7
  182. package/dist/session/index.d.ts +7 -7
  183. package/dist/session/index.js +10 -3
  184. package/dist/session/index.js.map +1 -1
  185. package/dist/shadow/index.cjs.map +1 -1
  186. package/dist/shadow/index.d.cts +6 -6
  187. package/dist/shadow/index.d.ts +6 -6
  188. package/dist/shadow/index.js +2 -2
  189. package/dist/stale-HSC5YO2O.js +13 -0
  190. package/dist/stale-HSC5YO2O.js.map +1 -0
  191. package/dist/store/index.cjs +14 -0
  192. package/dist/store/index.cjs.map +1 -1
  193. package/dist/store/index.d.cts +6 -6
  194. package/dist/store/index.d.ts +6 -6
  195. package/dist/store/index.js +5 -2
  196. package/dist/{strategy-D-SrOLCl.d.cts → strategy-DSTrsZ8t.d.cts} +72 -19
  197. package/dist/{strategy-D-SrOLCl.d.ts → strategy-DSTrsZ8t.d.ts} +72 -19
  198. package/dist/sync/index.cjs.map +1 -1
  199. package/dist/sync/index.d.cts +5 -5
  200. package/dist/sync/index.d.ts +5 -5
  201. package/dist/sync/index.js +4 -4
  202. package/dist/team/index.cjs +1554 -2
  203. package/dist/team/index.cjs.map +1 -1
  204. package/dist/team/index.d.cts +6 -6
  205. package/dist/team/index.d.ts +6 -6
  206. package/dist/team/index.js +76 -9
  207. package/dist/tx/index.cjs +296 -44
  208. package/dist/tx/index.cjs.map +1 -1
  209. package/dist/tx/index.d.cts +6 -6
  210. package/dist/tx/index.d.ts +6 -6
  211. package/dist/tx/index.js +2 -2
  212. package/dist/{types-DD9eKKNc.d.ts → types-C4lwMKKF.d.cts} +2771 -322
  213. package/dist/{types-arFMsCtn.d.cts → types-DW9RGSSs.d.ts} +2771 -322
  214. package/dist/util/index.cjs.map +1 -1
  215. package/dist/util/index.js +1 -1
  216. package/dist/with-derivation-C8LDlV7t.d.cts +13 -0
  217. package/dist/with-derivation-g-pGoMzL.d.ts +13 -0
  218. package/dist/with-guard-DWOCK4Ca.d.ts +18 -0
  219. package/dist/with-guard-jI1x9Z3k.d.cts +18 -0
  220. package/dist/with-materialized-view-DaKR-N6J.d.ts +27 -0
  221. package/dist/with-materialized-view-DcTx4H3j.d.cts +27 -0
  222. package/dist/with-overlayed-view-D-6oWAgM.d.cts +13 -0
  223. package/dist/with-overlayed-view-N7jYuNOS.d.ts +13 -0
  224. package/package.json +53 -2
  225. package/dist/chunk-ACLDOTNQ.js.map +0 -1
  226. package/dist/chunk-BTDCBVJW.js +0 -160
  227. package/dist/chunk-BTDCBVJW.js.map +0 -1
  228. package/dist/chunk-CIMZBAZB.js.map +0 -1
  229. package/dist/chunk-GOUT6DND.js.map +0 -1
  230. package/dist/chunk-M5INGEFC.js.map +0 -1
  231. package/dist/chunk-PJK6IOBC.js.map +0 -1
  232. package/dist/chunk-SCZXXXU4.js.map +0 -1
  233. package/dist/chunk-TDR6T5CJ.js.map +0 -1
  234. package/dist/chunk-TOQK4KAN.js +0 -79
  235. package/dist/chunk-TOQK4KAN.js.map +0 -1
  236. package/dist/chunk-WN6UK7PM.js.map +0 -1
  237. package/dist/chunk-Y4CMTMUW.js.map +0 -1
  238. package/dist/chunk-YVFTBQHL.js.map +0 -1
  239. /package/dist/{chunk-HC7Z5EQZ.js.map → chunk-4TFSM22V.js.map} +0 -0
  240. /package/dist/{chunk-7XBQS42M.js.map → chunk-537VFZTR.js.map} +0 -0
  241. /package/dist/{chunk-M62XNWRA.js.map → chunk-5DWL3JBF.js.map} +0 -0
  242. /package/dist/{chunk-RSPLI376.js.map → chunk-5SCJ5UEF.js.map} +0 -0
  243. /package/dist/{chunk-ZFKD4QMV.js.map → chunk-DYECX3IX.js.map} +0 -0
  244. /package/dist/{chunk-2WGMYBYS.js.map → chunk-NIOHFJPJ.js.map} +0 -0
  245. /package/dist/{chunk-USKYUS74.js.map → chunk-P7EQ2S5O.js.map} +0 -0
  246. /package/dist/{chunk-R2ZTGEVP.js.map → chunk-VMIO4IXG.js.map} +0 -0
  247. /package/dist/{chunk-MR4424N3.js.map → chunk-WCA2NROQ.js.map} +0 -0
  248. /package/dist/{chunk-NPC4LFV5.js.map → chunk-YMYK7US4.js.map} +0 -0
  249. /package/dist/{chunk-R36SIKES.js.map → chunk-ZNOEIM6Y.js.map} +0 -0
  250. /package/dist/{crypto-IVKU7YTT.js.map → crypto-A7FRXYHC.js.map} +0 -0
  251. /package/dist/{delegation-2DBS2EOH.js.map → delegation-YBA4X4JN.js.map} +0 -0
  252. /package/dist/{ledger-UQIMMKO5.js.map → derivations/index.js.map} +0 -0
  253. /package/dist/{public-envelope-3QTQADDW.js.map → executor-7E3VFGW7.js.map} +0 -0
package/dist/index.js CHANGED
@@ -1,22 +1,42 @@
1
+ import {
2
+ DELEGATIONS_COLLECTION,
3
+ issueDelegation,
4
+ loadActiveDelegations,
5
+ revokeDelegation
6
+ } from "./chunk-I6MX32UC.js";
1
7
  import {
2
8
  DEFAULT_PUBLIC_ENVELOPE_SCHEMA,
3
9
  PUBLIC_ENVELOPE_FIELDS,
4
10
  resolveSchema
5
11
  } from "./chunk-EMIGCR7X.js";
6
12
  import {
7
- DELEGATIONS_COLLECTION,
8
- assertTierAccess,
9
- dekKey,
10
- effectiveClearance,
11
- issueDelegation,
12
- loadActiveDelegations,
13
- revokeDelegation
14
- } from "./chunk-SCZXXXU4.js";
13
+ TxCollection,
14
+ TxContext,
15
+ TxVault,
16
+ revertExecuted,
17
+ runTransaction
18
+ } from "./chunk-6HPZY4ON.js";
19
+ import {
20
+ withDerivation
21
+ } from "./chunk-EGQYGYIU.js";
22
+ import "./chunk-FCXOFQAJ.js";
23
+ import "./chunk-HB3Z2GCR.js";
24
+ import {
25
+ withMaterializedView
26
+ } from "./chunk-RD5LYKD6.js";
27
+ import "./chunk-SIZWEV2Y.js";
28
+ import "./chunk-DPMFBCV6.js";
29
+ import "./chunk-UZXLQCHP.js";
30
+ import {
31
+ OverlayedCollection,
32
+ withOverlayedView
33
+ } from "./chunk-Z72JH4KG.js";
34
+ import "./chunk-OMLIZL2P.js";
15
35
  import {
16
36
  LazyQuery,
17
37
  decodeIdxId,
18
38
  encodeIdxId
19
- } from "./chunk-ZFKD4QMV.js";
39
+ } from "./chunk-DYECX3IX.js";
20
40
  import {
21
41
  mergeCrdtStates,
22
42
  resolveCrdtSnapshot
@@ -31,7 +51,7 @@ import {
31
51
  readNoydbBundlePublicEnvelope,
32
52
  resetBrotliSupportCache,
33
53
  writeNoydbBundle
34
- } from "./chunk-WN6UK7PM.js";
54
+ } from "./chunk-7H6DOO3E.js";
35
55
  import {
36
56
  PUBLIC_ENVELOPE_RECORD_ID,
37
57
  isPublicEnvelope,
@@ -39,24 +59,24 @@ import {
39
59
  readPublicEnvelope,
40
60
  savePublicEnvelope,
41
61
  validatePublicEnvelopeInput
42
- } from "./chunk-RSPLI376.js";
62
+ } from "./chunk-5SCJ5UEF.js";
43
63
  import {
44
64
  CONSENT_AUDIT_COLLECTION
45
- } from "./chunk-M62XNWRA.js";
65
+ } from "./chunk-5DWL3JBF.js";
46
66
  import {
47
67
  PERIODS_COLLECTION
48
- } from "./chunk-7XBQS42M.js";
68
+ } from "./chunk-537VFZTR.js";
49
69
  import "./chunk-UF3BUNQZ.js";
70
+ import {
71
+ withGuard
72
+ } from "./chunk-MKSA2V7A.js";
73
+ import "./chunk-PEULZC6M.js";
74
+ import "./chunk-UMLVJTYV.js";
75
+ import "./chunk-34YSDCDP.js";
50
76
  import {
51
77
  CollectionFrame,
52
78
  VaultFrame
53
- } from "./chunk-R36SIKES.js";
54
- import {
55
- TxCollection,
56
- TxContext,
57
- TxVault,
58
- runTransaction
59
- } from "./chunk-BTDCBVJW.js";
79
+ } from "./chunk-ZNOEIM6Y.js";
60
80
  import {
61
81
  DICT_COLLECTION_PREFIX,
62
82
  DictionaryHandle,
@@ -69,7 +89,7 @@ import {
69
89
  isI18nTextDescriptor,
70
90
  resolveI18nText,
71
91
  validateI18nTextValue
72
- } from "./chunk-2WGMYBYS.js";
92
+ } from "./chunk-NIOHFJPJ.js";
73
93
  import {
74
94
  createBundleStore,
75
95
  routeStore,
@@ -81,30 +101,73 @@ import {
81
101
  withRetry,
82
102
  wrapBundleStore,
83
103
  wrapStore
84
- } from "./chunk-USKYUS74.js";
104
+ } from "./chunk-P7EQ2S5O.js";
85
105
  import {
106
+ MAGIC_LINK_CONTENT_INFO_PREFIX,
107
+ MAGIC_LINK_GRANTS_COLLECTION,
108
+ MAGIC_LINK_KEK_INFO_PREFIX,
109
+ ManagedRecoveryNotEnrolledError,
110
+ PolicyDeniedError,
111
+ RecoveryNotEnrolledError,
112
+ RecoveryProfileNotImplementedError,
86
113
  SYNC_CREDENTIALS_COLLECTION,
114
+ burnPaperRecoveryEntry,
87
115
  credentialStatus,
88
116
  deleteCredential,
117
+ deriveMagicLinkContentKey,
118
+ enrollAuthenticator,
119
+ findAuthenticator,
89
120
  getCredential,
121
+ hasRecoveryEnrolled,
122
+ hasStrongRecoveryEnrolled,
123
+ isMagicLinkGrantExpired,
90
124
  listCredentials,
91
- putCredential
92
- } from "./chunk-TOQK4KAN.js";
125
+ listMagicLinkGrants,
126
+ loadPaperRecoveryEntries,
127
+ loadShamirRecoveryEntries,
128
+ magicLinkGrantRecordId,
129
+ mintPaperRecoveryEntry,
130
+ mintShamirRecoveryEntry,
131
+ mintWrappedDeksBlob,
132
+ putCredential,
133
+ readMagicLinkGrantRecord,
134
+ recoverPassphrase,
135
+ recoverUser,
136
+ removeAuthenticator,
137
+ revokeMagicLinkGrant,
138
+ rotatePassphrase,
139
+ savePaperRecoveryEntries,
140
+ saveShamirRecoveryEntries,
141
+ unwrapDeksFromBlob,
142
+ unwrapDeksFromPaperEntry,
143
+ unwrapDeksFromShamirEntry,
144
+ unwrapMagicLinkGrant,
145
+ updateAuthenticator,
146
+ writeMagicLinkGrant
147
+ } from "./chunk-CBAHB2BF.js";
148
+ import {
149
+ assertTierAccess,
150
+ dekKey,
151
+ effectiveClearance
152
+ } from "./chunk-DYBQG5PQ.js";
93
153
  import {
94
154
  PresenceHandle,
95
155
  SyncEngine,
96
156
  SyncTransaction
97
- } from "./chunk-HC7Z5EQZ.js";
157
+ } from "./chunk-4TFSM22V.js";
98
158
  import {
159
+ DIRECTORY_RECORD_ID,
99
160
  USER_ENVELOPE_COLLECTION,
100
161
  USER_ENVELOPE_MAX_BYTES,
101
162
  UserEnvelopeOversizedError,
163
+ VISIBILITY_RECORD_PREFIX,
102
164
  WeakPassphraseError,
103
165
  assertStrongPassphrase,
104
166
  buildRecipientKeyringFile,
105
167
  changeSecret,
106
168
  createOwnerKeyring,
107
169
  deleteUserEnvelope,
170
+ deleteUserVisibility,
108
171
  ensureCollectionDEK,
109
172
  estimateEntropy,
110
173
  evaluateExportCapability,
@@ -119,12 +182,17 @@ import {
119
182
  listUsersWithEnvelopes,
120
183
  loadKeyring,
121
184
  loadUserEnvelope,
122
- persistKeyring,
185
+ persistDirectoryConfig,
186
+ persistUserVisibility,
187
+ readDirectoryConfig,
188
+ readUserVisibility,
123
189
  revoke,
124
190
  rotateKeys,
125
191
  saveUserEnvelope,
126
- validatePassphrase
127
- } from "./chunk-YVFTBQHL.js";
192
+ updateKeyringIdentity,
193
+ validatePassphrase,
194
+ visibilityRecordId
195
+ } from "./chunk-PA6R5ZCI.js";
128
196
  import {
129
197
  BUNDLE_STORE_POLICY,
130
198
  INDEXED_STORE_POLICY,
@@ -144,7 +212,7 @@ import {
144
212
  revokeAllSessions,
145
213
  revokeSession,
146
214
  validateSessionPolicy
147
- } from "./chunk-VQBTTTUN.js";
215
+ } from "./chunk-KESP7GOK.js";
148
216
  import {
149
217
  generateULID,
150
218
  isULID
@@ -154,22 +222,22 @@ import {
154
222
  VaultInstant,
155
223
  diff,
156
224
  formatDiff
157
- } from "./chunk-NXFEYLVG.js";
225
+ } from "./chunk-MIQHZESA.js";
158
226
  import {
159
227
  LEDGER_COLLECTION,
160
228
  LEDGER_DELTAS_COLLECTION,
161
229
  LedgerStore,
162
230
  applyPatch,
163
231
  computePatch
164
- } from "./chunk-Y4CMTMUW.js";
232
+ } from "./chunk-UA4RI7OT.js";
165
233
  import {
166
234
  canonicalJson,
167
235
  envelopePayloadHash,
168
236
  hashEntry,
169
237
  paddedIndex,
170
238
  parseIndex,
171
- sha256Hex
172
- } from "./chunk-CIMZBAZB.js";
239
+ sha256Hex as sha256Hex2
240
+ } from "./chunk-2AXFIYHT.js";
173
241
  import {
174
242
  DEFAULT_JOIN_MAX_ROWS,
175
243
  NO_AGGREGATE,
@@ -179,29 +247,32 @@ import {
179
247
  buildLiveQuery,
180
248
  executePlan,
181
249
  resetJoinWarnings
182
- } from "./chunk-GOUT6DND.js";
250
+ } from "./chunk-23TTQXVO.js";
183
251
  import {
184
252
  CollectionIndexes
185
- } from "./chunk-NPC4LFV5.js";
253
+ } from "./chunk-YMYK7US4.js";
254
+ import {
255
+ avg,
256
+ count,
257
+ max,
258
+ min,
259
+ sum
260
+ } from "./chunk-5ZGZ6HIZ.js";
186
261
  import {
187
262
  Aggregation,
188
263
  GROUPBY_MAX_CARDINALITY,
189
264
  GROUPBY_WARN_CARDINALITY,
190
265
  GroupedAggregation,
191
266
  GroupedQuery,
192
- avg,
193
- count,
267
+ GroupedQueryN,
194
268
  groupAndReduce,
195
- max,
196
- min,
197
- reduceRecords,
198
- sum
199
- } from "./chunk-TDR6T5CJ.js";
269
+ reduceRecords
270
+ } from "./chunk-XGSOTWYX.js";
200
271
  import {
201
272
  evaluateClause,
202
273
  evaluateFieldClause,
203
274
  readPath
204
- } from "./chunk-M5INGEFC.js";
275
+ } from "./chunk-MRIBLZL3.js";
205
276
  import {
206
277
  BLOB_CHUNKS_COLLECTION,
207
278
  BLOB_COLLECTION,
@@ -216,58 +287,74 @@ import {
216
287
  detectMimeType,
217
288
  isPreCompressed,
218
289
  runCompaction
219
- } from "./chunk-R2ZTGEVP.js";
290
+ } from "./chunk-VMIO4IXG.js";
220
291
  import {
221
292
  NOYDB_BACKUP_VERSION,
222
293
  NOYDB_FORMAT_VERSION,
223
294
  NOYDB_KEYRING_VERSION,
224
295
  NOYDB_SYNC_VERSION,
225
296
  createStore
226
- } from "./chunk-PJK6IOBC.js";
297
+ } from "./chunk-YS3POABP.js";
227
298
  import {
228
299
  base64ToBuffer,
229
300
  bufferToBase64,
230
301
  decrypt,
231
302
  decryptBytes,
232
303
  decryptDeterministic,
233
- deriveKey,
234
304
  derivePresenceKey,
235
305
  encrypt,
236
306
  encryptBytes,
237
307
  encryptDeterministic,
238
- generateSalt,
239
- unwrapKey,
240
- wrapKey
241
- } from "./chunk-MR4424N3.js";
308
+ sha256Hex
309
+ } from "./chunk-WCA2NROQ.js";
242
310
  import {
243
311
  AlreadyElevatedError,
312
+ AmendmentForbiddenError,
244
313
  BackupCorruptedError,
245
314
  BackupLedgerError,
246
315
  BundleIntegrityError,
316
+ BundleSealMismatchError,
247
317
  BundleVersionConflictError,
248
318
  ConflictError,
249
319
  DanglingReferenceError,
250
320
  DecryptionError,
251
321
  DelegationTargetMissingError,
322
+ DerivationCapExceededError,
323
+ DerivationCycleError,
324
+ DerivationDepthError,
325
+ DerivationOutputShapeError,
326
+ DerivationOutputUnknownError,
252
327
  DictKeyInUseError,
253
328
  DictKeyMissingError,
329
+ DirectoryDisabledError,
254
330
  ElevationExpiredError,
255
331
  ExportCapabilityError,
332
+ FieldFrozenError,
256
333
  FilenameSanitizationError,
257
334
  GroupCardinalityError,
258
335
  ImportCapabilityError,
259
336
  IndexRequiredError,
260
337
  IndexWriteFailureError,
261
338
  InvalidKeyError,
339
+ InvariantError,
262
340
  JoinTooLargeError,
341
+ KeyringCorruptError,
263
342
  KeyringExpiredError,
264
343
  LedgerContentionError,
265
344
  LocaleNotSpecifiedError,
345
+ MaterializedViewConfigError,
346
+ MaterializedViewCycleError,
347
+ MaterializedViewSourceUnknownError,
348
+ MaterializedViewTooLargeError,
266
349
  MissingTranslationError,
267
350
  NetworkError,
268
351
  NoAccessError,
269
352
  NotFoundError,
270
353
  NoydbError,
354
+ OverlayBaseIsVirtualError,
355
+ OverlayCollectionUnavailableError,
356
+ OverlayIdMismatchError,
357
+ OverlayNameCollisionError,
271
358
  PathEscapeError,
272
359
  PeriodClosedError,
273
360
  PermissionDeniedError,
@@ -275,6 +362,7 @@ import {
275
362
  ReadOnlyAtInstantError,
276
363
  ReadOnlyError,
277
364
  ReadOnlyFrameError,
365
+ RecordLockedError,
278
366
  ReservedCollectionNameError,
279
367
  SchemaValidationError,
280
368
  SessionExpiredError,
@@ -286,7 +374,7 @@ import {
286
374
  TierNotGrantedError,
287
375
  TranslatorNotConfiguredError,
288
376
  ValidationError
289
- } from "./chunk-ACLDOTNQ.js";
377
+ } from "./chunk-ADQ5MQ54.js";
290
378
 
291
379
  // src/schema.ts
292
380
  async function validateSchemaInput(schema, value, context) {
@@ -326,6 +414,109 @@ function formatPath(path) {
326
414
  ).join(".");
327
415
  }
328
416
 
417
+ // src/persisted-schemas/canonicalize.ts
418
+ function canonicalize(value) {
419
+ if (value === null || typeof value !== "object") {
420
+ return JSON.stringify(value);
421
+ }
422
+ if (Array.isArray(value)) {
423
+ return "[" + value.map(canonicalize).join(",") + "]";
424
+ }
425
+ const obj = value;
426
+ const keys = Object.keys(obj).sort();
427
+ const parts = keys.map((k) => JSON.stringify(k) + ":" + canonicalize(obj[k]));
428
+ return "{" + parts.join(",") + "}";
429
+ }
430
+
431
+ // src/persisted-schemas/derive.ts
432
+ function isZodSchema(value) {
433
+ if (value === null || typeof value !== "object") return false;
434
+ const def = value._def;
435
+ if (!def || typeof def !== "object") return false;
436
+ return typeof def.typeName === "string" && def.typeName.startsWith("Zod");
437
+ }
438
+ function detectKind(validator) {
439
+ if (isZodSchema(validator)) return "Zod";
440
+ return "Unknown";
441
+ }
442
+ async function loadZodConverter() {
443
+ try {
444
+ const mod = await import("zod-to-json-schema");
445
+ if (!mod.zodToJsonSchema) {
446
+ throw new Error("zod-to-json-schema export missing");
447
+ }
448
+ return mod.zodToJsonSchema;
449
+ } catch (err) {
450
+ throw new Error(
451
+ `persistJsonSchema requires the optional peer-dep \`zod-to-json-schema\`. Install it: \`pnpm add zod-to-json-schema\` (or npm/yarn equivalent). Original error: ${err instanceof Error ? err.message : String(err)}`
452
+ );
453
+ }
454
+ }
455
+ async function derivePersistedSchema(validator) {
456
+ const kind = detectKind(validator);
457
+ const derivedAt = (/* @__PURE__ */ new Date()).toISOString();
458
+ if (kind === "Zod") {
459
+ const convert = await loadZodConverter();
460
+ const jsonSchema = convert(validator);
461
+ const canonical = canonicalize(jsonSchema);
462
+ const hash = await sha256Hex(new TextEncoder().encode(canonical));
463
+ return { _noydb_schema: 1, kind, jsonSchema, hash, derivedAt };
464
+ }
465
+ return {
466
+ _noydb_schema: 1,
467
+ kind,
468
+ jsonSchema: null,
469
+ hash: null,
470
+ reason: `derivation not yet supported for kind=${kind} (v0 supports Zod only)`,
471
+ derivedAt
472
+ };
473
+ }
474
+
475
+ // src/persisted-schemas/storage.ts
476
+ var SCHEMAS_COLLECTION = "_schemas";
477
+ async function loadPersistedSchema(store, vault, collection, dek) {
478
+ const envelope = await store.get(vault, SCHEMAS_COLLECTION, collection);
479
+ if (!envelope) return void 0;
480
+ try {
481
+ const plaintext = await decrypt(envelope._iv, envelope._data, dek);
482
+ const parsed = JSON.parse(plaintext);
483
+ if (parsed._noydb_schema !== 1) return void 0;
484
+ return parsed;
485
+ } catch {
486
+ return void 0;
487
+ }
488
+ }
489
+ async function savePersistedSchema(store, vault, collection, dek, payload) {
490
+ const json = JSON.stringify(payload);
491
+ const { iv, data } = await encrypt(json, dek);
492
+ const prior = await store.get(vault, SCHEMAS_COLLECTION, collection);
493
+ const env = {
494
+ _noydb: NOYDB_FORMAT_VERSION,
495
+ _v: (prior?._v ?? 0) + 1,
496
+ _ts: (/* @__PURE__ */ new Date()).toISOString(),
497
+ _iv: iv,
498
+ _data: data
499
+ };
500
+ await store.put(vault, SCHEMAS_COLLECTION, collection, env);
501
+ }
502
+
503
+ // src/persisted-schemas/register.ts
504
+ async function persistSchemaIfNeeded(opts) {
505
+ const fresh = await derivePersistedSchema(opts.validator);
506
+ const stored = await loadPersistedSchema(opts.store, opts.vault, opts.collectionName, opts.dek);
507
+ if (stored && isEquivalent(stored, fresh)) {
508
+ return { written: false, skipped: true, envelope: stored };
509
+ }
510
+ await savePersistedSchema(opts.store, opts.vault, opts.collectionName, opts.dek, fresh);
511
+ return { written: true, skipped: false, envelope: fresh };
512
+ }
513
+ function isEquivalent(a, b) {
514
+ if (a.kind !== b.kind) return false;
515
+ if (a.hash && b.hash) return a.hash === b.hash;
516
+ if (a.hash === null && b.hash === null) return true;
517
+ return false;
518
+ }
519
+
329
520
  // src/refs.ts
330
521
  var RefIntegrityError = class extends NoydbError {
331
522
  collection;
@@ -426,56 +617,6 @@ var RefRegistry = class {
426
617
  }
427
618
  };
428
619
 
429
- // src/team/authenticators.ts
430
- async function enrollAuthenticator(store, vault, keyring, options) {
431
- const existing = keyring.authenticators.find((a) => a.id === options.id);
432
- if (existing) {
433
- throw new ValidationError(
434
- `enrollAuthenticator: slot id "${options.id}" already exists in vault "${vault}". Remove the slot first or pick a unique id.`
435
- );
436
- }
437
- const base = {
438
- id: options.id,
439
- method: options.method,
440
- enrolled_at: (/* @__PURE__ */ new Date()).toISOString(),
441
- enrolled_via_tier: options.enrolled_via_tier ?? 1,
442
- meta: options.meta
443
- };
444
- const slot = options.wrapKind === "deks" ? {
445
- ...base,
446
- wrapKind: "deks",
447
- wrapped_deks: options.wrapped_deks,
448
- iv: options.iv
449
- } : {
450
- ...base,
451
- wrapped_kek: options.wrapped_kek
452
- };
453
- const next = appendSlot(keyring, slot);
454
- await persistKeyring(store, vault, next);
455
- return next;
456
- }
457
- async function removeAuthenticator(store, vault, keyring, slotId) {
458
- const filtered = keyring.authenticators.filter((a) => a.id !== slotId);
459
- if (filtered.length === keyring.authenticators.length) {
460
- return keyring;
461
- }
462
- const next = {
463
- ...keyring,
464
- authenticators: filtered
465
- };
466
- await persistKeyring(store, vault, next);
467
- return next;
468
- }
469
- function findAuthenticator(keyring, slotId) {
470
- return keyring.authenticators.find((a) => a.id === slotId);
471
- }
472
- function appendSlot(keyring, slot) {
473
- return {
474
- ...keyring,
475
- authenticators: [...keyring.authenticators, slot]
476
- };
477
- }
478
-
479
620
  // src/session/unlock-state.ts
480
621
  var QuickUnlockStore = class {
481
622
  states = /* @__PURE__ */ new Map();
@@ -517,357 +658,6 @@ var QuickUnlockStore = class {
517
658
  }
518
659
  };
519
660
 
520
- // src/policy/errors.ts
521
- var PolicyDeniedError = class extends NoydbError {
522
- gate;
523
- reason;
524
- required;
525
- constructor(gate, reason, required, message) {
526
- super(
527
- "POLICY_DENIED",
528
- message ?? `Gate "${gate}" denied: ${reason}.`
529
- );
530
- this.name = "PolicyDeniedError";
531
- this.gate = gate;
532
- this.reason = reason;
533
- this.required = required;
534
- }
535
- };
536
- var RecoveryNotEnrolledError = class extends NoydbError {
537
- constructor(message = 'Recovery profile not enrolled. Pass `recovery: [{ profile: "paper", codes: 10 }]` to `createNoydb()`, or set `policy.gates["recover-passphrase"].enabled = false` to opt out of recovery (passphrase loss = data loss). See docs/subsystems/session-tiers.md.') {
538
- super("RECOVERY_NOT_ENROLLED", message);
539
- this.name = "RecoveryNotEnrolledError";
540
- }
541
- };
542
- var RecoveryProfileNotImplementedError = class extends NoydbError {
543
- profile;
544
- tracking;
545
- constructor(profile, tracking) {
546
- super(
547
- "RECOVERY_PROFILE_NOT_IMPLEMENTED",
548
- `Recovery profile "${profile}" is not yet implemented in this hub release. Tracking: ${tracking}. Use the "paper" profile via @noy-db/on-recovery in the meantime.`
549
- );
550
- this.name = "RecoveryProfileNotImplementedError";
551
- this.profile = profile;
552
- this.tracking = tracking;
553
- }
554
- };
555
-
556
- // src/team/wrapped-deks.ts
557
- var PBKDF2_ITERATIONS = 6e5;
558
- var SALT_BYTES = 32;
559
- var IV_BYTES = 12;
560
- var subtle = globalThis.crypto.subtle;
561
- async function mintWrappedDeksBlob(deks, credential) {
562
- const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES));
563
- const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
564
- const wrappingKey = await deriveWrappingKey(credential, salt);
565
- const exported = {};
566
- for (const [coll, dek] of deks) {
567
- const raw = await subtle.exportKey("raw", dek);
568
- exported[coll] = bytesToBase64(new Uint8Array(raw));
569
- }
570
- const plaintext = new TextEncoder().encode(JSON.stringify({ deks: exported }));
571
- const ciphertext = await subtle.encrypt(
572
- { name: "AES-GCM", iv },
573
- wrappingKey,
574
- plaintext
575
- );
576
- return {
577
- salt: bytesToBase64(salt),
578
- iv: bytesToBase64(iv),
579
- wrappedDeks: bytesToBase64(new Uint8Array(ciphertext))
580
- };
581
- }
582
- async function unwrapDeksFromBlob(blob, credential) {
583
- const wrappingKey = await deriveWrappingKey(credential, base64ToBytes(blob.salt));
584
- const plaintext = await subtle.decrypt(
585
- { name: "AES-GCM", iv: base64ToBytes(blob.iv) },
586
- wrappingKey,
587
- base64ToBytes(blob.wrappedDeks)
588
- );
589
- const parsed = JSON.parse(new TextDecoder().decode(plaintext));
590
- const deks = /* @__PURE__ */ new Map();
591
- for (const [coll, b64] of Object.entries(parsed.deks)) {
592
- const raw = base64ToBytes(b64);
593
- const key = await subtle.importKey(
594
- "raw",
595
- raw,
596
- { name: "AES-GCM", length: 256 },
597
- true,
598
- ["encrypt", "decrypt"]
599
- );
600
- deks.set(coll, key);
601
- }
602
- return deks;
603
- }
604
- async function deriveWrappingKey(credential, salt) {
605
- const ikm = await subtle.importKey(
606
- "raw",
607
- new TextEncoder().encode(credential),
608
- "PBKDF2",
609
- false,
610
- ["deriveKey"]
611
- );
612
- return subtle.deriveKey(
613
- {
614
- name: "PBKDF2",
615
- salt,
616
- iterations: PBKDF2_ITERATIONS,
617
- hash: "SHA-256"
618
- },
619
- ikm,
620
- { name: "AES-GCM", length: 256 },
621
- false,
622
- ["encrypt", "decrypt"]
623
- );
624
- }
625
- function bytesToBase64(b) {
626
- let s = "";
627
- for (const x of b) s += String.fromCharCode(x);
628
- return btoa(s);
629
- }
630
- function base64ToBytes(b64) {
631
- const s = atob(b64);
632
- const out = new Uint8Array(s.length);
633
- for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
634
- return out;
635
- }
636
-
637
- // src/team/recovery.ts
638
- var PAPER_DOC_ID = "recovery-paper";
639
- async function loadPaperRecoveryEntries(store, vault) {
640
- const env = await store.get(vault, "_meta", PAPER_DOC_ID);
641
- if (!env) return [];
642
- try {
643
- const doc = JSON.parse(env._data);
644
- if (doc.profile !== "paper" || !Array.isArray(doc.entries)) return [];
645
- return doc.entries;
646
- } catch {
647
- return [];
648
- }
649
- }
650
- async function savePaperRecoveryEntries(store, vault, entries) {
651
- const doc = {
652
- _noydb_recovery: 1,
653
- profile: "paper",
654
- entries
655
- };
656
- const envelope = {
657
- _noydb: NOYDB_FORMAT_VERSION,
658
- _v: 1,
659
- _ts: (/* @__PURE__ */ new Date()).toISOString(),
660
- _iv: "",
661
- _data: JSON.stringify(doc)
662
- };
663
- await store.put(vault, "_meta", PAPER_DOC_ID, envelope);
664
- }
665
- async function burnPaperRecoveryEntry(store, vault, codeId) {
666
- const entries = await loadPaperRecoveryEntries(store, vault);
667
- const remaining = entries.filter((e) => e.codeId !== codeId);
668
- await savePaperRecoveryEntries(store, vault, remaining);
669
- }
670
- async function hasRecoveryEnrolled(store, vault) {
671
- const paper = await loadPaperRecoveryEntries(store, vault);
672
- return paper.length > 0;
673
- }
674
- async function mintPaperRecoveryEntry(deks, code, codeId) {
675
- const blob = await mintWrappedDeksBlob(deks, code);
676
- return {
677
- ...blob,
678
- codeId,
679
- enrolledAt: (/* @__PURE__ */ new Date()).toISOString()
680
- };
681
- }
682
- async function unwrapDeksFromPaperEntry(entry, code) {
683
- return unwrapDeksFromBlob(entry, code);
684
- }
685
-
686
- // src/team/rotate-recover.ts
687
- async function rotatePassphrase(store, vault, userId, input) {
688
- if (!input.allowWeakPassphrase) {
689
- assertStrongPassphrase(input.newPassphrase, input.passphrasePolicy);
690
- }
691
- const env = await store.get(vault, "_keyring", userId);
692
- if (!env) {
693
- throw new NoAccessError(`No keyring found for user "${userId}" in vault "${vault}".`);
694
- }
695
- const file = JSON.parse(env._data);
696
- const oldSalt = base64ToBuffer(file.salt);
697
- const oldKek = await deriveKey(input.oldPassphrase, oldSalt);
698
- const deks = /* @__PURE__ */ new Map();
699
- for (const [coll, wrapped] of Object.entries(file.deks)) {
700
- deks.set(coll, await unwrapKey(wrapped, oldKek));
701
- }
702
- const newSalt = generateSalt();
703
- const newKek = await deriveKey(input.newPassphrase, newSalt);
704
- const wrappedDeks = {};
705
- for (const [coll, dek] of deks) {
706
- wrappedDeks[coll] = await wrapKey(dek, newKek);
707
- }
708
- const oldSlots = file.authenticators ?? [];
709
- const newSlots = [];
710
- if (input.slotCeremonies && oldSlots.length > 0) {
711
- for (const oldSlot of oldSlots) {
712
- const ceremony = input.slotCeremonies[oldSlot.id];
713
- if (!ceremony) continue;
714
- const result = await ceremony({ newKek, newDeks: deks, oldSlot });
715
- if (result.id !== oldSlot.id) {
716
- throw new ValidationError(
717
- `slotCeremonies['${oldSlot.id}'] returned id="${result.id}". The id must match the rotated slot \u2014 a ceremony cannot change a slot's identity.`
718
- );
719
- }
720
- if (result.method !== oldSlot.method) {
721
- throw new ValidationError(
722
- `slotCeremonies['${oldSlot.id}'] returned method="${result.method}", expected "${oldSlot.method}". The method must match the rotated slot \u2014 a ceremony cannot change the auth method (e.g. webauthn \u2192 password) under cover of rotation.`
723
- );
724
- }
725
- const baseFields = {
726
- id: result.id,
727
- method: result.method,
728
- // Preserve original enrolled_at — rotation is rewrapping, not
729
- // re-enrollment. The slot's enrolment timestamp tracks when
730
- // the user originally added the slot, not when it was last
731
- // rewrapped. Forensics consumers reading enrolled_at are
732
- // tracking the slot's ORIGIN, not its CURRENT wrapping.
733
- enrolled_at: oldSlot.enrolled_at,
734
- enrolled_via_tier: result.enrolled_via_tier ?? oldSlot.enrolled_via_tier,
735
- meta: result.meta
736
- };
737
- const newSlot = result.wrapKind === "deks" ? {
738
- ...baseFields,
739
- wrapKind: "deks",
740
- wrapped_deks: result.wrapped_deks,
741
- iv: result.iv
742
- } : {
743
- ...baseFields,
744
- wrapped_kek: result.wrapped_kek
745
- };
746
- newSlots.push(newSlot);
747
- }
748
- }
749
- const next = {
750
- ...file,
751
- _noydb_keyring: NOYDB_KEYRING_VERSION,
752
- deks: wrappedDeks,
753
- salt: bufferToBase64(newSalt),
754
- authenticators: newSlots
755
- };
756
- await writeKeyringFile(store, vault, userId, next);
757
- return {
758
- userId: file.user_id,
759
- displayName: file.display_name,
760
- role: file.role,
761
- permissions: file.permissions,
762
- deks,
763
- kek: newKek,
764
- salt: newSalt,
765
- authenticators: newSlots,
766
- ...file.export_capability !== void 0 && { exportCapability: file.export_capability },
767
- ...file.import_capability !== void 0 && { importCapability: file.import_capability }
768
- };
769
- }
770
- async function recoverPassphrase(store, vault, userId, input) {
771
- if (!input.allowWeakPassphrase) {
772
- assertStrongPassphrase(input.newPassphrase, input.passphrasePolicy);
773
- }
774
- switch (input.recoveryProof.profile) {
775
- case "paper":
776
- return recoverViaPaperCode(store, vault, userId, input);
777
- case "shamir":
778
- throw new RecoveryProfileNotImplementedError(
779
- "shamir",
780
- "https://github.com/vLannaAi/noy-db/issues/10"
781
- );
782
- case "multi-channel":
783
- throw new RecoveryProfileNotImplementedError(
784
- "multi-channel",
785
- "https://github.com/vLannaAi/noy-db/issues/10"
786
- );
787
- case "admin-mediated":
788
- throw new RecoveryProfileNotImplementedError(
789
- "admin-mediated",
790
- "https://github.com/vLannaAi/noy-db/issues/10"
791
- );
792
- default: {
793
- const _exhaustive = input.recoveryProof;
794
- throw new Error(`Unknown recovery profile: ${String(_exhaustive)}`);
795
- }
796
- }
797
- }
798
- async function recoverViaPaperCode(store, vault, userId, input) {
799
- if (input.recoveryProof.profile !== "paper") throw new Error("unreachable");
800
- const { code } = input.recoveryProof.payload;
801
- const env = await store.get(vault, "_keyring", userId);
802
- if (!env) {
803
- throw new NoAccessError(`No keyring found for user "${userId}" in vault "${vault}".`);
804
- }
805
- const file = JSON.parse(env._data);
806
- const entries = await loadPaperRecoveryEntries(store, vault);
807
- if (entries.length === 0) {
808
- throw new NoAccessError(
809
- `No paper-recovery entries enrolled for vault "${vault}". Enroll via \`db.enrollRecovery({ profile: "paper", entries })\` before relying on recovery.`
810
- );
811
- }
812
- const normalized = normalizePaperCode(code);
813
- let recovered;
814
- for (const entry of entries) {
815
- try {
816
- const deks2 = await unwrapDeksFromPaperEntry(entry, normalized);
817
- recovered = { deks: deks2, entry };
818
- break;
819
- } catch {
820
- }
821
- }
822
- if (!recovered) {
823
- throw new InvalidKeyError(
824
- "Recovery code does not match any enrolled paper entry. The code may have been previously used (single-use) or typed incorrectly."
825
- );
826
- }
827
- const deks = recovered.deks;
828
- const newSalt = generateSalt();
829
- const newKek = await deriveKey(input.newPassphrase, newSalt);
830
- const wrappedDeks = {};
831
- for (const [coll, dek] of deks) {
832
- wrappedDeks[coll] = await wrapKey(dek, newKek);
833
- }
834
- const next = {
835
- ...file,
836
- _noydb_keyring: NOYDB_KEYRING_VERSION,
837
- deks: wrappedDeks,
838
- salt: bufferToBase64(newSalt),
839
- authenticators: []
840
- // tier-2 slots wrap old KEK, drop them
841
- };
842
- await writeKeyringFile(store, vault, userId, next);
843
- await burnPaperRecoveryEntry(store, vault, recovered.entry.codeId);
844
- return {
845
- userId: file.user_id,
846
- displayName: file.display_name,
847
- role: file.role,
848
- permissions: file.permissions,
849
- deks,
850
- kek: newKek,
851
- salt: newSalt,
852
- authenticators: [],
853
- ...file.export_capability !== void 0 && { exportCapability: file.export_capability },
854
- ...file.import_capability !== void 0 && { importCapability: file.import_capability }
855
- };
856
- }
857
- function normalizePaperCode(input) {
858
- return input.toUpperCase().replace(/[\s\-_]/g, "");
859
- }
860
- async function writeKeyringFile(store, vault, userId, file) {
861
- const envelope = {
862
- _noydb: 1,
863
- _v: 1,
864
- _ts: (/* @__PURE__ */ new Date()).toISOString(),
865
- _iv: "",
866
- _data: JSON.stringify(file)
867
- };
868
- await store.put(vault, "_keyring", userId, envelope);
869
- }
870
-
871
661
  // src/meta/user-envelope/api.ts
872
662
  var UserApi = class {
873
663
  constructor(adapter, vaultName, writerKeyringId, getDek, checkGate2) {
@@ -895,6 +685,17 @@ var UserApi = class {
895
685
  * the envelope on first call. Optimistic-concurrency safe — a stale
896
686
  * `_v` (parallel writer on another device) throws `ConflictError`.
897
687
  *
688
+ * Patch semantics (#57):
689
+ * - `undefined` (or omitted key) — skip; existing value preserved
690
+ * - `null` — delete the field from the merged result
691
+ * - any other value — overwrite (deep-merge for plain objects,
692
+ * replace for primitives / arrays)
693
+ *
694
+ * To clear a field, pass `null` rather than `undefined`. Callers
695
+ * with shape `T = string | null` where `null` is a meaningful value
696
+ * should use `setMe` for that specific field instead — `null` here
697
+ * always means delete.
698
+ *
898
699
  * Gated by the `edit-own-profile` policy gate (default `minTier: 3`).
899
700
  * Pass `presented` to satisfy tightened policies that require a
900
701
  * factor proof (e.g. STRICT_POLICY's TOTP requirement).
@@ -940,6 +741,41 @@ var UserApi = class {
940
741
  this.fireChange(this.writerKeyringId, written);
941
742
  return written;
942
743
  }
744
+ // ─── Visibility (#122) ───────────────────────────────────────────────
745
+ /**
746
+ * Read the current user's visibility flag from
747
+ * `_meta/visibility/<keyringId>`. Returns `{ hidden: false }` when no
748
+ * document has been persisted (the default-visible case).
749
+ */
750
+ async getMyVisibility() {
751
+ const persisted = await readUserVisibility(this.adapter, this.vaultName, this.writerKeyringId);
752
+ return persisted ?? { hidden: false };
753
+ }
754
+ /**
755
+ * Update the current user's visibility in the team directory.
756
+ *
757
+ * - `hidden: true` — opt out of the default `listUsersWithEnvelopes`
758
+ * listing. `owner`/`admin` callers can still see the user by passing
759
+ * `{ includeHidden: true }`.
760
+ * - `hidden: false` — opt back in.
761
+ *
762
+ * Own-only by construction: the keyringId argument doesn't exist on
763
+ * this method, so no caller can hide or unhide another principal.
764
+ *
765
+ * Honest caveat: this is a UX flag, not a privacy guarantee. The
766
+ * envelope ciphertext at `_users/<keyringId>` and the keyring file at
767
+ * `_keyring/<userId>` are both still observable to anyone with direct
768
+ * store read access. See `docs/subsystems/user-envelope.md` →
769
+ * "Directory visibility".
770
+ */
771
+ async setMyVisibility(visibility) {
772
+ await persistUserVisibility(
773
+ this.adapter,
774
+ this.vaultName,
775
+ this.writerKeyringId,
776
+ { hidden: visibility.hidden }
777
+ );
778
+ }
943
779
  // ─── Read-anyone ─────────────────────────────────────────────────────
944
780
  /**
945
781
  * Read another principal's envelope by their keyringId. Returns null
@@ -1066,9 +902,17 @@ function deepMerge(source, patch) {
1066
902
  }
1067
903
  const out = { ...source };
1068
904
  for (const [key, patchVal] of Object.entries(patch)) {
905
+ if (patchVal === void 0) {
906
+ continue;
907
+ }
908
+ if (patchVal === null) {
909
+ delete out[key];
910
+ continue;
911
+ }
1069
912
  const sourceVal = source[key];
1070
- if (isPlainObject(sourceVal) && isPlainObject(patchVal)) {
1071
- out[key] = deepMerge(sourceVal, patchVal);
913
+ if (isPlainObject(patchVal)) {
914
+ const recurseSource = isPlainObject(sourceVal) ? sourceVal : {};
915
+ out[key] = deepMerge(recurseSource, patchVal);
1072
916
  } else {
1073
917
  out[key] = patchVal;
1074
918
  }
@@ -1124,7 +968,7 @@ async function describeAuthConfig(store, vault) {
1124
968
  lines.push(` Phrase format: ${policy.passphrase?.minWords ?? 6}+ words, lowercase letters, \u2265${policy.passphrase?.minWordLength ?? 3} chars/word`);
1125
969
  lines.push(" Strength validator: enforced (override available for tests only)");
1126
970
  lines.push("");
1127
- lines.push("Tier 2 \u2014 Authenticate (daily login)");
971
+ lines.push("Tier 2 \u2014 Authenticate (routine login)");
1128
972
  lines.push(" Allowed methods: WebAuthn (passkey), OIDC, Password");
1129
973
  lines.push(" Slots per user: unlimited");
1130
974
  lines.push("");
@@ -1236,68 +1080,131 @@ function sanitizeId(s) {
1236
1080
  return s.replace(/[^a-zA-Z0-9]/g, "_");
1237
1081
  }
1238
1082
 
1239
- // src/team/peer-recover.ts
1240
- var ADMIN_RECOVERABLE_TARGETS = ["operator", "viewer", "client", "admin"];
1241
- function canRecover(callerRole, targetRole) {
1242
- if (callerRole === "owner") return true;
1243
- if (callerRole === "admin") return ADMIN_RECOVERABLE_TARGETS.includes(targetRole);
1244
- return false;
1245
- }
1246
- async function recoverUser(store, vault, callerKeyring, options) {
1247
- const env = await store.get(vault, "_keyring", options.userId);
1248
- if (!env) {
1249
- throw new NoAccessError(
1250
- `recoverUser: user "${options.userId}" has no keyring in vault "${vault}".`
1251
- );
1252
- }
1253
- const target = JSON.parse(env._data);
1254
- const targetRole = options.role ?? target.role;
1255
- if (!canRecover(callerKeyring.role, targetRole)) {
1256
- throw new PermissionDeniedError(
1257
- `Role "${callerKeyring.role}" cannot recover role "${targetRole}"`
1258
- );
1083
+ // src/team/managed-passphrase.ts
1084
+ var MemorySealingKeyProvider = class {
1085
+ id;
1086
+ fingerprint;
1087
+ keyBytes;
1088
+ constructor(opts) {
1089
+ this.id = opts.id;
1090
+ const encoded = new TextEncoder().encode(opts.id);
1091
+ let h = 0;
1092
+ for (let i = 0; i < encoded.length; i++) {
1093
+ h = h * 31 + encoded[i] >>> 0;
1094
+ }
1095
+ this.fingerprint = new Uint8Array([
1096
+ h >>> 24 & 255,
1097
+ h >>> 16 & 255,
1098
+ h >>> 8 & 255,
1099
+ h & 255
1100
+ ]);
1101
+ this.keyBytes = new Uint8Array(16);
1102
+ for (let i = 0; i < 16; i++) {
1103
+ this.keyBytes[i] = this.fingerprint[i % 4] ^ i * 17;
1104
+ }
1259
1105
  }
1260
- if (!canRecover(callerKeyring.role, target.role)) {
1261
- throw new PermissionDeniedError(
1262
- `Role "${callerKeyring.role}" cannot recover role "${target.role}"`
1263
- );
1106
+ async seal(passphrase) {
1107
+ const out = new Uint8Array(4 + passphrase.length);
1108
+ out.set(this.fingerprint, 0);
1109
+ for (let i = 0; i < passphrase.length; i++) {
1110
+ out[4 + i] = passphrase[i] ^ this.keyBytes[i % 16];
1111
+ }
1112
+ return out;
1264
1113
  }
1265
- for (const coll of Object.keys(target.deks)) {
1266
- if (!callerKeyring.deks.has(coll)) {
1267
- throw new PrivilegeEscalationError(coll);
1114
+ async unseal(sealed) {
1115
+ if (sealed.length < 4) {
1116
+ throw new Error("MemorySealingKeyProvider: sealed input too short");
1117
+ }
1118
+ for (let i = 0; i < 4; i++) {
1119
+ if (sealed[i] !== this.fingerprint[i]) {
1120
+ throw new Error(
1121
+ `MemorySealingKeyProvider("${this.id}"): provider-id mismatch on unseal (sealed bytes were produced by a different provider)`
1122
+ );
1123
+ }
1268
1124
  }
1125
+ const body = sealed.subarray(4);
1126
+ const out = new Uint8Array(body.length);
1127
+ for (let i = 0; i < body.length; i++) {
1128
+ out[i] = body[i] ^ this.keyBytes[i % 16];
1129
+ }
1130
+ return out;
1269
1131
  }
1270
- if (options.validatePassphrase && !options.allowWeakPassphrase) {
1271
- assertStrongPassphrase(options.passphrase, options.passphrasePolicy);
1132
+ };
1133
+ var SEALED_PASSPHRASE_RECORD_ID = "sealed-passphrase";
1134
+ function bytesToBase64(bytes) {
1135
+ let binary = "";
1136
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
1137
+ return btoa(binary);
1138
+ }
1139
+ function base64ToBytes(b64) {
1140
+ const binary = atob(b64);
1141
+ const out = new Uint8Array(binary.length);
1142
+ for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
1143
+ return out;
1144
+ }
1145
+ function parseSealedEnvelope(raw) {
1146
+ if (typeof raw !== "object" || raw === null) return void 0;
1147
+ const r = raw;
1148
+ if (r._noydb_sealed !== 1) return void 0;
1149
+ if (r.v === 1 && typeof r.pid === "string" && typeof r.payload === "string") {
1150
+ return {
1151
+ _noydb_sealed: 1,
1152
+ providerId: r.pid,
1153
+ sealed: base64ToBytes(r.payload)
1154
+ };
1272
1155
  }
1273
- const newSalt = generateSalt();
1274
- const newKek = await deriveKey(options.passphrase, newSalt);
1275
- const wrappedDeks = {};
1276
- for (const coll of Object.keys(target.deks)) {
1277
- const callerDek = callerKeyring.deks.get(coll);
1278
- if (!callerDek) {
1279
- throw new PrivilegeEscalationError(coll);
1280
- }
1281
- wrappedDeks[coll] = await wrapKey(callerDek, newKek);
1156
+ if (typeof r.providerId === "string" && typeof r.sealed === "string") {
1157
+ return {
1158
+ _noydb_sealed: 1,
1159
+ providerId: r.providerId,
1160
+ sealed: base64ToBytes(r.sealed)
1161
+ };
1282
1162
  }
1283
- const next = {
1284
- ...target,
1285
- _noydb_keyring: NOYDB_KEYRING_VERSION,
1286
- role: targetRole,
1287
- display_name: options.displayName ?? target.display_name,
1288
- deks: wrappedDeks,
1289
- salt: bufferToBase64(newSalt),
1290
- granted_by: callerKeyring.userId,
1291
- authenticators: []
1163
+ return void 0;
1164
+ }
1165
+ async function saveSealedPassphrase(store, vault, payload) {
1166
+ const persisted = {
1167
+ v: 1,
1168
+ _noydb_sealed: 1,
1169
+ pid: payload.providerId,
1170
+ payload: bytesToBase64(payload.sealed)
1292
1171
  };
1293
- const envelope = {
1294
- _noydb: 1,
1295
- _v: 1,
1172
+ const prior = await store.get(vault, "_meta", SEALED_PASSPHRASE_RECORD_ID);
1173
+ const env = {
1174
+ _noydb: NOYDB_FORMAT_VERSION,
1175
+ _v: (prior?._v ?? 0) + 1,
1296
1176
  _ts: (/* @__PURE__ */ new Date()).toISOString(),
1177
+ // AES-GCM bypassed — the sealing layer is the security boundary.
1297
1178
  _iv: "",
1298
- _data: JSON.stringify(next)
1179
+ _data: JSON.stringify(persisted)
1299
1180
  };
1300
- await store.put(vault, "_keyring", options.userId, envelope);
1181
+ await store.put(vault, "_meta", SEALED_PASSPHRASE_RECORD_ID, env);
1182
+ }
1183
+ async function loadSealedPassphrase(store, vault) {
1184
+ const envelope = await store.get(vault, "_meta", SEALED_PASSPHRASE_RECORD_ID);
1185
+ if (!envelope) return void 0;
1186
+ try {
1187
+ return parseSealedEnvelope(JSON.parse(envelope._data));
1188
+ } catch {
1189
+ return void 0;
1190
+ }
1191
+ }
1192
+ async function resolveManagedSecret(store, vault, provider) {
1193
+ const existing = await loadSealedPassphrase(store, vault);
1194
+ if (existing) {
1195
+ if (existing.providerId !== provider.id) {
1196
+ throw new Error(
1197
+ `Managed-mode vault "${vault}" was sealed under provider id "${existing.providerId}" but the current SealingKeyProvider is "${provider.id}". Pass the same provider that originally enrolled the vault, or treat this as a fresh enrollment and clear \`_meta/sealed-passphrase\` first.`
1198
+ );
1199
+ }
1200
+ const plaintext = await provider.unseal(existing.sealed);
1201
+ return bytesToBase64(plaintext);
1202
+ }
1203
+ const random = new Uint8Array(32);
1204
+ globalThis.crypto.getRandomValues(random);
1205
+ const sealed = await provider.seal(random);
1206
+ await saveSealedPassphrase(store, vault, { providerId: provider.id, sealed });
1207
+ return bytesToBase64(random);
1301
1208
  }
1302
1209
 
1303
1210
  // src/crdt/strategy.ts
@@ -1573,6 +1480,79 @@ var NO_BLOBS = {
1573
1480
  }
1574
1481
  };
1575
1482
 
1483
+ // src/derivations/stale.ts
1484
+ var _staleByRegistry = /* @__PURE__ */ new WeakMap();
1485
+ var keyFor = (source, sourceId) => `${source}/${sourceId}`;
1486
+ async function markStale(registry, strategy, sourceId) {
1487
+ let map = _staleByRegistry.get(registry);
1488
+ if (!map) {
1489
+ map = /* @__PURE__ */ new Map();
1490
+ _staleByRegistry.set(registry, map);
1491
+ }
1492
+ const k = keyFor(strategy.source, sourceId);
1493
+ let set = map.get(k);
1494
+ if (!set) {
1495
+ set = /* @__PURE__ */ new Set();
1496
+ map.set(k, set);
1497
+ }
1498
+ set.add(strategy);
1499
+ }
1500
+ async function resolveStaleOnRead(accessor, outputCollection, id) {
1501
+ const registry = accessor.registry();
1502
+ const producers = registry.strategiesProducingOutput(outputCollection);
1503
+ if (producers.length === 0) return;
1504
+ const map = _staleByRegistry.get(registry);
1505
+ if (!map) return;
1506
+ let DerivationExecutor = null;
1507
+ for (const { spec, strategyHash } of producers) {
1508
+ const k = keyFor(spec.source, id);
1509
+ const pending = map.get(k);
1510
+ if (!pending || !pending.has(spec)) continue;
1511
+ const sourceColl = accessor.getCollection(spec.source);
1512
+ const source = await sourceColl.get(id);
1513
+ if (!source) {
1514
+ pending.delete(spec);
1515
+ continue;
1516
+ }
1517
+ const sourceWithId = { ...source, id };
1518
+ if (DerivationExecutor === null) {
1519
+ ({ DerivationExecutor } = await import("./executor-X4SQ3ZLC.js"));
1520
+ }
1521
+ const ctx = { vault: accessor.getReadOnlyFacade() };
1522
+ const result = await DerivationExecutor.run(spec, sourceWithId, 0, strategyHash, ctx);
1523
+ for (const key of Object.keys(spec.outputs)) {
1524
+ const out = result.outputs[key];
1525
+ if (!out) continue;
1526
+ if (out.kind === "failed") {
1527
+ const err = out.error;
1528
+ if (spec.strict) {
1529
+ throw err;
1530
+ }
1531
+ console.warn(
1532
+ `[derivation] lazy output "${key}" for source "${spec.source}" id="${id}" failed:`,
1533
+ err
1534
+ );
1535
+ continue;
1536
+ }
1537
+ if (out.kind === "array") {
1538
+ console.warn(
1539
+ `[derivation] unexpected array-shape output "${key}" in lazy resolve path; array-shape derivations require lifecycle: "eager" (#200 slice 1).`
1540
+ );
1541
+ continue;
1542
+ }
1543
+ const outSpec = spec.outputs[key];
1544
+ if (!outSpec) continue;
1545
+ const outputColl = accessor.getCollection(outSpec.collection);
1546
+ if (out.skipped === true) {
1547
+ await outputColl._internalDelete(id, accessor.getActiveTxContext());
1548
+ continue;
1549
+ }
1550
+ await outputColl.put(id, out.value);
1551
+ }
1552
+ pending.delete(spec);
1553
+ }
1554
+ }
1555
+
1576
1556
  // src/collection.ts
1577
1557
  var fallbackWarned = /* @__PURE__ */ new Set();
1578
1558
  function warnOnceFallback(adapterName) {
@@ -1580,7 +1560,7 @@ function warnOnceFallback(adapterName) {
1580
1560
  fallbackWarned.add(adapterName);
1581
1561
  if (typeof process !== "undefined" && process.env["NODE_ENV"] === "test") return;
1582
1562
  console.warn(
1583
- `[noy-db] Adapter "${adapterName}" does not implement listPage(); Collection.scan()/listPage() are using a synthetic fallback (slower). Add a listPage method to opt into the streaming fast path.`
1563
+ `[noy-db] Store "${adapterName}" does not implement listPage(); Collection.scan()/listPage() are using a synthetic fallback (slower). Add a listPage method to opt into the streaming fast path.`
1584
1564
  );
1585
1565
  }
1586
1566
  var Collection = class {
@@ -1790,6 +1770,34 @@ var Collection = class {
1790
1770
  * adapter on first use.
1791
1771
  */
1792
1772
  periodGuard;
1773
+ /**
1774
+ * Optional back-reference to the owning vault's guard registry + a
1775
+ * read-only vault facade. When present, `Collection.put` and
1776
+ * `Collection.delete` consult the registry for guards declared
1777
+ * against this collection and run their `check` + `frozenFields`
1778
+ * before the adapter write. Absent in unit tests that construct
1779
+ * a Collection directly; production code always sets it via
1780
+ * `Vault.collection()`.
1781
+ *
1782
+ * Typed structurally rather than as `Vault` to avoid a circular
1783
+ * import (mirrors the `refEnforcer` / `joinResolver` pattern).
1784
+ */
1785
+ guardSource;
1786
+ /**
1787
+ * Vault-internal hook for derivation dispatch. When set,
1788
+ * `Collection.put` consults the registry after the source-write
1789
+ * commits and writes derived outputs through `getCollection(name).put`.
1790
+ * Same structural-interface pattern as `guardSource` to avoid a
1791
+ * circular Vault import.
1792
+ */
1793
+ derivationSource;
1794
+ /**
1795
+ * Vault-internal hook for materialized-view dispatch (#143/#150).
1796
+ * Parallel to `derivationSource` — when set, `Collection.put` fires
1797
+ * `MaterializedViewRegistry.onSourceWrite` after the source-write
1798
+ * commits + after `dispatchDerivations` has run.
1799
+ */
1800
+ materializedViewSource;
1793
1801
  /**
1794
1802
  * Optional back-reference to the owning compartment's ref
1795
1803
  * enforcer. When present, `Collection.put` calls
@@ -1860,6 +1868,9 @@ var Collection = class {
1860
1868
  this.syncAdapter = opts.syncAdapter;
1861
1869
  this.onAccess = opts.onAccess;
1862
1870
  this.periodGuard = opts.periodGuard;
1871
+ this.guardSource = opts.guardSource;
1872
+ this.derivationSource = opts.derivationSource;
1873
+ this.materializedViewSource = opts.materializedViewSource;
1863
1874
  this.tiers = opts.tiers && opts.tiers.length > 0 ? new Set(opts.tiers) : null;
1864
1875
  this.tierMode = opts.tierMode ?? "invisibility";
1865
1876
  this.onCrossTierAccess = opts.onCrossTierAccess;
@@ -1984,6 +1995,16 @@ var Collection = class {
1984
1995
  * `null` if not found.
1985
1996
  */
1986
1997
  async get(id, locale) {
1998
+ if (this.derivationSource !== void 0) {
1999
+ const registry = this.derivationSource.registry();
2000
+ if (registry.strategiesProducingOutput(this.name).length > 0) {
2001
+ await resolveStaleOnRead(this.derivationSource, this.name, id);
2002
+ }
2003
+ }
2004
+ if (this.materializedViewSource !== void 0) {
2005
+ const { resolveStaleMVOnRead } = await import("./stale-HSC5YO2O.js");
2006
+ await resolveStaleMVOnRead(this.materializedViewSource, this.name);
2007
+ }
1987
2008
  let record;
1988
2009
  if (this.lazy && this.lru) {
1989
2010
  const cached = this.lru.get(id);
@@ -2047,11 +2068,53 @@ var Collection = class {
2047
2068
  if (opts?.pollIntervalMs !== void 0) presenceOpts.pollIntervalMs = opts.pollIntervalMs;
2048
2069
  return this.syncStrategy.buildPresence(presenceOpts);
2049
2070
  }
2050
- /** Create or update a record. */
2051
- async put(id, record) {
2071
+ /**
2072
+ * Create or update a record.
2073
+ *
2074
+ * @param id Record identifier.
2075
+ * @param record The record body (validated by the collection's schema
2076
+ * if one was attached at `vault.collection(...)` time).
2077
+ * @param options Optional metadata for audit + import workflows.
2078
+ * `reason` is stamped onto the resulting ledger entry
2079
+ * (see #1) so audit consumers can filter via
2080
+ * `entries.filter(e => e.reason?.startsWith('import:'))`.
2081
+ */
2082
+ async put(id, record, options) {
2052
2083
  if (!hasWritePermission(this.keyring, this.name)) {
2053
2084
  throw new ReadOnlyError();
2054
2085
  }
2086
+ if (this.guardSource) {
2087
+ const registry = this.guardSource.registry();
2088
+ const guards = registry.guardsFor(this.name);
2089
+ if (guards.length > 0) {
2090
+ const existingEnv = await this.adapter.get(this.vault, this.name, id);
2091
+ let existingRecord = null;
2092
+ if (existingEnv) {
2093
+ try {
2094
+ existingRecord = await this.decryptRecord(existingEnv, { skipValidation: true });
2095
+ } catch {
2096
+ existingRecord = null;
2097
+ }
2098
+ }
2099
+ const incomingRecord = record;
2100
+ const ctx = {
2101
+ existing: existingRecord,
2102
+ vault: this.guardSource.readOnlyVault(),
2103
+ userId: this.keyring.userId,
2104
+ role: this.keyring.role
2105
+ };
2106
+ if (registry.isAmendmentActive()) {
2107
+ const vBefore = existingEnv?._v ?? 0;
2108
+ registry.collectChange(this.name, id, existingRecord, incomingRecord, vBefore, vBefore + 1);
2109
+ } else {
2110
+ await registry.runChecks(this.name, incomingRecord, ctx);
2111
+ const { GuardExecutor } = await import("./executor-CEWX2FQI.js");
2112
+ for (const g of guards) {
2113
+ await GuardExecutor.checkFrozenFields(g, id, existingRecord, incomingRecord);
2114
+ }
2115
+ }
2116
+ }
2117
+ }
2055
2118
  if (this.periodGuard !== void 0) {
2056
2119
  const existingEnv = await this.adapter.get(this.vault, this.name, id);
2057
2120
  let priorRecord = null;
@@ -2160,6 +2223,7 @@ var Collection = class {
2160
2223
  payloadHash: await this.historyStrategy.envelopePayloadHash(envelope2)
2161
2224
  };
2162
2225
  if (existingResolved) appendInput.delta = this.historyStrategy.computePatch(resolvedRecord, existingResolved.record);
2226
+ if (options?.reason !== void 0) appendInput.reason = options.reason;
2163
2227
  await this.ledger.append(appendInput);
2164
2228
  }
2165
2229
  if (this.lazy && this.lru) {
@@ -2177,6 +2241,8 @@ var Collection = class {
2177
2241
  await this.onDirty?.(this.name, id, "put", version2);
2178
2242
  this.emitter.emit("change", { vault: this.vault, collection: this.name, id, action: "put" });
2179
2243
  await this.onAccess?.("put", id);
2244
+ await this.dispatchDerivations(id, record, version2);
2245
+ await this.dispatchMaterializedViews(id, record);
2180
2246
  return;
2181
2247
  }
2182
2248
  let existing;
@@ -2223,6 +2289,7 @@ var Collection = class {
2223
2289
  if (existing) {
2224
2290
  appendInput.delta = this.historyStrategy.computePatch(record, existing.record);
2225
2291
  }
2292
+ if (options?.reason !== void 0) appendInput.reason = options.reason;
2226
2293
  await this.ledger.append(appendInput);
2227
2294
  }
2228
2295
  if (this.lazy && this.lru) {
@@ -2240,13 +2307,256 @@ var Collection = class {
2240
2307
  action: "put"
2241
2308
  });
2242
2309
  await this.onAccess?.("put", id);
2310
+ await this.dispatchDerivations(id, record, version);
2311
+ await this.dispatchMaterializedViews(id, record);
2312
+ }
2313
+ /**
2314
+ * Fire registered MV strategies whose dependency set includes this
2315
+ * collection. Eager-mode MVs re-materialize inline via
2316
+ * `MaterializedViewExecutor.refresh`; lazy / manual modes are
2317
+ * no-ops in the foundation (subtask #150) — wired in #151.
2318
+ *
2319
+ * Skips entirely when the record being written is itself an
2320
+ * MV-emitted row (carries `_materializedFrom`) — defensive guard
2321
+ * against missed cycle detection.
2322
+ *
2323
+ * @internal
2324
+ */
2325
+ async dispatchMaterializedViews(id, record) {
2326
+ void id;
2327
+ if (this.materializedViewSource === void 0) return;
2328
+ const incoming = record;
2329
+ if (incoming && typeof incoming === "object" && "_materializedFrom" in incoming) return;
2330
+ const registry = this.materializedViewSource.registry();
2331
+ const mvs = registry.mvsForSource(this.name);
2332
+ if (mvs.length === 0) return;
2333
+ let executor = null;
2334
+ let staleHelpers = null;
2335
+ for (const reg of mvs) {
2336
+ const mode = reg.spec.refresh;
2337
+ if (mode === "eager") {
2338
+ if (executor === null) {
2339
+ ;
2340
+ ({ MaterializedViewExecutor: executor } = await import("./executor-7E3VFGW7.js"));
2341
+ }
2342
+ await executor.refresh(reg, {
2343
+ getCollection: (name) => this.materializedViewSource.getCollection(name),
2344
+ getActiveTxContext: () => this.materializedViewSource.getActiveTxContext(),
2345
+ getQueryContext: () => this.materializedViewSource.getQueryContext()
2346
+ });
2347
+ } else if (mode === "lazy") {
2348
+ if (staleHelpers === null) {
2349
+ staleHelpers = await import("./stale-HSC5YO2O.js");
2350
+ }
2351
+ staleHelpers.markMVStale(registry, reg.spec.name);
2352
+ }
2353
+ }
2354
+ }
2355
+ /**
2356
+ * Fire registered derivation strategies for this source collection.
2357
+ * Eager mode runs `derive` inline and writes each output via the
2358
+ * sibling `Collection.put`; lazy mode marks dependent outputs stale
2359
+ * (D11 stub today). Errors in non-strict mode are logged and
2360
+ * skipped; strict mode propagates the first failing output's error.
2361
+ *
2362
+ * Skips entirely when the record being written is itself a derived
2363
+ * output (carries `_derivedFrom`) — defensive guard against missed
2364
+ * cycle detection.
2365
+ */
2366
+ async dispatchDerivations(id, record, version) {
2367
+ if (this.derivationSource === void 0) return;
2368
+ const incoming = record;
2369
+ if (incoming && typeof incoming === "object" && "_derivedFrom" in incoming) return;
2370
+ const registry = this.derivationSource.registry();
2371
+ const strategies = registry.strategiesForSource(this.name);
2372
+ if (strategies.length === 0) return;
2373
+ let DerivationExecutor = null;
2374
+ for (const { spec, strategyHash } of strategies) {
2375
+ const mode = typeof spec.lifecycle === "string" ? spec.lifecycle : spec.lifecycle.mode;
2376
+ if (mode === "eager") {
2377
+ if (DerivationExecutor === null) {
2378
+ ({ DerivationExecutor } = await import("./executor-X4SQ3ZLC.js"));
2379
+ }
2380
+ const sourceWithId = { ...incoming, id };
2381
+ const ctx = { vault: this.derivationSource.getReadOnlyFacade() };
2382
+ const result = await DerivationExecutor.run(spec, sourceWithId, version, strategyHash, ctx);
2383
+ for (const key of Object.keys(spec.outputs)) {
2384
+ const out = result.outputs[key];
2385
+ if (!out) continue;
2386
+ if (out.kind === "failed") {
2387
+ const err = out.error;
2388
+ if (spec.strict) throw err;
2389
+ console.warn(`[derivation] output "${key}" for source "${spec.source}" id="${id}" failed:`, err);
2390
+ continue;
2391
+ }
2392
+ const outSpec = spec.outputs[key];
2393
+ if (!outSpec) continue;
2394
+ const outputCollection = this.derivationSource.getCollection(outSpec.collection);
2395
+ const txCtx = this.derivationSource.getActiveTxContext();
2396
+ if (out.kind === "array") {
2397
+ const { loadFanoutSidecar, saveFanoutSidecar } = await import("./fanout-sidecar-VJ52RIEY.js");
2398
+ const prior = await loadFanoutSidecar(
2399
+ this.adapter,
2400
+ this.vault,
2401
+ spec.source,
2402
+ id,
2403
+ key
2404
+ );
2405
+ const prevKeys = new Set(prior?.keys ?? []);
2406
+ const newKeysList = out.entries.map((e) => e.key);
2407
+ const newKeysSet = new Set(newKeysList);
2408
+ for (const k of prevKeys) {
2409
+ if (newKeysSet.has(k)) continue;
2410
+ await outputCollection._internalDelete(k, txCtx);
2411
+ }
2412
+ for (const entry of out.entries) {
2413
+ if (txCtx !== null) {
2414
+ const priorEnvelope = await this.adapter.get(this.vault, outSpec.collection, entry.key);
2415
+ txCtx._executed.push({
2416
+ op: {
2417
+ type: "put",
2418
+ vaultName: this.vault,
2419
+ collectionName: outSpec.collection,
2420
+ id: entry.key
2421
+ },
2422
+ priorEnvelope
2423
+ });
2424
+ }
2425
+ await outputCollection.put(entry.key, entry.value);
2426
+ }
2427
+ await saveFanoutSidecar(this.adapter, this.vault, {
2428
+ source: spec.source,
2429
+ sourceId: id,
2430
+ outputKey: key,
2431
+ outputCollection: outSpec.collection,
2432
+ keys: newKeysList
2433
+ });
2434
+ continue;
2435
+ }
2436
+ if (out.skipped === true) {
2437
+ await outputCollection._internalDelete(id, txCtx);
2438
+ continue;
2439
+ }
2440
+ if (txCtx !== null) {
2441
+ const prior = await this.adapter.get(this.vault, outSpec.collection, id);
2442
+ txCtx._executed.push({
2443
+ op: {
2444
+ type: "put",
2445
+ vaultName: this.vault,
2446
+ collectionName: outSpec.collection,
2447
+ id
2448
+ },
2449
+ priorEnvelope: prior
2450
+ });
2451
+ }
2452
+ await outputCollection.put(id, out.value);
2453
+ }
2454
+ } else {
2455
+ await markStale(registry, spec, id);
2456
+ }
2457
+ }
2243
2458
  }
2244
2459
  /** Delete a record by ID. */
2245
2460
  async delete(id) {
2461
+ await this._doDelete(id, false);
2462
+ }
2463
+ /**
2464
+ * @internal — system-internal delete that bypasses user-facing
2465
+ * delete hooks (`onDelete`, accounting-period guard, FK ref
2466
+ * enforcer). Used by derivation tombstones (#144) and MV refresh
2467
+ * (Dim 14 v2) — system housekeeping shouldn't trip user invariants
2468
+ * registered against the output collection. The ledger entry and
2469
+ * history snapshot still fire so backup integrity and time-travel
2470
+ * reconstruction stay consistent.
2471
+ *
2472
+ * Returns silently for delete-of-absent (idempotent contract — both
2473
+ * paths honour this: the `txCtx === null` path also reads the prior
2474
+ * envelope and short-circuits before the ledger/event side-effects).
2475
+ *
2476
+ * When a `txCtx` is supplied, the prior envelope is captured and
2477
+ * pushed onto `txCtx._executed` BEFORE the delete fires — mirrors
2478
+ * the #133 rollback hardening for puts. Callers outside a
2479
+ * multi-record transaction pass `null` and skip the tracking.
2480
+ *
2481
+ * Amendment composition: if `_internalDelete` runs while a vault's
2482
+ * `GuardRegistry` has an amendment window open, the `{before, after:
2483
+ * null}` change pair is pushed onto the amendment change-set the
2484
+ * same way a user-initiated delete would. The `onDelete` user-hook
2485
+ * is still skipped (housekeeping must not trip user invariants in
2486
+ * normal mode), but the amendment's invariant DOES see the change
2487
+ * — so a `RCT-CANCEL-001`-style invariant pairing can reject a
2488
+ * derivation-driven tombstone fired during an admin amendment.
2489
+ *
2490
+ * Constraint to surface to consumers: output collections of
2491
+ * derivations with `optional: true` outputs should not be the
2492
+ * targets of `strict` or `cascade` inbound foreign-key refs —
2493
+ * `_internalDelete` bypasses the ref enforcer by design (the
2494
+ * `onDelete` bypass primitive). Treat the housekeeping path as
2495
+ * "system can tombstone its own emissions regardless of FK shape."
2496
+ *
2497
+ * Permission handling is unchanged: the caller must still hold
2498
+ * write permission on the collection (derivations run under the
2499
+ * user's keyring).
2500
+ */
2501
+ async _internalDelete(id, txCtx = null) {
2502
+ const prior = await this.adapter.get(this.vault, this.name, id);
2503
+ if (prior === null) return;
2504
+ if (txCtx !== null) {
2505
+ txCtx._executed.push({
2506
+ op: {
2507
+ type: "delete",
2508
+ vaultName: this.vault,
2509
+ collectionName: this.name,
2510
+ id
2511
+ },
2512
+ priorEnvelope: prior
2513
+ });
2514
+ }
2515
+ await this._doDelete(id, true);
2516
+ }
2517
+ async _doDelete(id, internal) {
2246
2518
  if (!hasWritePermission(this.keyring, this.name)) {
2247
2519
  throw new ReadOnlyError();
2248
2520
  }
2249
- if (this.periodGuard !== void 0) {
2521
+ if (this.guardSource) {
2522
+ const registry = this.guardSource.registry();
2523
+ const guards = registry.guardsFor(this.name);
2524
+ if (guards.length > 0) {
2525
+ const existingEnv = await this.adapter.get(this.vault, this.name, id);
2526
+ if (existingEnv) {
2527
+ let existingRecord = null;
2528
+ try {
2529
+ existingRecord = await this.decryptRecord(existingEnv, { skipValidation: true });
2530
+ } catch {
2531
+ existingRecord = null;
2532
+ }
2533
+ if (registry.isAmendmentActive()) {
2534
+ const vBefore = existingEnv._v;
2535
+ registry.collectChange(
2536
+ this.name,
2537
+ id,
2538
+ existingRecord,
2539
+ null,
2540
+ vBefore,
2541
+ vBefore
2542
+ );
2543
+ } else if (!internal) {
2544
+ const ctx = {
2545
+ existing: existingRecord,
2546
+ vault: this.guardSource.readOnlyVault(),
2547
+ userId: this.keyring.userId,
2548
+ role: this.keyring.role
2549
+ };
2550
+ await registry.runOnDelete(
2551
+ this.name,
2552
+ existingRecord ?? {},
2553
+ ctx
2554
+ );
2555
+ }
2556
+ }
2557
+ }
2558
+ }
2559
+ if (!internal && this.periodGuard !== void 0) {
2250
2560
  const existingEnv = await this.adapter.get(this.vault, this.name, id);
2251
2561
  let priorRecord = null;
2252
2562
  if (existingEnv) {
@@ -2261,7 +2571,7 @@ var Collection = class {
2261
2571
  null
2262
2572
  );
2263
2573
  }
2264
- if (this.refEnforcer !== void 0) {
2574
+ if (!internal && this.refEnforcer !== void 0) {
2265
2575
  await this.refEnforcer.enforceRefsOnDelete(this.name, id);
2266
2576
  }
2267
2577
  let existing;
@@ -2313,6 +2623,87 @@ var Collection = class {
2313
2623
  action: "delete"
2314
2624
  });
2315
2625
  await this.onAccess?.("delete", id);
2626
+ if (!internal) {
2627
+ await this.dispatchMaterializedViewsOnDelete(id);
2628
+ await this.dispatchArrayDerivationsOnDelete(id);
2629
+ }
2630
+ }
2631
+ /**
2632
+ * Cascade deletes of array-shape derived rows when a source row is
2633
+ * deleted (#200). Reads each registered strategy's fanout sidecar
2634
+ * for this source id, deletes every listed derived row, then
2635
+ * deletes the sidecar itself.
2636
+ *
2637
+ * Record-shape derivations are skipped — see _doDelete's comment
2638
+ * for why the asymmetry is correct.
2639
+ *
2640
+ * @internal
2641
+ */
2642
+ async dispatchArrayDerivationsOnDelete(id) {
2643
+ if (this.derivationSource === void 0) return;
2644
+ const registry = this.derivationSource.registry();
2645
+ const strategies = registry.strategiesForSource(this.name);
2646
+ if (strategies.length === 0) return;
2647
+ let helpers = null;
2648
+ const txCtx = this.derivationSource.getActiveTxContext();
2649
+ for (const { spec } of strategies) {
2650
+ for (const [outputKey, outSpec] of Object.entries(spec.outputs)) {
2651
+ if (outSpec.shape !== "array") continue;
2652
+ if (helpers === null) {
2653
+ helpers = await import("./fanout-sidecar-VJ52RIEY.js");
2654
+ }
2655
+ const sidecar = await helpers.loadFanoutSidecar(
2656
+ this.adapter,
2657
+ this.vault,
2658
+ spec.source,
2659
+ id,
2660
+ outputKey
2661
+ );
2662
+ if (!sidecar) continue;
2663
+ const outputCollection = this.derivationSource.getCollection(outSpec.collection);
2664
+ for (const derivedId of sidecar.keys) {
2665
+ await outputCollection._internalDelete(derivedId, txCtx);
2666
+ }
2667
+ await helpers.deleteFanoutSidecar(this.adapter, this.vault, spec.source, id, outputKey);
2668
+ }
2669
+ }
2670
+ }
2671
+ /**
2672
+ * Mirror of {@link dispatchMaterializedViews} for the delete path
2673
+ * (#181). No record content is available (it's gone), so the
2674
+ * `_materializedFrom` skip used by the put-side dispatch doesn't
2675
+ * apply here — instead, the recursion guard is the `internal` gate
2676
+ * at the `_doDelete` call site above.
2677
+ *
2678
+ * @internal
2679
+ */
2680
+ async dispatchMaterializedViewsOnDelete(id) {
2681
+ void id;
2682
+ if (this.materializedViewSource === void 0) return;
2683
+ const registry = this.materializedViewSource.registry();
2684
+ const mvs = registry.mvsForSource(this.name);
2685
+ if (mvs.length === 0) return;
2686
+ let executor = null;
2687
+ let staleHelpers = null;
2688
+ for (const reg of mvs) {
2689
+ const mode = reg.spec.refresh;
2690
+ if (mode === "eager") {
2691
+ if (executor === null) {
2692
+ ;
2693
+ ({ MaterializedViewExecutor: executor } = await import("./executor-7E3VFGW7.js"));
2694
+ }
2695
+ await executor.refresh(reg, {
2696
+ getCollection: (name) => this.materializedViewSource.getCollection(name),
2697
+ getActiveTxContext: () => this.materializedViewSource.getActiveTxContext(),
2698
+ getQueryContext: () => this.materializedViewSource.getQueryContext()
2699
+ });
2700
+ } else if (mode === "lazy") {
2701
+ if (staleHelpers === null) {
2702
+ staleHelpers = await import("./stale-HSC5YO2O.js");
2703
+ }
2704
+ staleHelpers.markMVStale(registry, reg.spec.name);
2705
+ }
2706
+ }
2316
2707
  }
2317
2708
  /**
2318
2709
  * List all records in the collection.
@@ -2330,6 +2721,10 @@ var Collection = class {
2330
2721
  `Collection "${this.name}": list() is not available in lazy mode (prefetch: false). Use collection.scan({ pageSize }) to iterate over the full collection.`
2331
2722
  );
2332
2723
  }
2724
+ if (this.materializedViewSource !== void 0) {
2725
+ const { resolveStaleMVOnRead } = await import("./stale-HSC5YO2O.js");
2726
+ await resolveStaleMVOnRead(this.materializedViewSource, this.name);
2727
+ }
2333
2728
  await this.ensureHydrated();
2334
2729
  const records = [...this.cache.values()].map((e) => e.record);
2335
2730
  if (!locale) return records;
@@ -2404,22 +2799,42 @@ var Collection = class {
2404
2799
  }
2405
2800
  }
2406
2801
  }
2407
- const executed = [];
2802
+ const txCtx = this.derivationSource?.createTxContext() ?? null;
2803
+ if (txCtx !== null && this.derivationSource) {
2804
+ this.derivationSource.setActiveTxContext(txCtx);
2805
+ }
2806
+ const localExecuted = [];
2408
2807
  try {
2409
2808
  for (const [id, record] of entries) {
2809
+ const entry = {
2810
+ op: { type: "put", vaultName: this.vault, collectionName: this.name, id },
2811
+ priorEnvelope: priors.get(id) ?? null
2812
+ };
2813
+ if (txCtx !== null) txCtx._executed.push(entry);
2814
+ else localExecuted.push(entry);
2410
2815
  await this.put(id, record);
2411
- executed.push({ id, prior: priors.get(id) ?? null });
2412
2816
  }
2413
- return { ok: true, success: executed.map((e) => e.id), failures: [] };
2817
+ return { ok: true, success: entries.map(([id]) => id), failures: [] };
2414
2818
  } catch (err) {
2415
- for (const { id, prior } of executed.slice().reverse()) {
2819
+ const executedForRevert = txCtx !== null ? txCtx._executed : localExecuted;
2820
+ await revertExecuted(executedForRevert, this.adapter);
2821
+ for (const { op } of [...executedForRevert].reverse()) {
2822
+ if (op.vaultName !== this.vault) continue;
2416
2823
  try {
2417
- if (prior) await this.adapter.put(this.vault, this.name, id, prior);
2418
- else await this.adapter.delete(this.vault, this.name, id);
2824
+ if (op.collectionName === this.name) {
2825
+ await this._invalidateCacheEntry(op.id);
2826
+ } else if (this.derivationSource) {
2827
+ const sibling = this.derivationSource.getCollection(op.collectionName);
2828
+ await sibling._invalidateCacheEntry(op.id);
2829
+ }
2419
2830
  } catch {
2420
2831
  }
2421
2832
  }
2422
2833
  throw err;
2834
+ } finally {
2835
+ if (txCtx !== null && this.derivationSource) {
2836
+ this.derivationSource.clearActiveTxContext(txCtx);
2837
+ }
2423
2838
  }
2424
2839
  }
2425
2840
  /**
@@ -2785,6 +3200,11 @@ var Collection = class {
2785
3200
  * .aggregate({ total: sum('amount'), n: count() })
2786
3201
  * ```
2787
3202
  *
3203
+ * **Lazy-MV gap (#157):** `scan()` is synchronous-build and does
3204
+ * NOT trigger lazy materialized-view resolve-on-read. For lazy
3205
+ * MVs, call `list()` (which DOES resolve) or `vault.refreshView(name)`
3206
+ * before scanning. Same shape as the `query()` limitation.
3207
+ *
2788
3208
  * Returns a `ScanBuilder<T>` instead of the raw async iterator
2789
3209
  * that previous versions used. The builder implements
2790
3210
  * `AsyncIterable<T>`, so every existing `for await … of` call
@@ -2828,6 +3248,38 @@ var Collection = class {
2828
3248
  }
2829
3249
  // ─── Internal ──────────────────────────────────────────────────
2830
3250
  /** Load all records from adapter into memory cache. */
3251
+ /**
3252
+ * @internal — refresh the in-memory cache entry for a single id by
3253
+ * re-reading from the adapter. Used by the transaction executor's
3254
+ * Phase-3 revert path: that path writes the prior envelope directly
3255
+ * via the raw store (to avoid re-firing Collection-level side
3256
+ * effects), which would otherwise leave this Collection's eager
3257
+ * cache holding the rolled-back value. After revert, the executor
3258
+ * calls this hook so subsequent `get` / `query` reads see the
3259
+ * actual on-disk state.
3260
+ *
3261
+ * Lazy mode: drops the LRU entry; the next `get` repopulates from
3262
+ * the adapter. Eager mode: re-reads the envelope and either sets
3263
+ * the cache entry (record still present) or deletes it (record was
3264
+ * gone before the tx and the revert deleted it again).
3265
+ */
3266
+ async _invalidateCacheEntry(id) {
3267
+ if (this.lazy && this.lru) {
3268
+ this.lru.remove(id);
3269
+ return;
3270
+ }
3271
+ if (!this.hydrated) return;
3272
+ const previous = this.cache.get(id);
3273
+ const envelope = await this.adapter.get(this.vault, this.name, id);
3274
+ if (!envelope) {
3275
+ this.cache.delete(id);
3276
+ if (previous) this.indexes?.remove(id, previous.record);
3277
+ return;
3278
+ }
3279
+ const record = await this.decryptRecord(envelope);
3280
+ this.cache.set(id, { record, version: envelope._v });
3281
+ this.indexes?.upsert(id, record, previous ? previous.record : null);
3282
+ }
2831
3283
  async ensureHydrated() {
2832
3284
  if (this.hydrated) return;
2833
3285
  const ids = await this.adapter.list(this.vault, this.name);
@@ -3799,97 +4251,246 @@ var NO_PERIODS = {
3799
4251
  }
3800
4252
  };
3801
4253
 
3802
- // src/team/magic-link-grant.ts
3803
- var MAGIC_LINK_GRANTS_COLLECTION = "_magic_link_grants";
3804
- var MAGIC_LINK_CONTENT_INFO_PREFIX = "noydb-magic-link-content-v1:";
3805
- var MAGIC_LINK_KEK_INFO_PREFIX = "noydb-magic-link-v1:";
3806
- async function deriveMagicLinkContentKey(serverSecret, token, vault) {
3807
- const subtle2 = globalThis.crypto.subtle;
3808
- const ikmBytes = serverSecret instanceof Uint8Array ? serverSecret : new TextEncoder().encode(serverSecret);
3809
- const tokenBytes = new TextEncoder().encode(token);
3810
- const saltBuffer = await subtle2.digest("SHA-256", tokenBytes);
3811
- const info = new TextEncoder().encode(MAGIC_LINK_CONTENT_INFO_PREFIX + vault);
3812
- const ikm = await subtle2.importKey("raw", ikmBytes, "HKDF", false, ["deriveKey"]);
3813
- return subtle2.deriveKey(
3814
- { name: "HKDF", hash: "SHA-256", salt: saltBuffer, info },
3815
- ikm,
3816
- { name: "AES-GCM", length: 256 },
3817
- false,
3818
- ["encrypt", "decrypt"]
3819
- );
4254
+ // src/introspection/fields.ts
4255
+ function jsonSchemaType(node) {
4256
+ if (Array.isArray(node.type)) {
4257
+ const non = node.type.filter((t) => t !== "null");
4258
+ return non[0] ?? "opaque";
4259
+ }
4260
+ if (node.enum && Array.isArray(node.enum)) return "enum";
4261
+ if (typeof node.type === "string") return node.type;
4262
+ return "opaque";
3820
4263
  }
3821
- async function writeMagicLinkGrant(store, vault, grantor, contentKey, grantKek, recordId, opts) {
3822
- const collectionName = opts.collection ?? null;
3823
- const sourceKey = collectionName ? dekKey(collectionName, opts.tier) : `__any#${opts.tier}`;
3824
- const sourceDek = grantor.deks.get(sourceKey);
3825
- if (!sourceDek) {
3826
- throw new DelegationTargetMissingError(
3827
- `grantor cannot find tier ${opts.tier} DEK for ${collectionName ?? "(any)"}`
3828
- );
4264
+ function constraintsFor(node) {
4265
+ const out = {};
4266
+ if (node.enum) out.values = node.enum;
4267
+ if (node.minLength !== void 0) out.minLength = node.minLength;
4268
+ if (node.maxLength !== void 0) out.maxLength = node.maxLength;
4269
+ if (node.pattern !== void 0) out.pattern = node.pattern;
4270
+ if (node.format !== void 0) out.format = node.format;
4271
+ if (node.minimum !== void 0) out.minimum = node.minimum;
4272
+ if (node.maximum !== void 0) out.maximum = node.maximum;
4273
+ if (node.exclusiveMinimum !== void 0) out.gt = node.exclusiveMinimum;
4274
+ if (node.exclusiveMaximum !== void 0) out.lt = node.exclusiveMaximum;
4275
+ return Object.keys(out).length === 0 ? void 0 : out;
4276
+ }
4277
+ function jsonSchemaToFields(jsonSchema, source, refs) {
4278
+ if (!jsonSchema || typeof jsonSchema !== "object") return {};
4279
+ const root = jsonSchema;
4280
+ if (!root.properties || typeof root.properties !== "object") return {};
4281
+ const required = new Set(Array.isArray(root.required) ? root.required : []);
4282
+ const out = {};
4283
+ for (const [name, node] of Object.entries(root.properties)) {
4284
+ const descriptor = {
4285
+ type: jsonSchemaType(node),
4286
+ source,
4287
+ ...required.has(name) ? {} : { optional: true },
4288
+ ...refs?.[name] ? { references: `${refs[name].target}.id` } : {}
4289
+ };
4290
+ const constraints = constraintsFor(node);
4291
+ if (constraints) descriptor.constraints = constraints;
4292
+ out[name] = descriptor;
3829
4293
  }
3830
- const wrappedDek = await wrapKey(sourceDek, grantKek);
3831
- const until = typeof opts.until === "string" ? opts.until : opts.until.toISOString();
3832
- const createdAt = (/* @__PURE__ */ new Date()).toISOString();
3833
- const payload = {
3834
- id: recordId,
3835
- toUser: opts.toUser,
3836
- fromUser: grantor.userId,
3837
- tier: opts.tier,
3838
- collection: collectionName,
3839
- ...opts.record && { record: opts.record },
3840
- until,
3841
- wrappedDek,
3842
- createdAt,
3843
- ...opts.note && { note: opts.note }
3844
- };
3845
- const { iv, data } = await encrypt(JSON.stringify(payload), contentKey);
3846
- const envelope = {
3847
- _noydb: 1,
3848
- _v: 1,
3849
- _ts: createdAt,
3850
- _iv: iv,
3851
- _data: data,
3852
- _by: grantor.userId
4294
+ return out;
4295
+ }
4296
+
4297
+ // src/introspection/walk.ts
4298
+ var INTERNAL_PREFIX = "_";
4299
+ var KNOWN_INTERNAL_NAMES = ["_keyring", "_ledger", "_meta", "_schemas", "_deltas"];
4300
+ async function dumpVaultSchema(vault, opts) {
4301
+ const state = vault._introspectState();
4302
+ const sampleSize = opts.sampleSize ?? 50;
4303
+ const withStats = opts.withStats === true;
4304
+ const cacheNames = [...state.collectionCache.keys()];
4305
+ const storageNames = (await safeListAllCollections(state.adapter, state.name)).filter((n) => !n.startsWith(INTERNAL_PREFIX));
4306
+ const allNames = Array.from(/* @__PURE__ */ new Set([...cacheNames, ...storageNames])).sort();
4307
+ const collections = {};
4308
+ for (const name of allNames) {
4309
+ collections[name] = await describeCollection(state, name, sampleSize, withStats);
4310
+ }
4311
+ const materializedViews = describeMVs(state.mvRegistry);
4312
+ const overlayViews = describeOverlays(state.overlayRegistry);
4313
+ const derivations = describeDerivations(state.derivationRegistry);
4314
+ let internal;
4315
+ if (withStats) {
4316
+ internal = {};
4317
+ for (const name of KNOWN_INTERNAL_NAMES) {
4318
+ const stats = await statsForCollection(state.adapter, state.name, name);
4319
+ if (stats.records > 0) {
4320
+ internal[name] = { records: stats.records, bytes: stats.bytes };
4321
+ }
4322
+ }
4323
+ }
4324
+ const snap = {
4325
+ _noydb_snapshot: 1,
4326
+ vault: state.name,
4327
+ emittedAt: (/* @__PURE__ */ new Date()).toISOString(),
4328
+ subsystems: state.subsystems,
4329
+ collections,
4330
+ materializedViews,
4331
+ overlayViews,
4332
+ derivations,
4333
+ ...internal !== void 0 ? { internal } : {}
3853
4334
  };
3854
- await store.put(vault, MAGIC_LINK_GRANTS_COLLECTION, recordId, envelope);
3855
- return { recordId, payload };
4335
+ return snap;
3856
4336
  }
3857
- async function readMagicLinkGrantRecord(store, vault, contentKey, recordId) {
3858
- const env = await store.get(vault, MAGIC_LINK_GRANTS_COLLECTION, recordId);
3859
- if (!env) return null;
4337
+ async function safeListAllCollections(adapter, vault) {
3860
4338
  try {
3861
- const json = await decrypt(env._iv, env._data, contentKey);
3862
- return JSON.parse(json);
4339
+ const snap = await adapter.loadAll(vault);
4340
+ return Object.keys(snap);
3863
4341
  } catch {
3864
- return null;
4342
+ return [];
4343
+ }
4344
+ }
4345
+ async function describeCollection(state, collectionName, sampleSize, withStats) {
4346
+ let fields = {};
4347
+ let validator;
4348
+ const refsRaw = state.refRegistry.getOutbound(collectionName);
4349
+ const refs = {};
4350
+ for (const [name, desc] of Object.entries(refsRaw)) {
4351
+ refs[name] = { target: desc.target, mode: desc.mode };
4352
+ }
4353
+ try {
4354
+ const dek = await state.getDEK(collectionName);
4355
+ const persisted = await loadPersistedSchema(state.adapter, state.name, collectionName, dek);
4356
+ if (persisted) {
4357
+ validator = { kind: persisted.kind, source: "persisted" };
4358
+ if (persisted.jsonSchema) {
4359
+ fields = jsonSchemaToFields(persisted.jsonSchema, "persisted", refsRaw);
4360
+ }
4361
+ }
4362
+ } catch {
4363
+ }
4364
+ if (!validator) {
4365
+ const coll = state.collectionCache.get(collectionName);
4366
+ const schema = coll?.getSchema();
4367
+ if (schema) {
4368
+ try {
4369
+ const derived = await derivePersistedSchema(schema);
4370
+ validator = { kind: derived.kind, source: "live-validator" };
4371
+ if (derived.jsonSchema) {
4372
+ fields = jsonSchemaToFields(derived.jsonSchema, "live-validator", refsRaw);
4373
+ }
4374
+ } catch {
4375
+ }
4376
+ }
4377
+ }
4378
+ if (Object.keys(fields).length === 0 && sampleSize > 0) {
4379
+ }
4380
+ const descriptor = {
4381
+ fields,
4382
+ indexes: [],
4383
+ refs,
4384
+ ...validator ? { validator } : {}
4385
+ };
4386
+ if (withStats) {
4387
+ const stats = await statsForCollection(state.adapter, state.name, collectionName);
4388
+ descriptor.stats = stats;
4389
+ }
4390
+ return descriptor;
4391
+ }
4392
+ async function statsForCollection(adapter, vault, collection) {
4393
+ const ids = await adapter.list(vault, collection);
4394
+ if (ids.length === 0) {
4395
+ return { records: 0, bytes: 0, bytesAvg: 0, bytesMin: 0, bytesMax: 0, oldest: "", newest: "" };
4396
+ }
4397
+ let total = 0;
4398
+ let min2 = Number.POSITIVE_INFINITY;
4399
+ let max2 = 0;
4400
+ let oldest = "\uFFFF";
4401
+ let newest = "";
4402
+ for (const id of ids) {
4403
+ const env = await adapter.get(vault, collection, id);
4404
+ if (!env) continue;
4405
+ const size = env._data.length;
4406
+ total += size;
4407
+ if (size < min2) min2 = size;
4408
+ if (size > max2) max2 = size;
4409
+ if (env._ts < oldest) oldest = env._ts;
4410
+ if (env._ts > newest) newest = env._ts;
3865
4411
  }
4412
+ return {
4413
+ records: ids.length,
4414
+ bytes: total,
4415
+ bytesAvg: Math.round(total / ids.length),
4416
+ bytesMin: min2 === Number.POSITIVE_INFINITY ? 0 : min2,
4417
+ bytesMax: max2,
4418
+ oldest: oldest === "\uFFFF" ? "" : oldest,
4419
+ newest
4420
+ };
3866
4421
  }
3867
- async function listMagicLinkGrants(store, vault, contentKey, token) {
3868
- const ids = await store.list(vault, MAGIC_LINK_GRANTS_COLLECTION);
3869
- const matching = ids.filter((id) => id === token || id.startsWith(`${token}:`));
3870
- const out = [];
3871
- for (const id of matching) {
3872
- const payload = await readMagicLinkGrantRecord(store, vault, contentKey, id);
3873
- if (payload) out.push(payload);
4422
+ function describeMVs(registry) {
4423
+ if (!registry || typeof registry !== "object") return {};
4424
+ const items = listFromRegistry(registry);
4425
+ const out = {};
4426
+ for (const item of items) {
4427
+ const reg = item;
4428
+ const spec = reg.spec;
4429
+ if (!spec?.name) continue;
4430
+ const sources = spec.unionSources ? spec.unionSources.map((u) => u.collection) : reg.dependencies ? [...reg.dependencies].sort() : [];
4431
+ const groupBy = spec.groupBy ? Array.isArray(spec.groupBy) ? [...spec.groupBy] : [spec.groupBy] : void 0;
4432
+ const aggregate = spec.aggregate ? Object.fromEntries(
4433
+ Object.entries(spec.aggregate).map(([k, v]) => [k, summariseAggregateOp(v)])
4434
+ ) : void 0;
4435
+ out[spec.name] = {
4436
+ sources,
4437
+ ...groupBy ? { groupBy } : {},
4438
+ ...aggregate ? { aggregate } : {},
4439
+ refresh: spec.refresh ?? "eager"
4440
+ };
3874
4441
  }
3875
4442
  return out;
3876
4443
  }
3877
- async function unwrapMagicLinkGrant(payload, grantKek) {
3878
- return unwrapKey(payload.wrappedDek, grantKek);
4444
+ function describeOverlays(registry) {
4445
+ if (!registry || typeof registry !== "object") return {};
4446
+ const specs = listFromRegistry(registry);
4447
+ const out = {};
4448
+ for (const spec of specs) {
4449
+ const s = spec;
4450
+ if (!s.name || !s.base || !s.overlay) continue;
4451
+ out[s.name] = { base: s.base, overlay: s.overlay };
4452
+ }
4453
+ return out;
3879
4454
  }
3880
- async function revokeMagicLinkGrant(store, vault, token) {
3881
- const ids = await store.list(vault, MAGIC_LINK_GRANTS_COLLECTION);
3882
- const matching = ids.filter((id) => id === token || id.startsWith(`${token}:`));
3883
- for (const id of matching) {
3884
- await store.delete(vault, MAGIC_LINK_GRANTS_COLLECTION, id);
4455
+ function describeDerivations(registry) {
4456
+ if (!registry || typeof registry !== "object") return {};
4457
+ const specs = listFromRegistry(registry);
4458
+ const out = {};
4459
+ for (const spec of specs) {
4460
+ const s = spec;
4461
+ if (!s.name) continue;
4462
+ out[s.name] = {
4463
+ source: s.source ?? "",
4464
+ outputs: s.outputs ?? []
4465
+ };
3885
4466
  }
3886
- return matching.length;
4467
+ return out;
3887
4468
  }
3888
- function magicLinkGrantRecordId(token, index) {
3889
- return index === 0 ? token : `${token}:${index}`;
4469
+ function listFromRegistry(reg) {
4470
+ for (const method of ["all", "list", "specs", "values"]) {
4471
+ const fn = reg[method];
4472
+ if (typeof fn === "function") {
4473
+ try {
4474
+ const out = fn.call(reg);
4475
+ if (Array.isArray(out)) return out;
4476
+ if (out && typeof out.values === "function") {
4477
+ return [...out.values()];
4478
+ }
4479
+ } catch {
4480
+ continue;
4481
+ }
4482
+ }
4483
+ }
4484
+ return [];
3890
4485
  }
3891
- function isMagicLinkGrantExpired(payload, now = /* @__PURE__ */ new Date()) {
3892
- return payload.until <= now.toISOString();
4486
+ function summariseAggregateOp(value) {
4487
+ if (value && typeof value === "object") {
4488
+ const op = value.op ?? value.kind;
4489
+ const field = value.field;
4490
+ if (op && field) return `${op}(${field})`;
4491
+ if (op) return op;
4492
+ }
4493
+ return String(value);
3893
4494
  }
3894
4495
 
3895
4496
  // src/vault.ts
@@ -3934,6 +4535,40 @@ var Vault = class {
3934
4535
  historyStrategy;
3935
4536
  i18nStrategy;
3936
4537
  syncStrategy;
4538
+ /**
4539
+ * Per-vault guard registry. `null` until `_initGuards()` runs; stays
4540
+ * `null` for vaults that never register any guard strategy. The
4541
+ * runtime class is dynamic-imported on demand so consumers that
4542
+ * never use guards don't pull `GuardRegistry`/`GuardExecutor` into
4543
+ * their bundle (#130).
4544
+ */
4545
+ guardRegistry = null;
4546
+ /**
4547
+ * Per-vault derivation registry. Same lazy-load contract as
4548
+ * `guardRegistry` — `null` until `_initDerivations()` runs with at
4549
+ * least one strategy handle. See #130 for the bundle motivation.
4550
+ */
4551
+ derivationRegistry = null;
4552
+ /**
4553
+ * Per-vault materialized-view registry (#143/#150). Same lazy-load
4554
+ * contract as `derivationRegistry` — `null` until
4555
+ * `_initMaterializedViews()` runs with at least one MV handle.
4556
+ */
4557
+ materializedViewRegistry = null;
4558
+ /**
4559
+ * Per-vault overlay registry (#154). Same lazy-load contract as
4560
+ * `materializedViewRegistry` — `null` until `_initOverlayedViews()`
4561
+ * runs with at least one handle.
4562
+ */
4563
+ overlayedViewRegistry = null;
4564
+ /**
4565
+ * Cached read-only facade handed to guard callbacks via `ctx.vault`,
4566
+ * and to derivation callbacks via `derive(source, ctx)`. Allocated
4567
+ * eagerly inside `_initGuards()` and/or `_initDerivations()` so read
4568
+ * accessors stay synchronous (callers in `tx/transaction.ts` rely on
4569
+ * that). Stays `null` for vaults with neither subsystem configured.
4570
+ */
4571
+ readOnlyFacade = null;
3937
4572
  getDEK;
3938
4573
  /**
3939
4574
  * Per-principal user envelope API.
@@ -3981,6 +4616,16 @@ var Vault = class {
3981
4616
  * docstring.
3982
4617
  */
3983
4618
  ledgerStore = null;
4619
+ /**
4620
+ * Background writes for persisted-schema envelopes (#schema-dump v0
4621
+ * slice 1). One promise per `collection({ persistJsonSchema: true })`
4622
+ * registration that actually fired a derive call. Fire-and-forget
4623
+ * from the collection factory; tests await
4624
+ * {@link _drainPendingSchemaWrites} before asserting on storage.
4625
+ * Production code does not need to drain — the writes are
4626
+ * idempotent fingerprints, not correctness invariants.
4627
+ */
4628
+ _pendingSchemaWrites = [];
3984
4629
  /**
3985
4630
  * Per-vault foreign-key reference registry. Collections
3986
4631
  * register their `refs` option here on construction; the
@@ -4075,6 +4720,7 @@ var Vault = class {
4075
4720
  this.historyStrategy = opts.historyStrategy ?? NO_HISTORY;
4076
4721
  this.i18nStrategy = opts.i18nStrategy ?? NO_I18N;
4077
4722
  this.syncStrategy = opts.syncStrategy ?? NO_SYNC;
4723
+ void opts.guardStrategies;
4078
4724
  this.historyConfig = opts.historyConfig ?? { enabled: true };
4079
4725
  this.reloadKeyring = opts.reloadKeyring;
4080
4726
  this.locale = opts.locale;
@@ -4134,6 +4780,16 @@ var Vault = class {
4134
4780
  * Collection constructor for the rationale.
4135
4781
  */
4136
4782
  collection(collectionName, options) {
4783
+ const overlayRegistry = this.overlayedViewRegistry;
4784
+ if (overlayRegistry !== null && overlayRegistry.isOverlay(collectionName)) {
4785
+ const spec = overlayRegistry.byName(collectionName);
4786
+ if (spec) {
4787
+ const base = this.collection(spec.base);
4788
+ const overlay = this.collection(spec.overlay);
4789
+ const baseRowKey = overlayRegistry.resolveBaseRowKey(collectionName, this.materializedViewRegistry);
4790
+ return new OverlayedCollection(spec, base, overlay, baseRowKey);
4791
+ }
4792
+ }
4137
4793
  if (isDictCollectionName(collectionName)) {
4138
4794
  throw new ReservedCollectionNameError(collectionName);
4139
4795
  }
@@ -4181,7 +4837,38 @@ var Vault = class {
4181
4837
  defaultLocale: this.locale,
4182
4838
  onRegisterConflictResolver: this.onRegisterConflictResolver,
4183
4839
  onAccess: (op, id) => this._logConsent(op, collectionName, id),
4184
- periodGuard: (existing, incoming) => this._assertTsWritable(existing, incoming)
4840
+ periodGuard: (existing, incoming) => this._assertTsWritable(existing, incoming),
4841
+ // Guard / derivation sources are only wired when the
4842
+ // corresponding registry has been initialised. Vaults without
4843
+ // guards/derivations skip this entirely so `Collection.put`'s
4844
+ // `if (this.guardSource)` / `if (this.derivationSource)`
4845
+ // branches no-op without ever touching the subsystem code.
4846
+ ...this.guardRegistry !== null ? {
4847
+ guardSource: {
4848
+ registry: () => this.guardRegistry,
4849
+ readOnlyVault: () => this._ensureReadOnlyFacade()
4850
+ }
4851
+ } : {},
4852
+ ...this.derivationRegistry !== null ? {
4853
+ derivationSource: {
4854
+ registry: () => this.derivationRegistry,
4855
+ getCollection: (name) => this.collection(name),
4856
+ getReadOnlyFacade: () => this._ensureReadOnlyFacade(),
4857
+ getActiveTxContext: () => this.noydb._activeTxContextOrNull,
4858
+ createTxContext: () => this.noydb._createTxContext(),
4859
+ setActiveTxContext: (ctx) => this.noydb._setActiveTxContext(ctx),
4860
+ clearActiveTxContext: (ctx) => this.noydb._clearActiveTxContext(ctx)
4861
+ }
4862
+ } : {},
4863
+ ...this.materializedViewRegistry !== null ? {
4864
+ materializedViewSource: {
4865
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
4866
+ registry: () => this.materializedViewRegistry,
4867
+ getCollection: (name) => this.collection(name),
4868
+ getActiveTxContext: () => this.noydb._activeTxContextOrNull,
4869
+ getQueryContext: () => this
4870
+ }
4871
+ } : {}
4185
4872
  };
4186
4873
  if (options?.indexes !== void 0) collOpts.indexes = options.indexes;
4187
4874
  if (options?.reconcileOnOpen !== void 0) collOpts.reconcileOnOpen = options.reconcileOnOpen;
@@ -4218,9 +4905,39 @@ var Vault = class {
4218
4905
  }
4219
4906
  coll = new Collection(collOpts);
4220
4907
  this.collectionCache.set(collectionName, coll);
4908
+ if (options?.persistJsonSchema === true && options.schema !== void 0) {
4909
+ const validator = options.schema;
4910
+ const work = (async () => {
4911
+ try {
4912
+ const dek = await this.getDEK(collectionName);
4913
+ await persistSchemaIfNeeded({
4914
+ store: this.adapter,
4915
+ vault: this.name,
4916
+ collectionName,
4917
+ validator,
4918
+ dek
4919
+ });
4920
+ } catch (err) {
4921
+ console.warn(
4922
+ `[noy-db] persisted-schema write failed for "${collectionName}": ` + (err instanceof Error ? err.message : String(err))
4923
+ );
4924
+ }
4925
+ })();
4926
+ this._pendingSchemaWrites.push(work);
4927
+ }
4221
4928
  }
4222
4929
  return coll;
4223
4930
  }
4931
+ /**
4932
+ * Await all background persisted-schema writes triggered by
4933
+ * `collection({ persistJsonSchema: true })` calls on this vault.
4934
+ * Used in tests; production code does not need to call this.
4935
+ */
4936
+ async _drainPendingSchemaWrites() {
4937
+ const pending = this._pendingSchemaWrites;
4938
+ this._pendingSchemaWrites = [];
4939
+ await Promise.allSettled(pending);
4940
+ }
4224
4941
  /**
4225
4942
  * Validate i18nText fields on a `put()`. Called by Collection just
4226
4943
  * before the adapter write, after schema validation. Throws
@@ -4804,6 +5521,270 @@ var Vault = class {
4804
5521
  }
4805
5522
  return this.ledgerStore;
4806
5523
  }
5524
+ /**
5525
+ * @internal — called by `Noydb.openVault` after construction.
5526
+ * Dynamic-imports `GuardRegistry` + `ReadOnlyVaultFacade` and seeds
5527
+ * the registry with the supplied strategy handles. No-op when the
5528
+ * handles array is empty — keeps the guard subsystem out of the
5529
+ * floor bundle for consumers that don't use guards (#130).
5530
+ *
5531
+ * The read-only facade is eagerly instantiated here so the sync
5532
+ * accessor `_getReadOnlyFacade()` (called from the tx amendment
5533
+ * runner) stays synchronous.
5534
+ */
5535
+ async _initGuards(handles) {
5536
+ if (handles.length === 0) return;
5537
+ const [{ GuardRegistry }, { ReadOnlyVaultFacade }] = await Promise.all([
5538
+ import("./registry-RFGGMVNJ.js"),
5539
+ import("./read-only-facade-ITU6L7BL.js")
5540
+ ]);
5541
+ const registry = new GuardRegistry();
5542
+ for (const h of handles) registry.register(h.spec);
5543
+ this.guardRegistry = registry;
5544
+ this.readOnlyFacade = new ReadOnlyVaultFacade(this);
5545
+ }
5546
+ /**
5547
+ * @internal — Collection.put calls into this. Returns `null` for
5548
+ * vaults that never registered any guard strategy. Callers MUST
5549
+ * gate on null (the existing `if (this.guardSource)` branches in
5550
+ * `Collection` already do this transitively).
5551
+ */
5552
+ _getGuardRegistry() {
5553
+ return this.guardRegistry;
5554
+ }
5555
+ /**
5556
+ * @internal — called by `Noydb.openVault` after construction.
5557
+ * Dynamic-imports `DerivationRegistry` and registers the supplied
5558
+ * derivation strategies (async because `strategyHash` computation
5559
+ * goes through `crypto.subtle.digest`). No-op when the handles
5560
+ * array is empty — keeps the derivation subsystem out of the floor
5561
+ * bundle for consumers that don't use derivations (#130). Throws
5562
+ * `DerivationCycleError` if a cycle is detected after registration.
5563
+ */
5564
+ async _initDerivations(handles) {
5565
+ if (handles.length === 0) return;
5566
+ const [{ DerivationRegistry }, { ReadOnlyVaultFacade }] = await Promise.all([
5567
+ import("./registry-WLLMODKN.js"),
5568
+ import("./read-only-facade-ITU6L7BL.js")
5569
+ ]);
5570
+ const registry = new DerivationRegistry();
5571
+ for (const h of handles) {
5572
+ await registry.register(h.spec);
5573
+ }
5574
+ registry.validate();
5575
+ this.derivationRegistry = registry;
5576
+ if (this.readOnlyFacade === null) {
5577
+ this.readOnlyFacade = new ReadOnlyVaultFacade(this);
5578
+ }
5579
+ }
5580
+ /**
5581
+ * @internal — consumed by `Collection.put` at write-time. Returns
5582
+ * `null` for vaults that never registered any derivation strategy.
5583
+ */
5584
+ _getDerivationRegistry() {
5585
+ return this.derivationRegistry;
5586
+ }
5587
+ /**
5588
+ * @internal — called by `Noydb.openVault` after collections are
5589
+ * wired. Dynamic-imports `MaterializedViewRegistry`, registers each
5590
+ * MV spec (which invokes its `query()` once for dependency
5591
+ * analysis), then runs the unified cycle detection across the MV +
5592
+ * derivation graphs. No-op when the handles array is empty — keeps
5593
+ * the MV subsystem out of the floor bundle (mirrors v1 #130).
5594
+ * Throws `MaterializedViewCycleError` if a cycle is detected.
5595
+ */
5596
+ async _initMaterializedViews(handles) {
5597
+ if (handles.length === 0) return;
5598
+ const { MaterializedViewRegistry } = await import("./registry-3L3N3PTG.js");
5599
+ const registry = new MaterializedViewRegistry();
5600
+ this.materializedViewRegistry = registry;
5601
+ const db = this;
5602
+ for (const h of handles) {
5603
+ await registry.register(h.spec, db);
5604
+ }
5605
+ registry.validate(this.derivationRegistry);
5606
+ }
5607
+ /**
5608
+ * @internal — consumed by `Collection.put` at write-time. Returns
5609
+ * `null` for vaults that never registered any MV strategy.
5610
+ */
5611
+ _getMaterializedViewRegistry() {
5612
+ return this.materializedViewRegistry;
5613
+ }
5614
+ /**
5615
+ * @internal — called by `Noydb.openVault` after MVs are wired.
5616
+ * Dynamic-imports `OverlayedViewRegistry`, registers each spec,
5617
+ * validates against the MV registry for name/base/overlay collisions.
5618
+ * Throws on validation failure.
5619
+ */
5620
+ async _initOverlayedViews(handles) {
5621
+ if (handles.length === 0) return;
5622
+ const { OverlayedViewRegistry } = await import("./registry-O47PUPSY.js");
5623
+ const registry = new OverlayedViewRegistry();
5624
+ const mvRegistry = this.materializedViewRegistry;
5625
+ const overlayNames = /* @__PURE__ */ new Set();
5626
+ for (const h of handles) overlayNames.add(h.spec.name);
5627
+ const isMVOutput = (name) => {
5628
+ if (!mvRegistry) return false;
5629
+ for (const reg of mvRegistry.all()) {
5630
+ if (reg.outputCollection === name) return true;
5631
+ }
5632
+ return false;
5633
+ };
5634
+ for (const h of handles) {
5635
+ registry.register(h.spec, {
5636
+ isOverlayName: (n) => overlayNames.has(n) && n !== h.spec.name,
5637
+ isMVOutput
5638
+ });
5639
+ }
5640
+ this.overlayedViewRegistry = registry;
5641
+ }
5642
+ /**
5643
+ * @internal — consumed by `Vault.collection()`. Returns `null` for
5644
+ * vaults with no overlays registered.
5645
+ */
5646
+ _getOverlayedViewRegistry() {
5647
+ return this.overlayedViewRegistry;
5648
+ }
5649
+ /**
5650
+ * Manual re-materialize for a single registered MV (#151). Useful
5651
+ * for `refresh: 'manual'` MVs (whose consumer drives refreshes
5652
+ * externally), for stale-bit recovery on vault re-open, and as the
5653
+ * explicit bulk-recompute escape hatch after a strategy change.
5654
+ *
5655
+ * Returns `{ written, deleted, failed }`. `deleted` is always 0 in
5656
+ * foundation + this sub-issue — tombstoning lands in #152.
5657
+ *
5658
+ * Throws if `name` is not a registered MV.
5659
+ */
5660
+ async refreshView(name) {
5661
+ const registry = this.materializedViewRegistry;
5662
+ if (registry === null) {
5663
+ return { written: 0, deleted: 0, failed: 0 };
5664
+ }
5665
+ const reg = registry.byName(name);
5666
+ if (!reg) {
5667
+ throw new Error(`refreshView: no MV registered with name "${name}"`);
5668
+ }
5669
+ const { MaterializedViewExecutor } = await import("./executor-7E3VFGW7.js");
5670
+ const result = await MaterializedViewExecutor.refresh(reg, {
5671
+ getCollection: (n) => this.collection(n),
5672
+ getActiveTxContext: () => this.noydb._activeTxContextOrNull,
5673
+ getQueryContext: () => this
5674
+ });
5675
+ const { clearMVStale } = await import("./stale-HSC5YO2O.js");
5676
+ clearMVStale(registry, name);
5677
+ return result;
5678
+ }
5679
+ /**
5680
+ * Re-derive every record in the named source collection. Useful
5681
+ * after a strategy change to bring previously-derived records
5682
+ * up-to-date.
5683
+ *
5684
+ * Sequential in v1; parallelisation deferred to v2.
5685
+ */
5686
+ async deriveAll(sourceCollection) {
5687
+ const registry = this._getDerivationRegistry();
5688
+ if (registry === null) return { derived: 0, failed: 0 };
5689
+ const strategies = registry.strategiesForSource(sourceCollection);
5690
+ if (strategies.length === 0) return { derived: 0, failed: 0 };
5691
+ const { DerivationExecutor } = await import("./executor-X4SQ3ZLC.js");
5692
+ const sourceColl = this.collection(sourceCollection);
5693
+ const records = await sourceColl.list();
5694
+ const ctx = { vault: this.readOnlyFacade ?? new (await import("./read-only-facade-ITU6L7BL.js")).ReadOnlyVaultFacade(this) };
5695
+ let derived = 0;
5696
+ let failed = 0;
5697
+ for (const record of records) {
5698
+ if (typeof record !== "object" || record === null) continue;
5699
+ const id = record.id;
5700
+ if (typeof id !== "string") continue;
5701
+ for (const { spec, strategyHash } of strategies) {
5702
+ const sourceWithId = { ...record, id };
5703
+ const result = await DerivationExecutor.run(spec, sourceWithId, 0, strategyHash, ctx);
5704
+ let anyFailed = false;
5705
+ for (const key of Object.keys(spec.outputs)) {
5706
+ const out = result.outputs[key];
5707
+ if (!out) continue;
5708
+ if (out.kind === "failed") {
5709
+ anyFailed = true;
5710
+ continue;
5711
+ }
5712
+ const outSpec = spec.outputs[key];
5713
+ if (!outSpec) continue;
5714
+ const outputColl = this.collection(outSpec.collection);
5715
+ if (out.kind === "array") {
5716
+ const { loadFanoutSidecar, saveFanoutSidecar } = await import("./fanout-sidecar-VJ52RIEY.js");
5717
+ const prior = await loadFanoutSidecar(this.adapter, this.name, spec.source, id, key);
5718
+ const prevKeys = new Set(prior?.keys ?? []);
5719
+ const newKeysList = out.entries.map((e) => e.key);
5720
+ const newKeysSet = new Set(newKeysList);
5721
+ for (const k of prevKeys) {
5722
+ if (newKeysSet.has(k)) continue;
5723
+ await outputColl._internalDelete(k);
5724
+ }
5725
+ for (const entry of out.entries) {
5726
+ await outputColl.put(entry.key, entry.value);
5727
+ }
5728
+ await saveFanoutSidecar(this.adapter, this.name, {
5729
+ source: spec.source,
5730
+ sourceId: id,
5731
+ outputKey: key,
5732
+ outputCollection: outSpec.collection,
5733
+ keys: newKeysList
5734
+ });
5735
+ continue;
5736
+ }
5737
+ if (out.skipped === true) {
5738
+ await outputColl._internalDelete(id);
5739
+ continue;
5740
+ }
5741
+ await outputColl.put(id, out.value);
5742
+ }
5743
+ if (anyFailed) failed++;
5744
+ else derived++;
5745
+ }
5746
+ }
5747
+ return { derived, failed };
5748
+ }
5749
+ /**
5750
+ * @internal — exposed for `runTransaction({ amendment: true })` so
5751
+ * the amendment invariant runner can pass the SAME read-only vault
5752
+ * facade that the per-record `Collection.put` guard hook uses
5753
+ * (`guardSource.readOnlyVault()` above). Eagerly instantiated by
5754
+ * `_initGuards()` so this accessor stays synchronous; returns
5755
+ * `null` for vaults that never registered any guard (amendments
5756
+ * require at least one guard, so the caller should never see null).
5757
+ */
5758
+ _getReadOnlyFacade() {
5759
+ return this.readOnlyFacade;
5760
+ }
5761
+ /**
5762
+ * Internal lazy-allocator for the read-only facade. Used by the
5763
+ * per-collection `guardSource.readOnlyVault` callback when guards
5764
+ * ARE configured but `_initGuards()` raced with the first guard
5765
+ * invocation (theoretically impossible — `Noydb.openVault` awaits
5766
+ * `_initGuards` before returning — but we keep the defensive lazy
5767
+ * path so the closure's contract stays "always returns a facade").
5768
+ */
5769
+ _ensureReadOnlyFacade() {
5770
+ if (this.readOnlyFacade !== null) return this.readOnlyFacade;
5771
+ throw new Error(
5772
+ "Vault: guard hook fired before _initGuards() completed. This typically means the vault was opened via the sync fallback path (Noydb.vault(name)) without first calling await db.openVault(name). See issue #132."
5773
+ );
5774
+ }
5775
+ /**
5776
+ * @internal — exposed for `runTransaction({ amendment: true })`
5777
+ * to append the structured `op: 'amendment'` audit entry without
5778
+ * dragging this private accessor onto the public surface or
5779
+ * forcing the tx executor to depend on the history-strategy
5780
+ * shape directly. Returns `null` when no history strategy is
5781
+ * configured, in which case the amendment commits silently
5782
+ * (the records still write through; only the multi-record
5783
+ * audit summary is skipped).
5784
+ */
5785
+ _getLedgerOrNull() {
5786
+ return this.getLedgerOrNull();
5787
+ }
4807
5788
  /**
4808
5789
  * Return a read-only view of this vault as it existed at
4809
5790
  * `timestamp`. Time-machine queries are reconstructed from the
@@ -4956,7 +5937,7 @@ var Vault = class {
4956
5937
  * collection.
4957
5938
  */
4958
5939
  async delegate(opts) {
4959
- const { issueDelegation: issueDelegation2, DELEGATIONS_COLLECTION: DELEGATIONS_COLLECTION2 } = await import("./delegation-2DBS2EOH.js");
5940
+ const { issueDelegation: issueDelegation2, DELEGATIONS_COLLECTION: DELEGATIONS_COLLECTION2 } = await import("./delegation-YBA4X4JN.js");
4960
5941
  if (!this.keyring.kek) {
4961
5942
  throw new ValidationError(
4962
5943
  "issueDelegation: keyring.kek is null \u2014 issuing a delegation requires a tier-1 unlock. Re-authenticate at tier 1 (passphrase) first."
@@ -4978,7 +5959,7 @@ var Vault = class {
4978
5959
  * if the id does not exist.
4979
5960
  */
4980
5961
  async revokeDelegation(id) {
4981
- const { revokeDelegation: revokeDelegation2, DELEGATIONS_COLLECTION: DELEGATIONS_COLLECTION2 } = await import("./delegation-2DBS2EOH.js");
5962
+ const { revokeDelegation: revokeDelegation2, DELEGATIONS_COLLECTION: DELEGATIONS_COLLECTION2 } = await import("./delegation-YBA4X4JN.js");
4982
5963
  await revokeDelegation2(this.adapter, this.name, id);
4983
5964
  void DELEGATIONS_COLLECTION2;
4984
5965
  }
@@ -5303,6 +6284,54 @@ var Vault = class {
5303
6284
  const snapshot = await this.adapter.loadAll(this.name);
5304
6285
  return Object.keys(snapshot);
5305
6286
  }
6287
+ /**
6288
+ * Emit a structured introspection snapshot of this vault — vault name,
6289
+ * subsystem opt-in matrix, collections + their fields, materialized
6290
+ * views, overlay views, derivations. With `withStats: true`, walks
6291
+ * every collection's envelopes to compute record counts, byte totals,
6292
+ * and oldest/newest timestamps.
6293
+ *
6294
+ * Consumed by the `noydb describe` CLI to produce human-readable
6295
+ * audit YAML/JSON from a `.noydb` bundle.
6296
+ *
6297
+ * Field provenance:
6298
+ * - `persisted`: read from `_schemas/<col>` envelope (Route B opt-in)
6299
+ * - `live-validator`: derived in-process from a Zod schema attached
6300
+ * to the live `Collection`
6301
+ * - `sampled`: inferred from decrypted records (deferred to a follow-up)
6302
+ * - `unknown`: no schema info available
6303
+ *
6304
+ * @see docs/superpowers/specs/2026-05-22-schema-dump-design.md
6305
+ */
6306
+ async dumpSchema(opts = {}) {
6307
+ return dumpVaultSchema(this, opts);
6308
+ }
6309
+ /**
6310
+ * Internal accessor for {@link dumpVaultSchema}. Exposes the structural
6311
+ * state the walker needs (collection cache, registries, ref registry,
6312
+ * adapter) without widening the public Vault surface.
6313
+ *
6314
+ * @internal
6315
+ */
6316
+ _introspectState() {
6317
+ return {
6318
+ name: this.name,
6319
+ adapter: this.adapter,
6320
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
6321
+ collectionCache: this.collectionCache,
6322
+ refRegistry: this.refRegistry,
6323
+ getDEK: this.getDEK,
6324
+ subsystems: {
6325
+ guards: this.guardRegistry !== null,
6326
+ derivations: this.derivationRegistry !== null,
6327
+ materializedViews: this.materializedViewRegistry !== null,
6328
+ overlayViews: this.overlayedViewRegistry !== null
6329
+ },
6330
+ mvRegistry: this.materializedViewRegistry,
6331
+ overlayRegistry: this.overlayedViewRegistry,
6332
+ derivationRegistry: this.derivationRegistry
6333
+ };
6334
+ }
5306
6335
  /**
5307
6336
  * Return the stable opaque bundle handle for this vault,
5308
6337
  * generating and persisting a fresh ULID on first call.
@@ -5378,7 +6407,7 @@ var Vault = class {
5378
6407
  * @see docs/subsystems/public-envelope.md
5379
6408
  */
5380
6409
  async getPublicEnvelope(opts = {}) {
5381
- const { readPublicEnvelope: readPublicEnvelope2 } = await import("./public-envelope-3QTQADDW.js");
6410
+ const { readPublicEnvelope: readPublicEnvelope2 } = await import("./public-envelope-PY6NKFLI.js");
5382
6411
  return readPublicEnvelope2(this.adapter, this.name, opts);
5383
6412
  }
5384
6413
  /**
@@ -5403,7 +6432,7 @@ var Vault = class {
5403
6432
  }
5404
6433
  }
5405
6434
  const internalSnapshot = {};
5406
- for (const internalName of [LEDGER_COLLECTION, LEDGER_DELTAS_COLLECTION]) {
6435
+ for (const internalName of [LEDGER_COLLECTION, LEDGER_DELTAS_COLLECTION, SCHEMAS_COLLECTION]) {
5407
6436
  const ids = await this.adapter.list(this.name, internalName);
5408
6437
  if (ids.length === 0) continue;
5409
6438
  const records = {};
@@ -5552,6 +6581,7 @@ var Vault = class {
5552
6581
  for (let i = allEntries.length - 1; i >= 0; i--) {
5553
6582
  const entry = allEntries[i];
5554
6583
  if (!entry) continue;
6584
+ if (entry.op === "amendment") continue;
5555
6585
  const key = `${entry.collection}/${entry.id}`;
5556
6586
  if (seen.has(key)) continue;
5557
6587
  seen.add(key);
@@ -5573,7 +6603,7 @@ var Vault = class {
5573
6603
  message: `Ledger expects data record "${collection}/${id}" to exist, but the adapter has no envelope for it.`
5574
6604
  };
5575
6605
  }
5576
- const actualHash = await sha256Hex(envelope._data);
6606
+ const actualHash = await sha256Hex2(envelope._data);
5577
6607
  if (actualHash !== expectedHash) {
5578
6608
  return {
5579
6609
  ok: false,
@@ -5974,8 +7004,18 @@ var PERSONAL_POLICY = Object.freeze({
5974
7004
  minTier: 1,
5975
7005
  enabled: true
5976
7006
  },
7007
+ // rotate-recovery (#121): deliberate paper-sheet regeneration
7008
+ // when the user remembers their passphrase. PERSONAL matches the
7009
+ // pre-#121 low-level flow's bar — knowing the passphrase is enough.
7010
+ "rotate-recovery": { minTier: 1 },
5977
7011
  "enroll-authenticator": { minTier: 1 },
5978
7012
  "remove-authenticator": { minTier: 1 },
7013
+ // update-authenticator: meta-only mutation (slot rename, label
7014
+ // changes). Symmetric with enroll/remove under PERSONAL — tier-1
7015
+ // unlock alone. The structural anti-slot-swap guard inside the
7016
+ // implementation enforces wrap-material/id/method immutability
7017
+ // regardless of this gate's settings.
7018
+ "update-authenticator": { minTier: 1 },
5979
7019
  "rotate-unlock": { minTier: 2 },
5980
7020
  "enroll-user": { minTier: 1 },
5981
7021
  "revoke-user": { minTier: 1 },
@@ -5985,6 +7025,12 @@ var PERSONAL_POLICY = Object.freeze({
5985
7025
  // virtue of being a co-owner). Tier-1 unlock is the floor; the
5986
7026
  // STRICT preset adds a recovery/email-OTP requirement.
5987
7027
  "peer-recover-user": { minTier: 1 },
7028
+ // update-user: post-grant identity mutation (role/displayName/
7029
+ // permissions). PERSONAL_POLICY treats this on par with enroll-user
7030
+ // / revoke-user — tier-1 unlock alone. The role-elevation guard
7031
+ // inside the implementation is the structural backstop that this
7032
+ // gate's settings cannot weaken.
7033
+ "update-user": { minTier: 1 },
5988
7034
  "export-bundle": { minTier: 1 },
5989
7035
  "export-plaintext": {
5990
7036
  minTier: 1,
@@ -6021,6 +7067,14 @@ var STRICT_POLICY = Object.freeze({
6021
7067
  minTier: 1,
6022
7068
  enabled: true
6023
7069
  },
7070
+ // rotate-recovery (#121): STRICT requires an off-device factor —
7071
+ // rotating recovery is an off-site-trust event; a stolen unlocked
7072
+ // laptop must not be able to silently mint a new sheet for the
7073
+ // attacker. Matches the `peer-recover-user` STRICT default.
7074
+ "rotate-recovery": {
7075
+ minTier: 1,
7076
+ factors: [{ anyOf: ["totp", "email-otp", "webauthn-roaming"] }]
7077
+ },
6024
7078
  "enroll-authenticator": {
6025
7079
  minTier: 1,
6026
7080
  factors: [{ anyOf: ["totp", "email-otp"] }]
@@ -6029,6 +7083,15 @@ var STRICT_POLICY = Object.freeze({
6029
7083
  minTier: 1,
6030
7084
  factors: [{ anyOf: ["totp", "email-otp"] }]
6031
7085
  },
7086
+ // STRICT update-authenticator: same factor floor as enroll/remove.
7087
+ // Even though meta changes don't touch wrap material, a malicious
7088
+ // rename could mislead the user about which device a slot
7089
+ // corresponds to ("MacBook Touch ID" → "iPhone Touch ID" on a
7090
+ // shared workstation). STRICT requires a fresh factor proof.
7091
+ "update-authenticator": {
7092
+ minTier: 1,
7093
+ factors: [{ anyOf: ["totp", "email-otp"] }]
7094
+ },
6032
7095
  "rotate-unlock": { minTier: 1 },
6033
7096
  "enroll-user": {
6034
7097
  minTier: 1,
@@ -6051,6 +7114,18 @@ var STRICT_POLICY = Object.freeze({
6051
7114
  minTier: 1,
6052
7115
  factors: [{ anyOf: ["recovery", "totp", "email-otp", "webauthn-roaming"] }]
6053
7116
  },
7117
+ // STRICT update-user: matches the enroll-user / revoke-user shape
7118
+ // (off-device factor required). Update-user is admin-shaped — it
7119
+ // mutates someone else's role/permissions; STRICT requires a fresh
7120
+ // off-device factor proof so the operator affirmatively re-asserts
7121
+ // identity at the moment of mutation. Platform-bound factors
7122
+ // (Touch ID / password / PIN) intentionally excluded: same logic as
7123
+ // peer-recover-user — the off-device requirement is the whole
7124
+ // point under STRICT.
7125
+ "update-user": {
7126
+ minTier: 1,
7127
+ factors: [{ anyOf: ["totp", "email-otp"] }]
7128
+ },
6054
7129
  "export-bundle": {
6055
7130
  minTier: 1,
6056
7131
  factors: [{ anyOf: ["totp", "email-otp"] }],
@@ -6188,6 +7263,14 @@ var Noydb = class {
6188
7263
  * `_meta/policy` load; replaced by `db.updatePolicy()`.
6189
7264
  */
6190
7265
  policyCache = /* @__PURE__ */ new Map();
7266
+ /**
7267
+ * One-shot bypass for the managed-mode strong-recovery check (#195).
7268
+ * Set true by {@link openVaultAndEnrollRecovery} for the duration of
7269
+ * the bootstrap window so the keyring can be created before the
7270
+ * strong recovery is enrolled. Always cleared (try/finally).
7271
+ * @internal
7272
+ */
7273
+ _skipNextManagedRecoveryCheck = false;
6191
7274
  /** Per-vault tier-3 (PIN / quick-resume) state — issue #11. */
6192
7275
  quickUnlock = new QuickUnlockStore();
6193
7276
  /**
@@ -6203,6 +7286,17 @@ var Noydb = class {
6203
7286
  txStrategy;
6204
7287
  sessionStrategy;
6205
7288
  syncStrategy;
7289
+ /**
7290
+ * Currently-running multi-record transaction, set by
7291
+ * `runTransaction` at the start of Phase 2 (commit) and cleared in
7292
+ * the same function's `finally` block. Side-effect writes triggered
7293
+ * during a staged op's `Collection.put` (today: eager derivation
7294
+ * outputs) register their pre-write envelope on `_executed` here so
7295
+ * a mid-batch failure rolls them back alongside the main staged ops
7296
+ * (#133). `null` outside of Phase 2.
7297
+ * @internal
7298
+ */
7299
+ _activeTxContext = null;
6206
7300
  // ─── plaintextTranslator state ─────────────────────────
6207
7301
  /**
6208
7302
  * In-process translation cache. Key is `"${field}\x00${collection}\x00${from}\x00${to}\x00${text}"`.
@@ -6287,7 +7381,7 @@ var Noydb = class {
6287
7381
  }
6288
7382
  return comp;
6289
7383
  }
6290
- const keyring = await this.getKeyring(name);
7384
+ const keyring = await this.getKeyringInternal(name);
6291
7385
  if (!this.activeTier.has(name)) {
6292
7386
  this.activeTier.set(name, 1);
6293
7387
  }
@@ -6354,6 +7448,7 @@ var Noydb = class {
6354
7448
  ...this.options.historyStrategy !== void 0 ? { historyStrategy: this.options.historyStrategy } : {},
6355
7449
  ...this.options.i18nStrategy !== void 0 ? { i18nStrategy: this.options.i18nStrategy } : {},
6356
7450
  ...this.options.syncStrategy !== void 0 ? { syncStrategy: this.options.syncStrategy } : {},
7451
+ ...this.options.guardStrategies !== void 0 ? { guardStrategies: this.options.guardStrategies } : {},
6357
7452
  locale: opts?.locale,
6358
7453
  // Thread the translator hook so Collection.put() can invoke it
6359
7454
  plaintextTranslator: this.options.plaintextTranslator ? (text, from, to, field, collection) => this.invokeTranslator(text, from, to, field, collection) : void 0,
@@ -6374,6 +7469,10 @@ var Noydb = class {
6374
7469
  return refreshed;
6375
7470
  } : void 0
6376
7471
  });
7472
+ await comp._initGuards(this.options.guardStrategies ?? []);
7473
+ await comp._initDerivations(this.options.derivationStrategies ?? []);
7474
+ await comp._initMaterializedViews(this.options.materializedViewStrategies ?? []);
7475
+ await comp._initOverlayedViews(this.options.overlayedViewStrategies ?? []);
6377
7476
  this.vaultCache.set(name, comp);
6378
7477
  return comp;
6379
7478
  }
@@ -6400,7 +7499,8 @@ var Noydb = class {
6400
7499
  ...this.options.shadowStrategy !== void 0 ? { shadowStrategy: this.options.shadowStrategy } : {},
6401
7500
  ...this.options.historyStrategy !== void 0 ? { historyStrategy: this.options.historyStrategy } : {},
6402
7501
  ...this.options.i18nStrategy !== void 0 ? { i18nStrategy: this.options.i18nStrategy } : {},
6403
- ...this.options.syncStrategy !== void 0 ? { syncStrategy: this.options.syncStrategy } : {}
7502
+ ...this.options.syncStrategy !== void 0 ? { syncStrategy: this.options.syncStrategy } : {},
7503
+ ...this.options.guardStrategies !== void 0 ? { guardStrategies: this.options.guardStrategies } : {}
6404
7504
  });
6405
7505
  this.vaultCache.set(name, comp2);
6406
7506
  return comp2;
@@ -6428,23 +7528,93 @@ var Noydb = class {
6428
7528
  ...this.options.historyStrategy !== void 0 ? { historyStrategy: this.options.historyStrategy } : {},
6429
7529
  ...this.options.i18nStrategy !== void 0 ? { i18nStrategy: this.options.i18nStrategy } : {},
6430
7530
  ...this.options.syncStrategy !== void 0 ? { syncStrategy: this.options.syncStrategy } : {},
7531
+ ...this.options.guardStrategies !== void 0 ? { guardStrategies: this.options.guardStrategies } : {},
6431
7532
  emitter: this.emitter
6432
7533
  });
6433
7534
  this.vaultCache.set(name, comp);
6434
7535
  return comp;
6435
7536
  }
6436
- /** Grant access to a user for a vault. */
6437
- async grant(vault, options) {
7537
+ /**
7538
+ * Grant access to a user for a vault.
7539
+ *
7540
+ * Gated by `enroll-user`. `STRICT_POLICY` requires a TOTP / email-OTP
7541
+ * factor proof so the operator affirmatively re-asserts identity at
7542
+ * the moment of grant; `PERSONAL_POLICY` accepts a tier-1 unlock alone.
7543
+ *
7544
+ * The legacy `requireReAuthFor: ['grant']` session-policy check still
7545
+ * fires on top — both are independent opt-ins.
7546
+ */
7547
+ async grant(vault, options, factors) {
6438
7548
  this.checkPolicyOperation(vault, "grant");
6439
- const keyring = await this.getKeyring(vault);
7549
+ await this.checkGate(vault, "enroll-user", factors);
7550
+ const keyring = await this.getKeyringInternal(vault);
6440
7551
  await grant(this.options.store, vault, keyring, options);
6441
7552
  }
6442
- /** Revoke a user's access to a vault. */
6443
- async revoke(vault, options) {
7553
+ /**
7554
+ * Revoke a user's access to a vault.
7555
+ *
7556
+ * Gated by `revoke-user`. `STRICT_POLICY` requires a TOTP / email-OTP
7557
+ * factor proof; `PERSONAL_POLICY` accepts a tier-1 unlock alone.
7558
+ *
7559
+ * The legacy `requireReAuthFor: ['revoke']` session-policy check still
7560
+ * fires on top — both are independent opt-ins.
7561
+ */
7562
+ async revoke(vault, options, factors) {
6444
7563
  this.checkPolicyOperation(vault, "revoke");
6445
- const keyring = await this.getKeyring(vault);
7564
+ await this.checkGate(vault, "revoke-user", factors);
7565
+ const keyring = await this.getKeyringInternal(vault);
6446
7566
  await revoke(this.options.store, vault, keyring, options);
6447
7567
  }
7568
+ /**
7569
+ * Mutate post-grant identity fields on an existing keyring — `role`,
7570
+ * `displayName`, and/or `permissions`. Pure plaintext-header rewrite:
7571
+ * no DEK rewrap, no KEK required, no authenticator slots touched.
7572
+ * Tier-2 enrollments and recovery codes survive.
7573
+ *
7574
+ * Different from `db.revoke + db.grant`:
7575
+ *
7576
+ * - Same `userId`, same DEK wrappings, same `granted_by`, same
7577
+ * `_users/<keyringId>` envelope. Only the specified header
7578
+ * fields move. Last-write-wins via the standard keyring put.
7579
+ * - No cascade on role demotion (admins demoted to operator keep
7580
+ * the keyrings they previously granted; the cascade rules are
7581
+ * a `db.revoke` concern, not `db.updateUser`).
7582
+ * - Tier-2 slots NOT dropped — the wrapping is unaffected.
7583
+ *
7584
+ * Role-elevation guard: BOTH the old and new role must satisfy
7585
+ * `db.grant`'s hierarchy. Owner can do anything; admin manages
7586
+ * admin/operator/viewer/client laterally; admin cannot promote to
7587
+ * owner OR demote from owner. The guard runs regardless of the
7588
+ * `update-user` policy gate's settings — gates can only be more
7589
+ * permissive than the structural floor, never less.
7590
+ *
7591
+ * Gated by `update-user`. `STRICT_POLICY` requires a TOTP/email-OTP
7592
+ * factor proof so the operator affirmatively re-asserts identity at
7593
+ * the moment of mutation; `PERSONAL_POLICY` accepts a tier-1 unlock
7594
+ * alone.
7595
+ *
7596
+ * ```ts
7597
+ * await db.updateUser('acme', {
7598
+ * userId: 'bob',
7599
+ * role: 'operator', // promote
7600
+ * permissions: { invoices: 'rw' },
7601
+ * }, { factors: [{ kind: 'totp' }] })
7602
+ * ```
7603
+ *
7604
+ * @throws `NoAccessError` when no keyring exists for the target.
7605
+ * @throws `PermissionDeniedError` when the role hierarchy rejects.
7606
+ * @throws `ValidationError` when no field is provided.
7607
+ *
7608
+ * @see #54
7609
+ */
7610
+ async updateUser(vault, options, factors) {
7611
+ await this.checkGate(vault, "update-user", factors);
7612
+ const keyring = await this.getKeyringInternal(vault);
7613
+ await updateKeyringIdentity(this.options.store, vault, keyring, options);
7614
+ if (options.userId === this.options.user) {
7615
+ this.keyringCache.delete(vault);
7616
+ }
7617
+ }
6448
7618
  /**
6449
7619
  * Rotate the DEKs for the given collections in a vault.
6450
7620
  *
@@ -6465,7 +7635,7 @@ var Noydb = class {
6465
7635
  */
6466
7636
  async rotate(vault, collections) {
6467
7637
  this.checkPolicyOperation(vault, "rotate");
6468
- const keyring = await this.getKeyring(vault);
7638
+ const keyring = await this.getKeyringInternal(vault);
6469
7639
  await rotateKeys(this.options.store, vault, keyring, collections);
6470
7640
  this.keyringCache.set(vault, keyring);
6471
7641
  }
@@ -6568,7 +7738,7 @@ var Noydb = class {
6568
7738
  this.options.secret
6569
7739
  );
6570
7740
  } catch (err) {
6571
- if (err instanceof NoAccessError || err instanceof InvalidKeyError) {
7741
+ if (err instanceof NoAccessError || err instanceof InvalidKeyError || err instanceof KeyringCorruptError) {
6572
7742
  continue;
6573
7743
  }
6574
7744
  throw err;
@@ -6665,15 +7835,23 @@ var Noydb = class {
6665
7835
  }
6666
7836
  return results;
6667
7837
  }
6668
- /** Change the current user's passphrase for a vault. */
6669
- async changeSecret(vault, newPassphrase) {
7838
+ /**
7839
+ * Change the current user's passphrase for a vault.
7840
+ *
7841
+ * Validates the new passphrase against the strength rules. Pass
7842
+ * `{ allowWeakPassphrase: true }` to skip — typically only useful for
7843
+ * fixtures and migrations. Pass a `PassphrasePolicy` to override the
7844
+ * default rules (e.g. consumer-tunable `pattern` / `customValidator`).
7845
+ */
7846
+ async changeSecret(vault, newPassphrase, options) {
6670
7847
  this.checkPolicyOperation(vault, "changeSecret");
6671
- const keyring = await this.getKeyring(vault);
7848
+ const keyring = await this.getKeyringInternal(vault);
6672
7849
  const updated = await changeSecret(
6673
7850
  this.options.store,
6674
7851
  vault,
6675
7852
  keyring,
6676
- newPassphrase
7853
+ newPassphrase,
7854
+ options
6677
7855
  );
6678
7856
  this.keyringCache.set(vault, updated);
6679
7857
  }
@@ -6718,10 +7896,18 @@ var Noydb = class {
6718
7896
  }
6719
7897
  return result;
6720
7898
  }
6721
- transaction(arg) {
7899
+ transaction(arg, maybeFn) {
6722
7900
  if (typeof arg === "function") {
6723
7901
  return this.txStrategy.runTransaction(this, arg);
6724
7902
  }
7903
+ if (typeof arg === "object" && arg !== null && arg.amendment === true) {
7904
+ if (typeof maybeFn !== "function") {
7905
+ throw new ValidationError(
7906
+ "db.transaction({ amendment: true }, fn) requires the callback as the second argument."
7907
+ );
7908
+ }
7909
+ return this.txStrategy.runTransaction(this, maybeFn, arg);
7910
+ }
6725
7911
  const vault = arg;
6726
7912
  const comp = this.vaultCache.get(vault);
6727
7913
  if (!comp) {
@@ -6742,6 +7928,59 @@ var Noydb = class {
6742
7928
  get _store() {
6743
7929
  return this.options.store;
6744
7930
  }
7931
+ /**
7932
+ * Currently-running multi-record transaction, or `null` outside
7933
+ * Phase 2. `Collection.dispatchDerivations` consults this so a
7934
+ * recursive derived-output write inside `Collection.put` can register
7935
+ * its envelope onto `ctx._executed` and roll back with the main
7936
+ * staged ops on mid-batch failure (#133).
7937
+ *
7938
+ * @internal
7939
+ */
7940
+ get _activeTxContextOrNull() {
7941
+ return this._activeTxContext;
7942
+ }
7943
+ /**
7944
+ * Called by `runTransaction` at Phase 2 start, and by
7945
+ * `Collection.putManyAtomic` (via `derivationSource.setActiveTxContext`)
7946
+ * for its own Phase 2 loop. Nested or concurrent (non-nested)
7947
+ * transactions on the same Noydb instance are NOT supported —
7948
+ * overwriting an active context means another transaction is still
7949
+ * running and its `_executed` list would be cross-contaminated by
7950
+ * the nested writes. We tolerate the overwrite (best-effort, no
7951
+ * throw) to keep the rare interleaving from breaking consumers who
7952
+ * currently get lucky with timing, but applications should ensure
7953
+ * their multi-record commits are serialised on a single Noydb.
7954
+ *
7955
+ * @internal
7956
+ */
7957
+ _setActiveTxContext(ctx) {
7958
+ this._activeTxContext = ctx;
7959
+ }
7960
+ /**
7961
+ * Factory for a transient `TxContext` bound to this Noydb. Used by
7962
+ * `Collection.putManyAtomic` (via `derivationSource.createTxContext`)
7963
+ * to publish an active context for the duration of its bulk-atomic
7964
+ * Phase 2 loop, so recursive derivation-output writes register on
7965
+ * `ctx._executed` and roll back together with the source ops (#133).
7966
+ *
7967
+ * @internal
7968
+ */
7969
+ _createTxContext() {
7970
+ return new TxContext(this);
7971
+ }
7972
+ /**
7973
+ * Called by `runTransaction` in its `finally`. Only clears when the
7974
+ * passed ctx matches the active one — a defensive no-op if some
7975
+ * other code path already cleared it.
7976
+ *
7977
+ * @internal
7978
+ */
7979
+ _clearActiveTxContext(ctx) {
7980
+ if (this._activeTxContext === ctx) {
7981
+ this._activeTxContext = null;
7982
+ }
7983
+ }
6745
7984
  /** Get sync status for a vault. */
6746
7985
  syncStatus(vault) {
6747
7986
  const engine = this.syncEngines.get(vault);
@@ -6750,6 +7989,15 @@ var Noydb = class {
6750
7989
  }
6751
7990
  return engine.status();
6752
7991
  }
7992
+ requireShamirProvider() {
7993
+ const p = this.options.shamirRecovery;
7994
+ if (!p) {
7995
+ throw new Error(
7996
+ "shamir recovery requires a ShamirRecoveryProvider \u2014 pass shamirRecovery: shamirRecoveryProvider() from '@noy-db/on-shamir' to createNoydb()"
7997
+ );
7998
+ }
7999
+ return p;
8000
+ }
6753
8001
  getSyncEngine(vault) {
6754
8002
  const engine = this.syncEngines.get(vault);
6755
8003
  if (!engine) {
@@ -6894,6 +8142,40 @@ var Noydb = class {
6894
8142
  this.policyCache.set(vault, merged);
6895
8143
  return merged;
6896
8144
  }
8145
+ /**
8146
+ * Read the current vault-level user-directory toggle (#122). Returns
8147
+ * the default-on shape (`{ enabled: true }`) when no `_meta/directory`
8148
+ * document has been persisted yet.
8149
+ *
8150
+ * No role gate — anyone who can open the vault can read the toggle.
8151
+ */
8152
+ async getDirectoryEnabled(vault) {
8153
+ if (this.closed) throw new ValidationError("Instance is closed");
8154
+ const persisted = await readDirectoryConfig(this.options.store, vault);
8155
+ return persisted?.enabled ?? true;
8156
+ }
8157
+ /**
8158
+ * Toggle the vault's user-directory listing on or off (#122).
8159
+ * Owner-only. When disabled, `listUsersWithEnvelopes()` throws
8160
+ * {@link import('./errors.js').DirectoryDisabledError} for callers
8161
+ * whose role is neither `owner` nor `admin`.
8162
+ *
8163
+ * Honest caveat: this is a UX flag, not a privacy guarantee. The
8164
+ * keyring file at `_keyring/<userId>` and the envelope ciphertext at
8165
+ * `_users/<keyringId>` remain observable to anyone with direct store
8166
+ * read access — only the hub-level enumeration is gated. See
8167
+ * `docs/subsystems/user-envelope.md` → "Directory visibility".
8168
+ */
8169
+ async setDirectoryEnabled(vault, enabled) {
8170
+ if (this.closed) throw new ValidationError("Instance is closed");
8171
+ const keyring = await this.getKeyringInternal(vault);
8172
+ if (keyring.role !== "owner") {
8173
+ throw new PermissionDeniedError(
8174
+ `setDirectoryEnabled requires owner role; caller has role "${keyring.role}"`
8175
+ );
8176
+ }
8177
+ await persistDirectoryConfig(this.options.store, vault, { enabled });
8178
+ }
6897
8179
  /**
6898
8180
  * Evaluate a policy gate against the active session tier and the
6899
8181
  * presented factor proofs. Throws {@link PolicyDeniedError} on
@@ -6904,40 +8186,61 @@ var Noydb = class {
6904
8186
  * or app-defined (`app:*`).
6905
8187
  * @param presented Caller-supplied factor proofs.
6906
8188
  */
6907
- async checkGate(vault, gate, presented) {
8189
+ async checkGate(vault, gate, factors) {
6908
8190
  const policy = await this.getPolicy(vault);
6909
8191
  const tier = this.activeTier.get(vault) ?? 1;
6910
8192
  await checkGate(policy, gate, {
6911
8193
  activeTier: tier,
6912
- ...presented?.factors !== void 0 ? { factors: presented.factors } : {},
6913
- ...presented?.sharedDevice !== void 0 ? { sharedDevice: presented.sharedDevice } : {}
8194
+ ...factors?.factors !== void 0 ? { factors: factors.factors } : {},
8195
+ ...factors?.sharedDevice !== void 0 ? { sharedDevice: factors.sharedDevice } : {}
6914
8196
  });
6915
8197
  }
6916
8198
  /** Read or persist the vault policy at `_meta/policy` on first open. */
6917
- async bootstrapPolicy(vault) {
8199
+ async bootstrapPolicy(vault, opts) {
6918
8200
  const onDisk = await loadVaultPolicy(this.options.store, vault);
6919
8201
  if (onDisk) {
6920
8202
  this.policyCache.set(vault, onDisk);
6921
- await this.assertRecoveryEnrolled(vault, onDisk);
8203
+ await this.assertRecoveryEnrolled(vault, onDisk, opts);
6922
8204
  return;
6923
8205
  }
6924
8206
  const initial = this.options.policy ? mergePolicy(PERSONAL_POLICY, this.options.policy) : PERSONAL_POLICY;
6925
8207
  await saveVaultPolicy(this.options.store, vault, initial);
6926
8208
  this.policyCache.set(vault, initial);
6927
- await this.assertRecoveryEnrolled(vault, initial);
6928
- }
6929
- /**
6930
- * Throw {@link RecoveryNotEnrolledError} when the developer
6931
- * explicitly opts into strict mandatory-recovery enforcement
6932
- * (`createNoydb({ requireRecovery: true })`) and no recovery
6933
- * entries are persisted.
6934
- *
6935
- * The default behavior is lenient — `recover-passphrase` is enabled
6936
- * in `PERSONAL_POLICY` but the hub does not block vault open on
6937
- * missing enrollment. v1.0 will flip the default to strict; for now,
6938
- * apps that want the spec-mandated check turn it on per-vault.
6939
- */
6940
- async assertRecoveryEnrolled(vault, policy) {
8209
+ await this.assertRecoveryEnrolled(vault, initial, opts);
8210
+ }
8211
+ /**
8212
+ * Throw {@link RecoveryNotEnrolledError} or
8213
+ * {@link ManagedRecoveryNotEnrolledError} when recovery enrollment
8214
+ * is missing.
8215
+ *
8216
+ * Two enforcement modes:
8217
+ *
8218
+ * 1. **Managed-mode mandatory strong-recovery (#195).** When
8219
+ * `passphraseMode === 'managed'`, the vault MUST have at least
8220
+ * one **strong** recovery profile (Shamir today). Paper alone is
8221
+ * rejected because under managed mode the user has no memorized
8222
+ * passphrase, so losing the paper sheet = losing every record.
8223
+ * This check is unconditional — independent of `requireRecovery`
8224
+ * and the `recover-passphrase` gate.
8225
+ *
8226
+ * 2. **Opt-in strict mandatory-recovery.** When
8227
+ * `requireRecovery: true` is set on createNoydb (and the gate is
8228
+ * not explicitly disabled), require ANY recovery profile (paper
8229
+ * or shamir). This is the v0.x default-off behavior; v1.0 may
8230
+ * flip it default-on.
8231
+ *
8232
+ * The managed-mode check fires from {@link bootstrapPolicy} unless
8233
+ * the `skipManagedCheck` flag is set (used by
8234
+ * {@link openVaultAndEnrollRecovery} to allow atomic create-and-enroll).
8235
+ */
8236
+ async assertRecoveryEnrolled(vault, policy, opts) {
8237
+ const skipManaged = (opts?.skipManagedCheck ?? false) || this._skipNextManagedRecoveryCheck;
8238
+ if (this.options.passphraseMode === "managed" && !skipManaged) {
8239
+ const enrolled2 = await hasStrongRecoveryEnrolled(this.options.store, vault);
8240
+ if (!enrolled2) {
8241
+ throw new ManagedRecoveryNotEnrolledError(vault);
8242
+ }
8243
+ }
6941
8244
  if (this.options.requireRecovery !== true) return;
6942
8245
  const gate = policy.gates["recover-passphrase"];
6943
8246
  if (gate?.enabled === false) return;
@@ -6966,9 +8269,9 @@ var Noydb = class {
6966
8269
  * Gated by `enroll-authenticator`; `presented` carries any factor
6967
8270
  * proofs the active policy demands.
6968
8271
  */
6969
- async enrollAuthenticator(vault, options, presented) {
6970
- await this.checkGate(vault, "enroll-authenticator", presented);
6971
- const keyring = await this.getKeyring(vault);
8272
+ async enrollAuthenticator(vault, options, factors) {
8273
+ await this.checkGate(vault, "enroll-authenticator", factors);
8274
+ const keyring = await this.getKeyringInternal(vault);
6972
8275
  const next = await enrollAuthenticator(this.options.store, vault, keyring, options);
6973
8276
  this.keyringCache.set(vault, next);
6974
8277
  }
@@ -6977,17 +8280,51 @@ var Noydb = class {
6977
8280
  * non-existent slot is a successful no-op. Gated by
6978
8281
  * `remove-authenticator`.
6979
8282
  */
6980
- async removeAuthenticator(vault, slotId, presented) {
6981
- await this.checkGate(vault, "remove-authenticator", presented);
6982
- const keyring = await this.getKeyring(vault);
8283
+ async removeAuthenticator(vault, slotId, factors) {
8284
+ await this.checkGate(vault, "remove-authenticator", factors);
8285
+ const keyring = await this.getKeyringInternal(vault);
6983
8286
  const next = await removeAuthenticator(this.options.store, vault, keyring, slotId);
6984
8287
  this.keyringCache.set(vault, next);
6985
8288
  }
6986
8289
  /** Read the slot list for a vault. Internal — `describeAuthConfig` (#13) consumes this. */
6987
8290
  async listAuthenticators(vault) {
6988
- const keyring = await this.getKeyring(vault);
8291
+ const keyring = await this.getKeyringInternal(vault);
6989
8292
  return keyring.authenticators;
6990
8293
  }
8294
+ /**
8295
+ * Mutate the `meta` blob on an existing authenticator slot — slot
8296
+ * rename, label change, attachment of UI hints. The slot's `id`,
8297
+ * `method`, and wrap material (`wrapped_kek` / `wrapped_deks` + `iv`)
8298
+ * are immutable through this method. Anti-slot-swap is structural,
8299
+ * not gate-driven.
8300
+ *
8301
+ * `meta` patch semantics (#57-aligned):
8302
+ * - Top-level merge — absent keys preserved
8303
+ * - `null` value — delete that meta key
8304
+ * - Other values — replace verbatim
8305
+ *
8306
+ * Use case: per-slot nickname for "iPhone Touch ID" vs "MacBook
8307
+ * Touch ID" disambiguation in admin UIs. The slot id (auto-derived
8308
+ * from credentialId prefix) is not human-friendly; `meta.nickname`
8309
+ * is.
8310
+ *
8311
+ * Gated by `update-authenticator`. PERSONAL_POLICY: tier-1 unlock
8312
+ * alone (matches enroll/remove). STRICT_POLICY: tier-1 +
8313
+ * TOTP/email-OTP factor proof — a malicious rename on a shared
8314
+ * workstation could mislead the user about which device a slot
8315
+ * corresponds to, so STRICT requires fresh factor binding.
8316
+ *
8317
+ * @throws `NoAccessError` when no slot with the given id exists.
8318
+ * @throws `ValidationError` when no patch field is provided.
8319
+ *
8320
+ * @see #55
8321
+ */
8322
+ async updateAuthenticator(vault, slotId, options, factors) {
8323
+ await this.checkGate(vault, "update-authenticator", factors);
8324
+ const keyring = await this.getKeyringInternal(vault);
8325
+ const next = await updateAuthenticator(this.options.store, vault, keyring, slotId, options);
8326
+ this.keyringCache.set(vault, next);
8327
+ }
6991
8328
  /**
6992
8329
  * Native WebAuthn enrollment using the **real** internal keyring (#16).
6993
8330
  *
@@ -7035,9 +8372,9 @@ var Noydb = class {
7035
8372
  *
7036
8373
  * @see #16
7037
8374
  */
7038
- async enrollWebAuthn(vault, ceremony, presented) {
7039
- await this.checkGate(vault, "enroll-authenticator", presented);
7040
- const keyring = await this.getKeyring(vault);
8375
+ async enrollWebAuthn(vault, ceremony, factors) {
8376
+ await this.checkGate(vault, "enroll-authenticator", factors);
8377
+ const keyring = await this.getKeyringInternal(vault);
7041
8378
  const slotOptions = await ceremony(keyring);
7042
8379
  if (slotOptions.method !== "webauthn") {
7043
8380
  throw new ValidationError(
@@ -7064,7 +8401,7 @@ var Noydb = class {
7064
8401
  * @see #16
7065
8402
  */
7066
8403
  async listWebAuthnSlots(vault) {
7067
- const keyring = await this.getKeyring(vault);
8404
+ const keyring = await this.getKeyringInternal(vault);
7068
8405
  return keyring.authenticators.filter((a) => a.method === "webauthn").map((a) => {
7069
8406
  const credentialId = a.meta.credentialId;
7070
8407
  return {
@@ -7084,7 +8421,7 @@ var Noydb = class {
7084
8421
  * `checkGate` calls see a tier-2 unlock.
7085
8422
  */
7086
8423
  async unlockViaAuthenticator(vault, slotId, verify) {
7087
- const keyring = await this.getKeyring(vault);
8424
+ const keyring = await this.getKeyringInternal(vault);
7088
8425
  const slot = findAuthenticator(keyring, slotId);
7089
8426
  if (!slot) {
7090
8427
  throw new ValidationError(
@@ -7182,6 +8519,14 @@ var Noydb = class {
7182
8519
  * @throws `InvalidKeyError` when `oldPassphrase` is wrong.
7183
8520
  */
7184
8521
  async rotatePassphrase(vault, input, factors) {
8522
+ if (this.options.passphraseMode === "managed") {
8523
+ throw new PolicyDeniedError(
8524
+ "rotate-passphrase",
8525
+ "disabled",
8526
+ { minTier: 1, enabled: false },
8527
+ "Managed-passphrase mode (#14): the passphrase is hub-generated and sealed under the SealingKeyProvider \u2014 there is no plaintext to rotate. Use the recovery flow (follow-up issue) to mint a fresh sealed passphrase."
8528
+ );
8529
+ }
7185
8530
  await this.checkGate(vault, "rotate-passphrase", factors);
7186
8531
  const userId = this.options.user;
7187
8532
  const next = await rotatePassphrase(this.options.store, vault, userId, input);
@@ -7198,7 +8543,7 @@ var Noydb = class {
7198
8543
  await this.checkGate(vault, "recover-passphrase", factors);
7199
8544
  const userId = this.options.user;
7200
8545
  const entriesBeforeRecovery = await loadPaperRecoveryEntries(this.options.store, vault);
7201
- const next = await recoverPassphrase(this.options.store, vault, userId, input);
8546
+ const next = await recoverPassphrase(this.options.shamirRecovery, this.options.store, vault, userId, input);
7202
8547
  this.keyringCache.set(vault, next);
7203
8548
  const rotateRemaining = input.rotateRemainingCodes ?? true;
7204
8549
  const remainingAfterBurn = Math.max(0, entriesBeforeRecovery.length - 1);
@@ -7218,6 +8563,256 @@ var Noydb = class {
7218
8563
  await savePaperRecoveryEntries(this.options.store, vault, newEntries);
7219
8564
  return { newCodes: codes };
7220
8565
  }
8566
+ /**
8567
+ * Deliberate paper-recovery-code regeneration (#121). User knows their
8568
+ * passphrase but wants a fresh sheet — they lost the printout or
8569
+ * suspect compromise of the off-site copy.
8570
+ *
8571
+ * Symmetric to {@link rotatePassphrase} for the recovery profile:
8572
+ * gated, audit-trackable, ergonomic. Replaces (not appends) the
8573
+ * paper sheet under `_meta/recovery-paper` in a single envelope `put`.
8574
+ *
8575
+ * Gated by the `rotate-recovery` policy gate:
8576
+ * - PERSONAL_POLICY: `{ minTier: 1 }` — knowing the passphrase
8577
+ * suffices, matching the pre-#121 low-level flow's bar.
8578
+ * - STRICT_POLICY: `{ minTier: 1, factors: [{ anyOf: ['totp',
8579
+ * 'email-otp', 'webauthn-roaming'] }] }` — rotation is an
8580
+ * off-site-trust event; require an off-device factor so a
8581
+ * stolen unlocked laptop cannot silently mint a sheet for the
8582
+ * attacker.
8583
+ *
8584
+ * Defaults `count` to the existing sheet size so consumers aren't
8585
+ * surprised by a different code count. Explicit `count` overrides.
8586
+ *
8587
+ * @throws {@link RecoveryProfileNotImplementedError} when `profile`
8588
+ * is anything other than `'paper'` (v1 dispatch limit).
8589
+ * @throws {@link PolicyDeniedError} when the gate denies (missing
8590
+ * factor, tier mismatch, ...).
8591
+ * @throws on missing paper sheet — "nothing to rotate" surfaces as
8592
+ * an error rather than silently minting an entire new sheet.
8593
+ *
8594
+ * @example Default count + show-once UI
8595
+ * ```ts
8596
+ * const { newCodes } = await db.rotateRecovery('acme', { profile: 'paper' })
8597
+ * showCodesToUser(newCodes)
8598
+ * ```
8599
+ *
8600
+ * @example STRICT-policy site with TOTP factor proof
8601
+ * ```ts
8602
+ * await db.rotateRecovery(
8603
+ * 'acme',
8604
+ * { profile: 'paper', count: 10 },
8605
+ * { factors: [{ kind: 'totp', proof: '123456' }] },
8606
+ * )
8607
+ * ```
8608
+ */
8609
+ async rotateRecovery(vault, options, factors) {
8610
+ if (options.profile === "paper") {
8611
+ return this.rotateRecoveryPaper(vault, options, factors);
8612
+ }
8613
+ if (options.profile === "shamir") {
8614
+ return this.rotateRecoveryShamir(vault, options, factors);
8615
+ }
8616
+ throw new RecoveryProfileNotImplementedError(
8617
+ options.profile,
8618
+ "#196"
8619
+ );
8620
+ }
8621
+ async rotateRecoveryPaper(vault, options, factors) {
8622
+ await this.checkGate(vault, "rotate-recovery", factors);
8623
+ const existing = await loadPaperRecoveryEntries(this.options.store, vault);
8624
+ if (existing.length === 0) {
8625
+ throw new Error(
8626
+ `db.rotateRecovery: no recovery codes are enrolled for vault "${vault}". Call db.enrollRecovery({ profile: 'paper', entries }) first; rotateRecovery replaces an existing sheet rather than minting one from scratch.`
8627
+ );
8628
+ }
8629
+ const keyring = await this.getKeyring(vault);
8630
+ const codeGen = options.codeGenerator ?? generateULID;
8631
+ const count2 = options.count ?? existing.length;
8632
+ const codes = [];
8633
+ const newEntries = [];
8634
+ for (let i = 0; i < count2; i++) {
8635
+ const rawCode = codeGen();
8636
+ const entry = await mintPaperRecoveryEntry(keyring.deks, rawCode, generateULID());
8637
+ codes.push(rawCode);
8638
+ newEntries.push(entry);
8639
+ }
8640
+ await savePaperRecoveryEntries(this.options.store, vault, newEntries);
8641
+ return { newCodes: codes, entryId: "paper-batch" };
8642
+ }
8643
+ async rotateRecoveryShamir(vault, options, factors) {
8644
+ await this.checkGate(vault, "rotate-recovery", factors);
8645
+ const existing = await loadShamirRecoveryEntries(this.options.store, vault);
8646
+ if (existing.length === 0) {
8647
+ throw new Error(
8648
+ `db.rotateRecovery: no Shamir recovery entry is enrolled for vault "${vault}". Call db.enrollRecovery({ profile: 'shamir', k, n }) first; rotateRecovery replaces an existing entry rather than minting one from scratch.`
8649
+ );
8650
+ }
8651
+ let targetEntryId;
8652
+ if (options.entryId !== void 0) {
8653
+ const found = existing.find((e) => e.entryId === options.entryId);
8654
+ if (!found) {
8655
+ throw new Error(
8656
+ `db.rotateRecovery: no Shamir entry with entryId="${options.entryId}" found in vault "${vault}". Available: ${existing.map((e) => `"${e.entryId}"`).join(", ")}.`
8657
+ );
8658
+ }
8659
+ targetEntryId = options.entryId;
8660
+ } else {
8661
+ if (existing.length > 1) {
8662
+ throw new Error(
8663
+ `db.rotateRecovery: vault "${vault}" has ${existing.length} Shamir entries enrolled (${existing.map((e) => `"${e.entryId}"`).join(", ")}). Pass \`entryId\` to disambiguate which one to rotate; ambiguous rotation would risk replacing the wrong entry.`
8664
+ );
8665
+ }
8666
+ targetEntryId = existing[0].entryId;
8667
+ }
8668
+ const keyring = await this.getKeyring(vault);
8669
+ const { entry, shareStrings } = await mintShamirRecoveryEntry(
8670
+ this.requireShamirProvider(),
8671
+ keyring.deks,
8672
+ targetEntryId,
8673
+ options.k,
8674
+ options.n,
8675
+ options.label
8676
+ );
8677
+ const next = existing.filter((e) => e.entryId !== targetEntryId).concat(entry);
8678
+ await saveShamirRecoveryEntries(this.options.store, vault, next);
8679
+ return { newShares: shareStrings, entryId: targetEntryId };
8680
+ }
8681
+ /**
8682
+ * **Atomic create-and-enroll for managed-mode vaults (#195).**
8683
+ *
8684
+ * Bootstraps a managed-mode vault and enrolls strong recovery in
8685
+ * a single ceremony. Under `passphraseMode: 'managed'`, every
8686
+ * `openVault` call requires a strong recovery profile (Shamir
8687
+ * today) to be enrolled — otherwise it throws
8688
+ * {@link ManagedRecoveryNotEnrolledError}. This method bypasses
8689
+ * the check temporarily so the keyring can be created, enrolls
8690
+ * the supplied recovery profile(s), then returns the vault.
8691
+ *
8692
+ * For Shamir enrollments, the show-once share strings come back
8693
+ * in `recoveryEnrollments[i].shares`. The hub never retains them
8694
+ * — the caller MUST display them to the user (once) before any
8695
+ * subsequent operation.
8696
+ *
8697
+ * Paper alone is NOT a strong profile under managed mode; passing
8698
+ * `{ profile: 'paper', ... }` without an accompanying shamir entry
8699
+ * is rejected at validation time.
8700
+ *
8701
+ * ```ts
8702
+ * const db = await createNoydb({
8703
+ * store, user: 'alice',
8704
+ * passphraseMode: 'managed',
8705
+ * sealingKey: macosKeychainSealingProvider({ ... }),
8706
+ * })
8707
+ *
8708
+ * const { vault, recoveryEnrollments } = await db.openVaultAndEnrollRecovery('acme', {
8709
+ * recovery: [{ profile: 'shamir', k: 2, n: 3 }],
8710
+ * })
8711
+ * for (const r of recoveryEnrollments) {
8712
+ * if (r.shares) showSharesToUser(r.shares) // ONCE
8713
+ * }
8714
+ * ```
8715
+ *
8716
+ * @throws ValidationError if recovery is empty, or contains no
8717
+ * strong profile under managed mode.
8718
+ */
8719
+ async openVaultAndEnrollRecovery(vault, opts) {
8720
+ if (opts.recovery.length === 0) {
8721
+ throw new ValidationError(
8722
+ "openVaultAndEnrollRecovery: at least one recovery enrollment is required."
8723
+ );
8724
+ }
8725
+ if (this.options.passphraseMode === "managed") {
8726
+ const hasStrong = opts.recovery.some((r) => r.profile === "shamir");
8727
+ if (!hasStrong) {
8728
+ throw new ValidationError(
8729
+ 'openVaultAndEnrollRecovery: managed-mode vaults require at least one strong recovery profile in the `recovery` array. Paper alone is not strong under managed mode (no user passphrase to fall back on). Include { profile: "shamir", k, n } in `recovery`.'
8730
+ );
8731
+ }
8732
+ }
8733
+ this._skipNextManagedRecoveryCheck = true;
8734
+ let vaultHandle;
8735
+ try {
8736
+ vaultHandle = await this.openVault(vault, opts.locale !== void 0 ? { locale: opts.locale } : void 0);
8737
+ } finally {
8738
+ this._skipNextManagedRecoveryCheck = false;
8739
+ }
8740
+ const recoveryEnrollments = [];
8741
+ for (const enrollment of opts.recovery) {
8742
+ recoveryEnrollments.push(await this.enrollRecovery(vault, enrollment));
8743
+ }
8744
+ if (this.options.passphraseMode === "managed") {
8745
+ const policy = this.policyCache.get(vault);
8746
+ if (policy) {
8747
+ await this.assertRecoveryEnrolled(vault, policy);
8748
+ }
8749
+ }
8750
+ return { vault: vaultHandle, recoveryEnrollments };
8751
+ }
8752
+ /**
8753
+ * **Recovery flow under managed-passphrase mode (#195).**
8754
+ *
8755
+ * Replaces the sealed passphrase of a managed-mode vault with a
8756
+ * fresh 256-bit random, sealed under the configured
8757
+ * `SealingKeyProvider`. The user never sees the new passphrase.
8758
+ *
8759
+ * Internally:
8760
+ * 1. Verify the recovery proof (Shamir today) and unwrap the
8761
+ * DEK set.
8762
+ * 2. Mint a fresh 256-bit random as the new effective passphrase.
8763
+ * 3. Rewrap the DEK set under a fresh KEK derived from the new
8764
+ * passphrase (via the existing `recoverPassphrase` path).
8765
+ * 4. Seal the random bytes under the provider and overwrite
8766
+ * `_meta/sealed-passphrase`.
8767
+ * 5. Drop the keyring cache so the next operation re-derives.
8768
+ *
8769
+ * The vault's strong-recovery enrollment is preserved across
8770
+ * recovery (Shamir entries are not burned on use — see #196).
8771
+ *
8772
+ * @throws ValidationError if the Noydb instance is not in managed mode.
8773
+ */
8774
+ async recoverManagedPassphrase(vault, options) {
8775
+ if (this.options.passphraseMode !== "managed") {
8776
+ throw new ValidationError(
8777
+ "recoverManagedPassphrase: this method only applies to vaults opened in managed-passphrase mode. For standard mode, use db.recoverPassphrase."
8778
+ );
8779
+ }
8780
+ const provider = this.options.sealingKey;
8781
+ if (!provider) {
8782
+ throw new ValidationError(
8783
+ 'recoverManagedPassphrase: createNoydb({ passphraseMode: "managed" }) requires `sealingKey` to be supplied; without it the new sealed passphrase cannot be persisted.'
8784
+ );
8785
+ }
8786
+ const randomBytes = new Uint8Array(32);
8787
+ globalThis.crypto.getRandomValues(randomBytes);
8788
+ let binary = "";
8789
+ for (let i = 0; i < randomBytes.length; i++) binary += String.fromCharCode(randomBytes[i]);
8790
+ const newPassphrase = btoa(binary);
8791
+ try {
8792
+ const sealed = await provider.seal(randomBytes);
8793
+ await recoverPassphrase(
8794
+ this.options.shamirRecovery,
8795
+ this.options.store,
8796
+ vault,
8797
+ this.options.user,
8798
+ {
8799
+ newPassphrase,
8800
+ recoveryProof: options.recoveryProof,
8801
+ // The new passphrase IS 256 bits of random; policy gates on
8802
+ // length/entropy don't apply.
8803
+ allowWeakPassphrase: true,
8804
+ ...options.passphrasePolicy !== void 0 ? { passphrasePolicy: options.passphrasePolicy } : {}
8805
+ }
8806
+ );
8807
+ await saveSealedPassphrase(this.options.store, vault, {
8808
+ providerId: provider.id,
8809
+ sealed
8810
+ });
8811
+ } finally {
8812
+ randomBytes.fill(0);
8813
+ }
8814
+ this.keyringCache.delete(vault);
8815
+ }
7221
8816
  /**
7222
8817
  * Atomic peer-recovery — re-wraps an EXISTING user's keyring under
7223
8818
  * a fresh temp passphrase in a single store write. Closes #34's
@@ -7263,7 +8858,7 @@ var Noydb = class {
7263
8858
  */
7264
8859
  async recoverUser(vault, options, factors) {
7265
8860
  await this.checkGate(vault, "peer-recover-user", factors);
7266
- const callerKeyring = await this.getKeyring(vault);
8861
+ const callerKeyring = await this.getKeyringInternal(vault);
7267
8862
  await recoverUser(this.options.store, vault, callerKeyring, options);
7268
8863
  if (options.userId === this.options.user) {
7269
8864
  this.keyringCache.delete(vault);
@@ -7301,21 +8896,40 @@ var Noydb = class {
7301
8896
  * ```
7302
8897
  */
7303
8898
  async enrollRecovery(vault, enrollment) {
7304
- if (enrollment.profile !== "paper") {
7305
- throw new ValidationError(
7306
- `enrollRecovery: only 'paper' is implemented in v0.1.0-pre.5. Profile '${enrollment.profile}' is tracked under issue #10.`
8899
+ if (enrollment.profile === "paper") {
8900
+ const existing = await loadPaperRecoveryEntries(this.options.store, vault);
8901
+ await savePaperRecoveryEntries(this.options.store, vault, [
8902
+ ...existing,
8903
+ ...enrollment.entries
8904
+ ]);
8905
+ return { entryId: "paper-batch" };
8906
+ }
8907
+ if (enrollment.profile === "shamir") {
8908
+ const keyring = await this.getKeyring(vault);
8909
+ const entryId = enrollment.entryId ?? generateULID();
8910
+ const { entry, shareStrings } = await mintShamirRecoveryEntry(
8911
+ this.requireShamirProvider(),
8912
+ keyring.deks,
8913
+ entryId,
8914
+ enrollment.k,
8915
+ enrollment.n,
8916
+ enrollment.label
7307
8917
  );
7308
- }
7309
- const existing = await loadPaperRecoveryEntries(this.options.store, vault);
7310
- await savePaperRecoveryEntries(this.options.store, vault, [
7311
- ...existing,
7312
- ...enrollment.entries
7313
- ]);
8918
+ const existing = await loadShamirRecoveryEntries(this.options.store, vault);
8919
+ const next = existing.filter((e) => e.entryId !== entryId).concat(entry);
8920
+ await saveShamirRecoveryEntries(this.options.store, vault, next);
8921
+ return { entryId, shares: shareStrings };
8922
+ }
8923
+ throw new RecoveryProfileNotImplementedError(
8924
+ enrollment.profile,
8925
+ "#196"
8926
+ );
7314
8927
  }
7315
- /** Read the persisted paper-recovery entries. Used by `describeAuthConfig` (#13). */
8928
+ /** Read the persisted recovery entries (paper + Shamir). Used by `describeAuthConfig` (#13). */
7316
8929
  async listRecoveryEntries(vault) {
7317
8930
  const paper = await loadPaperRecoveryEntries(this.options.store, vault);
7318
- return { paper };
8931
+ const shamir = await loadShamirRecoveryEntries(this.options.store, vault);
8932
+ return { paper, shamir };
7319
8933
  }
7320
8934
  // ─── Tier-3 enroll / unlock (issue #11) ────────────────────────
7321
8935
  /**
@@ -7327,8 +8941,8 @@ var Noydb = class {
7327
8941
  * Gated by `rotate-unlock` (the same gate covers "set" and "rotate"
7328
8942
  * because tier-3 is a single-slot rolling secret).
7329
8943
  */
7330
- async enrollUnlock(vault, state, presented) {
7331
- await this.checkGate(vault, "rotate-unlock", presented);
8944
+ async enrollUnlock(vault, state, factors) {
8945
+ await this.checkGate(vault, "rotate-unlock", factors);
7332
8946
  this.quickUnlock.set(vault, state);
7333
8947
  }
7334
8948
  /**
@@ -7355,8 +8969,17 @@ var Noydb = class {
7355
8969
  /**
7356
8970
  * Public accessor for the unlocked keyring of a vault — issue #28.
7357
8971
  *
7358
- * Returns the cached `UnlockedKeyring` (already in memory after
7359
- * `createNoydb` + first vault touch); loads it on demand if absent.
8972
+ * Returns a **defensive shallow copy** so consumers can read the DEK
8973
+ * map and authenticator list without the risk of mutating the hub's
8974
+ * internal cache (#88). Internal hub code paths use a live reference
8975
+ * via `getKeyringInternal`; ceremonies and external consumers always
8976
+ * get a snapshot.
8977
+ *
8978
+ * The CryptoKey values inside `deks` are not cloned — Web Crypto
8979
+ * keys are opaque handles, and a shared handle is intentional
8980
+ * (encrypt / decrypt go through the same key the cache holds).
8981
+ * Only the container Map / authenticator array is fresh.
8982
+ *
7360
8983
  * Used by `@noy-db/on-*` ceremonies that need the live DEK set
7361
8984
  * (paper recovery via {@link mintPaperRecoveryEntry}, tier-3 PIN
7362
8985
  * enrolment via on-pin's `enrollPin`, custom on-* ceremonies that
@@ -7371,11 +8994,33 @@ var Noydb = class {
7371
8994
  * ```ts
7372
8995
  * const keyring = await db.getKeyring('acme')
7373
8996
  * // keyring.deks: Map<collection, CryptoKey>
7374
- * // keyring.kek: CryptoKey (non-extractable; null for tier-3 sessions)
8997
+ * // keyring.kek: CryptoKey | null (null for tier-3 / wrap-DEKs sessions)
7375
8998
  * // keyring.role / .permissions / .authenticators
7376
8999
  * ```
7377
9000
  */
7378
9001
  async getKeyring(vault) {
9002
+ const live = await this.getKeyringInternal(vault);
9003
+ return {
9004
+ ...live,
9005
+ deks: new Map(live.deks),
9006
+ permissions: { ...live.permissions },
9007
+ authenticators: live.authenticators.map((a) => ({
9008
+ ...a,
9009
+ meta: { ...a.meta }
9010
+ })),
9011
+ ...live.policy !== void 0 ? { policy: { ...live.policy } } : {},
9012
+ ...live.exportCapability !== void 0 ? { exportCapability: { ...live.exportCapability } } : {},
9013
+ ...live.importCapability !== void 0 ? { importCapability: { ...live.importCapability } } : {}
9014
+ };
9015
+ }
9016
+ /**
9017
+ * Live-reference variant used by the hub's own code paths. Internal
9018
+ * mutations on `deks` (e.g. {@link ensureCollectionDEK} adding a
9019
+ * collection key) need to land on the cached keyring so subsequent
9020
+ * accesses see them. Not exposed publicly — callers outside hub
9021
+ * should use {@link getKeyring}, which returns a defensive copy.
9022
+ */
9023
+ async getKeyringInternal(vault) {
7379
9024
  if (this.options.encrypt === false) {
7380
9025
  return createPlaintextKeyring(this.options.user);
7381
9026
  }
@@ -7386,20 +9031,36 @@ var Noydb = class {
7386
9031
  this.keyringCache.set(vault, keyring2);
7387
9032
  return keyring2;
7388
9033
  }
7389
- if (!this.options.secret) {
9034
+ let effectiveSecret;
9035
+ if (this.options.passphraseMode === "managed") {
9036
+ effectiveSecret = await resolveManagedSecret(
9037
+ this.options.store,
9038
+ vault,
9039
+ this.options.sealingKey
9040
+ );
9041
+ } else {
9042
+ effectiveSecret = this.options.secret;
9043
+ }
9044
+ if (!effectiveSecret) {
7390
9045
  throw new ValidationError("A secret (passphrase) or getKeyring callback is required when encryption is enabled");
7391
9046
  }
7392
9047
  let keyring;
7393
9048
  try {
7394
- keyring = await loadKeyring(this.options.store, vault, this.options.user, this.options.secret);
9049
+ keyring = await loadKeyring(this.options.store, vault, this.options.user, effectiveSecret);
7395
9050
  } catch (err) {
7396
9051
  if (err instanceof NoAccessError) {
7397
9052
  keyring = await createOwnerKeyring(
7398
9053
  this.options.store,
7399
9054
  vault,
7400
9055
  this.options.user,
7401
- this.options.secret,
7402
- { validate: this.options.validatePassphrase === true }
9056
+ effectiveSecret,
9057
+ {
9058
+ // Managed mode generates 256-bit base64 strings that don't satisfy
9059
+ // the human-passphrase strength rules (no spaces, no "words").
9060
+ // Skip validation in managed mode — the entropy floor is already
9061
+ // 256 bits by construction.
9062
+ validate: this.options.passphraseMode === "managed" ? false : this.options.validatePassphrase === true
9063
+ }
7403
9064
  );
7404
9065
  } else if (err instanceof InvalidKeyError && this.options.onInvalidKey === "reset") {
7405
9066
  await this.options.store.delete(vault, "_keyring", this.options.user);
@@ -7407,8 +9068,10 @@ var Noydb = class {
7407
9068
  this.options.store,
7408
9069
  vault,
7409
9070
  this.options.user,
7410
- this.options.secret,
7411
- { validate: this.options.validatePassphrase === true }
9071
+ effectiveSecret,
9072
+ {
9073
+ validate: this.options.passphraseMode === "managed" ? false : this.options.validatePassphrase === true
9074
+ }
7412
9075
  );
7413
9076
  } else {
7414
9077
  throw err;
@@ -7420,10 +9083,28 @@ var Noydb = class {
7420
9083
  };
7421
9084
  async function createNoydb(options) {
7422
9085
  const encrypted = options.encrypt !== false;
9086
+ const managed = options.passphraseMode === "managed";
7423
9087
  if (options.secret && options.getKeyring) {
7424
9088
  throw new ValidationError("Provide either `secret` or `getKeyring`, not both");
7425
9089
  }
7426
- if (encrypted && !options.secret && !options.getKeyring) {
9090
+ if (managed) {
9091
+ if (options.secret) {
9092
+ throw new ValidationError(
9093
+ '`passphraseMode: "managed"` is mutually exclusive with `secret` \u2014 managed mode generates the passphrase itself. Drop `secret`.'
9094
+ );
9095
+ }
9096
+ if (options.getKeyring) {
9097
+ throw new ValidationError(
9098
+ '`passphraseMode: "managed"` is mutually exclusive with `getKeyring` \u2014 a custom unlock callback would bypass the sealing flow. Drop `getKeyring`.'
9099
+ );
9100
+ }
9101
+ if (!options.sealingKey) {
9102
+ throw new ValidationError(
9103
+ '`passphraseMode: "managed"` requires `sealingKey: SealingKeyProvider` (see @noy-db/seal-macos-keychain / @noy-db/seal-aws-kms / etc.).'
9104
+ );
9105
+ }
9106
+ }
9107
+ if (encrypted && !managed && !options.secret && !options.getKeyring) {
7427
9108
  throw new ValidationError("A secret (passphrase) or getKeyring callback is required when encryption is enabled");
7428
9109
  }
7429
9110
  return new Noydb(options);
@@ -7591,6 +9272,7 @@ function shortJSON(value) {
7591
9272
  export {
7592
9273
  Aggregation,
7593
9274
  AlreadyElevatedError,
9275
+ AmendmentForbiddenError,
7594
9276
  BLOB_CHUNKS_COLLECTION,
7595
9277
  BLOB_COLLECTION,
7596
9278
  BLOB_INDEX_COLLECTION,
@@ -7601,6 +9283,7 @@ export {
7601
9283
  BackupLedgerError,
7602
9284
  BlobSet,
7603
9285
  BundleIntegrityError,
9286
+ BundleSealMismatchError,
7604
9287
  BundleVersionConflictError,
7605
9288
  CONSENT_AUDIT_COLLECTION,
7606
9289
  Collection,
@@ -7614,28 +9297,39 @@ export {
7614
9297
  DEFAULT_PUBLIC_ENVELOPE_SCHEMA,
7615
9298
  DELEGATIONS_COLLECTION,
7616
9299
  DICT_COLLECTION_PREFIX,
9300
+ DIRECTORY_RECORD_ID,
7617
9301
  DanglingReferenceError,
7618
9302
  DecryptionError,
7619
9303
  DelegationTargetMissingError,
9304
+ DerivationCapExceededError,
9305
+ DerivationCycleError,
9306
+ DerivationDepthError,
9307
+ DerivationOutputShapeError,
9308
+ DerivationOutputUnknownError,
7620
9309
  DictKeyInUseError,
7621
9310
  DictKeyMissingError,
7622
9311
  DictionaryHandle,
9312
+ DirectoryDisabledError,
7623
9313
  ELEVATION_AUDIT_COLLECTION,
7624
9314
  ElevatedHandle,
7625
9315
  ElevationExpiredError,
7626
9316
  ExportCapabilityError,
9317
+ FieldFrozenError,
7627
9318
  FilenameSanitizationError,
7628
9319
  GROUPBY_MAX_CARDINALITY,
7629
9320
  GROUPBY_WARN_CARDINALITY,
7630
9321
  GroupCardinalityError,
7631
9322
  GroupedAggregation,
7632
9323
  GroupedQuery,
9324
+ GroupedQueryN,
7633
9325
  INDEXED_STORE_POLICY,
7634
9326
  ImportCapabilityError,
7635
9327
  IndexRequiredError,
7636
9328
  IndexWriteFailureError,
7637
9329
  InvalidKeyError,
9330
+ InvariantError,
7638
9331
  JoinTooLargeError,
9332
+ KeyringCorruptError,
7639
9333
  KeyringExpiredError,
7640
9334
  LEDGER_COLLECTION,
7641
9335
  LEDGER_DELTAS_COLLECTION,
@@ -7647,6 +9341,12 @@ export {
7647
9341
  MAGIC_LINK_GRANTS_COLLECTION,
7648
9342
  MAGIC_LINK_KEK_INFO_PREFIX,
7649
9343
  META_COLLECTION,
9344
+ ManagedRecoveryNotEnrolledError,
9345
+ MaterializedViewConfigError,
9346
+ MaterializedViewCycleError,
9347
+ MaterializedViewSourceUnknownError,
9348
+ MaterializedViewTooLargeError,
9349
+ MemorySealingKeyProvider,
7650
9350
  MissingTranslationError,
7651
9351
  NOYDB_BACKUP_VERSION,
7652
9352
  NOYDB_BUNDLE_FORMAT_VERSION,
@@ -7660,6 +9360,10 @@ export {
7660
9360
  NotFoundError,
7661
9361
  Noydb,
7662
9362
  NoydbError,
9363
+ OverlayBaseIsVirtualError,
9364
+ OverlayCollectionUnavailableError,
9365
+ OverlayIdMismatchError,
9366
+ OverlayNameCollisionError,
7663
9367
  PERIODS_COLLECTION,
7664
9368
  PERSONAL_POLICY,
7665
9369
  POLICY_RECORD_ID,
@@ -7677,12 +9381,15 @@ export {
7677
9381
  ReadOnlyAtInstantError,
7678
9382
  ReadOnlyError,
7679
9383
  ReadOnlyFrameError,
9384
+ RecordLockedError,
7680
9385
  RecoveryNotEnrolledError,
7681
9386
  RecoveryProfileNotImplementedError,
7682
9387
  RefIntegrityError,
7683
9388
  RefRegistry,
7684
9389
  RefScopeError,
7685
9390
  ReservedCollectionNameError,
9391
+ SCHEMAS_COLLECTION,
9392
+ SEALED_PASSPHRASE_RECORD_ID,
7686
9393
  STRICT_POLICY,
7687
9394
  SYNC_CREDENTIALS_COLLECTION,
7688
9395
  ScanBuilder,
@@ -7705,6 +9412,7 @@ export {
7705
9412
  USER_ENVELOPE_MAX_BYTES,
7706
9413
  UserApi,
7707
9414
  UserEnvelopeOversizedError,
9415
+ VISIBILITY_RECORD_PREFIX,
7708
9416
  ValidationError,
7709
9417
  Vault,
7710
9418
  VaultFrame,
@@ -7738,7 +9446,9 @@ export {
7738
9446
  dekKey,
7739
9447
  deleteCredential,
7740
9448
  deleteUserEnvelope,
9449
+ deleteUserVisibility,
7741
9450
  deriveMagicLinkContentKey,
9451
+ derivePersistedSchema,
7742
9452
  derivePresenceKey,
7743
9453
  describeAllUsersAuth,
7744
9454
  describeAuthConfig,
@@ -7784,6 +9494,7 @@ export {
7784
9494
  isPublicEnvelope,
7785
9495
  isSessionAlive,
7786
9496
  isULID,
9497
+ isZodSchema,
7787
9498
  issueDelegation,
7788
9499
  recoverPassphrase as keyringRecoverPassphrase,
7789
9500
  rotatePassphrase as keyringRotatePassphrase,
@@ -7795,7 +9506,10 @@ export {
7795
9506
  loadActiveDelegations,
7796
9507
  loadDevUnlock,
7797
9508
  loadPaperRecoveryEntries,
9509
+ loadPersistedSchema,
7798
9510
  loadPublicEnvelope,
9511
+ loadSealedPassphrase,
9512
+ loadShamirRecoveryEntries,
7799
9513
  loadUserEnvelope,
7800
9514
  loadVaultPolicy,
7801
9515
  magicLinkGrantRecordId,
@@ -7804,17 +9518,24 @@ export {
7804
9518
  mergePolicy,
7805
9519
  min,
7806
9520
  mintPaperRecoveryEntry,
9521
+ mintShamirRecoveryEntry,
7807
9522
  mintWrappedDeksBlob,
7808
9523
  paddedIndex,
7809
9524
  parseBytes,
7810
9525
  parseIndex,
9526
+ parseSealedEnvelope,
9527
+ persistDirectoryConfig,
9528
+ persistSchemaIfNeeded,
9529
+ persistUserVisibility,
7811
9530
  putCredential,
9531
+ readDirectoryConfig,
7812
9532
  readMagicLinkGrantRecord,
7813
9533
  readNoydbBundle,
7814
9534
  readNoydbBundleHeader,
7815
9535
  readNoydbBundlePublicEnvelope,
7816
9536
  readPath,
7817
9537
  readPublicEnvelope,
9538
+ readUserVisibility,
7818
9539
  recoverUser,
7819
9540
  reduceRecords,
7820
9541
  ref,
@@ -7832,13 +9553,17 @@ export {
7832
9553
  routeStore,
7833
9554
  runTransaction,
7834
9555
  savePaperRecoveryEntries,
9556
+ savePersistedSchema,
7835
9557
  savePublicEnvelope,
9558
+ saveSealedPassphrase,
9559
+ saveShamirRecoveryEntries,
7836
9560
  saveUserEnvelope,
7837
9561
  saveVaultPolicy,
7838
- sha256Hex,
9562
+ sha256Hex2 as sha256Hex,
7839
9563
  sum,
7840
9564
  unwrapDeksFromBlob,
7841
9565
  unwrapDeksFromPaperEntry,
9566
+ unwrapDeksFromShamirEntry,
7842
9567
  unwrapMagicLinkGrant,
7843
9568
  validateI18nTextValue,
7844
9569
  validatePassphrase,
@@ -7846,11 +9571,16 @@ export {
7846
9571
  validateSchemaInput,
7847
9572
  validateSchemaOutput,
7848
9573
  validateSessionPolicy,
9574
+ visibilityRecordId,
7849
9575
  withCache,
7850
9576
  withCircuitBreaker,
9577
+ withDerivation,
9578
+ withGuard,
7851
9579
  withHealthCheck,
7852
9580
  withLogging,
9581
+ withMaterializedView,
7853
9582
  withMetrics,
9583
+ withOverlayedView,
7854
9584
  withRetry,
7855
9585
  wrapBundleStore,
7856
9586
  wrapStore,