mindoodb 0.0.23 → 0.0.32

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 (340) hide show
  1. package/README-server.md +9 -1
  2. package/README.md +5 -3
  3. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.d.ts +1 -1
  4. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.d.ts.map +1 -1
  5. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.js +24 -5
  6. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.js.map +1 -1
  7. package/dist/appendonlystores/network/HttpTransport.d.ts +14 -2
  8. package/dist/appendonlystores/network/HttpTransport.d.ts.map +1 -1
  9. package/dist/appendonlystores/network/HttpTransport.js +124 -27
  10. package/dist/appendonlystores/network/HttpTransport.js.map +1 -1
  11. package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.d.ts +159 -4
  12. package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.d.ts.map +1 -1
  13. package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.js +334 -25
  14. package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.js.map +1 -1
  15. package/dist/browser/appendonlystores/IndexedDBContentAddressedStore.d.ts +9 -0
  16. package/dist/browser/appendonlystores/IndexedDBContentAddressedStore.d.ts.map +1 -1
  17. package/dist/browser/appendonlystores/IndexedDBContentAddressedStore.js +77 -5
  18. package/dist/browser/appendonlystores/IndexedDBContentAddressedStore.js.map +1 -1
  19. package/dist/browser/index.mjs +103 -0
  20. package/dist/core/BaseMindooDB.d.ts +697 -4
  21. package/dist/core/BaseMindooDB.d.ts.map +1 -1
  22. package/dist/core/BaseMindooDB.js +3273 -180
  23. package/dist/core/BaseMindooDB.js.map +1 -1
  24. package/dist/core/BaseMindooTenant.d.ts +192 -1
  25. package/dist/core/BaseMindooTenant.d.ts.map +1 -1
  26. package/dist/core/BaseMindooTenant.js +633 -88
  27. package/dist/core/BaseMindooTenant.js.map +1 -1
  28. package/dist/core/BaseMindooTenantDirectory.d.ts +770 -10
  29. package/dist/core/BaseMindooTenantDirectory.d.ts.map +1 -1
  30. package/dist/core/BaseMindooTenantDirectory.js +2715 -292
  31. package/dist/core/BaseMindooTenantDirectory.js.map +1 -1
  32. package/dist/core/BaseMindooTenantFactory.d.ts +2 -0
  33. package/dist/core/BaseMindooTenantFactory.d.ts.map +1 -1
  34. package/dist/core/BaseMindooTenantFactory.js +20 -0
  35. package/dist/core/BaseMindooTenantFactory.js.map +1 -1
  36. package/dist/core/accesscontrol/AccessDeniedError.d.ts +22 -0
  37. package/dist/core/accesscontrol/AccessDeniedError.d.ts.map +1 -0
  38. package/dist/core/accesscontrol/AccessDeniedError.js +28 -0
  39. package/dist/core/accesscontrol/AccessDeniedError.js.map +1 -0
  40. package/dist/core/accesscontrol/DirectoryStateNode.d.ts +220 -0
  41. package/dist/core/accesscontrol/DirectoryStateNode.d.ts.map +1 -0
  42. package/dist/core/accesscontrol/DirectoryStateNode.js +297 -0
  43. package/dist/core/accesscontrol/DirectoryStateNode.js.map +1 -0
  44. package/dist/core/accesscontrol/DirectoryTimeTravelIndex.d.ts +118 -0
  45. package/dist/core/accesscontrol/DirectoryTimeTravelIndex.d.ts.map +1 -0
  46. package/dist/core/accesscontrol/DirectoryTimeTravelIndex.js +173 -0
  47. package/dist/core/accesscontrol/DirectoryTimeTravelIndex.js.map +1 -0
  48. package/dist/core/accesscontrol/directoryProjection.d.ts +26 -0
  49. package/dist/core/accesscontrol/directoryProjection.d.ts.map +1 -0
  50. package/dist/core/accesscontrol/directoryProjection.js +174 -0
  51. package/dist/core/accesscontrol/directoryProjection.js.map +1 -0
  52. package/dist/core/accesscontrol/evaluate.d.ts +77 -0
  53. package/dist/core/accesscontrol/evaluate.d.ts.map +1 -0
  54. package/dist/core/accesscontrol/evaluate.js +206 -0
  55. package/dist/core/accesscontrol/evaluate.js.map +1 -0
  56. package/dist/core/accesscontrol/grantKeys.d.ts +113 -0
  57. package/dist/core/accesscontrol/grantKeys.d.ts.map +1 -0
  58. package/dist/core/accesscontrol/grantKeys.js +302 -0
  59. package/dist/core/accesscontrol/grantKeys.js.map +1 -0
  60. package/dist/core/accesscontrol/keyBagReconciler.d.ts +22 -0
  61. package/dist/core/accesscontrol/keyBagReconciler.d.ts.map +1 -0
  62. package/dist/core/accesscontrol/keyBagReconciler.js +3 -0
  63. package/dist/core/accesscontrol/keyBagReconciler.js.map +1 -0
  64. package/dist/core/accesscontrol/materializationGuard.d.ts +89 -0
  65. package/dist/core/accesscontrol/materializationGuard.d.ts.map +1 -0
  66. package/dist/core/accesscontrol/materializationGuard.js +69 -0
  67. package/dist/core/accesscontrol/materializationGuard.js.map +1 -0
  68. package/dist/core/accesscontrol/quarantine.d.ts +65 -0
  69. package/dist/core/accesscontrol/quarantine.d.ts.map +1 -0
  70. package/dist/core/accesscontrol/quarantine.js +79 -0
  71. package/dist/core/accesscontrol/quarantine.js.map +1 -0
  72. package/dist/core/accesscontrol/receiptValidation.d.ts +113 -0
  73. package/dist/core/accesscontrol/receiptValidation.d.ts.map +1 -0
  74. package/dist/core/accesscontrol/receiptValidation.js +142 -0
  75. package/dist/core/accesscontrol/receiptValidation.js.map +1 -0
  76. package/dist/core/accesscontrol/timestamp/Ed25519WitnessProvider.d.ts +31 -0
  77. package/dist/core/accesscontrol/timestamp/Ed25519WitnessProvider.d.ts.map +1 -0
  78. package/dist/core/accesscontrol/timestamp/Ed25519WitnessProvider.js +66 -0
  79. package/dist/core/accesscontrol/timestamp/Ed25519WitnessProvider.js.map +1 -0
  80. package/dist/core/accesscontrol/timestamp/TimestampProvider.d.ts +79 -0
  81. package/dist/core/accesscontrol/timestamp/TimestampProvider.d.ts.map +1 -0
  82. package/dist/core/accesscontrol/timestamp/TimestampProvider.js +22 -0
  83. package/dist/core/accesscontrol/timestamp/TimestampProvider.js.map +1 -0
  84. package/dist/core/accesscontrol/types.d.ts +825 -0
  85. package/dist/core/accesscontrol/types.d.ts.map +1 -0
  86. package/dist/core/accesscontrol/types.js +534 -0
  87. package/dist/core/accesscontrol/types.js.map +1 -0
  88. package/dist/core/appendonlystores/InMemoryContentAddressedStore.d.ts +9 -0
  89. package/dist/core/appendonlystores/InMemoryContentAddressedStore.d.ts.map +1 -1
  90. package/dist/core/appendonlystores/InMemoryContentAddressedStore.js +54 -0
  91. package/dist/core/appendonlystores/InMemoryContentAddressedStore.js.map +1 -1
  92. package/dist/core/appendonlystores/network/AuthenticationService.d.ts +38 -2
  93. package/dist/core/appendonlystores/network/AuthenticationService.d.ts.map +1 -1
  94. package/dist/core/appendonlystores/network/AuthenticationService.js +199 -26
  95. package/dist/core/appendonlystores/network/AuthenticationService.js.map +1 -1
  96. package/dist/core/appendonlystores/network/NetworkTransport.d.ts +17 -3
  97. package/dist/core/appendonlystores/network/NetworkTransport.d.ts.map +1 -1
  98. package/dist/core/appendonlystores/network/types.d.ts +58 -4
  99. package/dist/core/appendonlystores/network/types.d.ts.map +1 -1
  100. package/dist/core/appendonlystores/network/types.js +4 -0
  101. package/dist/core/appendonlystores/network/types.js.map +1 -1
  102. package/dist/core/appendonlystores/scanUtils.d.ts +27 -0
  103. package/dist/core/appendonlystores/scanUtils.d.ts.map +1 -0
  104. package/dist/core/appendonlystores/scanUtils.js +87 -0
  105. package/dist/core/appendonlystores/scanUtils.js.map +1 -0
  106. package/dist/core/appendonlystores/types.d.ts +23 -2
  107. package/dist/core/appendonlystores/types.d.ts.map +1 -1
  108. package/dist/core/cache/CacheManager.d.ts +13 -3
  109. package/dist/core/cache/CacheManager.d.ts.map +1 -1
  110. package/dist/core/cache/CacheManager.js +7 -4
  111. package/dist/core/cache/CacheManager.js.map +1 -1
  112. package/dist/core/crypto/EntrySignature.d.ts +137 -0
  113. package/dist/core/crypto/EntrySignature.d.ts.map +1 -0
  114. package/dist/core/crypto/EntrySignature.js +210 -0
  115. package/dist/core/crypto/EntrySignature.js.map +1 -0
  116. package/dist/core/crypto/MindooDocSigner.d.ts.map +1 -1
  117. package/dist/core/crypto/MindooDocSigner.js +6 -2
  118. package/dist/core/crypto/MindooDocSigner.js.map +1 -1
  119. package/dist/core/crypto/WitnessReceipt.d.ts +137 -0
  120. package/dist/core/crypto/WitnessReceipt.d.ts.map +1 -0
  121. package/dist/core/crypto/WitnessReceipt.js +182 -0
  122. package/dist/core/crypto/WitnessReceipt.js.map +1 -0
  123. package/dist/core/crypto/encryptedFields.d.ts +27 -0
  124. package/dist/core/crypto/encryptedFields.d.ts.map +1 -0
  125. package/dist/core/crypto/encryptedFields.js +58 -0
  126. package/dist/core/crypto/encryptedFields.js.map +1 -0
  127. package/dist/core/crypto/pbkdf2Iterations.d.ts +12 -0
  128. package/dist/core/crypto/pbkdf2Iterations.d.ts.map +1 -1
  129. package/dist/core/crypto/pbkdf2Iterations.js +23 -0
  130. package/dist/core/crypto/pbkdf2Iterations.js.map +1 -1
  131. package/dist/core/crypto/privateKeyEncryption.d.ts.map +1 -1
  132. package/dist/core/crypto/privateKeyEncryption.js +3 -1
  133. package/dist/core/crypto/privateKeyEncryption.js.map +1 -1
  134. package/dist/core/databaseIdValidation.d.ts +1 -1
  135. package/dist/core/databaseIdValidation.d.ts.map +1 -1
  136. package/dist/core/databaseIdValidation.js +4 -4
  137. package/dist/core/databaseIdValidation.js.map +1 -1
  138. package/dist/core/errors.d.ts +26 -0
  139. package/dist/core/errors.d.ts.map +1 -1
  140. package/dist/core/errors.js +42 -1
  141. package/dist/core/errors.js.map +1 -1
  142. package/dist/core/expressions/builder.d.ts +102 -0
  143. package/dist/core/expressions/builder.d.ts.map +1 -0
  144. package/dist/core/expressions/builder.js +249 -0
  145. package/dist/core/expressions/builder.js.map +1 -0
  146. package/dist/core/expressions/evaluateExpression.d.ts +78 -0
  147. package/dist/core/expressions/evaluateExpression.d.ts.map +1 -0
  148. package/dist/core/expressions/evaluateExpression.js +386 -0
  149. package/dist/core/expressions/evaluateExpression.js.map +1 -0
  150. package/dist/core/expressions/formulaSource.d.ts +24 -0
  151. package/dist/core/expressions/formulaSource.d.ts.map +1 -0
  152. package/dist/core/expressions/formulaSource.js +765 -0
  153. package/dist/core/expressions/formulaSource.js.map +1 -0
  154. package/dist/core/expressions/index.d.ts +8 -0
  155. package/dist/core/expressions/index.d.ts.map +1 -0
  156. package/dist/core/expressions/index.js +46 -0
  157. package/dist/core/expressions/index.js.map +1 -0
  158. package/dist/core/expressions/metadata.d.ts +23 -0
  159. package/dist/core/expressions/metadata.d.ts.map +1 -0
  160. package/dist/core/expressions/metadata.js +191 -0
  161. package/dist/core/expressions/metadata.js.map +1 -0
  162. package/dist/core/expressions/types.d.ts +164 -0
  163. package/dist/core/expressions/types.d.ts.map +1 -0
  164. package/dist/core/expressions/types.js +3 -0
  165. package/dist/core/expressions/types.js.map +1 -0
  166. package/dist/core/extraction/types.d.ts +47 -0
  167. package/dist/core/extraction/types.d.ts.map +1 -0
  168. package/dist/core/extraction/types.js +42 -0
  169. package/dist/core/extraction/types.js.map +1 -0
  170. package/dist/core/index.d.ts +13 -3
  171. package/dist/core/index.d.ts.map +1 -1
  172. package/dist/core/index.js +113 -4
  173. package/dist/core/index.js.map +1 -1
  174. package/dist/core/indexing/createViewDataProvider.d.ts +84 -0
  175. package/dist/core/indexing/createViewDataProvider.d.ts.map +1 -0
  176. package/dist/core/indexing/createViewDataProvider.js +139 -0
  177. package/dist/core/indexing/createViewDataProvider.js.map +1 -0
  178. package/dist/core/indexing/fulltext/DocumentFullTextIndex.d.ts +180 -0
  179. package/dist/core/indexing/fulltext/DocumentFullTextIndex.d.ts.map +1 -0
  180. package/dist/core/indexing/fulltext/DocumentFullTextIndex.js +548 -0
  181. package/dist/core/indexing/fulltext/DocumentFullTextIndex.js.map +1 -0
  182. package/dist/core/indexing/fulltext/SearchEngineAdapter.d.ts +69 -0
  183. package/dist/core/indexing/fulltext/SearchEngineAdapter.d.ts.map +1 -0
  184. package/dist/core/indexing/fulltext/SearchEngineAdapter.js +183 -0
  185. package/dist/core/indexing/fulltext/SearchEngineAdapter.js.map +1 -0
  186. package/dist/core/indexing/fulltext/extractFulltextText.d.ts +34 -0
  187. package/dist/core/indexing/fulltext/extractFulltextText.d.ts.map +1 -0
  188. package/dist/core/indexing/fulltext/extractFulltextText.js +129 -0
  189. package/dist/core/indexing/fulltext/extractFulltextText.js.map +1 -0
  190. package/dist/core/indexing/fulltext/index.d.ts +5 -0
  191. package/dist/core/indexing/fulltext/index.d.ts.map +1 -0
  192. package/dist/core/indexing/fulltext/index.js +20 -0
  193. package/dist/core/indexing/fulltext/index.js.map +1 -0
  194. package/dist/core/indexing/fulltext/types.d.ts +168 -0
  195. package/dist/core/indexing/fulltext/types.d.ts.map +1 -0
  196. package/dist/core/indexing/fulltext/types.js +89 -0
  197. package/dist/core/indexing/fulltext/types.js.map +1 -0
  198. package/dist/core/indexing/summary/DocumentSummaryStore.d.ts +137 -0
  199. package/dist/core/indexing/summary/DocumentSummaryStore.d.ts.map +1 -0
  200. package/dist/core/indexing/summary/DocumentSummaryStore.js +438 -0
  201. package/dist/core/indexing/summary/DocumentSummaryStore.js.map +1 -0
  202. package/dist/core/indexing/summary/SummaryVirtualViewDataProvider.d.ts +72 -0
  203. package/dist/core/indexing/summary/SummaryVirtualViewDataProvider.d.ts.map +1 -0
  204. package/dist/core/indexing/summary/SummaryVirtualViewDataProvider.js +221 -0
  205. package/dist/core/indexing/summary/SummaryVirtualViewDataProvider.js.map +1 -0
  206. package/dist/core/indexing/summary/extractSummaryFields.d.ts +75 -0
  207. package/dist/core/indexing/summary/extractSummaryFields.d.ts.map +1 -0
  208. package/dist/core/indexing/summary/extractSummaryFields.js +266 -0
  209. package/dist/core/indexing/summary/extractSummaryFields.js.map +1 -0
  210. package/dist/core/indexing/summary/index.d.ts +5 -0
  211. package/dist/core/indexing/summary/index.d.ts.map +1 -0
  212. package/dist/core/indexing/summary/index.js +23 -0
  213. package/dist/core/indexing/summary/index.js.map +1 -0
  214. package/dist/core/indexing/summary/types.d.ts +119 -0
  215. package/dist/core/indexing/summary/types.d.ts.map +1 -0
  216. package/dist/core/indexing/summary/types.js +81 -0
  217. package/dist/core/indexing/summary/types.js.map +1 -0
  218. package/dist/core/indexing/virtualviews/IVirtualViewDataProvider.d.ts +47 -1
  219. package/dist/core/indexing/virtualviews/IVirtualViewDataProvider.d.ts.map +1 -1
  220. package/dist/core/indexing/virtualviews/MindooDBVirtualViewDataProvider.d.ts +24 -8
  221. package/dist/core/indexing/virtualviews/MindooDBVirtualViewDataProvider.d.ts.map +1 -1
  222. package/dist/core/indexing/virtualviews/MindooDBVirtualViewDataProvider.js +96 -41
  223. package/dist/core/indexing/virtualviews/MindooDBVirtualViewDataProvider.js.map +1 -1
  224. package/dist/core/indexing/virtualviews/ViewEntrySortKey.d.ts +2 -0
  225. package/dist/core/indexing/virtualviews/ViewEntrySortKey.d.ts.map +1 -1
  226. package/dist/core/indexing/virtualviews/ViewEntrySortKey.js +8 -3
  227. package/dist/core/indexing/virtualviews/ViewEntrySortKey.js.map +1 -1
  228. package/dist/core/indexing/virtualviews/VirtualView.d.ts +111 -5
  229. package/dist/core/indexing/virtualviews/VirtualView.d.ts.map +1 -1
  230. package/dist/core/indexing/virtualviews/VirtualView.js +266 -75
  231. package/dist/core/indexing/virtualviews/VirtualView.js.map +1 -1
  232. package/dist/core/indexing/virtualviews/VirtualViewColumn.d.ts +12 -0
  233. package/dist/core/indexing/virtualviews/VirtualViewColumn.d.ts.map +1 -1
  234. package/dist/core/indexing/virtualviews/VirtualViewColumn.js +1 -0
  235. package/dist/core/indexing/virtualviews/VirtualViewColumn.js.map +1 -1
  236. package/dist/core/indexing/virtualviews/VirtualViewEntryData.d.ts +12 -0
  237. package/dist/core/indexing/virtualviews/VirtualViewEntryData.d.ts.map +1 -1
  238. package/dist/core/indexing/virtualviews/VirtualViewEntryData.js +66 -5
  239. package/dist/core/indexing/virtualviews/VirtualViewEntryData.js.map +1 -1
  240. package/dist/core/indexing/virtualviews/VirtualViewFactory.d.ts +50 -6
  241. package/dist/core/indexing/virtualviews/VirtualViewFactory.d.ts.map +1 -1
  242. package/dist/core/indexing/virtualviews/VirtualViewFactory.js +54 -12
  243. package/dist/core/indexing/virtualviews/VirtualViewFactory.js.map +1 -1
  244. package/dist/core/indexing/virtualviews/index.d.ts +3 -3
  245. package/dist/core/indexing/virtualviews/index.d.ts.map +1 -1
  246. package/dist/core/indexing/virtualviews/index.js.map +1 -1
  247. package/dist/core/indexing/virtualviews/types.d.ts +6 -0
  248. package/dist/core/indexing/virtualviews/types.d.ts.map +1 -1
  249. package/dist/core/indexing/virtualviews/types.js +11 -3
  250. package/dist/core/indexing/virtualviews/types.js.map +1 -1
  251. package/dist/core/keys/KeyBag.d.ts +25 -0
  252. package/dist/core/keys/KeyBag.d.ts.map +1 -1
  253. package/dist/core/keys/KeyBag.js +37 -1
  254. package/dist/core/keys/KeyBag.js.map +1 -1
  255. package/dist/core/query/executeQuery.d.ts +18 -0
  256. package/dist/core/query/executeQuery.d.ts.map +1 -0
  257. package/dist/core/query/executeQuery.js +313 -0
  258. package/dist/core/query/executeQuery.js.map +1 -0
  259. package/dist/core/query/index.d.ts +5 -0
  260. package/dist/core/query/index.d.ts.map +1 -0
  261. package/dist/core/query/index.js +14 -0
  262. package/dist/core/query/index.js.map +1 -0
  263. package/dist/core/query/queryLive.d.ts +27 -0
  264. package/dist/core/query/queryLive.d.ts.map +1 -0
  265. package/dist/core/query/queryLive.js +104 -0
  266. package/dist/core/query/queryLive.js.map +1 -0
  267. package/dist/core/query/queryView.d.ts +156 -0
  268. package/dist/core/query/queryView.d.ts.map +1 -0
  269. package/dist/core/query/queryView.js +266 -0
  270. package/dist/core/query/queryView.js.map +1 -0
  271. package/dist/core/query/types.d.ts +121 -0
  272. package/dist/core/query/types.d.ts.map +1 -0
  273. package/dist/core/query/types.js +18 -0
  274. package/dist/core/query/types.js.map +1 -0
  275. package/dist/core/storeEntryTime.d.ts +57 -0
  276. package/dist/core/storeEntryTime.d.ts.map +1 -0
  277. package/dist/core/storeEntryTime.js +72 -0
  278. package/dist/core/storeEntryTime.js.map +1 -0
  279. package/dist/core/types.d.ts +1509 -39
  280. package/dist/core/types.d.ts.map +1 -1
  281. package/dist/core/types.js +13 -1
  282. package/dist/core/types.js.map +1 -1
  283. package/dist/core/uri/MindooURI.d.ts +53 -1
  284. package/dist/core/uri/MindooURI.d.ts.map +1 -1
  285. package/dist/core/uri/MindooURI.js +135 -0
  286. package/dist/core/uri/MindooURI.js.map +1 -1
  287. package/dist/core/utils/urlSafety.d.ts +46 -0
  288. package/dist/core/utils/urlSafety.d.ts.map +1 -0
  289. package/dist/core/utils/urlSafety.js +144 -0
  290. package/dist/core/utils/urlSafety.js.map +1 -0
  291. package/dist/node/appendonlystores/BasicOnDiskContentAddressedStore.d.ts +14 -0
  292. package/dist/node/appendonlystores/BasicOnDiskContentAddressedStore.d.ts.map +1 -1
  293. package/dist/node/appendonlystores/BasicOnDiskContentAddressedStore.js +95 -4
  294. package/dist/node/appendonlystores/BasicOnDiskContentAddressedStore.js.map +1 -1
  295. package/dist/node/server/CapabilityMatcher.d.ts +10 -0
  296. package/dist/node/server/CapabilityMatcher.d.ts.map +1 -1
  297. package/dist/node/server/CapabilityMatcher.js +14 -2
  298. package/dist/node/server/CapabilityMatcher.js.map +1 -1
  299. package/dist/node/server/MindooDBServer.d.ts.map +1 -1
  300. package/dist/node/server/MindooDBServer.js +117 -8
  301. package/dist/node/server/MindooDBServer.js.map +1 -1
  302. package/dist/node/server/PurgedDocRegistry.d.ts +32 -0
  303. package/dist/node/server/PurgedDocRegistry.d.ts.map +1 -0
  304. package/dist/node/server/PurgedDocRegistry.js +94 -0
  305. package/dist/node/server/PurgedDocRegistry.js.map +1 -0
  306. package/dist/node/server/ServerSync.d.ts.map +1 -1
  307. package/dist/node/server/ServerSync.js +5 -2
  308. package/dist/node/server/ServerSync.js.map +1 -1
  309. package/dist/node/server/SystemAdminAuth.d.ts +10 -0
  310. package/dist/node/server/SystemAdminAuth.d.ts.map +1 -1
  311. package/dist/node/server/SystemAdminAuth.js +25 -0
  312. package/dist/node/server/SystemAdminAuth.js.map +1 -1
  313. package/dist/node/server/TenantManager.d.ts +95 -1
  314. package/dist/node/server/TenantManager.d.ts.map +1 -1
  315. package/dist/node/server/TenantManager.js +382 -15
  316. package/dist/node/server/TenantManager.js.map +1 -1
  317. package/dist/node/server/config.d.ts.map +1 -1
  318. package/dist/node/server/config.js +5 -1
  319. package/dist/node/server/config.js.map +1 -1
  320. package/dist/node/server/types.d.ts +29 -3
  321. package/dist/node/server/types.d.ts.map +1 -1
  322. package/dist/node/server/types.js +12 -0
  323. package/dist/node/server/types.js.map +1 -1
  324. package/dist/node/server/validation.d.ts +6 -0
  325. package/dist/node/server/validation.d.ts.map +1 -1
  326. package/dist/node/server/validation.js +7 -1
  327. package/dist/node/server/validation.js.map +1 -1
  328. package/package.json +4 -3
  329. package/dist/appendonlystores/BasicOnDiskContentAddressedStoreFactory.d.ts +0 -8
  330. package/dist/appendonlystores/BasicOnDiskContentAddressedStoreFactory.d.ts.map +0 -1
  331. package/dist/appendonlystores/BasicOnDiskContentAddressedStoreFactory.js +0 -16
  332. package/dist/appendonlystores/BasicOnDiskContentAddressedStoreFactory.js.map +0 -1
  333. package/dist/core/appendonlystores/BasicOnDiskContentAddressedStore.d.ts +0 -68
  334. package/dist/core/appendonlystores/BasicOnDiskContentAddressedStore.d.ts.map +0 -1
  335. package/dist/core/appendonlystores/BasicOnDiskContentAddressedStore.js +0 -659
  336. package/dist/core/appendonlystores/BasicOnDiskContentAddressedStore.js.map +0 -1
  337. package/dist/node/attachments/writeAttachmentToFile.d.ts +0 -15
  338. package/dist/node/attachments/writeAttachmentToFile.d.ts.map +0 -1
  339. package/dist/node/attachments/writeAttachmentToFile.js +0 -73
  340. package/dist/node/attachments/writeAttachmentToFile.js.map +0 -1
@@ -3,7 +3,23 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.BaseMindooTenantDirectory = void 0;
4
4
  const types_1 = require("./types");
5
5
  const logging_1 = require("./logging");
6
+ const grantKeys_1 = require("./accesscontrol/grantKeys");
7
+ const types_2 = require("./accesscontrol/types");
8
+ const DirectoryTimeTravelIndex_1 = require("./accesscontrol/DirectoryTimeTravelIndex");
9
+ const directoryProjection_1 = require("./accesscontrol/directoryProjection");
10
+ const types_3 = require("./accesscontrol/types");
11
+ const evaluate_1 = require("./accesscontrol/evaluate");
12
+ const RSAEncryption_1 = require("./crypto/RSAEncryption");
13
+ const encryptedFields_1 = require("./crypto/encryptedFields");
6
14
  const DIRECTORY_SYNC_INTERVAL_MS = 5 * 60 * 1000;
15
+ // How long the resolved storage-format floor (`requireMetadataSignatureSince`)
16
+ // is reused before re-reading the directory head. Short, because it is consulted
17
+ // on the entry-verification hot path; the cutoff itself changes very rarely.
18
+ const METADATA_SIGNATURE_CUTOFF_TTL_MS = 30 * 1000;
19
+ /** De-duplicated union of two string lists, preserving first-seen order. */
20
+ function unionStrings(existing, added) {
21
+ return Array.from(new Set([...existing, ...added]));
22
+ }
7
23
  class BaseMindooTenantDirectory {
8
24
  constructor(tenant, logger) {
9
25
  this.directoryDB = null;
@@ -12,20 +28,63 @@ class BaseMindooTenantDirectory {
12
28
  // Note: No recursion guard needed because directory DB is admin-only,
13
29
  // so loading entries doesn't trigger validatePublicSigningKey recursively
14
30
  this.trustedKeysCache = new Map();
15
- // Mapping from grant document ID to public key (needed for revocation lookups)
16
- this.grantDocIdToPublicKey = new Map();
31
+ // Mapping from grant document ID to the signing public keys it currently
32
+ // grants. Revocation is expressed by removing keys from a grant document
33
+ // (docs/accesscontrol.md §6.5), so the set of trusted keys is recomputed as
34
+ // the union of all grant documents' current key arrays after each cache pass.
35
+ this.grantDocIdToSigningKeys = new Map();
17
36
  // Best-effort reverse lookup cache for public signing key -> user details.
18
37
  // Entries remain available even after revocation so historical UIs can still show identity labels.
19
38
  this.userLookupCache = new Map();
39
+ // Remote-wipe directive index (docs/accesscontrol.md §6.5): signing public key
40
+ // targeted for wipe -> id of the admin-signed grant document carrying the
41
+ // directive. Self-contained key values survive key removal, so this stays
42
+ // populated even after the user is revoked by key-array removal.
43
+ this.wipeKeyToGrantDocId = new Map();
20
44
  // Cache for settings documents
21
45
  this.tenantSettingsCache = null;
22
46
  this.dbSettingsCache = new Map();
23
47
  // Cache for groups: key -> merged group data (key: lowercase groupName)
24
48
  // We store merged data separately to avoid mutating MindooDoc objects.
25
49
  this.groupsCache = new Map();
50
+ // Cache for key-distribution documents (acl_keydistribution_<keyId>), keyed by
51
+ // keyId. Folded incrementally in updateUnifiedCache (singleton per keyId,
52
+ // last-seen-wins; a deleted doc removes its entry). Powers the revoked-key
53
+ // resolver (server pull/push blacklist) and the KeyBag reconcile without
54
+ // re-scanning directory docs each call (docs/accesscontrol.md §13).
55
+ this.keyDistributionCache = new Map();
56
+ // Cache for app-distribution documents (acl_appdistribution_<appId>), keyed by
57
+ // appId. Folded incrementally in updateUnifiedCache (singleton per appId,
58
+ // last-seen-wins; a deleted doc removes its entry). Holds only the
59
+ // server-readable recipient hash lists so the per-user reconcile can compute
60
+ // membership (have / notHave) without re-scanning directory docs; the
61
+ // encrypted payload (version / appData) is read from the doc when needed
62
+ // (docs/accesscontrol.md §13).
63
+ this.appDistributionCache = new Map();
64
+ // Incremental cache of sync-setup-policy recipient hash lists + enforcement
65
+ // mode, maintained by updateUnifiedCache (mirrors appDistributionCache). The
66
+ // encrypted database id list is read from the doc when the per-user reconcile
67
+ // runs (getSyncSetupForCurrentUser → listSyncSetupPolicies).
68
+ this.syncSetupPolicyCache = new Map();
69
+ // Time-travel access-control state (docs/accesscontrol.md §8). Built from the
70
+ // revision-grain changefeed (`iterateChangeRevisionsSince`) so every directory
71
+ // revision becomes its own chain node, stamped by trusted time. Persisted to
72
+ // disk as a compact delta log via the tenant CacheManager. Decoupled from the
73
+ // legacy doc-grain caches above, which remain the source of truth for the
74
+ // existing "now" public methods.
75
+ this.timeTravel = null;
76
+ // Last in-memory changefeed cursor the time-travel chain was built against,
77
+ // used to skip the (potentially expensive) feed rebuild when the directory
78
+ // has not changed since the previous build.
79
+ this.lastTimeTravelChangeSeq = null;
26
80
  // Unified cache cursor for all document types
27
81
  this.unifiedCacheLastCursor = null;
28
82
  this.lastDirectorySyncTimestamp = 0;
83
+ // Cached storage-format floor (`requireMetadataSignatureSince`) with re-entrancy
84
+ // guard; see getRequireMetadataSignatureSince.
85
+ this.metadataSignatureCutoffCache = undefined;
86
+ this.metadataSignatureCutoffCachedAt = 0;
87
+ this.resolvingMetadataSignatureCutoff = false;
29
88
  this.tenant = tenant;
30
89
  // Create logger if not provided (for backward compatibility)
31
90
  this.logger =
@@ -41,19 +100,31 @@ class BaseMindooTenantDirectory {
41
100
  if (!this.directoryDB.isAdminOnlyDb()) {
42
101
  throw new Error("Directory database must be opened with adminOnlyDb=true for security");
43
102
  }
103
+ // SDK-driven key-distribution reconcile on directory bring-up
104
+ // (docs/accesscontrol.md §13). Fire-and-forget and single-flight so it
105
+ // never blocks the first directory access. This is the cold-start /
106
+ // backup-restore safety net: KeyBag.load() is silent (no onChanges, no
107
+ // cursor bump), so a restored older bag that still holds revoked keys
108
+ // would otherwise go un-reconciled until the next network sync. Runs only
109
+ // once per directory object (the creation branch); the driver's own
110
+ // getDirectoryDB calls reuse the now-set instance and do not re-trigger.
111
+ void this.tenant.reconcileKeyDistributionsForCurrentUserSafe();
44
112
  }
45
113
  return this.directoryDB;
46
114
  }
47
- async registerUser(userId, administrationPrivateKey, administrationPrivateKeyPassword, userDetails) {
115
+ async registerUser(userId, administrationPrivateKey, administrationPrivateKeyPassword, userDetails, label) {
48
116
  this.logger.info(`Registering user: ${userId.username}`);
49
- // Check if user with same username (case-insensitive) already exists
117
+ // Check if user with same username (case-insensitive) already has ACTIVE
118
+ // access. A grant whose keys were removed (revocation, §6.5) is ignored
119
+ // here so the user can be re-registered after being revoked.
50
120
  const existingDocs = await this.findGrantAccessDocuments(userId.username);
51
- if (existingDocs.length > 0) {
52
- // Check if the keys match the existing registration
53
- const existingDoc = existingDocs[existingDocs.length - 1]; // Use most recent
121
+ const activeDocs = existingDocs.filter((doc) => (0, grantKeys_1.extractSigningPublicKeys)(doc.getData()).length > 0);
122
+ if (activeDocs.length > 0) {
123
+ // Check if the keys match the existing active registration
124
+ const existingDoc = activeDocs[activeDocs.length - 1]; // Use most recent
54
125
  const existingData = existingDoc.getData();
55
- const keysMatch = existingData.userSigningPublicKey === userId.userSigningPublicKey &&
56
- existingData.userEncryptionPublicKey === userId.userEncryptionPublicKey;
126
+ const keysMatch = (0, grantKeys_1.extractSigningPublicKeys)(existingData).includes(userId.userSigningPublicKey) &&
127
+ (0, grantKeys_1.extractEncryptionPublicKeys)(existingData).includes(userId.userEncryptionPublicKey);
57
128
  if (keysMatch) {
58
129
  // Same user with same keys - skip re-registration
59
130
  this.logger.debug(`User ${userId.username} already registered with same keys, skipping`);
@@ -72,9 +143,22 @@ class BaseMindooTenantDirectory {
72
143
  publicKey: baseTenant.getAdministrationPublicKey(),
73
144
  privateKey: administrationPrivateKey,
74
145
  };
75
- // Compute username hash and encrypted user details before creating the document.
76
- const usernameHash = await this.hashUsername(userId.username);
146
+ // Compute username hash (salted v2 for new documents) and encrypted user
147
+ // details before creating the document.
148
+ const usernameHash = await this.hashUsernameForWrite(userId.username);
77
149
  const userDetailsEncrypted = await this.encryptUserDetailsForTenant(this.buildUserDetailsPayload(userId.username, userDetails));
150
+ // Precompute the identity-hash bundle from the cleartext name (the only
151
+ // place it exists) so the server can match wildcard/group read rules in
152
+ // hash space without the cleartext (docs/accesscontrol.md §6.5). The bundle
153
+ // is the v1+v2 hashes of every DN-hierarchy variant: e.g. for
154
+ // "CN=Karsten Lehmann/OU=CEO/OU=Management/O=Mindoo" the variants are the
155
+ // exact name plus the wildcards
156
+ // "*/OU=CEO/OU=Management/O=Mindoo",
157
+ // "*/OU=Management/O=Mindoo",
158
+ // "*/O=Mindoo", and
159
+ // "*"
160
+ // so a read rule like "*/O=Mindoo" matches this user purely in hash space.
161
+ const identityHashes = await this.computeIdentityHashes(userId.username);
78
162
  // Add user to directory database
79
163
  this.logger.debug(`Creating document for user registration`);
80
164
  const directoryDB = await this.getDirectoryDB();
@@ -89,9 +173,28 @@ class BaseMindooTenantDirectory {
89
173
  data.form = "useroperation";
90
174
  data.type = "grantaccess";
91
175
  data.username_hash = usernameHash;
176
+ data.username_hash_v = BaseMindooTenantDirectory.USERNAME_HASH_VERSION;
92
177
  data.user_details_encrypted = userDetailsEncrypted;
93
- data.userSigningPublicKey = userId.userSigningPublicKey;
94
- data.userEncryptionPublicKey = userId.userEncryptionPublicKey;
178
+ // Follow the `_encrypted` / `_encrypted_key` convention so view formulas
179
+ // (v.decryptJson) can resolve the key id without hardcoding "default".
180
+ data.user_details_encrypted_key = types_1.DEFAULT_TENANT_KEY_ID;
181
+ // $publicinfos-readable identity-hash bundle (§6.5): the grant doc is
182
+ // encrypted under PUBLIC_INFOS_KEY_ID, so these plain fields are
183
+ // server-readable without the default tenant key.
184
+ data.identity_hashes = identityHashes;
185
+ data.identity_hashes_v = BaseMindooTenantDirectory.IDENTITY_VARIANTS_VERSION;
186
+ // New grants write the canonical `userKeyPairs` form (§6.5), pairing
187
+ // the device's signing+encryption keys with an optional label, while
188
+ // applyKeyPairFields mirrors the legacy array/scalar fields so older
189
+ // clients keep working. The admin can later add more devices or relabel.
190
+ const trimmedLabel = typeof label === "string" ? label.trim() : "";
191
+ const initialPair = {
192
+ signingPublicKey: userId.userSigningPublicKey,
193
+ encryptionPublicKey: userId.userEncryptionPublicKey,
194
+ };
195
+ if (trimmedLabel.length > 0)
196
+ initialPair.label = trimmedLabel;
197
+ (0, grantKeys_1.applyKeyPairFields)(data, [initialPair]);
95
198
  }, {
96
199
  signingKeyPair: adminSigningKeyPair,
97
200
  signingKeyPassword: administrationPrivateKeyPassword,
@@ -110,88 +213,110 @@ class BaseMindooTenantDirectory {
110
213
  await directoryDB.syncStoreChanges();
111
214
  // Update unified cache to ensure we have latest data
112
215
  await this.updateUnifiedCache();
113
- // Compute the hash to search for
114
- const targetHash = await this.hashUsername(username);
216
+ // Compute the candidate hashes to search for. We match against BOTH the
217
+ // legacy (v1, unsalted) and salted (v2) forms so that documents written
218
+ // under either scheme are found (docs/accesscontrol.md §6.5).
219
+ const targetHashes = new Set(await this.usernameHashCandidates(username));
220
+ // Callers may pass an already-hashed `username_hash` rather than a cleartext
221
+ // name — e.g. when validateToken resolves the principal from a device key via
222
+ // getUserBySigningPublicKey, whose `username` is the hash when the cleartext
223
+ // is not recoverable on this server. Match it directly too so a key-resolved
224
+ // identity round-trips: a cleartext name never collides with a 64-char hex
225
+ // hash, so this is safe (docs/accesscontrol.md §6.5).
226
+ targetHashes.add(username);
227
+ const matchesTarget = (value) => typeof value === "string" && targetHashes.has(value);
115
228
  const matchingDocs = [];
116
- const revokedDocIds = new Set();
117
- // Use the generator-based iteration API for cleaner code
118
- // No signature verification needed - DB already enforces admin-only
229
+ // Use the generator-based iteration API for cleaner code.
230
+ // No signature verification needed - DB already enforces admin-only.
231
+ // Revocation no longer uses a separate document: it removes keys from the
232
+ // grant document in place (docs/accesscontrol.md §6.5). A fully-revoked user
233
+ // therefore still has a grant document here, but with empty key arrays.
234
+ // Callers that need "active access" should inspect the key arrays (see
235
+ // {@link isUserRevoked} / {@link getUserPublicKeys}).
119
236
  for await (const { doc } of directoryDB.iterateChangesSince(null)) {
120
237
  const data = doc.getData();
121
- // Check if this is a grant access document we're looking for (by username_hash)
122
238
  if (data.form === "useroperation" &&
123
239
  data.type === "grantaccess" &&
124
- data.username_hash === targetHash) {
125
- this.logger.debug(`Found grant access document for username hash: ${targetHash}`);
240
+ matchesTarget(data.username_hash)) {
241
+ this.logger.debug(`Found grant access document for username: ${username}`);
126
242
  matchingDocs.push(doc);
127
243
  }
128
- // Check if this is a revoke access document for the same username (by username_hash)
129
- if (data.form === "useroperation" &&
130
- data.type === "revokeaccess" &&
131
- data.username_hash === targetHash &&
132
- data.revokeDocId &&
133
- typeof data.revokeDocId === "string") {
134
- this.logger.debug(`Found revoke access document for username hash: ${targetHash}, revoking doc ID: ${data.revokeDocId}`);
135
- revokedDocIds.add(data.revokeDocId);
136
- }
137
- }
138
- // Filter out revoked documents
139
- const activeDocs = matchingDocs.filter(doc => !revokedDocIds.has(doc.getId()));
140
- if (activeDocs.length === 0) {
141
- this.logger.debug(`No active grant access documents found for username: ${username} (found ${matchingDocs.length} total, ${revokedDocIds.size} revoked)`);
142
- }
143
- else {
144
- this.logger.debug(`Found ${activeDocs.length} active grant access document(s) for username: ${username} (${matchingDocs.length} total, ${revokedDocIds.size} revoked)`);
145
244
  }
146
- return activeDocs;
245
+ this.logger.debug(`Found ${matchingDocs.length} grant access document(s) for username: ${username}`);
246
+ return matchingDocs;
147
247
  }
148
- async revokeUser(username, requestDataWipe, administrationPrivateKey, administrationPrivateKeyPassword) {
248
+ /**
249
+ * Revoke a user's access by removing their keys from the grant document
250
+ * (docs/accesscontrol.md §6.5). This replaces the legacy `revokeaccess`
251
+ * document model entirely: revocation is now an in-place edit of the grant
252
+ * document's `userSigningPublicKeys` / `userEncryptionPublicKeys` arrays.
253
+ *
254
+ * By default (no `options.signingKeys`) the user is fully revoked: every
255
+ * signing AND encryption key is removed, so the grant document remains but
256
+ * carries no keys. To revoke a single device/key, pass the specific
257
+ * `signingKeys` (and optionally matching `encryptionKeys`) to remove.
258
+ *
259
+ * When `options.requestDataWipe` is true, the removed signing keys are also
260
+ * flagged for a remote device wipe (§6.5) before removal, so the targeted
261
+ * devices delete their local tenant on next sync. The wipe directive is
262
+ * stored self-contained on the grant document and survives key removal.
263
+ *
264
+ * BREAKING CHANGE: the previous signature took a positional `requestDataWipe`
265
+ * boolean; it now takes an options object so specific keys can be targeted.
266
+ *
267
+ * @param username The user to revoke (format: "CN=<username>/O=<tenantId>").
268
+ * @param options.signingKeys Specific signing keys to remove; omit/empty to
269
+ * remove all of the user's keys (full revocation).
270
+ * @param options.encryptionKeys Specific encryption keys to remove. Ignored
271
+ * for a full revocation (all encryption keys are removed in that case).
272
+ * @param options.requestDataWipe Also request a remote wipe of the removed
273
+ * signing keys' devices.
274
+ */
275
+ async revokeUser(username, options, administrationPrivateKey, administrationPrivateKeyPassword) {
149
276
  this.logger.info(`Revoking user: ${username}`);
150
- // Find all grant access documents for this user
151
277
  const grantAccessDocs = await this.findGrantAccessDocuments(username);
152
- // If no grant access documents found, exit early
153
278
  if (grantAccessDocs.length === 0) {
154
279
  this.logger.debug(`No grant access documents found for ${username}, exiting revocation`);
155
280
  return;
156
281
  }
157
- // Cast to BaseMindooTenant to access protected methods
158
- const baseTenant = this.tenant;
159
- const directoryDB = await this.getDirectoryDB();
160
- // Create SigningKeyPair for the administration key
161
- const adminSigningKeyPair = {
162
- publicKey: baseTenant.getAdministrationPublicKey(),
163
- privateKey: administrationPrivateKey,
164
- };
165
- // Compute username hash and encrypted user details before creating documents
166
- const usernameHash = await this.hashUsername(username);
167
- const userDetailsEncrypted = await this.encryptUserDetailsForTenant(this.buildUserDetailsPayload(username));
168
- // Create revocation documents for each grant access document found
169
- for (const grantAccessDoc of grantAccessDocs) {
170
- const revokeDocId = grantAccessDoc.getId();
171
- // Create document with admin signing key so the initial entry is trusted
172
- // Use PUBLIC_INFOS_KEY_ID so servers can validate users without full tenant access
173
- const newDoc = await directoryDB.createDocumentWithSigningKey(adminSigningKeyPair, administrationPrivateKeyPassword, types_1.PUBLIC_INFOS_KEY_ID);
174
- // Set the document data fields with the admin key at entry level
175
- await directoryDB.changeDoc(newDoc, async (doc) => {
176
- const data = doc.getData();
177
- data.form = "useroperation";
178
- data.type = "revokeaccess";
179
- data.username_hash = usernameHash;
180
- data.user_details_encrypted = userDetailsEncrypted;
181
- data.revokeDocId = revokeDocId;
182
- data.requestDataWipe = requestDataWipe;
183
- }, {
184
- signingKeyPair: adminSigningKeyPair,
185
- signingKeyPassword: administrationPrivateKeyPassword,
186
- });
187
- this.logger.debug(`Created revocation document for grant access doc: ${revokeDocId}`);
282
+ const fullRevoke = !options.signingKeys || options.signingKeys.length === 0;
283
+ // Determine which signing/encryption keys to strip from the grant. A full
284
+ // revocation removes every key currently present across the user's grant
285
+ // documents; a targeted revocation removes only the supplied keys.
286
+ let signingToRemove;
287
+ let encryptionToRemove;
288
+ if (fullRevoke) {
289
+ const allSigning = new Set();
290
+ const allEncryption = new Set();
291
+ for (const grant of grantAccessDocs) {
292
+ const data = grant.getData();
293
+ for (const key of (0, grantKeys_1.extractSigningPublicKeys)(data))
294
+ allSigning.add(key);
295
+ for (const key of (0, grantKeys_1.extractEncryptionPublicKeys)(data))
296
+ allEncryption.add(key);
297
+ }
298
+ signingToRemove = Array.from(allSigning);
299
+ encryptionToRemove = Array.from(allEncryption);
188
300
  }
189
- // Update the cache so that subsequent validatePublicSigningKey calls
190
- // see the revocation without waiting for the next sync interval
301
+ else {
302
+ signingToRemove = options.signingKeys ?? [];
303
+ encryptionToRemove = options.encryptionKeys ?? [];
304
+ }
305
+ // Flag the removed devices for remote wipe BEFORE removing the keys, so the
306
+ // self-contained wipe directive is recorded on the grant document (§6.5).
307
+ if (options.requestDataWipe && signingToRemove.length > 0) {
308
+ await this.requestDeviceWipe(username, signingToRemove, administrationPrivateKey, administrationPrivateKeyPassword);
309
+ }
310
+ await this.removeUserKeys(username, signingToRemove, encryptionToRemove, administrationPrivateKey, administrationPrivateKeyPassword);
311
+ // removeUserKeys already refreshes the unified cache; refresh once more to
312
+ // be explicit so subsequent validatePublicSigningKey calls observe the
313
+ // revocation without waiting for the next sync interval.
191
314
  await this.updateUnifiedCache();
192
- this.logger.info(`Revoked user: ${username} (created ${grantAccessDocs.length} revocation document(s))`);
315
+ this.logger.info(fullRevoke
316
+ ? `Revoked user: ${username} (removed all keys from ${grantAccessDocs.length} grant document(s))`
317
+ : `Revoked ${signingToRemove.length} signing key(s) for user: ${username}`);
193
318
  }
194
- async validatePublicSigningKey(publicKey) {
319
+ async validatePublicSigningKey(publicKey, opts) {
195
320
  this.logger.debug(`Validating public signing key`);
196
321
  // Cast to BaseMindooTenant to access protected methods
197
322
  const baseTenant = this.tenant;
@@ -212,168 +337,2132 @@ class BaseMindooTenantDirectory {
212
337
  return additionalResult;
213
338
  }
214
339
  }
215
- // Refresh directory state at most once per interval.
216
- const now = Date.now();
217
- let didSync = false;
218
- if (now - this.lastDirectorySyncTimestamp >= DIRECTORY_SYNC_INTERVAL_MS) {
219
- const directoryDB = await this.getDirectoryDB();
220
- await directoryDB.syncStoreChanges();
221
- await this.updateUnifiedCache();
222
- this.lastDirectorySyncTimestamp = now;
223
- didSync = true;
340
+ // Refresh directory state at most once per interval — unless the caller
341
+ // forces a refresh (audit #4): the server push path must observe a freshly
342
+ // pushed revocation immediately rather than lagging by up to
343
+ // DIRECTORY_SYNC_INTERVAL_MS, which would let a just-revoked key keep
344
+ // pushing entries.
345
+ const now = Date.now();
346
+ let didSync = false;
347
+ if (opts?.forceRefresh ||
348
+ now - this.lastDirectorySyncTimestamp >= DIRECTORY_SYNC_INTERVAL_MS) {
349
+ const directoryDB = await this.getDirectoryDB();
350
+ await directoryDB.syncStoreChanges();
351
+ await this.updateUnifiedCache();
352
+ this.lastDirectorySyncTimestamp = now;
353
+ didSync = true;
354
+ }
355
+ let cachedResult = this.trustedKeysCache.get(publicKey);
356
+ if (cachedResult === undefined && !didSync) {
357
+ // Cache miss and not synced yet - sync once and re-check immediately.
358
+ const directoryDB = await this.getDirectoryDB();
359
+ await directoryDB.syncStoreChanges();
360
+ await this.updateUnifiedCache();
361
+ this.lastDirectorySyncTimestamp = Date.now();
362
+ cachedResult = this.trustedKeysCache.get(publicKey);
363
+ }
364
+ if (cachedResult !== undefined) {
365
+ this.logger.debug(`Public key validation result (from cache): ${cachedResult}`);
366
+ return cachedResult;
367
+ }
368
+ // Key not found in cache means it was never granted access
369
+ this.logger.debug(`Public key not found in cache, returning false`);
370
+ return false;
371
+ }
372
+ /**
373
+ * Update all caches (trusted keys, groups, settings) by processing new changes since the last cursor.
374
+ * This unified method processes all document types in a single loop for efficiency.
375
+ * No signature verification is needed since the DB already enforces admin-only access.
376
+ */
377
+ async updateUnifiedCache() {
378
+ const directoryDB = await this.getDirectoryDB();
379
+ // Determine starting cursor (null = process all, otherwise incremental)
380
+ const startCursor = this.unifiedCacheLastCursor;
381
+ // If processing from the beginning, clear all caches first
382
+ if (startCursor === null) {
383
+ this.trustedKeysCache.clear();
384
+ this.grantDocIdToSigningKeys.clear();
385
+ this.userLookupCache.clear();
386
+ this.wipeKeyToGrantDocId.clear();
387
+ this.tenantSettingsCache = null;
388
+ this.dbSettingsCache.clear();
389
+ this.groupsCache.clear();
390
+ this.keyDistributionCache.clear();
391
+ }
392
+ // Track group documents by name for merging (handles offline sync scenarios)
393
+ const groupDocsByName = new Map();
394
+ // Process changes (documents are returned in order by lastModified, oldest to newest)
395
+ for await (const { doc, cursor } of directoryDB.iterateChangesSince(startCursor)) {
396
+ const data = doc.getData();
397
+ // Process user operation documents (grant/revoke access)
398
+ if (data.form === "useroperation") {
399
+ // Check if this is a grant access document. A grant may carry MULTIPLE
400
+ // signing keys (key rollover / multiple devices, §6.5); honor all of
401
+ // them, with a legacy scalar fallback handled by extractSigningPublicKeys.
402
+ const grantedSigningKeys = data.type === "grantaccess" ? (0, grantKeys_1.extractSigningPublicKeys)(data) : [];
403
+ // Index any remote-wipe directive on this grant (§6.5) regardless of how
404
+ // many signing keys remain: the wipe values are self-contained and must
405
+ // survive full key-array revocation so a revoked device can still be told
406
+ // to wipe. Maps each wipe-targeted signing key to this grant doc's id.
407
+ if (data.type === "grantaccess") {
408
+ for (const wipeKey of (0, grantKeys_1.extractWipeRequestedSigningKeys)(data)) {
409
+ this.wipeKeyToGrantDocId.set(wipeKey, doc.getId());
410
+ }
411
+ }
412
+ if (data.type === "grantaccess") {
413
+ // Record this grant document's CURRENT signing keys. Because grant
414
+ // documents are Automerge-merged in place, removing keys (the only
415
+ // revocation mechanism, §6.5) is reflected by a smaller array here.
416
+ // The authoritative trustedKeysCache is rebuilt from the union of
417
+ // all grant documents once the change loop completes, so a key
418
+ // dropped from this document is no longer trusted unless another
419
+ // grant still lists it.
420
+ this.grantDocIdToSigningKeys.set(doc.getId(), grantedSigningKeys);
421
+ if (grantedSigningKeys.length > 0) {
422
+ const userLookup = await this.buildUserLookup(data);
423
+ if (userLookup) {
424
+ // Keep identity labels available even after the keys are later
425
+ // revoked, so historical UIs can still resolve old authors.
426
+ for (const userPublicKey of grantedSigningKeys) {
427
+ this.userLookupCache.set(userPublicKey, userLookup);
428
+ }
429
+ }
430
+ }
431
+ }
432
+ }
433
+ // Process group documents
434
+ if (data.form === "group" &&
435
+ data.type === "group" &&
436
+ data.groupName &&
437
+ typeof data.groupName === "string") {
438
+ const normalizedGroupName = this.normalizeGroupName(data.groupName);
439
+ if (!groupDocsByName.has(normalizedGroupName)) {
440
+ groupDocsByName.set(normalizedGroupName, []);
441
+ }
442
+ groupDocsByName.get(normalizedGroupName).push(doc);
443
+ }
444
+ // Process tenant settings (no signature verification needed - Automerge handles merging)
445
+ if (data.form === "tenantsettings") {
446
+ // Just overwrite - last one seen will be the latest
447
+ this.tenantSettingsCache = doc;
448
+ }
449
+ // Process DB settings (no signature verification needed - Automerge handles merging)
450
+ if (data.form === "dbsettings" && data.dbid && typeof data.dbid === "string") {
451
+ const dbId = data.dbid;
452
+ // Just overwrite - last one seen will be the latest
453
+ this.dbSettingsCache.set(dbId, doc);
454
+ }
455
+ // Process key-distribution documents (acl_keydistribution_<keyId>); a
456
+ // singleton per keyId. Cache head state here so the revoked-key resolver
457
+ // and the KeyBag reconcile read it without re-scanning directory docs
458
+ // (docs/accesscontrol.md §13). A deleted distribution drops its entry.
459
+ if (data.type === types_3.KEY_DISTRIBUTION_TYPE && typeof data.keyId === "string") {
460
+ const keyId = data.keyId;
461
+ if (doc.isDeleted()) {
462
+ this.keyDistributionCache.delete(keyId);
463
+ }
464
+ else {
465
+ this.keyDistributionCache.set(keyId, {
466
+ keyVersions: Array.isArray(data.keyVersions)
467
+ ? data.keyVersions
468
+ .filter((v) => !!v && typeof v.fingerprint === "string")
469
+ .map((v) => ({ createdAt: Number(v.createdAt) || 0, fingerprint: v.fingerprint }))
470
+ : [],
471
+ pullfrom_users_hashes: Array.isArray(data.pullfrom_users_hashes)
472
+ ? data.pullfrom_users_hashes.filter((h) => typeof h === "string")
473
+ : [],
474
+ pushto_users_hashes: Array.isArray(data.pushto_users_hashes)
475
+ ? data.pushto_users_hashes.filter((h) => typeof h === "string")
476
+ : [],
477
+ pushto_users_keys: data.pushto_users_keys && typeof data.pushto_users_keys === "object"
478
+ ? data.pushto_users_keys
479
+ : {},
480
+ });
481
+ }
482
+ }
483
+ // Process app-distribution documents (acl_appdistribution_<appId>); a
484
+ // singleton per appId. Cache the recipient hash lists so the per-user
485
+ // reconcile reads membership without re-scanning directory docs
486
+ // (docs/accesscontrol.md §13). A deleted distribution drops its entry.
487
+ if (data.type === types_3.APP_DISTRIBUTION_TYPE && typeof data.appId === "string") {
488
+ const appId = data.appId;
489
+ if (doc.isDeleted()) {
490
+ this.appDistributionCache.delete(appId);
491
+ }
492
+ else {
493
+ const stringArray = (value) => Array.isArray(value) ? value.filter((h) => typeof h === "string") : [];
494
+ this.appDistributionCache.set(appId, {
495
+ pushto_users_hashes: stringArray(data.pushto_users_hashes),
496
+ pushto_groups_hashes: stringArray(data.pushto_groups_hashes),
497
+ pullfrom_users_hashes: stringArray(data.pullfrom_users_hashes),
498
+ pullfrom_groups_hashes: stringArray(data.pullfrom_groups_hashes),
499
+ });
500
+ }
501
+ }
502
+ // Process sync-setup-policy documents (acl_syncsetuppolicy_<policyId>); one
503
+ // doc per policy. Cache the recipient hash lists + enforcement mode so the
504
+ // per-user reconcile reads membership without re-scanning directory docs.
505
+ // A deleted policy drops its entry.
506
+ if (data.type === types_3.SYNC_SETUP_POLICY_TYPE && typeof data.policyId === "string") {
507
+ const policyId = data.policyId;
508
+ if (doc.isDeleted()) {
509
+ this.syncSetupPolicyCache.delete(policyId);
510
+ }
511
+ else {
512
+ const stringArray = (value) => Array.isArray(value) ? value.filter((h) => typeof h === "string") : [];
513
+ this.syncSetupPolicyCache.set(policyId, {
514
+ mode: data.mode === "permanent" ? "permanent" : "initial",
515
+ pushto_users_hashes: stringArray(data.pushto_users_hashes),
516
+ pushto_groups_hashes: stringArray(data.pushto_groups_hashes),
517
+ pullfrom_users_hashes: stringArray(data.pullfrom_users_hashes),
518
+ pullfrom_groups_hashes: stringArray(data.pullfrom_groups_hashes),
519
+ });
520
+ }
521
+ }
522
+ // Update cursor after each document
523
+ this.unifiedCacheLastCursor = cursor;
524
+ }
525
+ // Rebuild the trusted signing-key set as the union of every grant
526
+ // document's current keys (docs/accesscontrol.md §6.5). Revocation removes
527
+ // keys from a grant, so a key absent from this union is no longer trusted;
528
+ // validatePublicSigningKey treats "not present" as untrusted. This is
529
+ // recomputed on every pass (full or incremental) because the per-grant map
530
+ // is the source of truth and persists across incremental updates.
531
+ this.trustedKeysCache.clear();
532
+ for (const signingKeys of this.grantDocIdToSigningKeys.values()) {
533
+ for (const key of signingKeys) {
534
+ this.trustedKeysCache.set(key, true);
535
+ }
536
+ }
537
+ // Merge group documents with the same name
538
+ for (const [normalizedGroupName, docs] of groupDocsByName.entries()) {
539
+ // Collect all member hashes and encrypted values from all documents with this group name
540
+ const allMembersHashes = new Set();
541
+ const allMembersEncrypted = new Set();
542
+ let latestDoc = null;
543
+ let isDeleted = false;
544
+ for (const doc of docs) {
545
+ const data = doc.getData();
546
+ // Track the latest document (by iteration order, which is by lastModified)
547
+ latestDoc = doc;
548
+ // Check if document is deleted via lifecycle metadata.
549
+ if (doc.isDeleted()) {
550
+ isDeleted = true;
551
+ }
552
+ // Use members_hashes for membership lookups.
553
+ if (data.members_hashes && Array.isArray(data.members_hashes)) {
554
+ for (const memberHash of data.members_hashes) {
555
+ if (typeof memberHash === "string") {
556
+ allMembersHashes.add(memberHash);
557
+ }
558
+ }
559
+ }
560
+ if (data.members_encrypted && Array.isArray(data.members_encrypted)) {
561
+ for (const encryptedMember of data.members_encrypted) {
562
+ if (typeof encryptedMember === "string") {
563
+ allMembersEncrypted.add(encryptedMember);
564
+ }
565
+ }
566
+ }
567
+ }
568
+ // If latest version is deleted, remove from cache
569
+ if (isDeleted) {
570
+ this.groupsCache.delete(normalizedGroupName);
571
+ continue;
572
+ }
573
+ // Store merged group data in cache (without mutating the MindooDoc)
574
+ if (latestDoc) {
575
+ this.groupsCache.set(normalizedGroupName, {
576
+ docId: latestDoc.getId(),
577
+ members_hashes: Array.from(allMembersHashes),
578
+ members_encrypted: Array.from(allMembersEncrypted),
579
+ });
580
+ }
581
+ }
582
+ }
583
+ /**
584
+ * The head time-travel directory-state node ("now"), after ensuring the chain
585
+ * is current. Powers access-control evaluation (§7) and audit queries.
586
+ */
587
+ async getDirectoryStateHead() {
588
+ const index = await this.ensureTimeTravelCurrent();
589
+ return index.getHead();
590
+ }
591
+ /**
592
+ * The time-travel directory-state node covering trusted time `T` (§8) — used
593
+ * by the evaluation algorithm and `wasAllowedAt` to judge an entry against the
594
+ * directory as it was at the entry's trusted time.
595
+ */
596
+ async getDirectoryStateAt(T) {
597
+ const index = await this.ensureTimeTravelCurrent();
598
+ return index.getStateAt(T);
599
+ }
600
+ /**
601
+ * Lazily create the time-travel index (restoring its persisted delta log and
602
+ * registering it with the tenant CacheManager on first use), then bring the
603
+ * directory-state chain up to date from the revision-grain changefeed
604
+ * (docs/accesscontrol.md §8).
605
+ *
606
+ * The (potentially expensive) feed rebuild is skipped when the directory's
607
+ * changefeed cursor has not advanced since the previous build and no
608
+ * un-witnessed revisions are pending a possible trusted-time re-stamp.
609
+ */
610
+ async ensureTimeTravelCurrent() {
611
+ const directoryDB = await this.getDirectoryDB();
612
+ // Projection used to (re)build the chain from stored revisions in
613
+ // trusted-time order. Captures this directory's group-name normalization.
614
+ const project = (builder, revision) => (0, directoryProjection_1.projectDirectoryRevision)(builder, {
615
+ docId: revision.docId,
616
+ data: revision.data,
617
+ deleted: revision.deleted,
618
+ trustedTime: revision.trustedTime,
619
+ normalizeGroupName: (name) => this.normalizeGroupName(name),
620
+ });
621
+ if (!this.timeTravel) {
622
+ const index = new DirectoryTimeTravelIndex_1.DirectoryTimeTravelIndex(`${this.tenant.getId()}/directory`);
623
+ const cacheManager = this.tenant.getCacheManager();
624
+ if (cacheManager) {
625
+ // Warm start: restore the persisted revisions (no decryption / Automerge)
626
+ // and replay them into the chain, then extend via the changefeed below.
627
+ await index.restoreFromCache(cacheManager.getStore());
628
+ index.rebuild(project);
629
+ cacheManager.register(index);
630
+ }
631
+ // Seed the in-memory gate cursor from the restored chain so a cold start
632
+ // can short-circuit when the directory has not advanced since the chain
633
+ // was flushed (otherwise the first call after every restart redundantly
634
+ // rebuilds the feed). `null` keeps the legacy behavior (force a build).
635
+ this.lastTimeTravelChangeSeq = index.lastChangeSeq;
636
+ this.timeTravel = index;
637
+ }
638
+ const index = this.timeTravel;
639
+ // Gate the (potentially expensive) feed rebuild on the in-memory changefeed
640
+ // cursor. We deliberately do NOT call syncStoreChanges() here: like the
641
+ // legacy updateUnifiedCache read path, this method must not trigger network
642
+ // pulls or re-entrant directory syncing when invoked from server-side entry
643
+ // validation. The revision feed reads the directory store directly, so it
644
+ // already observes every persisted entry; the cursor is only used to decide
645
+ // when a rebuild is worthwhile.
646
+ const latest = directoryDB.getLatestChangeCursor?.() ?? null;
647
+ const latestSeq = latest?.changeSeq ?? null;
648
+ // Fast path: the directory has not advanced since the last build. The
649
+ // un-witnessed head overlay is refreshed when `changeSeq` next advances
650
+ // (notably when a re-stamped entry is re-discovered with a `receivedAt`), so
651
+ // we do not need to re-run the feed on every call just to refresh the
652
+ // provisional `now` of un-witnessed entries.
653
+ if (this.lastTimeTravelChangeSeq !== null && latestSeq === this.lastTimeTravelChangeSeq) {
654
+ return index;
655
+ }
656
+ // Incrementally advance the feed from the persisted cursor; the feed parks
657
+ // the cursor before the earliest un-witnessed entry so those revisions are
658
+ // re-discovered (and re-stamped/superseded as needed) on this resume.
659
+ let changed = false;
660
+ for await (const rev of directoryDB.iterateChangeRevisionsSince(index.cursor)) {
661
+ const revChanged = index.upsertRevision({
662
+ entryId: rev.entryId,
663
+ docId: rev.docId,
664
+ data: rev.doc.getData(),
665
+ deleted: rev.doc.isDeleted(),
666
+ trustedTime: rev.trustedTime,
667
+ witnessed: rev.witnessed,
668
+ }, rev.cursor);
669
+ changed = changed || revChanged;
670
+ }
671
+ // Replay all revisions into the chain in trusted-time order. This absorbs
672
+ // out-of-order arrivals and supersessions (a re-emitted revision replaced its
673
+ // prior record above) without any special-casing in the chain builder.
674
+ if (changed) {
675
+ index.rebuild(project);
676
+ }
677
+ this.lastTimeTravelChangeSeq = latestSeq;
678
+ // Persist the observed changeSeq with the chain so the gate survives restart.
679
+ index.recordChangeSeq(latestSeq);
680
+ if (index.hasDirtyState()) {
681
+ this.tenant.getCacheManager()?.markDirty();
682
+ }
683
+ return index;
684
+ }
685
+ /**
686
+ * The set of witness public keys trusted at trusted time `T`
687
+ * (docs/accesscontrol.md §6.4), used by the client to validate witness
688
+ * receipts on incoming entries during materialization (§5.4).
689
+ */
690
+ async getTrustedWitnessKeysAt(T) {
691
+ const node = await this.getDirectoryStateAt(T);
692
+ return new Set(node.trustedWitnessKeys.keys());
693
+ }
694
+ /**
695
+ * Resolve the acting user's identity set for access-control evaluation
696
+ * (docs/accesscontrol.md §7 step 3): username hash (v1 + v2), every group hash
697
+ * (including nested groups), the user's resolved usernames/groups for
698
+ * placeholder expansion, and the applicable pseudo-tokens (`$everyone` always;
699
+ * `$admin`/`$author` when the caller indicates they apply).
700
+ *
701
+ * @param username The acting user's canonical username.
702
+ * @param opts.isAdmin Whether the acting signing key is the tenant admin key.
703
+ * @param opts.isAuthor Whether the acting user authored the target document
704
+ * (the caller resolves this by mapping the document's creator key and the
705
+ * change signer key to the same grant; see §6.3 `$author`).
706
+ */
707
+ async buildIdentitySet(username, opts = {}) {
708
+ await this.updateUnifiedCache();
709
+ const usernames = await this.getUserNamesList(username);
710
+ const groups = await this.resolveGroupsForUser(username);
711
+ const hashes = new Set();
712
+ for (const h of await this.usernameHashCandidates(username)) {
713
+ hashes.add(h);
714
+ }
715
+ // Group membership: rules target group-name hashes, so the identity set
716
+ // must include the hash of every group the user belongs to.
717
+ for (const group of groups) {
718
+ for (const h of await this.usernameHashCandidates(group)) {
719
+ hashes.add(h);
720
+ }
721
+ }
722
+ hashes.add(types_3.PSEUDO_TOKEN_EVERYONE);
723
+ if (opts.isAdmin)
724
+ hashes.add(types_3.PSEUDO_TOKEN_ADMIN);
725
+ if (opts.isAuthor)
726
+ hashes.add(types_3.PSEUDO_TOKEN_AUTHOR);
727
+ return { username, usernames, groups, hashes };
728
+ }
729
+ /**
730
+ * Server-side Tier 1 evaluation for a pushed entry (docs/accesscontrol.md §7,
731
+ * §10). The server identifies the author by signing key (it cannot read
732
+ * encrypted content), resolves their identity set, and evaluates the identity
733
+ * tier of the policy at `trustedTime`. Tier 2 (content) rules are deferred to
734
+ * clients — `evaluateAccess` is called with `isServer: true`, so a gate that
735
+ * only a Tier 2 rule could decide is treated as allowed here.
736
+ *
737
+ * @param input.op The operation (entry type) being pushed.
738
+ * @param input.dbid The database the entry targets (witness binds this).
739
+ * @param input.signingKey The author's Ed25519 signing public key (PEM).
740
+ * @param input.trustedTime The trusted time to evaluate at (the witness's
741
+ * acceptance time for an inbound push).
742
+ * @param input.isAuthor Whether the author is the document's original creator
743
+ * (resolved by the caller for `$author`; see §6.3). For `doc_create` the
744
+ * author is always the creator.
745
+ */
746
+ async evaluateAccessForSigningKey(input) {
747
+ const node = await this.getDirectoryStateAt(input.trustedTime);
748
+ const identity = await this.identitySetForSigningKey(input.signingKey, node, {
749
+ isAuthor: input.op === "doc_create" || !!input.isAuthor,
750
+ });
751
+ return (0, evaluate_1.evaluateAccess)({
752
+ op: input.op,
753
+ dbid: input.dbid,
754
+ identity,
755
+ node,
756
+ isServer: true,
757
+ });
758
+ }
759
+ /**
760
+ * Client-side (Tier 1 + Tier 2) evaluation for a single entry during
761
+ * materialization (docs/accesscontrol.md §7, §10). Unlike the server path,
762
+ * the client can read decrypted content, so it evaluates `withfields` clauses
763
+ * against the supplied `before`/`after` document states.
764
+ */
765
+ async evaluateClientAccess(input) {
766
+ const node = await this.getDirectoryStateAt(input.trustedTime);
767
+ const identity = await this.identitySetForSigningKey(input.signingKey, node, {
768
+ isAuthor: input.op === "doc_create" || input.isAuthor,
769
+ });
770
+ return (0, evaluate_1.evaluateAccess)({
771
+ op: input.op,
772
+ dbid: input.dbid,
773
+ identity,
774
+ node,
775
+ beforeDoc: input.beforeDoc,
776
+ afterDoc: input.afterDoc,
777
+ isServer: false,
778
+ });
779
+ }
780
+ /**
781
+ * Whether the active policy has any Tier 2 (`withfields`) content rule for
782
+ * operation `op` in database `dbid` at the current head state. Lets the
783
+ * client write prechecks skip the (potentially costly) Automerge -> JS
784
+ * materialization of the before/after document when only Tier 1
785
+ * (identity/op) checks could apply (docs/accesscontrol.md §9).
786
+ */
787
+ async hasWriteContentRules(op, dbid) {
788
+ const node = await this.getDirectoryStateHead();
789
+ const rules = node.rulesByType.get(op) ?? [];
790
+ return rules.some((r) => (r.dbid === dbid || r.dbid === "*") && (r.withfields?.length ?? 0) > 0);
791
+ }
792
+ /**
793
+ * Whether access control is active for this tenant right now: a default
794
+ * policy exists and the master kill-switch is not engaged
795
+ * (docs/accesscontrol.md §6.1, §7 step 0). When false, callers can skip the
796
+ * (more expensive) per-entry access evaluation entirely.
797
+ */
798
+ async isAccessControlActive() {
799
+ const head = await this.getDirectoryStateHead();
800
+ if (head.defaultPolicy === null)
801
+ return false;
802
+ return head.defaultPolicy.disableAllAccessChecksAndPolicies !== true;
803
+ }
804
+ /**
805
+ * The effective default `decryptionKeyId` for a `doc_create` in `dbid` at the
806
+ * current head state, or `undefined` when none is configured. Lets the client
807
+ * fill in the key when the caller omits one, instead of falling back to the
808
+ * hardcoded `"default"`. This is a create-time convenience, not a security
809
+ * control — which keys a user may create/read under is governed by key
810
+ * possession (the key distribution model, docs/accesscontrol.md §13).
811
+ */
812
+ async getEffectiveDefaultCreateKeyId(dbid) {
813
+ const head = await this.getDirectoryStateHead();
814
+ if (head.defaultPolicy === null)
815
+ return undefined;
816
+ const eff = (0, types_3.effectivePolicy)(head.defaultPolicy, head.dbPolicies.get(dbid) ?? null);
817
+ if (eff.disableAllAccessChecksAndPolicies)
818
+ return undefined;
819
+ return eff.defaultCreateKeyId;
820
+ }
821
+ /**
822
+ * The tenant-wide database-open policy at the current head state.
823
+ *
824
+ * This is a tenant-level control read only from the `acl_defaultpolicy`
825
+ * document (never layered per-db). When no default policy exists (access
826
+ * control off), or the field is omitted, the policy is `"open"`. In
827
+ * `"directory-restricted"` mode only `"directory"` (always implicitly
828
+ * allowed) and the returned `allowedDbIds` may be opened/synced.
829
+ */
830
+ async getDatabaseCreationPolicy() {
831
+ const head = await this.getDirectoryStateHead();
832
+ const policy = head.defaultPolicy;
833
+ if (policy === null || policy.databaseCreationPolicy !== "directory-restricted") {
834
+ return { mode: "open", allowedDbIds: [] };
835
+ }
836
+ return {
837
+ mode: "directory-restricted",
838
+ allowedDbIds: Array.isArray(policy.allowedDbIds) ? [...policy.allowedDbIds] : [],
839
+ };
840
+ }
841
+ /**
842
+ * The tenant-wide storage-format floor (`requireMetadataSignatureSince`): the
843
+ * trusted-time cutoff at/after which entries must carry the v2 metadata-binding
844
+ * signature, or `undefined` when no floor is configured (fully backward
845
+ * compatible). Tenant-level only, read from the `acl_defaultpolicy` head.
846
+ *
847
+ * Cached with a short TTL and guarded against re-entrancy: resolving the
848
+ * cutoff loads the directory head, and directory materialization itself runs
849
+ * signature verification — without the guard a verify→resolve→materialize→
850
+ * verify cycle could recurse. During such re-entry we return the last known
851
+ * value (the directory store is anyway exempt from the floor by its callers).
852
+ */
853
+ async getRequireMetadataSignatureSince() {
854
+ const now = Date.now();
855
+ if (this.resolvingMetadataSignatureCutoff) {
856
+ return this.metadataSignatureCutoffCache;
857
+ }
858
+ if (this.metadataSignatureCutoffCachedAt !== 0 &&
859
+ now - this.metadataSignatureCutoffCachedAt < METADATA_SIGNATURE_CUTOFF_TTL_MS) {
860
+ return this.metadataSignatureCutoffCache;
861
+ }
862
+ this.resolvingMetadataSignatureCutoff = true;
863
+ try {
864
+ const head = await this.getDirectoryStateHead();
865
+ const since = head.defaultPolicy?.requireMetadataSignatureSince;
866
+ this.metadataSignatureCutoffCache =
867
+ typeof since === "number" && Number.isFinite(since) ? since : undefined;
868
+ this.metadataSignatureCutoffCachedAt = now;
869
+ }
870
+ finally {
871
+ this.resolvingMetadataSignatureCutoff = false;
872
+ }
873
+ return this.metadataSignatureCutoffCache;
874
+ }
875
+ /**
876
+ * Whether `dbid` may be opened/synced under the current database-open policy.
877
+ *
878
+ * Always true for `"directory"` and in `"open"` mode. In
879
+ * `"directory-restricted"` mode, true only when `dbid` is in `allowedDbIds`
880
+ * or the optional `signingKey` is the tenant administration key (admin
881
+ * bypass).
882
+ */
883
+ async isDatabaseAllowed(dbid, opts) {
884
+ if (dbid === "directory")
885
+ return true;
886
+ const { mode, allowedDbIds } = await this.getDatabaseCreationPolicy();
887
+ if (mode === "open")
888
+ return true;
889
+ if (allowedDbIds.includes(dbid))
890
+ return true;
891
+ if (opts?.signingKey !== undefined) {
892
+ const adminKey = this.tenant.getAdministrationPublicKey();
893
+ if (opts.signingKey === adminKey)
894
+ return true;
895
+ }
896
+ return false;
897
+ }
898
+ /**
899
+ * Server-oriented variant of {@link isDatabaseAllowed} that resolves the
900
+ * admin bypass from a request principal's signing key. Used by the sync
901
+ * server to gate all sync operations for a database id.
902
+ */
903
+ async evaluateDbAccessForSigningKey(input) {
904
+ // Gate 1 — tenant-wide database-id allowlist (`databaseCreationPolicy`).
905
+ // Orthogonal to read access: a database must clear BOTH gates.
906
+ if (!(await this.isDatabaseAllowed(input.dbid, { signingKey: input.signingKey }))) {
907
+ return false;
908
+ }
909
+ // The directory database is never read-gated (it must always sync so the
910
+ // policy that defines the read gate can be read at all).
911
+ if (input.dbid === "directory")
912
+ return true;
913
+ // The tenant admin is exempt from the read gate.
914
+ const adminKey = this.tenant.getAdministrationPublicKey();
915
+ if (input.signingKey === adminKey)
916
+ return true;
917
+ // Gate 2 — per-user/group database read gate (`doc_read`, §6.6). Evaluated
918
+ // server-side (Tier 1) from the principal's signing key at acceptance time.
919
+ // A denied principal cannot pull or push the database, which also prevents
920
+ // creating data in it (read is required to create).
921
+ const decision = await this.evaluateAccessForSigningKey({
922
+ op: "doc_read",
923
+ dbid: input.dbid,
924
+ signingKey: input.signingKey,
925
+ trustedTime: Date.now(),
926
+ });
927
+ return decision.allowed;
928
+ }
929
+ /**
930
+ * Whether the current user may open and sync `dbid` under the database read
931
+ * gate (`doc_read`, §6.6). The client-side counterpart of
932
+ * {@link evaluateDbAccessForSigningKey}'s read gate: it evaluates the
933
+ * `doc_read` policy for the current user against the directory head. The
934
+ * `"directory"` database is never gated and the tenant admin is always
935
+ * exempt. This does NOT apply the tenant-wide database-id allowlist
936
+ * ({@link isDatabaseAllowed}); callers enforce both gates.
937
+ */
938
+ async canReadDatabase(dbid) {
939
+ if (dbid === "directory")
940
+ return true;
941
+ const currentUser = await this.tenant.getCurrentUserId();
942
+ const adminKey = this.tenant.getAdministrationPublicKey();
943
+ if (currentUser.userSigningPublicKey === adminKey)
944
+ return true;
945
+ const decision = await this.canDo("doc_read", dbid);
946
+ return decision.allowed;
947
+ }
948
+ // -------------------------------------------------------------------------
949
+ // Access-control authoring API (docs/accesscontrol.md §6). All writes are
950
+ // admin-signed and encrypted with `$publicinfos` so the sync server can read
951
+ // the Tier 1-relevant policy. Creating `acl_defaultpolicy` is what activates
952
+ // access control for the tenant (§6.1).
953
+ // -------------------------------------------------------------------------
954
+ /**
955
+ * Create or update the tenant-wide default policy (§6.1). The mere existence
956
+ * of this document activates access control. Only the fields supplied are
957
+ * written; omit a field to leave it unset (interpreted as its default).
958
+ */
959
+ async setDefaultAccessPolicy(policy, administrationPrivateKey, administrationPrivateKeyPassword) {
960
+ (0, types_3.validateAccessPolicy)(policy);
961
+ await this.writePolicyDoc(types_3.ACL_DEFAULT_POLICY_DOC_ID, policy, administrationPrivateKey, administrationPrivateKeyPassword);
962
+ }
963
+ /**
964
+ * Create or update a per-database policy override (§6.2). Fields set here
965
+ * override the tenant default for `dbid`; unset fields inherit it.
966
+ */
967
+ async setDatabaseAccessPolicy(dbid, policy, administrationPrivateKey, administrationPrivateKeyPassword) {
968
+ (0, types_3.validateAccessPolicy)(policy);
969
+ await this.writePolicyDoc((0, types_3.aclDbPolicyDocId)(dbid), policy, administrationPrivateKey, administrationPrivateKeyPassword);
970
+ }
971
+ /** Shared writer for default / per-db policy documents. */
972
+ async writePolicyDoc(docId, policy, administrationPrivateKey, administrationPrivateKeyPassword) {
973
+ const baseTenant = this.tenant;
974
+ const directoryDB = await this.getDirectoryDB();
975
+ const adminSigningKeyPair = {
976
+ publicKey: baseTenant.getAdministrationPublicKey(),
977
+ privateKey: administrationPrivateKey,
978
+ };
979
+ const doc = await this.getOrCreateAclDoc(directoryDB, docId, adminSigningKeyPair, administrationPrivateKeyPassword);
980
+ await directoryDB.changeDoc(doc, async (d) => {
981
+ const data = d.getData();
982
+ data.form = types_3.ACCESS_CONTROL_FORM;
983
+ data.type = "defaultpolicy";
984
+ for (const [key, value] of Object.entries(policy)) {
985
+ if (value !== undefined)
986
+ data[key] = value;
987
+ }
988
+ }, {
989
+ signingKeyPair: adminSigningKeyPair,
990
+ signingKeyPassword: administrationPrivateKeyPassword,
991
+ });
992
+ await this.updateUnifiedCache();
993
+ }
994
+ /**
995
+ * Create or replace an access-control rule (§6.3). A rule with `withfields`
996
+ * is Tier 2 (client-enforced); otherwise it is Tier 1 (server-enforced). The
997
+ * spec is validated before writing.
998
+ */
999
+ async createAccessRule(rule, administrationPrivateKey, administrationPrivateKeyPassword) {
1000
+ // Resolve any username/group names into hashes and merge with explicit
1001
+ // hashes / pseudo-tokens (§6.3). Names are hashed the same way as
1002
+ // membership entries so they intersect the acting user's identity set.
1003
+ const resolvedHashes = await this.resolveRuleTargetHashes(rule);
1004
+ const ruleDoc = {
1005
+ form: types_3.ACCESS_CONTROL_FORM,
1006
+ type: rule.type,
1007
+ ruleId: rule.ruleId,
1008
+ description: rule.description,
1009
+ dbid: rule.dbid ?? "*",
1010
+ withfields: rule.withfields,
1011
+ users_hashes: Array.from(resolvedHashes),
1012
+ users_encrypted: await this.encryptRuleTargetsForTenant({
1013
+ usernames: rule.usernames ?? [],
1014
+ groups: rule.groups ?? [],
1015
+ }),
1016
+ action: rule.action ?? "allow",
1017
+ };
1018
+ // Fail fast on malformed clauses / operators (§6.3 closed set).
1019
+ (0, types_3.validateAclRule)(ruleDoc);
1020
+ const baseTenant = this.tenant;
1021
+ const directoryDB = await this.getDirectoryDB();
1022
+ const adminSigningKeyPair = {
1023
+ publicKey: baseTenant.getAdministrationPublicKey(),
1024
+ privateKey: administrationPrivateKey,
1025
+ };
1026
+ const doc = await this.getOrCreateAclDoc(directoryDB, (0, types_3.aclRuleDocId)(rule.ruleId), adminSigningKeyPair, administrationPrivateKeyPassword);
1027
+ await directoryDB.changeDoc(doc, async (d) => {
1028
+ const data = d.getData();
1029
+ data.form = types_3.ACCESS_CONTROL_FORM;
1030
+ data.type = ruleDoc.type;
1031
+ data.ruleId = ruleDoc.ruleId;
1032
+ data.dbid = ruleDoc.dbid;
1033
+ data.action = ruleDoc.action;
1034
+ data.users_hashes = ruleDoc.users_hashes;
1035
+ data.users_encrypted = ruleDoc.users_encrypted;
1036
+ if (ruleDoc.description !== undefined)
1037
+ data.description = ruleDoc.description;
1038
+ if (ruleDoc.withfields !== undefined)
1039
+ data.withfields = ruleDoc.withfields;
1040
+ }, {
1041
+ signingKeyPair: adminSigningKeyPair,
1042
+ signingKeyPassword: administrationPrivateKeyPassword,
1043
+ });
1044
+ await this.updateUnifiedCache();
1045
+ }
1046
+ /**
1047
+ * List access-control rules currently in effect (§9), optionally filtered by
1048
+ * operation type and/or database. Rules with `dbid: "*"` match any database.
1049
+ */
1050
+ async listRules(filter) {
1051
+ const head = await this.getDirectoryStateHead();
1052
+ const out = [];
1053
+ for (const [type, rules] of head.rulesByType) {
1054
+ if (filter?.type && filter.type !== type)
1055
+ continue;
1056
+ for (const rule of rules) {
1057
+ if (filter?.dbid && rule.dbid !== filter.dbid && rule.dbid !== "*")
1058
+ continue;
1059
+ const targets = await this.decryptRuleTargetsForTenant(rule.users_encrypted);
1060
+ out.push(targets ? { ...rule, targets } : rule);
1061
+ }
1062
+ }
1063
+ return out;
1064
+ }
1065
+ /** Delete an access-control rule by id (§9): a soft-delete of its document. */
1066
+ async deleteRule(ruleId, administrationPrivateKey, administrationPrivateKeyPassword) {
1067
+ const baseTenant = this.tenant;
1068
+ const directoryDB = await this.getDirectoryDB();
1069
+ const adminSigningKeyPair = {
1070
+ publicKey: baseTenant.getAdministrationPublicKey(),
1071
+ privateKey: administrationPrivateKey,
1072
+ };
1073
+ try {
1074
+ await directoryDB.deleteDocument((0, types_3.aclRuleDocId)(ruleId), {
1075
+ signingKeyPair: adminSigningKeyPair,
1076
+ signingKeyPassword: administrationPrivateKeyPassword,
1077
+ });
1078
+ }
1079
+ catch (error) {
1080
+ this.logger.debug(`deleteRule: rule ${ruleId} not found or already deleted: ${error}`);
1081
+ }
1082
+ await this.updateUnifiedCache();
1083
+ }
1084
+ /**
1085
+ * Add (or refresh) a trusted witness (§6.4). The witness document is keyed by
1086
+ * a fingerprint of its public key so add/remove are symmetric.
1087
+ */
1088
+ async addTrustedWitness(witness, administrationPrivateKey, administrationPrivateKeyPassword) {
1089
+ const baseTenant = this.tenant;
1090
+ const directoryDB = await this.getDirectoryDB();
1091
+ const adminSigningKeyPair = {
1092
+ publicKey: baseTenant.getAdministrationPublicKey(),
1093
+ privateKey: administrationPrivateKey,
1094
+ };
1095
+ const fingerprint = await this.sha256Hex(witness.witnessPublicKey);
1096
+ const doc = await this.getOrCreateAclDoc(directoryDB, (0, types_2.aclTrustedWitnessDocId)(fingerprint), adminSigningKeyPair, administrationPrivateKeyPassword);
1097
+ await directoryDB.changeDoc(doc, async (d) => {
1098
+ const data = d.getData();
1099
+ data.form = types_3.ACCESS_CONTROL_FORM;
1100
+ data.type = "trustedwitness";
1101
+ data.witnessPublicKey = witness.witnessPublicKey;
1102
+ if (witness.serverUrl !== undefined)
1103
+ data.serverUrl = witness.serverUrl;
1104
+ }, { signingKeyPair: adminSigningKeyPair, signingKeyPassword: administrationPrivateKeyPassword });
1105
+ await this.updateUnifiedCache();
1106
+ }
1107
+ /** Remove a trusted witness by its public key (§6.4): soft-deletes its document. */
1108
+ async removeTrustedWitness(witnessPublicKey, administrationPrivateKey, administrationPrivateKeyPassword) {
1109
+ const baseTenant = this.tenant;
1110
+ const directoryDB = await this.getDirectoryDB();
1111
+ const adminSigningKeyPair = {
1112
+ publicKey: baseTenant.getAdministrationPublicKey(),
1113
+ privateKey: administrationPrivateKey,
1114
+ };
1115
+ const fingerprint = await this.sha256Hex(witnessPublicKey);
1116
+ try {
1117
+ await directoryDB.deleteDocument((0, types_2.aclTrustedWitnessDocId)(fingerprint), {
1118
+ signingKeyPair: adminSigningKeyPair,
1119
+ signingKeyPassword: administrationPrivateKeyPassword,
1120
+ });
1121
+ }
1122
+ catch (error) {
1123
+ this.logger.debug(`removeTrustedWitness: witness not found: ${error}`);
1124
+ }
1125
+ await this.updateUnifiedCache();
1126
+ }
1127
+ /**
1128
+ * Add device key pairs to a user's grant (§6.5 key rollover / new device).
1129
+ * Pairs are merged by signing key (a pair with an already-present signing key
1130
+ * updates its encryption key/label). See {@link mergeKeyPairs}.
1131
+ */
1132
+ async addUserKeys(username, keyPairs, administrationPrivateKey, administrationPrivateKeyPassword) {
1133
+ const additions = keyPairs.map((pair) => {
1134
+ const trimmedLabel = typeof pair.label === "string" ? pair.label.trim() : "";
1135
+ return trimmedLabel.length > 0
1136
+ ? { ...pair, label: trimmedLabel }
1137
+ : { signingPublicKey: pair.signingPublicKey, encryptionPublicKey: pair.encryptionPublicKey };
1138
+ });
1139
+ await this.editGrantArrays(username, administrationPrivateKey, administrationPrivateKeyPassword, (data) => {
1140
+ (0, grantKeys_1.applyKeyPairFields)(data, (0, grantKeys_1.mergeKeyPairs)((0, grantKeys_1.extractKeyPairs)(data), additions));
1141
+ });
1142
+ }
1143
+ /**
1144
+ * Revoke keys from a user's grant (§6.5 revoke a device/key). A device is
1145
+ * identified by its signing key; revoking a signing key marks its entire
1146
+ * paired entry revoked. `encryptionKeys` additionally targets any pair whose
1147
+ * encryption key matches (kept for callers that target encryption keys
1148
+ * directly).
1149
+ *
1150
+ * Revoked pairs are RETAINED on the grant document with `revoked: true` and a
1151
+ * `revokedAt` timestamp (instead of being deleted), so admin UIs can list and
1152
+ * optionally restore revoked devices. They are excluded from the active key
1153
+ * arrays (via {@link applyKeyPairFields}), so the server/auth treat them as
1154
+ * having no access — identical observable behavior to the previous
1155
+ * delete-based revocation.
1156
+ */
1157
+ async removeUserKeys(username, signingKeys, encryptionKeys, administrationPrivateKey, administrationPrivateKeyPassword) {
1158
+ const signingToRemove = new Set(signingKeys);
1159
+ const encToRemove = new Set(encryptionKeys);
1160
+ const revokedAt = Date.now();
1161
+ await this.editGrantArrays(username, administrationPrivateKey, administrationPrivateKeyPassword, (data) => {
1162
+ const next = (0, grantKeys_1.extractKeyPairs)(data).map((pair) => {
1163
+ if (pair.revoked)
1164
+ return pair; // already revoked; keep timestamp
1165
+ const targeted = signingToRemove.has(pair.signingPublicKey) ||
1166
+ (pair.encryptionPublicKey.length > 0 && encToRemove.has(pair.encryptionPublicKey));
1167
+ if (!targeted)
1168
+ return pair;
1169
+ return { ...pair, revoked: true, revokedAt };
1170
+ });
1171
+ (0, grantKeys_1.applyKeyPairFields)(data, next);
1172
+ });
1173
+ }
1174
+ /**
1175
+ * Set or clear the label of a device's key pair, identified by its signing
1176
+ * public key (§6.5). An empty/whitespace `label` clears it. No-op if no pair
1177
+ * with that signing key exists on the user's grant.
1178
+ */
1179
+ async setKeyPairLabel(username, signingPublicKey, label, administrationPrivateKey, administrationPrivateKeyPassword) {
1180
+ const trimmedLabel = typeof label === "string" ? label.trim() : "";
1181
+ await this.editGrantArrays(username, administrationPrivateKey, administrationPrivateKeyPassword, (data) => {
1182
+ const pairs = (0, grantKeys_1.extractKeyPairs)(data);
1183
+ let changed = false;
1184
+ const next = pairs.map((pair) => {
1185
+ if (pair.signingPublicKey !== signingPublicKey)
1186
+ return pair;
1187
+ changed = true;
1188
+ if (trimmedLabel.length > 0) {
1189
+ return { ...pair, label: trimmedLabel };
1190
+ }
1191
+ // Clear the label by dropping the field entirely, preserving any
1192
+ // retained-revocation flags (§6.5).
1193
+ const { label: _dropLabel, ...rest } = pair;
1194
+ return rest;
1195
+ });
1196
+ // Only rewrite when this grant actually carries the targeted key, to
1197
+ // avoid touching other grant documents for the same user.
1198
+ if (changed)
1199
+ (0, grantKeys_1.applyKeyPairFields)(data, next);
1200
+ });
1201
+ }
1202
+ /**
1203
+ * Request a remote wipe of specific devices (§6.5). Targets devices by signing
1204
+ * public key; the values are stored self-contained in
1205
+ * `wipeRequestedForSigningKeys` so they survive key removal. This is an
1206
+ * explicit, opt-in directive and is independent of revocation.
1207
+ */
1208
+ async requestDeviceWipe(username, signingKeys, administrationPrivateKey, administrationPrivateKeyPassword) {
1209
+ await this.editGrantArrays(username, administrationPrivateKey, administrationPrivateKeyPassword, (data) => {
1210
+ data.wipeRequestedForSigningKeys = unionStrings((0, grantKeys_1.extractWipeRequestedSigningKeys)(data), signingKeys);
1211
+ });
1212
+ }
1213
+ /** Cancel a previously-requested device wipe for the given signing keys (§6.5). */
1214
+ async cancelDeviceWipe(username, signingKeys, administrationPrivateKey, administrationPrivateKeyPassword) {
1215
+ const toCancel = new Set(signingKeys);
1216
+ await this.editGrantArrays(username, administrationPrivateKey, administrationPrivateKeyPassword, (data) => {
1217
+ data.wipeRequestedForSigningKeys = (0, grantKeys_1.extractWipeRequestedSigningKeys)(data).filter((k) => !toCancel.has(k));
1218
+ });
1219
+ }
1220
+ /**
1221
+ * Read the current grant overview for a user, for prefilling an admin
1222
+ * "manage user" UI: the decrypted user-details payload (requires the tenant
1223
+ * default key, which the admin client holds), the active device key pairs,
1224
+ * and the retained revoked device key pairs (each with its `revokedAt` and
1225
+ * remote-wipe status). Devices are de-duplicated by signing key across the
1226
+ * user's grant documents (most recent wins).
1227
+ */
1228
+ async getUserGrantOverview(username) {
1229
+ const grants = await this.findGrantAccessDocuments(username);
1230
+ const active = new Map();
1231
+ const revoked = new Map();
1232
+ let details = null;
1233
+ for (const grant of grants) {
1234
+ const data = grant.getData();
1235
+ const wipeRequestedKeys = new Set((0, grantKeys_1.extractWipeRequestedSigningKeys)(data));
1236
+ for (const pair of (0, grantKeys_1.extractActiveKeyPairs)(data)) {
1237
+ active.set(pair.signingPublicKey, {
1238
+ ...pair,
1239
+ wipeRequested: wipeRequestedKeys.has(pair.signingPublicKey),
1240
+ });
1241
+ revoked.delete(pair.signingPublicKey);
1242
+ }
1243
+ for (const pair of (0, grantKeys_1.extractRevokedKeyPairs)(data)) {
1244
+ if (active.has(pair.signingPublicKey))
1245
+ continue;
1246
+ revoked.set(pair.signingPublicKey, {
1247
+ ...pair,
1248
+ wipeRequested: wipeRequestedKeys.has(pair.signingPublicKey),
1249
+ });
1250
+ }
1251
+ if (typeof data.user_details_encrypted === "string" && data.user_details_encrypted) {
1252
+ const decoded = await this.decryptUserDetailsForTenant(data);
1253
+ if (decoded)
1254
+ details = decoded;
1255
+ }
1256
+ }
1257
+ return {
1258
+ details,
1259
+ activeDevices: Array.from(active.values()),
1260
+ revokedDevices: Array.from(revoked.values()),
1261
+ };
1262
+ }
1263
+ /**
1264
+ * Apply a batch of admin-signed edits to a user's grant in a single change per
1265
+ * grant document (docs/accesscontrol.md §6.5). In one pass this:
1266
+ * - rewrites `user_details_encrypted` (when `changes.details` is supplied),
1267
+ * - recomputes the `identity_hashes` bundle + version (the per-user backfill
1268
+ * trigger for grants written before the bundle existed),
1269
+ * - sets per-device labels (`deviceLabels`: signing key → label; empty clears),
1270
+ * - revokes devices (`revokeSigningKeys`: marks `revoked:true` + `revokedAt`,
1271
+ * retaining the pair) and restores devices (`restoreSigningKeys`: clears the
1272
+ * revoked flags, moving the key back to active and dropping any wipe flag),
1273
+ * - and sets the remote-wipe set (`wipeSigningKeys`, authoritative when
1274
+ * supplied) minus any restored keys.
1275
+ *
1276
+ * This is the SDK primitive behind the Haven "Manage user" dialog's batched
1277
+ * Save: every staged change is committed atomically per grant document.
1278
+ */
1279
+ async updateUserGrant(username, changes, administrationPrivateKey, administrationPrivateKeyPassword) {
1280
+ const revokeSet = new Set(changes.revokeSigningKeys ?? []);
1281
+ const restoreSet = new Set(changes.restoreSigningKeys ?? []);
1282
+ const labels = changes.deviceLabels ?? {};
1283
+ const now = Date.now();
1284
+ // Precompute async-derived fields before the (sync) per-doc mutate.
1285
+ const userDetailsEncrypted = changes.details
1286
+ ? await this.encryptUserDetailsForTenant(this.buildUserDetailsPayload(username, changes.details))
1287
+ : null;
1288
+ const identityHashes = await this.computeIdentityHashes(username);
1289
+ await this.editGrantArrays(username, administrationPrivateKey, administrationPrivateKeyPassword, (data) => {
1290
+ if (userDetailsEncrypted !== null) {
1291
+ data.user_details_encrypted = userDetailsEncrypted;
1292
+ data.user_details_encrypted_key = types_1.DEFAULT_TENANT_KEY_ID;
1293
+ }
1294
+ // Recompute the $publicinfos-readable identity-hash bundle (§6.5).
1295
+ data.identity_hashes = identityHashes;
1296
+ data.identity_hashes_v = BaseMindooTenantDirectory.IDENTITY_VARIANTS_VERSION;
1297
+ // Per-device labels + revoke/restore flags, preserving retention.
1298
+ const next = (0, grantKeys_1.extractKeyPairs)(data).map((pair) => {
1299
+ let p = pair;
1300
+ if (Object.prototype.hasOwnProperty.call(labels, pair.signingPublicKey)) {
1301
+ const trimmed = (labels[pair.signingPublicKey] ?? "").trim();
1302
+ if (trimmed.length > 0) {
1303
+ p = { ...p, label: trimmed };
1304
+ }
1305
+ else {
1306
+ const { label: _dropLabel, ...rest } = p;
1307
+ p = rest;
1308
+ }
1309
+ }
1310
+ if (restoreSet.has(pair.signingPublicKey)) {
1311
+ // Un-revoke: clear the retention flags, moving the key back to active.
1312
+ const { revoked: _r, revokedAt: _ra, ...rest } = p;
1313
+ p = rest;
1314
+ }
1315
+ else if (revokeSet.has(pair.signingPublicKey) && !p.revoked) {
1316
+ p = { ...p, revoked: true, revokedAt: now };
1317
+ }
1318
+ return p;
1319
+ });
1320
+ (0, grantKeys_1.applyKeyPairFields)(data, next);
1321
+ // Remote-wipe set: authoritative when supplied (else preserve existing),
1322
+ // restricted to keys present on this grant doc and never for restored keys.
1323
+ const present = new Set(next.map((p) => p.signingPublicKey));
1324
+ const desiredWipe = changes.wipeSigningKeys ?? (0, grantKeys_1.extractWipeRequestedSigningKeys)(data);
1325
+ data.wipeRequestedForSigningKeys = Array.from(new Set(desiredWipe.filter((k) => present.has(k) && !restoreSet.has(k))));
1326
+ });
1327
+ }
1328
+ /**
1329
+ * Predict whether the current user may perform `op` on `dbid` (§9). Pure read;
1330
+ * evaluates Tier 1 + Tier 2 against the head directory state, treating the
1331
+ * caller as the prospective author. `candidateDoc` supplies the content that
1332
+ * `withfields` clauses are checked against.
1333
+ */
1334
+ async canDo(op, dbid, candidateDoc) {
1335
+ const currentUser = await this.tenant.getCurrentUserId();
1336
+ return this.evaluateClientAccess({
1337
+ op,
1338
+ dbid,
1339
+ signingKey: currentUser.userSigningPublicKey,
1340
+ trustedTime: Date.now(),
1341
+ isAuthor: true,
1342
+ beforeDoc: candidateDoc ?? null,
1343
+ afterDoc: candidateDoc ?? null,
1344
+ });
1345
+ }
1346
+ /**
1347
+ * Audit query (§9): would `username` have been allowed to perform `op` on
1348
+ * `dbid` at trusted time `at`? Evaluates against the directory state as it was
1349
+ * at `at`, so historical decisions can be explained.
1350
+ */
1351
+ async wasAllowedAt(op, username, dbid, at, candidateDoc) {
1352
+ const node = await this.getDirectoryStateAt(at);
1353
+ const identity = await this.buildIdentitySet(username, { isAuthor: op === "doc_create" });
1354
+ return (0, evaluate_1.evaluateAccess)({
1355
+ op,
1356
+ dbid,
1357
+ identity,
1358
+ node,
1359
+ beforeDoc: candidateDoc ?? null,
1360
+ afterDoc: candidateDoc ?? null,
1361
+ isServer: false,
1362
+ });
1363
+ }
1364
+ // -------------------------------------------------------------------------
1365
+ // Key distribution (admin-blind, declarative). ONE singleton document per key
1366
+ // (`acl_keydistribution_<keyId>`) is the source of truth for who holds the
1367
+ // key. A key-HOLDER wraps every key version to each recipient device's RSA
1368
+ // encryption public key; an ADMIN merely signs and writes the document, so an
1369
+ // admin outside the recipient set never sees plaintext. Syncing clients
1370
+ // reconcile their KeyBag against the head state (docs/accesscontrol.md §13).
1371
+ // -------------------------------------------------------------------------
1372
+ /**
1373
+ * All ACTIVE (non-revoked) RSA encryption public keys (PEM) for `username`
1374
+ * across the user's grant documents — one per active device. De-duplicated.
1375
+ */
1376
+ async getUserEncryptionPublicKeys(username) {
1377
+ const grants = await this.findGrantAccessDocuments(username);
1378
+ const keys = new Set();
1379
+ for (const grant of grants) {
1380
+ for (const pem of (0, grantKeys_1.extractEncryptionPublicKeys)(grant.getData())) {
1381
+ if (pem)
1382
+ keys.add(pem);
1383
+ }
1384
+ }
1385
+ return Array.from(keys);
1386
+ }
1387
+ /**
1388
+ * Fingerprint of an RSA encryption public key (PEM): SHA-256 over the decoded
1389
+ * SPKI body, first 8 bytes as colon-separated hex. Matches Haven's
1390
+ * `getPublicKeyFingerprint`, so the device-map keys built here line up with
1391
+ * what Haven UIs display. Stable for a given key value across wrap (key-holder)
1392
+ * and reconcile (recipient) sides.
1393
+ */
1394
+ async encryptionKeyFingerprint(pem) {
1395
+ const body = pem
1396
+ .replace(/-----BEGIN [^-]+-----/g, "")
1397
+ .replace(/-----END [^-]+-----/g, "")
1398
+ .replace(/\s+/g, "");
1399
+ let source;
1400
+ try {
1401
+ source = body ? this.base64ToBytes(body) : new TextEncoder().encode(pem);
1402
+ }
1403
+ catch {
1404
+ source = new TextEncoder().encode(pem);
1405
+ }
1406
+ const subtle = this.tenant.getCryptoAdapter().getSubtle();
1407
+ const digest = await subtle.digest("SHA-256", source);
1408
+ return Array.from(new Uint8Array(digest).slice(0, 8))
1409
+ .map((b) => b.toString(16).padStart(2, "0"))
1410
+ .join(":");
1411
+ }
1412
+ /**
1413
+ * Wrap every stored version of `keyId` (from the caller's KeyBag) to ALL
1414
+ * active encryption devices of `username`, producing the exact `pushto`
1415
+ * device-map shape used by both the distribution doc (`pushto_users_keys`) and
1416
+ * the request URI (`KeyDistributionRequest.pushto[].devices`):
1417
+ * `deviceEncKeyFingerprint -> { versionFingerprint -> wrappedKey(b64) }`.
1418
+ * Throws if the caller does not hold the key or the recipient has no active
1419
+ * encryption device.
1420
+ */
1421
+ async wrapKeyForUserDevices(keyId, username) {
1422
+ const baseTenant = this.tenant;
1423
+ const versions = await baseTenant.fingerprintKeyVersions(keyId);
1424
+ if (versions.length === 0) {
1425
+ throw new Error(`Cannot wrap key: key "${keyId}" is not in your KeyBag`);
1426
+ }
1427
+ const devicePems = await this.getUserEncryptionPublicKeys(username);
1428
+ if (devicePems.length === 0) {
1429
+ throw new Error(`Cannot wrap key: recipient "${username}" has no active encryption device`);
1430
+ }
1431
+ const rsa = new RSAEncryption_1.RSAEncryption(this.tenant.getCryptoAdapter(), this.logger.createChild("RSAEncryption"));
1432
+ const devices = {};
1433
+ for (const pem of devicePems) {
1434
+ const deviceFp = await this.encryptionKeyFingerprint(pem);
1435
+ const wrappedByVersion = {};
1436
+ for (const version of versions) {
1437
+ const wrapped = await rsa.encrypt(version.bytes, pem);
1438
+ wrappedByVersion[version.fingerprint] = this.bytesToBase64(wrapped);
1439
+ }
1440
+ devices[deviceFp] = wrappedByVersion;
1441
+ }
1442
+ return {
1443
+ username,
1444
+ username_hash: await this.hashUsernameForWrite(username),
1445
+ devices,
1446
+ };
1447
+ }
1448
+ /**
1449
+ * The canonical write-time hash for `username` (the value stored in
1450
+ * `pushto_users_hashes` / `pullfrom_users_hashes`). Exposed so callers (the
1451
+ * Haven dialog/session) can build `pullfrom` entries for users they are
1452
+ * removing.
1453
+ */
1454
+ async getUsernameHash(username) {
1455
+ return this.hashUsernameForWrite(username);
1456
+ }
1457
+ /**
1458
+ * The version manifest (`{createdAt, fingerprint}`) of `keyId` from the
1459
+ * caller's KeyBag. The single source of truth for the distribution's
1460
+ * `keyVersions`. Empty when the key is not held.
1461
+ */
1462
+ async getKeyVersionManifest(keyId) {
1463
+ const versions = await this.tenant.fingerprintKeyVersions(keyId);
1464
+ return versions.map((v) => ({ createdAt: v.createdAt, fingerprint: v.fingerprint }));
1465
+ }
1466
+ /** Encrypt a UTF-8 string with the tenant default key, base64-encoded. */
1467
+ async encryptToDefaultField(plaintext) {
1468
+ const payload = new TextEncoder().encode(plaintext);
1469
+ const encrypted = await this.tenant.encryptPayload(payload, types_1.DEFAULT_TENANT_KEY_ID);
1470
+ return this.uint8ArrayToBase64(encrypted);
1471
+ }
1472
+ /**
1473
+ * Admin-sign and upsert the singleton `acl_keydistribution_<keyId>` document
1474
+ * from a {@link KeyDistributionRequest} (built in-dialog or decoded from a
1475
+ * request URI). The full desired state is written each time. Validates the
1476
+ * structural invariants plus, against the directory, that every `pushto` user
1477
+ * has an active grant and every active device is covered by wrapped material.
1478
+ */
1479
+ async publishKeyDistribution(request, administrationPrivateKey, administrationPrivateKeyPassword) {
1480
+ const keyId = request.keyId;
1481
+ // Fold per-recipient device maps into the doc's flat `<hash>|<deviceFp>` map.
1482
+ const pushtoUsersKeys = {};
1483
+ for (const p of request.pushto) {
1484
+ for (const [deviceFp, wrapped] of Object.entries(p.devices)) {
1485
+ pushtoUsersKeys[`${p.username_hash}|${deviceFp}`] = wrapped;
1486
+ }
1487
+ }
1488
+ const pushtoHashes = request.pushto.map((p) => p.username_hash);
1489
+ const pullfromHashes = request.pullfrom.map((p) => p.username_hash);
1490
+ (0, types_3.validateKeyDistribution)({
1491
+ keyId,
1492
+ keyVersions: request.keyVersions,
1493
+ pushto_users_hashes: pushtoHashes,
1494
+ pullfrom_users_hashes: pullfromHashes,
1495
+ pushto_users_keys: pushtoUsersKeys,
1496
+ });
1497
+ // Directory-level validation: every pushto user must have an active grant,
1498
+ // and every active encryption device must have wrapped material (otherwise a
1499
+ // newly-added device could not decrypt — re-wrap is required).
1500
+ const manifestFps = new Set(request.keyVersions.map((v) => v.fingerprint));
1501
+ for (const p of request.pushto) {
1502
+ const userKeys = await this.getUserPublicKeys(p.username);
1503
+ if (!userKeys) {
1504
+ throw new Error(`Cannot publish: pushto user "${p.username}" has no active grant`);
1505
+ }
1506
+ const activePems = await this.getUserEncryptionPublicKeys(p.username);
1507
+ for (const pem of activePems) {
1508
+ const deviceFp = await this.encryptionKeyFingerprint(pem);
1509
+ const wrapped = p.devices[deviceFp];
1510
+ if (!wrapped) {
1511
+ throw new Error(`Cannot publish: pushto user "${p.username}" has an active device without wrapped key material (re-wrap required)`);
1512
+ }
1513
+ const covered = Object.keys(wrapped);
1514
+ if (covered.length !== manifestFps.size || covered.some((fp) => !manifestFps.has(fp))) {
1515
+ throw new Error(`Cannot publish: device coverage for "${p.username}" does not match the version manifest`);
1516
+ }
1517
+ }
1518
+ }
1519
+ const titleEncrypted = await this.encryptToDefaultField(request.title ?? keyId);
1520
+ const commentEncrypted = request.comment
1521
+ ? await this.encryptToDefaultField(request.comment)
1522
+ : undefined;
1523
+ const pushtoUsernamesEncrypted = await this.encryptToDefaultField(JSON.stringify(request.pushto.map((p) => p.username)));
1524
+ const pullfromUsernamesEncrypted = await this.encryptToDefaultField(JSON.stringify(request.pullfrom.map((p) => p.username)));
1525
+ const docId = (0, types_3.aclKeyDistributionDocId)(keyId);
1526
+ const baseTenant = this.tenant;
1527
+ const directoryDB = await this.getDirectoryDB();
1528
+ const adminSigningKeyPair = {
1529
+ publicKey: baseTenant.getAdministrationPublicKey(),
1530
+ privateKey: administrationPrivateKey,
1531
+ };
1532
+ const doc = await this.getOrCreateAclDoc(directoryDB, docId, adminSigningKeyPair, administrationPrivateKeyPassword);
1533
+ await directoryDB.changeDoc(doc, async (d) => {
1534
+ const data = d.getData();
1535
+ data.form = types_3.ACCESS_CONTROL_FORM;
1536
+ data.type = types_3.KEY_DISTRIBUTION_TYPE;
1537
+ data.keyId = keyId;
1538
+ data.keyVersions = request.keyVersions.map((v) => ({
1539
+ createdAt: v.createdAt,
1540
+ fingerprint: v.fingerprint,
1541
+ }));
1542
+ data.preparedByPublicKey = request.preparedByPublicKey;
1543
+ data.title_encrypted = titleEncrypted;
1544
+ data.title_encrypted_key = "default";
1545
+ if (commentEncrypted !== undefined) {
1546
+ data.comment_encrypted = commentEncrypted;
1547
+ data.comment_encrypted_key = "default";
1548
+ }
1549
+ else {
1550
+ delete data.comment_encrypted;
1551
+ delete data.comment_encrypted_key;
1552
+ }
1553
+ data.pushto_users_hashes = pushtoHashes;
1554
+ data.pushto_users_encrypted = pushtoUsernamesEncrypted;
1555
+ data.pushto_users_encrypted_key = "default";
1556
+ data.pushto_users_keys = pushtoUsersKeys;
1557
+ data.pullfrom_users_hashes = pullfromHashes;
1558
+ data.pullfrom_users_encrypted = pullfromUsernamesEncrypted;
1559
+ data.pullfrom_users_encrypted_key = "default";
1560
+ }, {
1561
+ signingKeyPair: adminSigningKeyPair,
1562
+ signingKeyPassword: administrationPrivateKeyPassword,
1563
+ });
1564
+ await this.updateUnifiedCache();
1565
+ }
1566
+ /**
1567
+ * Decrypt a `<field>_encrypted` JSON array of strings on a distribution doc.
1568
+ * Returns null when the field is missing/unreadable (e.g. the tenant default
1569
+ * key is not held).
1570
+ */
1571
+ async decryptUsernameArray(data, fieldName) {
1572
+ const decoded = await (0, encryptedFields_1.decryptEncryptedField)(this.tenant, data, fieldName);
1573
+ if (decoded === null)
1574
+ return null;
1575
+ try {
1576
+ const parsed = JSON.parse(decoded);
1577
+ if (Array.isArray(parsed)) {
1578
+ return parsed.filter((v) => typeof v === "string");
1579
+ }
1580
+ return null;
1581
+ }
1582
+ catch {
1583
+ return null;
1584
+ }
1585
+ }
1586
+ /** Parse a raw distribution doc's data into a {@link KeyDistributionView}. */
1587
+ async toKeyDistributionView(data) {
1588
+ if (data.type !== types_3.KEY_DISTRIBUTION_TYPE || typeof data.keyId !== "string")
1589
+ return null;
1590
+ const keyVersions = Array.isArray(data.keyVersions)
1591
+ ? data.keyVersions
1592
+ .filter((v) => !!v && typeof v.fingerprint === "string")
1593
+ .map((v) => ({ createdAt: Number(v.createdAt) || 0, fingerprint: v.fingerprint }))
1594
+ : [];
1595
+ const title = await (0, encryptedFields_1.decryptEncryptedField)(this.tenant, data, "title_encrypted");
1596
+ const comment = await (0, encryptedFields_1.decryptEncryptedField)(this.tenant, data, "comment_encrypted");
1597
+ return {
1598
+ keyId: data.keyId,
1599
+ title,
1600
+ comment,
1601
+ keyVersions,
1602
+ preparedByPublicKey: typeof data.preparedByPublicKey === "string" ? data.preparedByPublicKey : "",
1603
+ pushto_users_hashes: Array.isArray(data.pushto_users_hashes)
1604
+ ? data.pushto_users_hashes.filter((h) => typeof h === "string")
1605
+ : [],
1606
+ pushto_users_keys: data.pushto_users_keys && typeof data.pushto_users_keys === "object"
1607
+ ? data.pushto_users_keys
1608
+ : {},
1609
+ pullfrom_users_hashes: Array.isArray(data.pullfrom_users_hashes)
1610
+ ? data.pullfrom_users_hashes.filter((h) => typeof h === "string")
1611
+ : [],
1612
+ pushtoUsernames: await this.decryptUsernameArray(data, "pushto_users_encrypted"),
1613
+ pullfromUsernames: await this.decryptUsernameArray(data, "pullfrom_users_encrypted"),
1614
+ };
1615
+ }
1616
+ /**
1617
+ * List all key-distribution documents at the directory head as
1618
+ * {@link KeyDistributionView}s (decrypting display fields when the tenant
1619
+ * default key is held; null-tolerant otherwise).
1620
+ */
1621
+ async listKeyDistributions() {
1622
+ const directoryDB = await this.getDirectoryDB();
1623
+ await directoryDB.syncStoreChanges();
1624
+ await this.updateUnifiedCache();
1625
+ const allIds = await directoryDB.getAllDocumentIds();
1626
+ const ids = allIds.filter((id) => id.startsWith(types_3.ACL_KEY_DISTRIBUTION_PREFIX));
1627
+ const views = [];
1628
+ for (const id of ids) {
1629
+ let doc;
1630
+ try {
1631
+ doc = await directoryDB.getDocument(id);
1632
+ }
1633
+ catch {
1634
+ continue;
1635
+ }
1636
+ if (!doc)
1637
+ continue;
1638
+ const view = await this.toKeyDistributionView(doc.getData());
1639
+ if (view)
1640
+ views.push(view);
1641
+ }
1642
+ return views;
1643
+ }
1644
+ /** Delete the singleton distribution document for `keyId` (admin-signed). */
1645
+ async deleteKeyDistribution(keyId, administrationPrivateKey, administrationPrivateKeyPassword) {
1646
+ const baseTenant = this.tenant;
1647
+ const directoryDB = await this.getDirectoryDB();
1648
+ const adminSigningKeyPair = {
1649
+ publicKey: baseTenant.getAdministrationPublicKey(),
1650
+ privateKey: administrationPrivateKey,
1651
+ };
1652
+ try {
1653
+ await directoryDB.deleteDocument((0, types_3.aclKeyDistributionDocId)(keyId), {
1654
+ signingKeyPair: adminSigningKeyPair,
1655
+ signingKeyPassword: administrationPrivateKeyPassword,
1656
+ });
1657
+ }
1658
+ catch (error) {
1659
+ this.logger.debug(`deleteKeyDistribution: ${keyId} not found or already deleted: ${error}`);
1660
+ }
1661
+ await this.updateUnifiedCache();
1662
+ }
1663
+ /**
1664
+ * Internal entry point for `MindooTenant.reconcileKeyDistributionsForCurrentUser`'s
1665
+ * import pass ({@link KeyBagReconciler}). Unlocks this directory's OWN tenant
1666
+ * session key and merges the key versions pushed to `username`. The key never
1667
+ * crosses an API boundary — callers cannot supply a foreign one. Returns the
1668
+ * imported/removed ids; both empty when the host is locked (no session key).
1669
+ */
1670
+ async reconcileImportedKeysForCurrentUser(username) {
1671
+ const baseTenant = this.tenant;
1672
+ const key = await baseTenant.getEncryptionPrivateKeyForReconcile();
1673
+ if (!key) {
1674
+ this.logger.debug("reconcileImportedKeysForCurrentUser: no encryption key available; " +
1675
+ "skipping import pass (revoke pass still ran)");
1676
+ return { imported: [], removed: [] };
1677
+ }
1678
+ return this.reconcileKeyDistributionsWithKey(username, key);
1679
+ }
1680
+ /**
1681
+ * Reconcile the local KeyBag against the directory head for `username`
1682
+ * (docs/accesscontrol.md §13), driven by an already-imported RSA-OAEP private
1683
+ * {@link CryptoKey} (usage `["decrypt"]`). Pure function of (head, bag):
1684
+ * idempotent and convergent, so an offline client catches up after one sync.
1685
+ * Private: the only callers are this class's own
1686
+ * {@link reconcileImportedKeysForCurrentUser}, which sources the key from the
1687
+ * tenant, so no foreign key is ever applied to the bag. Per
1688
+ * `acl_keydistribution_` doc:
1689
+ *
1690
+ * - **pullfrom** (wins): remove the whole key id from the bag (all versions),
1691
+ * matching the server's key-id-grain blacklist; a no-op when the bag does
1692
+ * not hold it. The KeyBag change feed triggers visibility reconciliation,
1693
+ * purging the scope once the key is gone. Never touches protected ids.
1694
+ * - **pushto**: unwrap own device entries, VERIFY each unwrapped version's
1695
+ * fingerprint against the manifest (reject + log on mismatch), and merge the
1696
+ * missing versions (never destructive). Self-healing: a locally deleted
1697
+ * pushed key is re-imported here.
1698
+ *
1699
+ * Returns the key ids imported and removed.
1700
+ */
1701
+ async reconcileKeyDistributionsWithKey(username, encryptionPrivateCryptoKey) {
1702
+ const directoryDB = await this.getDirectoryDB();
1703
+ await directoryDB.syncStoreChanges();
1704
+ await this.updateUnifiedCache();
1705
+ if (this.keyDistributionCache.size === 0)
1706
+ return { imported: [], removed: [] };
1707
+ const myHashes = new Set(await this.usernameHashCandidates(username));
1708
+ const baseTenant = this.tenant;
1709
+ const privateKey = encryptionPrivateCryptoKey;
1710
+ const rsa = new RSAEncryption_1.RSAEncryption(this.tenant.getCryptoAdapter(), this.logger.createChild("RSAEncryption"));
1711
+ const imported = [];
1712
+ const removed = [];
1713
+ for (const [keyId, entry] of this.keyDistributionCache.entries()) {
1714
+ if (types_3.PROTECTED_DISTRIBUTION_KEY_IDS.includes(keyId))
1715
+ continue;
1716
+ const manifest = entry.keyVersions;
1717
+ const meInPull = entry.pullfrom_users_hashes.some((h) => myHashes.has(h));
1718
+ const meInPush = entry.pushto_users_hashes.some((h) => myHashes.has(h));
1719
+ // Pull wins over push if a CRDT merge ever overlapped the lists. Revocation
1720
+ // removes the whole key id from the bag (matching the server's key-id-grain
1721
+ // blacklist); a no-op when the bag does not hold it.
1722
+ if (meInPull) {
1723
+ const didRemove = await baseTenant.removeNamedDecryptionKey(keyId);
1724
+ if (didRemove)
1725
+ removed.push(keyId);
1726
+ continue;
1727
+ }
1728
+ if (!meInPush)
1729
+ continue;
1730
+ const wrappedMap = entry.pushto_users_keys;
1731
+ // Collect device entries addressed to me (by hash prefix). We do not know
1732
+ // which device is ours without trying, so attempt to unwrap each; only our
1733
+ // private key succeeds.
1734
+ const myEntries = Object.entries(wrappedMap).filter(([entryKey]) => {
1735
+ const hash = entryKey.split("|")[0];
1736
+ return myHashes.has(hash);
1737
+ });
1738
+ if (myEntries.length === 0)
1739
+ continue;
1740
+ const collected = new Map();
1741
+ for (const [, wrappedByVersion] of myEntries) {
1742
+ for (const ref of manifest) {
1743
+ if (collected.has(ref.fingerprint))
1744
+ continue;
1745
+ const wrappedB64 = wrappedByVersion[ref.fingerprint];
1746
+ if (typeof wrappedB64 !== "string")
1747
+ continue;
1748
+ try {
1749
+ const bytes = await rsa.decrypt(this.base64ToBytes(wrappedB64), privateKey);
1750
+ const actualFp = await baseTenant.fingerprintKeyBytes(bytes);
1751
+ if (actualFp !== ref.fingerprint) {
1752
+ this.logger.warn(`reconcileKeyDistributions: fingerprint mismatch for ${keyId}@${ref.fingerprint} (got ${actualFp}); rejecting`);
1753
+ continue;
1754
+ }
1755
+ collected.set(ref.fingerprint, { bytes, keyVersionCreatedAt: ref.createdAt });
1756
+ }
1757
+ catch {
1758
+ // Not our device entry (or corrupt); try the next one.
1759
+ }
1760
+ }
1761
+ if (collected.size === manifest.length)
1762
+ break;
1763
+ }
1764
+ if (collected.size === 0)
1765
+ continue;
1766
+ const count = await baseTenant.importDeliveredDecryptionKeyVersions(keyId, Array.from(collected.values()));
1767
+ if (count > 0)
1768
+ imported.push(keyId);
1769
+ }
1770
+ return { imported, removed };
1771
+ }
1772
+ /**
1773
+ * Whether `keyId` is managed by a distribution at the directory head AND the
1774
+ * current/given user is in its `pushto` list (status is derived, never
1775
+ * persisted in the KeyBag).
1776
+ */
1777
+ async getManagedKeyIds(username) {
1778
+ const myHashes = new Set(await this.usernameHashCandidates(username));
1779
+ const views = await this.listKeyDistributions();
1780
+ return views
1781
+ .filter((v) => v.pushto_users_hashes.some((h) => myHashes.has(h)))
1782
+ .map((v) => v.keyId);
1783
+ }
1784
+ // -------------------------------------------------------------------------
1785
+ // App distribution (docs/accesscontrol.md §13). Mirrors key distribution; the
1786
+ // payload is a Haven application registration and recipient lists support
1787
+ // both users and groups, resolved client-side at reconcile time.
1788
+ // -------------------------------------------------------------------------
1789
+ /**
1790
+ * Admin-sign and upsert the singleton `acl_appdistribution_<appId>` document
1791
+ * from an {@link AppDistributionRequest} (built in-dialog or decoded from a
1792
+ * request URI). The full desired state is written each time. Validates the
1793
+ * structural invariants plus, against the directory, that every `pushto` user
1794
+ * has an active grant.
1795
+ */
1796
+ async publishAppDistribution(request, administrationPrivateKey, administrationPrivateKeyPassword) {
1797
+ const appId = request.appId;
1798
+ const pushtoUserHashes = await Promise.all(request.pushtoUsernames.map((u) => this.hashUsernameForWrite(u)));
1799
+ const pullfromUserHashes = await Promise.all(request.pullfromUsernames.map((u) => this.hashUsernameForWrite(u)));
1800
+ const pushtoGroupHashes = await Promise.all(request.pushtoGroups.map((g) => this.hashUsernameForWrite(this.normalizeGroupName(g))));
1801
+ const pullfromGroupHashes = await Promise.all(request.pullfromGroups.map((g) => this.hashUsernameForWrite(this.normalizeGroupName(g))));
1802
+ (0, types_3.validateAppDistribution)({
1803
+ appId,
1804
+ pushto_users_hashes: pushtoUserHashes,
1805
+ pullfrom_users_hashes: pullfromUserHashes,
1806
+ pushto_groups_hashes: pushtoGroupHashes,
1807
+ pullfrom_groups_hashes: pullfromGroupHashes,
1808
+ });
1809
+ // Directory-level validation: every pushto user must have an active grant.
1810
+ for (const username of request.pushtoUsernames) {
1811
+ const userKeys = await this.getUserPublicKeys(username);
1812
+ if (!userKeys) {
1813
+ throw new Error(`Cannot publish: pushto user "${username}" has no active grant`);
1814
+ }
1815
+ }
1816
+ const appidEncrypted = await this.encryptToDefaultField(appId);
1817
+ const versionEncrypted = await this.encryptToDefaultField(request.version ?? "");
1818
+ const titleEncrypted = await this.encryptToDefaultField(request.title ?? appId);
1819
+ const commentEncrypted = request.comment
1820
+ ? await this.encryptToDefaultField(request.comment)
1821
+ : undefined;
1822
+ const appdataEncrypted = await this.encryptToDefaultField(JSON.stringify(request.appData ?? {}));
1823
+ const pushtoUsernamesEncrypted = await this.encryptToDefaultField(JSON.stringify(request.pushtoUsernames));
1824
+ const pushtoGroupsEncrypted = await this.encryptToDefaultField(JSON.stringify(request.pushtoGroups));
1825
+ const pullfromUsernamesEncrypted = await this.encryptToDefaultField(JSON.stringify(request.pullfromUsernames));
1826
+ const pullfromGroupsEncrypted = await this.encryptToDefaultField(JSON.stringify(request.pullfromGroups));
1827
+ const docId = (0, types_3.aclAppDistributionDocId)(appId);
1828
+ const baseTenant = this.tenant;
1829
+ const directoryDB = await this.getDirectoryDB();
1830
+ const adminSigningKeyPair = {
1831
+ publicKey: baseTenant.getAdministrationPublicKey(),
1832
+ privateKey: administrationPrivateKey,
1833
+ };
1834
+ const doc = await this.getOrCreateAclDoc(directoryDB, docId, adminSigningKeyPair, administrationPrivateKeyPassword);
1835
+ await directoryDB.changeDoc(doc, async (d) => {
1836
+ const data = d.getData();
1837
+ data.form = types_3.ACCESS_CONTROL_FORM;
1838
+ data.type = types_3.APP_DISTRIBUTION_TYPE;
1839
+ data.appId = appId;
1840
+ data.appid_encrypted = appidEncrypted;
1841
+ data.appid_encrypted_key = "default";
1842
+ data.version_encrypted = versionEncrypted;
1843
+ data.version_encrypted_key = "default";
1844
+ data.title_encrypted = titleEncrypted;
1845
+ data.title_encrypted_key = "default";
1846
+ if (commentEncrypted !== undefined) {
1847
+ data.comment_encrypted = commentEncrypted;
1848
+ data.comment_encrypted_key = "default";
1849
+ }
1850
+ else {
1851
+ delete data.comment_encrypted;
1852
+ delete data.comment_encrypted_key;
1853
+ }
1854
+ data.appdata_encrypted = appdataEncrypted;
1855
+ data.appdata_encrypted_key = "default";
1856
+ data.preparedByPublicKey = request.preparedByPublicKey;
1857
+ data.pushto_users_hashes = pushtoUserHashes;
1858
+ data.pushto_users_encrypted = pushtoUsernamesEncrypted;
1859
+ data.pushto_users_encrypted_key = "default";
1860
+ data.pushto_groups_hashes = pushtoGroupHashes;
1861
+ data.pushto_groups_encrypted = pushtoGroupsEncrypted;
1862
+ data.pushto_groups_encrypted_key = "default";
1863
+ data.pullfrom_users_hashes = pullfromUserHashes;
1864
+ data.pullfrom_users_encrypted = pullfromUsernamesEncrypted;
1865
+ data.pullfrom_users_encrypted_key = "default";
1866
+ data.pullfrom_groups_hashes = pullfromGroupHashes;
1867
+ data.pullfrom_groups_encrypted = pullfromGroupsEncrypted;
1868
+ data.pullfrom_groups_encrypted_key = "default";
1869
+ }, {
1870
+ signingKeyPair: adminSigningKeyPair,
1871
+ signingKeyPassword: administrationPrivateKeyPassword,
1872
+ });
1873
+ await this.updateUnifiedCache();
1874
+ }
1875
+ /** Parse a raw app-distribution doc's data into an {@link AppDistributionView}. */
1876
+ async toAppDistributionView(data) {
1877
+ if (data.type !== types_3.APP_DISTRIBUTION_TYPE || typeof data.appId !== "string")
1878
+ return null;
1879
+ const stringArray = (value) => Array.isArray(value) ? value.filter((h) => typeof h === "string") : [];
1880
+ const title = await (0, encryptedFields_1.decryptEncryptedField)(this.tenant, data, "title_encrypted");
1881
+ const comment = await (0, encryptedFields_1.decryptEncryptedField)(this.tenant, data, "comment_encrypted");
1882
+ const version = await (0, encryptedFields_1.decryptEncryptedField)(this.tenant, data, "version_encrypted");
1883
+ let appData = null;
1884
+ const appdataDecoded = await (0, encryptedFields_1.decryptEncryptedField)(this.tenant, data, "appdata_encrypted");
1885
+ if (appdataDecoded !== null) {
1886
+ try {
1887
+ appData = JSON.parse(appdataDecoded);
1888
+ }
1889
+ catch {
1890
+ appData = null;
1891
+ }
1892
+ }
1893
+ return {
1894
+ appId: data.appId,
1895
+ title,
1896
+ comment,
1897
+ version,
1898
+ appData,
1899
+ preparedByPublicKey: typeof data.preparedByPublicKey === "string" ? data.preparedByPublicKey : "",
1900
+ pushto_users_hashes: stringArray(data.pushto_users_hashes),
1901
+ pushto_groups_hashes: stringArray(data.pushto_groups_hashes),
1902
+ pullfrom_users_hashes: stringArray(data.pullfrom_users_hashes),
1903
+ pullfrom_groups_hashes: stringArray(data.pullfrom_groups_hashes),
1904
+ pushtoUsernames: await this.decryptUsernameArray(data, "pushto_users_encrypted"),
1905
+ pushtoGroups: await this.decryptUsernameArray(data, "pushto_groups_encrypted"),
1906
+ pullfromUsernames: await this.decryptUsernameArray(data, "pullfrom_users_encrypted"),
1907
+ pullfromGroups: await this.decryptUsernameArray(data, "pullfrom_groups_encrypted"),
1908
+ };
1909
+ }
1910
+ /**
1911
+ * List all app-distribution documents at the directory head as
1912
+ * {@link AppDistributionView}s (decrypting display fields when the tenant
1913
+ * default key is held; null-tolerant otherwise).
1914
+ */
1915
+ async listAppDistributions() {
1916
+ const directoryDB = await this.getDirectoryDB();
1917
+ await directoryDB.syncStoreChanges();
1918
+ await this.updateUnifiedCache();
1919
+ const allIds = await directoryDB.getAllDocumentIds();
1920
+ const ids = allIds.filter((id) => id.startsWith(types_3.ACL_APP_DISTRIBUTION_PREFIX));
1921
+ const views = [];
1922
+ for (const id of ids) {
1923
+ let doc;
1924
+ try {
1925
+ doc = await directoryDB.getDocument(id);
1926
+ }
1927
+ catch {
1928
+ continue;
1929
+ }
1930
+ if (!doc)
1931
+ continue;
1932
+ const view = await this.toAppDistributionView(doc.getData());
1933
+ if (view)
1934
+ views.push(view);
1935
+ }
1936
+ return views;
1937
+ }
1938
+ /** Delete the singleton distribution document for `appId` (admin-signed). */
1939
+ async deleteAppDistribution(appId, administrationPrivateKey, administrationPrivateKeyPassword) {
1940
+ const baseTenant = this.tenant;
1941
+ const directoryDB = await this.getDirectoryDB();
1942
+ const adminSigningKeyPair = {
1943
+ publicKey: baseTenant.getAdministrationPublicKey(),
1944
+ privateKey: administrationPrivateKey,
1945
+ };
1946
+ try {
1947
+ await directoryDB.deleteDocument((0, types_3.aclAppDistributionDocId)(appId), {
1948
+ signingKeyPair: adminSigningKeyPair,
1949
+ signingKeyPassword: administrationPrivateKeyPassword,
1950
+ });
1951
+ }
1952
+ catch (error) {
1953
+ this.logger.debug(`deleteAppDistribution: ${appId} not found or already deleted: ${error}`);
1954
+ }
1955
+ await this.updateUnifiedCache();
1956
+ }
1957
+ /**
1958
+ * Build the set of recipient-matching hashes for `username`: the user's own
1959
+ * username-hash candidates plus the hash candidates of every group the user
1960
+ * belongs to (resolved against the directory head). App-distribution
1961
+ * `pushto`/`pullfrom` group hashes are written as the v3 hash of the
1962
+ * normalized group name, which is exactly the v3 candidate of the group name,
1963
+ * so a member intersects a group target here.
1964
+ */
1965
+ async appDistributionIdentityHashes(username) {
1966
+ const hashes = new Set(await this.usernameHashCandidates(username));
1967
+ const groups = await this.resolveGroupsForUser(username);
1968
+ for (const group of groups) {
1969
+ for (const h of await this.usernameHashCandidates(group)) {
1970
+ hashes.add(h);
1971
+ }
1972
+ }
1973
+ return hashes;
1974
+ }
1975
+ /**
1976
+ * The per-user app-distribution reconcile plan at the directory head
1977
+ * (docs/accesscontrol.md §13): `have` is every app the user is entitled to
1978
+ * (matched by `pushto` user/group and NOT by `pullfrom` — pull wins on any
1979
+ * overlap), `notHave` is every other distributed app id (the user must not
1980
+ * keep it). Drives the Haven install/update/remove pass after a directory
1981
+ * sync. Idempotent and convergent.
1982
+ */
1983
+ async getAppDistributionsForCurrentUser(username) {
1984
+ const myHashes = await this.appDistributionIdentityHashes(username);
1985
+ const views = await this.listAppDistributions();
1986
+ const have = [];
1987
+ const notHave = [];
1988
+ for (const view of views) {
1989
+ const meInPull = view.pullfrom_users_hashes.some((h) => myHashes.has(h)) ||
1990
+ view.pullfrom_groups_hashes.some((h) => myHashes.has(h));
1991
+ const meInPush = view.pushto_users_hashes.some((h) => myHashes.has(h)) ||
1992
+ view.pushto_groups_hashes.some((h) => myHashes.has(h));
1993
+ if (meInPush && !meInPull) {
1994
+ have.push({
1995
+ appId: view.appId,
1996
+ title: view.title ?? "",
1997
+ version: view.version ?? "",
1998
+ appData: view.appData,
1999
+ });
2000
+ }
2001
+ else {
2002
+ notHave.push(view.appId);
2003
+ }
2004
+ }
2005
+ return { have, notHave };
2006
+ }
2007
+ /**
2008
+ * The app ids the active/given user is entitled to receive via `pushto` at the
2009
+ * directory head (status is derived, never persisted). Convenience wrapper
2010
+ * over {@link getAppDistributionsForCurrentUser}.
2011
+ */
2012
+ async getManagedAppIds(username) {
2013
+ const plan = await this.getAppDistributionsForCurrentUser(username);
2014
+ return plan.have.map((entry) => entry.appId);
2015
+ }
2016
+ // -------------------------------------------------------------------------
2017
+ // Sync setup policy. Mirrors app distribution; the payload is a list of
2018
+ // database ids the targeted clients should have on their Haven Sync page,
2019
+ // and the per-policy `mode` controls whether the seeding is just initial
2020
+ // (user may change/disable) or permanently enforced (locked to bidirectional).
2021
+ // Reconciled client-side after the directory syncs; no server enforcement.
2022
+ // -------------------------------------------------------------------------
2023
+ /**
2024
+ * Admin-sign and upsert the `acl_syncsetuppolicy_<policyId>` document from a
2025
+ * {@link SyncSetupPolicyRequest} (built in-dialog or decoded from a request
2026
+ * URI). The full desired state is written each time. Validates the structural
2027
+ * invariants plus, against the directory, that every `pushto` user has an
2028
+ * active grant.
2029
+ */
2030
+ async publishSyncSetupPolicy(request, administrationPrivateKey, administrationPrivateKeyPassword) {
2031
+ const policyId = request.policyId;
2032
+ const mode = request.mode === "permanent" ? "permanent" : "initial";
2033
+ const databaseIds = Array.isArray(request.databaseIds)
2034
+ ? request.databaseIds.map((id) => id.trim()).filter((id) => id.length > 0)
2035
+ : [];
2036
+ const pushtoUserHashes = await Promise.all(request.pushtoUsernames.map((u) => this.hashUsernameForWrite(u)));
2037
+ const pullfromUserHashes = await Promise.all(request.pullfromUsernames.map((u) => this.hashUsernameForWrite(u)));
2038
+ const pushtoGroupHashes = await Promise.all(request.pushtoGroups.map((g) => this.hashUsernameForWrite(this.normalizeGroupName(g))));
2039
+ const pullfromGroupHashes = await Promise.all(request.pullfromGroups.map((g) => this.hashUsernameForWrite(this.normalizeGroupName(g))));
2040
+ (0, types_3.validateSyncSetupPolicy)({
2041
+ policyId,
2042
+ mode,
2043
+ databaseIds,
2044
+ pushto_users_hashes: pushtoUserHashes,
2045
+ pullfrom_users_hashes: pullfromUserHashes,
2046
+ pushto_groups_hashes: pushtoGroupHashes,
2047
+ pullfrom_groups_hashes: pullfromGroupHashes,
2048
+ });
2049
+ // Directory-level validation: every pushto user must have an active grant.
2050
+ for (const username of request.pushtoUsernames) {
2051
+ const userKeys = await this.getUserPublicKeys(username);
2052
+ if (!userKeys) {
2053
+ throw new Error(`Cannot publish: pushto user "${username}" has no active grant`);
2054
+ }
2055
+ }
2056
+ const policyidEncrypted = await this.encryptToDefaultField(policyId);
2057
+ const titleEncrypted = await this.encryptToDefaultField(request.title ?? policyId);
2058
+ const commentEncrypted = request.comment
2059
+ ? await this.encryptToDefaultField(request.comment)
2060
+ : undefined;
2061
+ const databaseidsEncrypted = await this.encryptToDefaultField(JSON.stringify(databaseIds));
2062
+ const pushtoUsernamesEncrypted = await this.encryptToDefaultField(JSON.stringify(request.pushtoUsernames));
2063
+ const pushtoGroupsEncrypted = await this.encryptToDefaultField(JSON.stringify(request.pushtoGroups));
2064
+ const pullfromUsernamesEncrypted = await this.encryptToDefaultField(JSON.stringify(request.pullfromUsernames));
2065
+ const pullfromGroupsEncrypted = await this.encryptToDefaultField(JSON.stringify(request.pullfromGroups));
2066
+ const docId = (0, types_3.aclSyncSetupPolicyDocId)(policyId);
2067
+ const baseTenant = this.tenant;
2068
+ const directoryDB = await this.getDirectoryDB();
2069
+ const adminSigningKeyPair = {
2070
+ publicKey: baseTenant.getAdministrationPublicKey(),
2071
+ privateKey: administrationPrivateKey,
2072
+ };
2073
+ const doc = await this.getOrCreateAclDoc(directoryDB, docId, adminSigningKeyPair, administrationPrivateKeyPassword);
2074
+ await directoryDB.changeDoc(doc, async (d) => {
2075
+ const data = d.getData();
2076
+ data.form = types_3.ACCESS_CONTROL_FORM;
2077
+ data.type = types_3.SYNC_SETUP_POLICY_TYPE;
2078
+ data.policyId = policyId;
2079
+ data.policyid_encrypted = policyidEncrypted;
2080
+ data.policyid_encrypted_key = "default";
2081
+ data.mode = mode;
2082
+ data.title_encrypted = titleEncrypted;
2083
+ data.title_encrypted_key = "default";
2084
+ if (commentEncrypted !== undefined) {
2085
+ data.comment_encrypted = commentEncrypted;
2086
+ data.comment_encrypted_key = "default";
2087
+ }
2088
+ else {
2089
+ delete data.comment_encrypted;
2090
+ delete data.comment_encrypted_key;
2091
+ }
2092
+ data.databaseids_encrypted = databaseidsEncrypted;
2093
+ data.databaseids_encrypted_key = "default";
2094
+ data.preparedByPublicKey = request.preparedByPublicKey;
2095
+ data.pushto_users_hashes = pushtoUserHashes;
2096
+ data.pushto_users_encrypted = pushtoUsernamesEncrypted;
2097
+ data.pushto_users_encrypted_key = "default";
2098
+ data.pushto_groups_hashes = pushtoGroupHashes;
2099
+ data.pushto_groups_encrypted = pushtoGroupsEncrypted;
2100
+ data.pushto_groups_encrypted_key = "default";
2101
+ data.pullfrom_users_hashes = pullfromUserHashes;
2102
+ data.pullfrom_users_encrypted = pullfromUsernamesEncrypted;
2103
+ data.pullfrom_users_encrypted_key = "default";
2104
+ data.pullfrom_groups_hashes = pullfromGroupHashes;
2105
+ data.pullfrom_groups_encrypted = pullfromGroupsEncrypted;
2106
+ data.pullfrom_groups_encrypted_key = "default";
2107
+ }, {
2108
+ signingKeyPair: adminSigningKeyPair,
2109
+ signingKeyPassword: administrationPrivateKeyPassword,
2110
+ });
2111
+ await this.updateUnifiedCache();
2112
+ }
2113
+ /** Parse a raw sync-setup-policy doc's data into a {@link SyncSetupPolicyView}. */
2114
+ async toSyncSetupPolicyView(data) {
2115
+ if (data.type !== types_3.SYNC_SETUP_POLICY_TYPE || typeof data.policyId !== "string")
2116
+ return null;
2117
+ const stringArray = (value) => Array.isArray(value) ? value.filter((h) => typeof h === "string") : [];
2118
+ const title = await (0, encryptedFields_1.decryptEncryptedField)(this.tenant, data, "title_encrypted");
2119
+ const comment = await (0, encryptedFields_1.decryptEncryptedField)(this.tenant, data, "comment_encrypted");
2120
+ let databaseIds = null;
2121
+ const databaseIdsDecoded = await (0, encryptedFields_1.decryptEncryptedField)(this.tenant, data, "databaseids_encrypted");
2122
+ if (databaseIdsDecoded !== null) {
2123
+ try {
2124
+ const parsed = JSON.parse(databaseIdsDecoded);
2125
+ databaseIds = Array.isArray(parsed)
2126
+ ? parsed.filter((id) => typeof id === "string")
2127
+ : null;
2128
+ }
2129
+ catch {
2130
+ databaseIds = null;
2131
+ }
2132
+ }
2133
+ return {
2134
+ policyId: data.policyId,
2135
+ mode: data.mode === "permanent" ? "permanent" : "initial",
2136
+ title,
2137
+ comment,
2138
+ databaseIds,
2139
+ preparedByPublicKey: typeof data.preparedByPublicKey === "string" ? data.preparedByPublicKey : "",
2140
+ pushto_users_hashes: stringArray(data.pushto_users_hashes),
2141
+ pushto_groups_hashes: stringArray(data.pushto_groups_hashes),
2142
+ pullfrom_users_hashes: stringArray(data.pullfrom_users_hashes),
2143
+ pullfrom_groups_hashes: stringArray(data.pullfrom_groups_hashes),
2144
+ pushtoUsernames: await this.decryptUsernameArray(data, "pushto_users_encrypted"),
2145
+ pushtoGroups: await this.decryptUsernameArray(data, "pushto_groups_encrypted"),
2146
+ pullfromUsernames: await this.decryptUsernameArray(data, "pullfrom_users_encrypted"),
2147
+ pullfromGroups: await this.decryptUsernameArray(data, "pullfrom_groups_encrypted"),
2148
+ };
2149
+ }
2150
+ /**
2151
+ * List all sync-setup-policy documents at the directory head as
2152
+ * {@link SyncSetupPolicyView}s (decrypting display fields when the tenant
2153
+ * default key is held; null-tolerant otherwise).
2154
+ */
2155
+ async listSyncSetupPolicies() {
2156
+ const directoryDB = await this.getDirectoryDB();
2157
+ await directoryDB.syncStoreChanges();
2158
+ await this.updateUnifiedCache();
2159
+ const allIds = await directoryDB.getAllDocumentIds();
2160
+ const ids = allIds.filter((id) => id.startsWith(types_3.ACL_SYNC_SETUP_POLICY_PREFIX));
2161
+ const views = [];
2162
+ for (const id of ids) {
2163
+ let doc;
2164
+ try {
2165
+ doc = await directoryDB.getDocument(id);
2166
+ }
2167
+ catch {
2168
+ continue;
2169
+ }
2170
+ if (!doc)
2171
+ continue;
2172
+ const view = await this.toSyncSetupPolicyView(doc.getData());
2173
+ if (view)
2174
+ views.push(view);
2175
+ }
2176
+ return views;
2177
+ }
2178
+ /** Delete the sync-setup-policy document for `policyId` (admin-signed). */
2179
+ async deleteSyncSetupPolicy(policyId, administrationPrivateKey, administrationPrivateKeyPassword) {
2180
+ const baseTenant = this.tenant;
2181
+ const directoryDB = await this.getDirectoryDB();
2182
+ const adminSigningKeyPair = {
2183
+ publicKey: baseTenant.getAdministrationPublicKey(),
2184
+ privateKey: administrationPrivateKey,
2185
+ };
2186
+ try {
2187
+ await directoryDB.deleteDocument((0, types_3.aclSyncSetupPolicyDocId)(policyId), {
2188
+ signingKeyPair: adminSigningKeyPair,
2189
+ signingKeyPassword: administrationPrivateKeyPassword,
2190
+ });
2191
+ }
2192
+ catch (error) {
2193
+ this.logger.debug(`deleteSyncSetupPolicy: ${policyId} not found or already deleted: ${error}`);
2194
+ }
2195
+ await this.updateUnifiedCache();
2196
+ }
2197
+ /**
2198
+ * The per-user sync-setup reconcile plan at the directory head: the union of
2199
+ * databases the user is targeted for across all policies (matched by `pushto`
2200
+ * user/group), each carrying the effective lock state. A database is locked
2201
+ * when a `permanent` policy targets it AND the user is not released via
2202
+ * `pullfrom`; lock wins on overlap. A user only present in `pullfrom` (never
2203
+ * in `pushto`) is not targeted. Drives the Haven Sync-page seed/lock pass
2204
+ * after a directory sync. Idempotent and convergent.
2205
+ */
2206
+ async getSyncSetupForCurrentUser(username) {
2207
+ const myHashes = await this.appDistributionIdentityHashes(username);
2208
+ const views = await this.listSyncSetupPolicies();
2209
+ const lockByDb = new Map();
2210
+ for (const view of views) {
2211
+ const meInPush = view.pushto_users_hashes.some((h) => myHashes.has(h)) ||
2212
+ view.pushto_groups_hashes.some((h) => myHashes.has(h));
2213
+ if (!meInPush)
2214
+ continue;
2215
+ const meInPull = view.pullfrom_users_hashes.some((h) => myHashes.has(h)) ||
2216
+ view.pullfrom_groups_hashes.some((h) => myHashes.has(h));
2217
+ const locked = view.mode === "permanent" && !meInPull;
2218
+ for (const databaseId of view.databaseIds ?? []) {
2219
+ const id = databaseId.trim();
2220
+ if (!id)
2221
+ continue;
2222
+ lockByDb.set(id, (lockByDb.get(id) ?? false) || locked);
2223
+ }
2224
+ }
2225
+ const databases = Array.from(lockByDb.entries()).map(([databaseId, locked]) => ({
2226
+ databaseId,
2227
+ locked,
2228
+ }));
2229
+ return { databases };
2230
+ }
2231
+ /**
2232
+ * The decryption key ids revoked for `username` at the directory head: every
2233
+ * `acl_keydistribution_<keyId>` whose `pullfrom_users_hashes` matches one of
2234
+ * the user's hash candidates (docs/accesscontrol.md §13). Protected ids (the
2235
+ * tenant default / public-infos keys) are never reported. Read straight from
2236
+ * the {@link keyDistributionCache} (O(cache size), no doc scan) so the sync
2237
+ * server can apply the per-user blacklist on every pull/push, and the client
2238
+ * can bulk-remove these ids from its KeyBag.
2239
+ */
2240
+ async getRevokedDecryptionKeyIdsForUser(username) {
2241
+ await this.updateUnifiedCache();
2242
+ const myHashes = new Set(await this.usernameHashCandidates(username));
2243
+ return this.collectRevokedKeyIdsForHashes(myHashes);
2244
+ }
2245
+ /**
2246
+ * Signing-key variant of {@link getRevokedDecryptionKeyIdsForUser}, for the
2247
+ * sync server which authenticates principals by signing public key. The tenant
2248
+ * admin and any non-user (service) key are never revoked, so they get an empty
2249
+ * list.
2250
+ *
2251
+ * Matching is done purely in hash space against the grant's precomputed,
2252
+ * `$publicinfos`-readable `identity_hashes` bundle (docs/accesscontrol.md §6.5,
2253
+ * §13). This is the ONLY form that works on a real sync server: the server
2254
+ * holds only the `$publicinfos` key, never the tenant default key, so it can
2255
+ * NEVER decrypt `user_details_encrypted` — {@link getUserBySigningPublicKey}
2256
+ * returns the bare `username_hash` as `username`. Re-hashing that single hash
2257
+ * (routing through {@link getRevokedDecryptionKeyIdsForUser}, the old behavior)
2258
+ * produces hash-of-hash values that cannot match the multi-variant
2259
+ * `pullfrom_users_hashes`, so the blacklist silently did nothing on the server.
2260
+ * The precomputed bundle, written from the cleartext name at grant time, is
2261
+ * server-readable and carries every variant.
2262
+ */
2263
+ async getRevokedDecryptionKeyIdsForSigningKey(signingKey) {
2264
+ if (signingKey === this.tenant.getAdministrationPublicKey())
2265
+ return [];
2266
+ const lookup = await this.getUserBySigningPublicKey(signingKey);
2267
+ if (!lookup)
2268
+ return [];
2269
+ const myHashes = await this.identityHashesForLookup(lookup);
2270
+ if (myHashes.size === 0)
2271
+ return [];
2272
+ return this.collectRevokedKeyIdsForHashes(myHashes);
2273
+ }
2274
+ /**
2275
+ * The hash set identifying a looked-up grant, for hash-space matching against
2276
+ * directory rules / blacklists (docs/accesscontrol.md §6.5). Prefers the
2277
+ * precomputed `$publicinfos`-readable `identity_hashes` bundle (the v1/v2/v3
2278
+ * hashes of every DN-hierarchy username variant), so it works on a sync server
2279
+ * that cannot read the cleartext name. Legacy grants written before the bundle
2280
+ * existed (`identityHashesV` 0/absent) carry only the single exact-match
2281
+ * `username_hash`; when the cleartext name IS available (a client that holds
2282
+ * the tenant key) we additionally recompute the scheme variants so matching is
2283
+ * no weaker than before the bundle existed.
2284
+ */
2285
+ async identityHashesForLookup(lookup) {
2286
+ const hashes = new Set(lookup.identityHashes ?? []);
2287
+ if ((lookup.identityHashesV ?? 0) === 0 && lookup.username) {
2288
+ for (const h of await this.usernameHashCandidates(lookup.username)) {
2289
+ hashes.add(h);
2290
+ }
2291
+ }
2292
+ return hashes;
2293
+ }
2294
+ /**
2295
+ * Hash-space core of the revoked-key blacklist (docs/accesscontrol.md §13):
2296
+ * every `acl_keydistribution_<keyId>` whose `pullfrom_users_hashes` intersects
2297
+ * `myHashes`. Protected ids (the tenant default / public-infos keys) are never
2298
+ * reported. Reads straight from {@link keyDistributionCache} (O(cache size),
2299
+ * no doc scan); the caller must have refreshed it first (both entry points do,
2300
+ * via `updateUnifiedCache` / `getUserBySigningPublicKey`).
2301
+ */
2302
+ collectRevokedKeyIdsForHashes(myHashes) {
2303
+ const revoked = [];
2304
+ for (const [keyId, entry] of this.keyDistributionCache.entries()) {
2305
+ if (types_3.PROTECTED_DISTRIBUTION_KEY_IDS.includes(keyId))
2306
+ continue;
2307
+ if (entry.pullfrom_users_hashes.some((h) => myHashes.has(h))) {
2308
+ revoked.push(keyId);
2309
+ }
2310
+ }
2311
+ return revoked;
2312
+ }
2313
+ /** Encode bytes as base64 (mirrors RSAEncryption's transport encoding). */
2314
+ bytesToBase64(bytes) {
2315
+ let binary = "";
2316
+ for (let i = 0; i < bytes.length; i++) {
2317
+ binary += String.fromCharCode(bytes[i]);
2318
+ }
2319
+ return btoa(binary);
2320
+ }
2321
+ /** Decode a base64 string into bytes. */
2322
+ base64ToBytes(base64) {
2323
+ const binary = atob(base64);
2324
+ const bytes = new Uint8Array(binary.length);
2325
+ for (let i = 0; i < binary.length; i++) {
2326
+ bytes[i] = binary.charCodeAt(i);
2327
+ }
2328
+ return bytes;
2329
+ }
2330
+ /**
2331
+ * Load the user's active grant document and apply an admin-signed edit to its
2332
+ * key/wipe arrays. Shared by {@link addUserKeys}, {@link removeUserKeys},
2333
+ * {@link requestDeviceWipe}, and {@link cancelDeviceWipe}.
2334
+ */
2335
+ async editGrantArrays(username, administrationPrivateKey, administrationPrivateKeyPassword, mutate) {
2336
+ const grants = await this.findGrantAccessDocuments(username);
2337
+ if (grants.length === 0) {
2338
+ throw new Error(`No active grant found for user "${username}"`);
224
2339
  }
225
- let cachedResult = this.trustedKeysCache.get(publicKey);
226
- if (cachedResult === undefined && !didSync) {
227
- // Cache miss and not synced yet - sync once and re-check immediately.
228
- const directoryDB = await this.getDirectoryDB();
229
- await directoryDB.syncStoreChanges();
230
- await this.updateUnifiedCache();
231
- this.lastDirectorySyncTimestamp = Date.now();
232
- cachedResult = this.trustedKeysCache.get(publicKey);
2340
+ const baseTenant = this.tenant;
2341
+ const directoryDB = await this.getDirectoryDB();
2342
+ const adminSigningKeyPair = {
2343
+ publicKey: baseTenant.getAdministrationPublicKey(),
2344
+ privateKey: administrationPrivateKey,
2345
+ };
2346
+ for (const grant of grants) {
2347
+ const doc = await directoryDB.getDocument(grant.getId());
2348
+ await directoryDB.changeDoc(doc, async (d) => {
2349
+ mutate(d.getData());
2350
+ }, { signingKeyPair: adminSigningKeyPair, signingKeyPassword: administrationPrivateKeyPassword });
233
2351
  }
234
- if (cachedResult !== undefined) {
235
- this.logger.debug(`Public key validation result (from cache): ${cachedResult}`);
236
- return cachedResult;
2352
+ await this.updateUnifiedCache();
2353
+ }
2354
+ /** Load an ACL document by its fixed id, creating it (admin-signed) if absent. */
2355
+ async getOrCreateAclDoc(directoryDB, docId, adminSigningKeyPair, administrationPrivateKeyPassword) {
2356
+ try {
2357
+ const existing = await directoryDB.getDocument(docId);
2358
+ if (existing)
2359
+ return existing;
237
2360
  }
238
- // Key not found in cache means it was never granted access
239
- this.logger.debug(`Public key not found in cache, returning false`);
240
- return false;
2361
+ catch {
2362
+ // Not found; fall through to create.
2363
+ }
2364
+ return directoryDB.createDocument({
2365
+ id: docId,
2366
+ signingKeyPair: adminSigningKeyPair,
2367
+ signingKeyPassword: administrationPrivateKeyPassword,
2368
+ decryptionKeyId: types_1.PUBLIC_INFOS_KEY_ID,
2369
+ });
241
2370
  }
242
2371
  /**
243
- * Update all caches (trusted keys, groups, settings) by processing new changes since the last cursor.
244
- * This unified method processes all document types in a single loop for efficiency.
245
- * No signature verification is needed since the DB already enforces admin-only access.
2372
+ * Resolve the identity set for a signing key (admin, granted user, or trusted
2373
+ * non-user key), shared by the server and client evaluation paths.
246
2374
  */
247
- async updateUnifiedCache() {
248
- const directoryDB = await this.getDirectoryDB();
249
- // Determine starting cursor (null = process all, otherwise incremental)
250
- const startCursor = this.unifiedCacheLastCursor;
251
- // If processing from the beginning, clear all caches first
252
- if (startCursor === null) {
253
- this.trustedKeysCache.clear();
254
- this.grantDocIdToPublicKey.clear();
255
- this.userLookupCache.clear();
256
- this.tenantSettingsCache = null;
257
- this.dbSettingsCache.clear();
258
- this.groupsCache.clear();
2375
+ async identitySetForSigningKey(signingKey, node, opts) {
2376
+ // The tenant admin key is the root of trust and is resolved even without a
2377
+ // grant document (it authors the directory itself).
2378
+ const adminKey = this.tenant.getAdministrationPublicKey();
2379
+ const isAdmin = signingKey === adminKey;
2380
+ // Audit #4 (revocation lag): resolve the acting identity strictly from the
2381
+ // directory state at the entry's trusted time `T`. A signing key that was
2382
+ // not an ACTIVE grant at `T` carries only the pseudo-tokens
2383
+ // (`$everyone`/`$author`), even though the live `userLookupCache`
2384
+ // intentionally still remembers its username/group label after revocation.
2385
+ // Without this gate, a just-revoked key would keep matching username/group
2386
+ // allow-rules until the trust cache refreshed (up to
2387
+ // DIRECTORY_SYNC_INTERVAL_MS later). The `node` is the time-`T` snapshot
2388
+ // whose `bySigningKey` only contains keys whose grant is active at `T`.
2389
+ const grantedAtT = node.bySigningKey.has(signingKey);
2390
+ // Resolve the author's username from their signing key. Trusted non-user
2391
+ // keys (e.g. server-to-server sync identities) have no grant; treat them as
2392
+ // an empty identity that still carries `$everyone`/`$author`. Keys that were
2393
+ // revoked (or not yet granted) at `T` are likewise reduced to the minimal
2394
+ // identity so they cannot match name/group rules.
2395
+ const lookup = isAdmin || !grantedAtT ? null : await this.getUserBySigningPublicKey(signingKey);
2396
+ if (!lookup) {
2397
+ return this.minimalIdentitySet({ isAdmin, isAuthor: opts.isAuthor });
259
2398
  }
260
- // Track group documents by name for merging (handles offline sync scenarios)
261
- const groupDocsByName = new Map();
262
- // Process changes (documents are returned in order by lastModified, oldest to newest)
263
- for await (const { doc, cursor } of directoryDB.iterateChangesSince(startCursor)) {
264
- const data = doc.getData();
265
- // Process user operation documents (grant/revoke access)
266
- if (data.form === "useroperation") {
267
- // Check if this is a grant access document
268
- if (data.type === "grantaccess" &&
269
- data.userSigningPublicKey &&
270
- typeof data.userSigningPublicKey === "string") {
271
- const userPublicKey = data.userSigningPublicKey;
272
- this.logger.debug(`Cache: adding trusted key from grant access document`);
273
- // Mark as active (true) unless already revoked
274
- if (!this.trustedKeysCache.has(userPublicKey) || this.trustedKeysCache.get(userPublicKey) === true) {
275
- this.trustedKeysCache.set(userPublicKey, true);
276
- }
277
- // Store mapping for future revocation lookups (persisted across iterations)
278
- this.grantDocIdToPublicKey.set(doc.getId(), userPublicKey);
279
- const userLookup = await this.buildUserLookup(data);
280
- if (userLookup) {
281
- this.userLookupCache.set(userPublicKey, userLookup);
282
- }
283
- }
284
- // Check if this is a revoke access document
285
- if (data.type === "revokeaccess" &&
286
- data.revokeDocId &&
287
- typeof data.revokeDocId === "string") {
288
- const revokedDocId = data.revokeDocId;
289
- // Find the public key for this grant doc ID and mark as revoked
290
- // Use the persistent mapping that survives across iterations
291
- const revokedPublicKey = this.grantDocIdToPublicKey.get(revokedDocId);
292
- if (revokedPublicKey) {
293
- this.logger.debug(`Cache: revoking key for doc ${revokedDocId}`);
294
- this.trustedKeysCache.set(revokedPublicKey, false);
295
- }
296
- else {
297
- this.logger.warn(`Cache: revocation for unknown grant doc ${revokedDocId}`);
298
- }
299
- }
300
- }
301
- // Process group documents
302
- if (data.form === "group" &&
303
- data.type === "group" &&
304
- data.groupName &&
305
- typeof data.groupName === "string") {
306
- const normalizedGroupName = this.normalizeGroupName(data.groupName);
307
- if (!groupDocsByName.has(normalizedGroupName)) {
308
- groupDocsByName.set(normalizedGroupName, []);
309
- }
310
- groupDocsByName.get(normalizedGroupName).push(doc);
311
- }
312
- // Process tenant settings (no signature verification needed - Automerge handles merging)
313
- if (data.form === "tenantsettings") {
314
- // Just overwrite - last one seen will be the latest
315
- this.tenantSettingsCache = doc;
316
- }
317
- // Process DB settings (no signature verification needed - Automerge handles merging)
318
- if (data.form === "dbsettings" && data.dbid && typeof data.dbid === "string") {
319
- const dbId = data.dbid;
320
- // Just overwrite - last one seen will be the latest
321
- this.dbSettingsCache.set(dbId, doc);
322
- }
323
- // Update cursor after each document
324
- this.unifiedCacheLastCursor = cursor;
2399
+ // A client holding the tenant default key decrypts `user_details_encrypted`,
2400
+ // so `lookup.username` is the cleartext name (`details` is populated). Build
2401
+ // the full identity set, including the cleartext usernames/groups needed for
2402
+ // Tier 2 `${user.*}` placeholder resolution.
2403
+ if (lookup.details !== null && lookup.username) {
2404
+ return this.buildIdentitySet(lookup.username, { isAdmin, isAuthor: opts.isAuthor });
325
2405
  }
326
- // Merge group documents with the same name
327
- for (const [normalizedGroupName, docs] of groupDocsByName.entries()) {
328
- // Collect all member hashes and encrypted values from all documents with this group name
329
- const allMembersHashes = new Set();
330
- const allMembersEncrypted = new Set();
331
- let latestDoc = null;
332
- let isDeleted = false;
333
- for (const doc of docs) {
334
- const data = doc.getData();
335
- // Track the latest document (by iteration order, which is by lastModified)
336
- latestDoc = doc;
337
- // Check if document is deleted via lifecycle metadata.
338
- if (doc.isDeleted()) {
339
- isDeleted = true;
340
- }
341
- // Use members_hashes for membership lookups.
342
- if (data.members_hashes && Array.isArray(data.members_hashes)) {
343
- for (const memberHash of data.members_hashes) {
344
- if (typeof memberHash === "string") {
345
- allMembersHashes.add(memberHash);
346
- }
347
- }
348
- }
349
- if (data.members_encrypted && Array.isArray(data.members_encrypted)) {
350
- for (const encryptedMember of data.members_encrypted) {
351
- if (typeof encryptedMember === "string") {
352
- allMembersEncrypted.add(encryptedMember);
353
- }
354
- }
355
- }
356
- }
357
- // If latest version is deleted, remove from cache
358
- if (isDeleted) {
359
- this.groupsCache.delete(normalizedGroupName);
360
- continue;
361
- }
362
- // Store merged group data in cache (without mutating the MindooDoc)
363
- if (latestDoc) {
364
- this.groupsCache.set(normalizedGroupName, {
365
- docId: latestDoc.getId(),
366
- members_hashes: Array.from(allMembersHashes),
367
- members_encrypted: Array.from(allMembersEncrypted),
368
- });
2406
+ // A $publicinfos-only sync server can NEVER decrypt `user_details_encrypted`
2407
+ // (it is under the tenant default key), so `getUserBySigningPublicKey`
2408
+ // degrades `username` to the bare `username_hash`. Re-hashing that single
2409
+ // hash (the old `buildIdentitySet(username)` path) produces hash-of-hash
2410
+ // values that match no rule, silently breaking server-side Tier 1
2411
+ // username/group rule matching. Build the matching hashes from the
2412
+ // precomputed, $publicinfos-readable `identity_hashes` bundle instead, which
2413
+ // carries every DN-variant hash (docs/accesscontrol.md §6.5, §7). The server
2414
+ // ignores Tier 2 (placeholder) rules, so the empty cleartext
2415
+ // usernames/groups are never read here.
2416
+ return this.buildIdentitySetFromHashes(await this.identityHashesForLookup(lookup), { isAdmin, isAuthor: opts.isAuthor });
2417
+ }
2418
+ /**
2419
+ * The identity set for a key with no resolvable username (admin or a trusted
2420
+ * service key): only the pseudo-tokens apply.
2421
+ */
2422
+ minimalIdentitySet(opts) {
2423
+ const hashes = new Set();
2424
+ hashes.add(types_3.PSEUDO_TOKEN_EVERYONE);
2425
+ if (opts.isAdmin)
2426
+ hashes.add(types_3.PSEUDO_TOKEN_ADMIN);
2427
+ if (opts.isAuthor)
2428
+ hashes.add(types_3.PSEUDO_TOKEN_AUTHOR);
2429
+ return { username: "", usernames: [], groups: [], hashes };
2430
+ }
2431
+ /**
2432
+ * Build an identity set for access evaluation purely from a set of identity
2433
+ * hashes (docs/accesscontrol.md §6.5/§7), without the cleartext username. Used
2434
+ * on a $publicinfos-only sync server, which cannot read the cleartext name but
2435
+ * must still match Tier 1 username/group rules in hash space. Group membership
2436
+ * is resolved from the hashes (group docs store `members_hashes` and a
2437
+ * `$publicinfos`-readable `groupName`), and each resolved group's name hashes
2438
+ * are folded in so group-targeted rules match. Cleartext
2439
+ * `username`/`usernames`/`groups` are left empty: the server defers every
2440
+ * Tier 2 (placeholder) rule to clients, so they are never read here.
2441
+ */
2442
+ async buildIdentitySetFromHashes(userHashes, opts) {
2443
+ await this.updateUnifiedCache();
2444
+ const hashes = new Set(userHashes);
2445
+ const groups = await this.resolveGroupsFromVariantHashes(userHashes);
2446
+ for (const group of groups) {
2447
+ for (const h of await this.usernameHashCandidates(group)) {
2448
+ hashes.add(h);
369
2449
  }
370
2450
  }
2451
+ hashes.add(types_3.PSEUDO_TOKEN_EVERYONE);
2452
+ if (opts.isAdmin)
2453
+ hashes.add(types_3.PSEUDO_TOKEN_ADMIN);
2454
+ if (opts.isAuthor)
2455
+ hashes.add(types_3.PSEUDO_TOKEN_AUTHOR);
2456
+ return { username: "", usernames: [], groups, hashes };
371
2457
  }
372
2458
  /**
373
2459
  * Normalize group name to lowercase for case-insensitive comparison.
374
2460
  */
375
2461
  normalizeGroupName(name) {
376
- return name.toLowerCase();
2462
+ // NFKC-normalize before lowercasing so visually-equivalent Unicode group
2463
+ // names collapse to one key (homoglyph/normalization defense). ASCII names
2464
+ // are unchanged.
2465
+ return name.normalize("NFKC").toLowerCase();
377
2466
  }
378
2467
  /**
379
2468
  * Generate username variants with wildcards for hierarchical matching.
@@ -404,15 +2493,27 @@ class BaseMindooTenantDirectory {
404
2493
  * Stops on cycles with a console warning.
405
2494
  */
406
2495
  async resolveGroupsForUser(username) {
2496
+ // Generate username variants for matching and compute their hashes.
2497
+ // Include both legacy (v1) and salted (v2) hash forms for every variant so
2498
+ // membership written under either scheme is matched (docs/accesscontrol.md §6.5).
2499
+ const usernameVariants = this.generateUsernameVariants(username);
2500
+ const variantHashes = new Set((await Promise.all(usernameVariants.map(v => this.usernameHashCandidates(v)))).flat());
2501
+ return this.resolveGroupsFromVariantHashes(variantHashes);
2502
+ }
2503
+ /**
2504
+ * Hash-space core of {@link resolveGroupsForUser}: given the set of a user's
2505
+ * username-variant hashes (v1+v2 of every DN-hierarchy variant), recursively
2506
+ * resolve all groups that contain the user (directly or via nested groups).
2507
+ * Works purely from hashes, so the server can resolve group membership for a
2508
+ * key-based reader using the precomputed `identity_hashes` bundle — no
2509
+ * cleartext username required (docs/accesscontrol.md §6.5).
2510
+ */
2511
+ async resolveGroupsFromVariantHashes(variantHashes) {
407
2512
  const resultGroups = new Set();
408
2513
  // Sync changes and update cache
409
2514
  const directoryDB = await this.getDirectoryDB();
410
2515
  await directoryDB.syncStoreChanges();
411
2516
  await this.updateUnifiedCache();
412
- // Generate username variants for matching and compute their hashes
413
- const usernameVariants = this.generateUsernameVariants(username);
414
- // Hash all variants for comparison with members_hashes
415
- const variantHashes = new Set(await Promise.all(usernameVariants.map(v => this.hashUsername(v))));
416
2517
  // First pass: find all groups that directly contain the user or username variants (by hash)
417
2518
  const directGroups = new Set();
418
2519
  for (const [groupName, groupData] of this.groupsCache.entries()) {
@@ -438,14 +2539,14 @@ class BaseMindooTenantDirectory {
438
2539
  continue;
439
2540
  }
440
2541
  visitedGroups.add(childGroup);
441
- // Hash the child group name for comparison
442
- const childGroupHash = await this.hashUsername(childGroup);
2542
+ // Hash the child group name for comparison (both legacy + salted forms).
2543
+ const childGroupHashes = await this.usernameHashCandidates(childGroup);
443
2544
  // Find all groups that contain this child group as a member (by hash)
444
2545
  for (const [parentGroupName, parentGroupData] of this.groupsCache.entries()) {
445
2546
  if (resultGroups.has(parentGroupName)) {
446
2547
  continue; // Already found this group
447
2548
  }
448
- if (parentGroupData.members_hashes.includes(childGroupHash)) {
2549
+ if (childGroupHashes.some(h => parentGroupData.members_hashes.includes(h))) {
449
2550
  resultGroups.add(parentGroupName);
450
2551
  nextGroups.add(parentGroupName);
451
2552
  }
@@ -464,18 +2565,22 @@ class BaseMindooTenantDirectory {
464
2565
  */
465
2566
  async getUserPublicKeys(username) {
466
2567
  this.logger.debug(`Getting public keys for username: ${username}`);
467
- // Find all active (non-revoked) grant access documents for this user
468
- const activeDocs = await this.findGrantAccessDocuments(username);
2568
+ // Find all grant access documents for this user, then keep only those that
2569
+ // still carry signing keys. Revocation removes keys in place (§6.5), so a
2570
+ // grant with an empty key array is a revoked grant and must be ignored.
2571
+ const grantDocs = await this.findGrantAccessDocuments(username);
2572
+ const activeDocs = grantDocs.filter((doc) => (0, grantKeys_1.extractSigningPublicKeys)(doc.getData()).length > 0);
469
2573
  if (activeDocs.length === 0) {
470
2574
  this.logger.debug(`No active grant access documents found for username: ${username}`);
471
2575
  return null;
472
2576
  }
473
- // Use the last active grant access document (most recent registration)
474
- // In most cases there should only be one active grant access document per user
2577
+ // Use the most recent active grant access document (most recent registration).
2578
+ // In most cases there should only be one active grant access document per user.
475
2579
  const doc = activeDocs[activeDocs.length - 1];
476
2580
  const data = doc.getData();
477
- const signingPublicKey = data.userSigningPublicKey;
478
- const encryptionPublicKey = data.userEncryptionPublicKey;
2581
+ // Prefer the array key form, falling back to the legacy scalar fields (§6.5).
2582
+ const signingPublicKey = (0, grantKeys_1.extractSigningPublicKeys)(data)[0] ?? null;
2583
+ const encryptionPublicKey = (0, grantKeys_1.extractEncryptionPublicKeys)(data)[0] ?? null;
479
2584
  if (typeof signingPublicKey !== "string" || typeof encryptionPublicKey !== "string") {
480
2585
  this.logger.error(`Invalid key data in grant access document for username: ${username}`);
481
2586
  return null;
@@ -494,6 +2599,66 @@ class BaseMindooTenantDirectory {
494
2599
  await this.updateUnifiedCache();
495
2600
  return this.userLookupCache.get(publicKey) ?? null;
496
2601
  }
2602
+ /**
2603
+ * Resolve the full set of signing keys relevant to authenticating a device for
2604
+ * `username` (docs/accesscontrol.md §6.5):
2605
+ * - `active`: keys currently granted (the device may sync normally), and
2606
+ * - `wipeRequested`: keys an admin has targeted for remote wipe.
2607
+ *
2608
+ * The two sets are independent: a stolen device may still be active yet
2609
+ * wipe-targeted, or revoked (removed from `active`) yet still wipe-targeted.
2610
+ * The server uses this to let a wipe-targeted (possibly revoked) device
2611
+ * authenticate just far enough to receive the wipe directive.
2612
+ */
2613
+ async getUserSigningKeyUniverse(username) {
2614
+ const grants = await this.findGrantAccessDocuments(username);
2615
+ const active = new Set();
2616
+ const wipeRequested = new Set();
2617
+ for (const grant of grants) {
2618
+ const data = grant.getData();
2619
+ for (const key of (0, grantKeys_1.extractSigningPublicKeys)(data))
2620
+ active.add(key);
2621
+ for (const key of (0, grantKeys_1.extractWipeRequestedSigningKeys)(data))
2622
+ wipeRequested.add(key);
2623
+ }
2624
+ return { active: Array.from(active), wipeRequested: Array.from(wipeRequested) };
2625
+ }
2626
+ /**
2627
+ * List a user's currently-granted device key pairs (§6.5), each with its
2628
+ * optional label and current remote-wipe status. Returns one entry per
2629
+ * signing key (revoked keys are excluded). Used by admin UIs to pick specific
2630
+ * devices to revoke or relabel. Grant documents are processed oldest→newest,
2631
+ * so the most recent label/encryption key for a signing key wins.
2632
+ */
2633
+ async getUserKeyPairs(username) {
2634
+ const grants = await this.findGrantAccessDocuments(username);
2635
+ const bySigningKey = new Map();
2636
+ for (const grant of grants) {
2637
+ const data = grant.getData();
2638
+ const wipeRequestedKeys = new Set((0, grantKeys_1.extractWipeRequestedSigningKeys)(data));
2639
+ // Active devices only: revoked pairs are retained on the document but
2640
+ // excluded here, preserving the "currently-granted devices" semantics.
2641
+ for (const pair of (0, grantKeys_1.extractActiveKeyPairs)(data)) {
2642
+ bySigningKey.set(pair.signingPublicKey, {
2643
+ ...pair,
2644
+ wipeRequested: wipeRequestedKeys.has(pair.signingPublicKey),
2645
+ });
2646
+ }
2647
+ }
2648
+ return Array.from(bySigningKey.values());
2649
+ }
2650
+ /**
2651
+ * If `signingKey` is the target of an admin-requested remote wipe (§6.5),
2652
+ * return the id of the admin-signed grant document carrying the directive so
2653
+ * the sync server can serve only that document to the targeted device. Returns
2654
+ * null when the key is not wipe-targeted.
2655
+ */
2656
+ async getWipeGrantDocId(signingKey) {
2657
+ const directoryDB = await this.getDirectoryDB();
2658
+ await directoryDB.syncStoreChanges();
2659
+ await this.updateUnifiedCache();
2660
+ return this.wipeKeyToGrantDocId.get(signingKey) ?? null;
2661
+ }
497
2662
  /**
498
2663
  * Check if a user has been revoked.
499
2664
  * A user is considered revoked if they have no active (non-revoked) grant access documents.
@@ -503,64 +2668,147 @@ class BaseMindooTenantDirectory {
503
2668
  */
504
2669
  async isUserRevoked(username) {
505
2670
  this.logger.debug(`Checking if user is revoked: ${username}`);
506
- // Find all active grant access documents for this user
507
- const activeDocs = await this.findGrantAccessDocuments(username);
508
- // User is revoked if they have no active grant access documents
509
- const isRevoked = activeDocs.length === 0;
2671
+ // Revocation removes the signing keys from the grant document(s) rather
2672
+ // than creating a separate revoke document (docs/accesscontrol.md §6.5).
2673
+ // A user is revoked when no grant document retains any signing key. Users
2674
+ // who were never granted access (no grant documents at all) are likewise
2675
+ // reported as having no active access.
2676
+ const grantDocs = await this.findGrantAccessDocuments(username);
2677
+ const hasActiveKey = grantDocs.some((doc) => (0, grantKeys_1.extractSigningPublicKeys)(doc.getData()).length > 0);
2678
+ const isRevoked = !hasActiveKey;
510
2679
  this.logger.debug(`User ${username} revoked: ${isRevoked}`);
511
2680
  return isRevoked;
512
2681
  }
513
- async requestDocHistoryPurge(dbId, docId, reason, administrationPrivateKey, administrationPrivateKeyPassword) {
514
- this.logger.info(`Requesting purge for document: ${docId} in ${dbId}`);
2682
+ /**
2683
+ * Publish an admin-signed document-history purge request (docs/accesscontrol.md
2684
+ * §13). Maps an unsigned {@link DocHistoryPurgeRequest} (built in-dialog or
2685
+ * decoded from an `mdb://doc-history-purge/...` URI) to a
2686
+ * {@link DocHistoryPurgeDoc} stored at the fixed id
2687
+ * `acl_dochistorypurge_<requestId>` (one doc per request; append-only audit
2688
+ * record). The doc shell is `$publicinfos`-readable so the sync server can read
2689
+ * the cleartext `dbId` + `docIds` routing fields and execute the purge on its
2690
+ * own stores; only `reason` is encrypted with the tenant `default` key.
2691
+ */
2692
+ async publishDocHistoryPurge(request, administrationPrivateKey, administrationPrivateKeyPassword) {
2693
+ (0, types_3.validateDocHistoryPurge)({
2694
+ requestId: request.requestId,
2695
+ dbId: request.dbId,
2696
+ docIds: request.docIds,
2697
+ });
2698
+ this.logger.info(`Publishing doc history purge request ${request.requestId} for ${request.docIds.length} doc(s) in ${request.dbId}`);
2699
+ const reasonEncrypted = request.reason
2700
+ ? await this.encryptToDefaultField(request.reason)
2701
+ : undefined;
2702
+ const docId = (0, types_3.aclDocHistoryPurgeDocId)(request.requestId);
2703
+ const requestedAt = Date.now();
515
2704
  const baseTenant = this.tenant;
516
2705
  const directoryDB = await this.getDirectoryDB();
517
2706
  const adminSigningKeyPair = {
518
2707
  publicKey: baseTenant.getAdministrationPublicKey(),
519
2708
  privateKey: administrationPrivateKey,
520
2709
  };
521
- // Create document with admin signing key so the initial entry is trusted
522
- const newDoc = await directoryDB.createDocumentWithSigningKey(adminSigningKeyPair, administrationPrivateKeyPassword);
523
- // Set the document data fields with the admin key at entry level
524
- await directoryDB.changeDoc(newDoc, async (doc) => {
525
- const data = doc.getData();
526
- data.form = "useroperation";
527
- data.type = "requestdochistorypurge";
528
- data.dbId = dbId;
529
- data.docId = docId;
530
- if (reason) {
531
- data.reason = reason;
532
- }
533
- data.requestedAt = Date.now();
2710
+ const doc = await this.getOrCreateAclDoc(directoryDB, docId, adminSigningKeyPair, administrationPrivateKeyPassword);
2711
+ await directoryDB.changeDoc(doc, async (d) => {
2712
+ const data = d.getData();
2713
+ data.form = types_3.ACCESS_CONTROL_FORM;
2714
+ data.type = types_3.DOC_HISTORY_PURGE_TYPE;
2715
+ data.requestId = request.requestId;
2716
+ data.dbId = request.dbId;
2717
+ data.docIds = [...request.docIds];
2718
+ if (reasonEncrypted !== undefined) {
2719
+ data.reason_encrypted = reasonEncrypted;
2720
+ data.reason_encrypted_key = "default";
2721
+ }
2722
+ else {
2723
+ delete data.reason_encrypted;
2724
+ delete data.reason_encrypted_key;
2725
+ }
2726
+ data.preparedByPublicKey = request.preparedByPublicKey;
2727
+ data.requestedAt = requestedAt;
534
2728
  }, {
535
2729
  signingKeyPair: adminSigningKeyPair,
536
2730
  signingKeyPassword: administrationPrivateKeyPassword,
537
2731
  });
538
- this.logger.info(`Created purge request for ${docId} in ${dbId}`);
2732
+ this.logger.info(`Created purge request ${request.requestId} in ${request.dbId}`);
539
2733
  }
540
- async getRequestedDocHistoryPurges() {
2734
+ /** Parse a raw purge-request doc's data into a {@link DocHistoryPurgeView}. */
2735
+ async toDocHistoryPurgeView(data, purgeRequestDocId) {
2736
+ if (data.type !== types_3.DOC_HISTORY_PURGE_TYPE)
2737
+ return null;
2738
+ const dbId = data.dbId;
2739
+ if (typeof dbId !== "string")
2740
+ return null;
2741
+ const docIds = Array.isArray(data.docIds)
2742
+ ? data.docIds.filter((id) => typeof id === "string")
2743
+ : [];
2744
+ if (docIds.length === 0)
2745
+ return null;
2746
+ const reason = await (0, encryptedFields_1.decryptEncryptedField)(this.tenant, data, "reason_encrypted");
2747
+ return {
2748
+ requestId: typeof data.requestId === "string" ? data.requestId : purgeRequestDocId,
2749
+ dbId,
2750
+ docIds,
2751
+ reason,
2752
+ preparedByPublicKey: typeof data.preparedByPublicKey === "string" ? data.preparedByPublicKey : "",
2753
+ requestedAt: typeof data.requestedAt === "number" ? data.requestedAt : 0,
2754
+ purgeRequestDocId,
2755
+ };
2756
+ }
2757
+ /**
2758
+ * List all purge-request documents at the directory head as
2759
+ * {@link DocHistoryPurgeView}s (decrypting `reason` when the tenant default
2760
+ * key is held; null-tolerant otherwise).
2761
+ */
2762
+ async listDocHistoryPurges() {
541
2763
  const directoryDB = await this.getDirectoryDB();
542
2764
  await directoryDB.syncStoreChanges();
543
- const purgeRequests = [];
544
- // No signature verification needed - DB already enforces admin-only
545
- for await (const { doc } of directoryDB.iterateChangesSince(null)) {
546
- const data = doc.getData();
547
- if (data.form === "useroperation" && data.type === "requestdochistorypurge") {
548
- const dbId = data.dbId;
549
- const docId = data.docId;
550
- const reason = data.reason;
551
- const requestedAt = data.requestedAt;
552
- if (typeof dbId === "string" && typeof docId === "string") {
553
- purgeRequests.push({
554
- dbId: dbId,
555
- docId: docId,
556
- reason: typeof reason === "string" ? reason : undefined,
557
- requestedAt: typeof requestedAt === "number" ? requestedAt : Date.now(),
558
- purgeRequestDocId: doc.getId(),
559
- });
560
- }
2765
+ const allIds = await directoryDB.getAllDocumentIds();
2766
+ const ids = allIds.filter((id) => id.startsWith(types_3.ACL_DOC_HISTORY_PURGE_PREFIX));
2767
+ const views = [];
2768
+ for (const id of ids) {
2769
+ let doc;
2770
+ try {
2771
+ doc = await directoryDB.getDocument(id);
2772
+ }
2773
+ catch {
2774
+ continue;
561
2775
  }
2776
+ if (!doc)
2777
+ continue;
2778
+ const view = await this.toDocHistoryPurgeView(doc.getData(), id);
2779
+ if (view)
2780
+ views.push(view);
2781
+ }
2782
+ return views;
2783
+ }
2784
+ /** Delete a still-pending purge request document (admin-signed). */
2785
+ async deleteDocHistoryPurge(requestId, administrationPrivateKey, administrationPrivateKeyPassword) {
2786
+ const baseTenant = this.tenant;
2787
+ const directoryDB = await this.getDirectoryDB();
2788
+ const adminSigningKeyPair = {
2789
+ publicKey: baseTenant.getAdministrationPublicKey(),
2790
+ privateKey: administrationPrivateKey,
2791
+ };
2792
+ try {
2793
+ await directoryDB.deleteDocument((0, types_3.aclDocHistoryPurgeDocId)(requestId), {
2794
+ signingKeyPair: adminSigningKeyPair,
2795
+ signingKeyPassword: administrationPrivateKeyPassword,
2796
+ });
2797
+ }
2798
+ catch (error) {
2799
+ this.logger.debug(`deleteDocHistoryPurge: ${requestId} not found or already deleted: ${error}`);
562
2800
  }
563
- return purgeRequests;
2801
+ }
2802
+ async getRequestedDocHistoryPurges() {
2803
+ const views = await this.listDocHistoryPurges();
2804
+ return views.map((view) => ({
2805
+ requestId: view.requestId,
2806
+ dbId: view.dbId,
2807
+ docIds: view.docIds,
2808
+ reason: view.reason ?? undefined,
2809
+ requestedAt: view.requestedAt,
2810
+ purgeRequestDocId: view.purgeRequestDocId,
2811
+ }));
564
2812
  }
565
2813
  async getTenantSettings() {
566
2814
  const directoryDB = await this.getDirectoryDB();
@@ -678,8 +2926,9 @@ class BaseMindooTenantDirectory {
678
2926
  publicKey: baseTenant.getAdministrationPublicKey(),
679
2927
  privateKey: administrationPrivateKey,
680
2928
  };
681
- // Compute hashes and encrypted values for all new usernames
682
- const newMembersHashes = await Promise.all(username.map(u => this.hashUsername(u)));
2929
+ // Compute hashes (salted v2 for new writes) and encrypted values for all
2930
+ // new usernames. Reads match both v1 and v2 forms (docs/accesscontrol.md §6.5).
2931
+ const newMembersHashes = await Promise.all(username.map(u => this.hashUsernameForWrite(u)));
683
2932
  const newMembersEncrypted = await Promise.all(username.map(u => this.encryptGroupMemberForTenant(u)));
684
2933
  // Get the actual document from the database (using cached docId if available)
685
2934
  const cachedGroup = this.groupsCache.get(normalizedGroupName);
@@ -747,8 +2996,9 @@ class BaseMindooTenantDirectory {
747
2996
  publicKey: baseTenant.getAdministrationPublicKey(),
748
2997
  privateKey: administrationPrivateKey,
749
2998
  };
750
- // Compute hashes of usernames to remove
751
- const hashesToRemove = new Set(await Promise.all(username.map(u => this.hashUsername(u))));
2999
+ // Compute hashes of usernames to remove. Include both legacy (v1) and
3000
+ // salted (v2) forms so members stored under either scheme are removed.
3001
+ const hashesToRemove = new Set((await Promise.all(username.map(u => this.usernameHashCandidates(u)))).flat());
752
3002
  // Remove users from members arrays
753
3003
  await directoryDB.changeDoc(groupDoc, async (d) => {
754
3004
  const data = d.getData();
@@ -819,15 +3069,79 @@ class BaseMindooTenantDirectory {
819
3069
  return Array.from(result);
820
3070
  }
821
3071
  /**
822
- * Compute SHA-256 hash of lowercase username for lookups.
823
- * This enables searching for users without exposing the actual username.
3072
+ * Compute the precomputed identity-hash bundle for a cleartext username
3073
+ * (docs/accesscontrol.md §6.5): the flattened, deduped v1+v2 hashes of every
3074
+ * DN-hierarchy variant from {@link generateUsernameVariants}. Stored on the
3075
+ * grant doc so the server can match wildcard/group read rules in hash space
3076
+ * without ever holding the cleartext name. Only username variant hashes are
3077
+ * included; group membership stays dynamic (resolved at read time).
3078
+ */
3079
+ async computeIdentityHashes(username) {
3080
+ const variants = this.generateUsernameVariants(username);
3081
+ const hashes = new Set();
3082
+ for (const variant of variants) {
3083
+ for (const h of await this.usernameHashCandidates(variant)) {
3084
+ hashes.add(h);
3085
+ }
3086
+ }
3087
+ return Array.from(hashes);
3088
+ }
3089
+ /**
3090
+ * Legacy (v1) username hash: `SHA-256(lower(username))`, hex-encoded.
3091
+ *
3092
+ * Retained because pre-existing directory documents (and clients that predate
3093
+ * the salted scheme) store this form, and lookups must keep matching them.
824
3094
  *
825
3095
  * @param username The username to hash
826
3096
  * @return The hex-encoded SHA-256 hash of the lowercase username
827
3097
  */
828
3098
  async hashUsername(username) {
3099
+ return this.sha256Hex(username.toLowerCase());
3100
+ }
3101
+ /**
3102
+ * Salted (v2) username hash: `SHA-256(tenantId + "/" + lower(username))`,
3103
+ * hex-encoded. The tenant id salt prevents precomputed/rainbow-table attacks
3104
+ * and cross-tenant hash correlation. New documents write this form
3105
+ * (docs/accesscontrol.md §6.5).
3106
+ */
3107
+ async hashUsernameSalted(username) {
3108
+ return this.sha256Hex(`${this.tenant.getId()}/${username.toLowerCase()}`);
3109
+ }
3110
+ /**
3111
+ * NFKC-normalized salted (v3) username hash:
3112
+ * `SHA-256(tenantId + "/" + lower(NFKC(username)))`, hex-encoded. NFKC
3113
+ * canonicalizes Unicode so visually-equivalent or differently-encoded forms
3114
+ * collapse to one hash, blocking homoglyph/normalization impersonation. New
3115
+ * documents write this form. For pure-ASCII usernames it equals v2, so
3116
+ * existing common cases are unaffected.
3117
+ */
3118
+ async hashUsernameSaltedNormalized(username) {
3119
+ return this.sha256Hex(`${this.tenant.getId()}/${username.normalize("NFKC").toLowerCase()}`);
3120
+ }
3121
+ /**
3122
+ * The canonical hash to WRITE on new directory documents (currently v3,
3123
+ * NFKC-normalized salted).
3124
+ */
3125
+ async hashUsernameForWrite(username) {
3126
+ return this.hashUsernameSaltedNormalized(username);
3127
+ }
3128
+ /**
3129
+ * All hash forms a given username could be stored as, for MATCHING/lookup.
3130
+ * Returns the legacy (v1), salted (v2) and NFKC-normalized salted (v3) hashes
3131
+ * so that documents written under any scheme are found. Deduplicated.
3132
+ */
3133
+ async usernameHashCandidates(username) {
3134
+ const [legacy, salted, saltedNormalized] = await Promise.all([
3135
+ this.hashUsername(username),
3136
+ this.hashUsernameSalted(username),
3137
+ this.hashUsernameSaltedNormalized(username),
3138
+ ]);
3139
+ return Array.from(new Set([legacy, salted, saltedNormalized]));
3140
+ }
3141
+ /** Hex-encoded SHA-256 of a UTF-8 string. */
3142
+ async sha256Hex(value) {
829
3143
  const encoder = new TextEncoder();
830
- const data = encoder.encode(username.toLowerCase());
3144
+ const data = encoder.encode(value);
831
3145
  const subtle = this.tenant.getCryptoAdapter().getSubtle();
832
3146
  const hashBuffer = await subtle.digest('SHA-256', data);
833
3147
  return Array.from(new Uint8Array(hashBuffer))
@@ -853,11 +3167,18 @@ class BaseMindooTenantDirectory {
853
3167
  const encrypted = await this.tenant.encryptPayload(payload, types_1.DEFAULT_TENANT_KEY_ID);
854
3168
  return this.uint8ArrayToBase64(encrypted);
855
3169
  }
856
- async decryptUserDetailsForTenant(encryptedBase64) {
3170
+ /**
3171
+ * Decrypt the `user_details_encrypted` blob on a grant document. Uses the
3172
+ * shared `_encrypted` / `_encrypted_key` field convention so the key id is
3173
+ * resolved from the optional `user_details_encrypted_key` companion field,
3174
+ * defaulting to the tenant default key for legacy grants that lack it.
3175
+ */
3176
+ async decryptUserDetailsForTenant(data) {
3177
+ const decoded = await (0, encryptedFields_1.decryptEncryptedField)(this.tenant, data, "user_details_encrypted");
3178
+ if (decoded === null) {
3179
+ return null;
3180
+ }
857
3181
  try {
858
- const encrypted = this.base64ToUint8Array(encryptedBase64);
859
- const decrypted = await this.tenant.decryptPayload(encrypted, types_1.DEFAULT_TENANT_KEY_ID);
860
- const decoded = new TextDecoder().decode(decrypted);
861
3182
  const parsed = JSON.parse(decoded);
862
3183
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
863
3184
  return null;
@@ -871,25 +3192,42 @@ class BaseMindooTenantDirectory {
871
3192
  return Object.keys(details).length ? details : null;
872
3193
  }
873
3194
  catch (error) {
874
- this.logger.debug("Could not decrypt tenant-readable user details, treating entry as legacy or inaccessible", error);
3195
+ this.logger.debug("Could not parse tenant-readable user details, treating entry as legacy or inaccessible", error);
875
3196
  return null;
876
3197
  }
877
3198
  }
878
3199
  async buildUserLookup(data) {
879
- const signingPublicKey = typeof data.userSigningPublicKey === "string" ? data.userSigningPublicKey : null;
880
- const encryptionPublicKey = typeof data.userEncryptionPublicKey === "string" ? data.userEncryptionPublicKey : null;
3200
+ // Prefer the array key form (key rollover / multiple devices) and fall back
3201
+ // to the legacy scalar fields (§6.5).
3202
+ const signingPublicKey = (0, grantKeys_1.extractSigningPublicKeys)(data)[0] ?? null;
3203
+ const encryptionPublicKey = (0, grantKeys_1.extractEncryptionPublicKeys)(data)[0] ?? null;
881
3204
  if (!signingPublicKey || !encryptionPublicKey) {
882
3205
  return null;
883
3206
  }
884
3207
  const encryptedDetails = typeof data.user_details_encrypted === "string" ? data.user_details_encrypted : null;
885
- const details = encryptedDetails ? await this.decryptUserDetailsForTenant(encryptedDetails) : null;
3208
+ const details = encryptedDetails ? await this.decryptUserDetailsForTenant(data) : null;
886
3209
  const username = details?.username
887
3210
  ?? (typeof data.username_hash === "string" ? data.username_hash : "");
3211
+ // Surface the precomputed identity-hash bundle (§6.5). Legacy grants written
3212
+ // before the bundle existed have neither field; fall back to the single
3213
+ // username_hash (exact-match only, version 0) so the reader can flag the
3214
+ // grant for backfill.
3215
+ const usernameHash = typeof data.username_hash === "string" ? data.username_hash : null;
3216
+ const identityHashes = Array.isArray(data.identity_hashes)
3217
+ ? data.identity_hashes.filter((h) => typeof h === "string" && h.length > 0)
3218
+ : usernameHash
3219
+ ? [usernameHash]
3220
+ : [];
3221
+ const identityHashesV = typeof data.identity_hashes_v === "number"
3222
+ ? data.identity_hashes_v
3223
+ : 0;
888
3224
  return {
889
3225
  username,
890
3226
  signingPublicKey,
891
3227
  encryptionPublicKey,
892
3228
  details,
3229
+ identityHashes,
3230
+ identityHashesV,
893
3231
  };
894
3232
  }
895
3233
  async encryptGroupMemberForTenant(plaintext) {
@@ -897,6 +3235,68 @@ class BaseMindooTenantDirectory {
897
3235
  const encrypted = await this.tenant.encryptPayload(encoder.encode(plaintext), types_1.DEFAULT_TENANT_KEY_ID);
898
3236
  return this.uint8ArrayToBase64(encrypted);
899
3237
  }
3238
+ /**
3239
+ * Encrypt a rule's cleartext targets (usernames + groups) with the tenant
3240
+ * default key so admin UIs can display who a rule targets without reversing
3241
+ * the salted hashes. Mirrors {@link encryptUserDetailsForTenant}: the blob is
3242
+ * opaque to the sync server (it never holds the tenant key) but readable by
3243
+ * tenant clients. Returns "" when there is nothing cleartext to store (e.g. a
3244
+ * rule authored from raw hashes / pseudo-tokens only).
3245
+ */
3246
+ /**
3247
+ * Resolve a rule's targets (explicit `users_hashes` + named `usernames` /
3248
+ * `groups`) to the salted hash set evaluation matches against. Usernames are
3249
+ * hashed as-is; **group names are normalized (lowercased) first** so they
3250
+ * match the identity set, which hashes the normalized group names returned by
3251
+ * {@link resolveGroupsForUser} (the group cache is keyed by
3252
+ * {@link normalizeGroupName}). Without this, a mixed-case group target like
3253
+ * "Analysts" would never intersect a member's identity (hashed as "analysts").
3254
+ */
3255
+ async resolveRuleTargetHashes(rule) {
3256
+ const resolvedHashes = new Set(rule.users_hashes ?? []);
3257
+ for (const name of rule.usernames ?? []) {
3258
+ resolvedHashes.add(await this.hashUsernameForWrite(name));
3259
+ }
3260
+ for (const group of rule.groups ?? []) {
3261
+ resolvedHashes.add(await this.hashUsernameForWrite(this.normalizeGroupName(group)));
3262
+ }
3263
+ return resolvedHashes;
3264
+ }
3265
+ async encryptRuleTargetsForTenant(targets) {
3266
+ const usernames = targets.usernames.filter((u) => typeof u === "string" && u.trim());
3267
+ const groups = targets.groups.filter((g) => typeof g === "string" && g.trim());
3268
+ if (usernames.length === 0 && groups.length === 0) {
3269
+ return "";
3270
+ }
3271
+ const payload = new TextEncoder().encode(JSON.stringify({ usernames, groups }));
3272
+ const encrypted = await this.tenant.encryptPayload(payload, types_1.DEFAULT_TENANT_KEY_ID);
3273
+ return this.uint8ArrayToBase64(encrypted);
3274
+ }
3275
+ /**
3276
+ * Decrypt a rule's {@link RuleTargets} display blob. Returns `null` for an
3277
+ * empty/unreadable blob (e.g. a client lacking the tenant default key), in
3278
+ * which case callers fall back to showing the raw `users_hashes`.
3279
+ */
3280
+ async decryptRuleTargetsForTenant(encryptedBase64) {
3281
+ if (!encryptedBase64)
3282
+ return null;
3283
+ try {
3284
+ const encrypted = this.base64ToUint8Array(encryptedBase64);
3285
+ const decrypted = await this.tenant.decryptPayload(encrypted, types_1.DEFAULT_TENANT_KEY_ID);
3286
+ const parsed = JSON.parse(new TextDecoder().decode(decrypted));
3287
+ const usernames = Array.isArray(parsed.usernames)
3288
+ ? parsed.usernames.filter((u) => typeof u === "string")
3289
+ : [];
3290
+ const groups = Array.isArray(parsed.groups)
3291
+ ? parsed.groups.filter((g) => typeof g === "string")
3292
+ : [];
3293
+ return { usernames, groups };
3294
+ }
3295
+ catch (error) {
3296
+ this.logger.debug("Could not decrypt rule targets, falling back to hashes", error);
3297
+ return null;
3298
+ }
3299
+ }
900
3300
  async decryptGroupMemberForTenant(encryptedBase64) {
901
3301
  try {
902
3302
  const encrypted = this.base64ToUint8Array(encryptedBase64);
@@ -929,4 +3329,27 @@ class BaseMindooTenantDirectory {
929
3329
  }
930
3330
  }
931
3331
  exports.BaseMindooTenantDirectory = BaseMindooTenantDirectory;
3332
+ /**
3333
+ * Current username-hash scheme version written on new directory documents.
3334
+ * v1 = unsalted `SHA-256(lower(username))` (legacy);
3335
+ * v2 = tenant-salted `SHA-256(tenantId + "/" + lower(username))`;
3336
+ * v3 = tenant-salted over the **NFKC-normalized** lowercase username,
3337
+ * `SHA-256(tenantId + "/" + lower(NFKC(username)))`.
3338
+ * v3 defends against Unicode homoglyph/normalization tricks (e.g. fullwidth
3339
+ * or decomposed forms that look identical but hash differently), so an
3340
+ * attacker cannot register a visually-equal variant of a privileged name and
3341
+ * have it treated as a distinct identity. See docs/accesscontrol.md §6.5
3342
+ * ("Username hashing"). Matching keeps v1/v2/v3 candidates so documents
3343
+ * written under any prior scheme still resolve (backward compatible).
3344
+ */
3345
+ BaseMindooTenantDirectory.USERNAME_HASH_VERSION = 3;
3346
+ /**
3347
+ * Version of the identity-hash bundle (`identity_hashes`) variant-generation
3348
+ * algorithm (docs/accesscontrol.md §6.5). This is a SEPARATE axis from
3349
+ * {@link USERNAME_HASH_VERSION} (which versions only the hash scheme): it
3350
+ * tracks how completely {@link generateUsernameVariants} enumerated the
3351
+ * DN-hierarchy wildcards. Bump it whenever `generateUsernameVariants` is
3352
+ * extended so readers can detect stale bundles and trigger a backfill.
3353
+ */
3354
+ BaseMindooTenantDirectory.IDENTITY_VARIANTS_VERSION = 1;
932
3355
  //# sourceMappingURL=BaseMindooTenantDirectory.js.map