mindoodb 0.0.51 → 0.0.54

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 (249) hide show
  1. package/README-server.md +136 -71
  2. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.d.ts +30 -1
  3. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.d.ts.map +1 -1
  4. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.js +67 -20
  5. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.js.map +1 -1
  6. package/dist/appendonlystores/network/HttpTransport.d.ts +2 -0
  7. package/dist/appendonlystores/network/HttpTransport.d.ts.map +1 -1
  8. package/dist/appendonlystores/network/HttpTransport.js +6 -62
  9. package/dist/appendonlystores/network/HttpTransport.js.map +1 -1
  10. package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.d.ts +82 -0
  11. package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.d.ts.map +1 -1
  12. package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.js +229 -25
  13. package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.js.map +1 -1
  14. package/dist/browser/index.mjs +59 -0
  15. package/dist/core/BaseMindooDB.d.ts +148 -80
  16. package/dist/core/BaseMindooDB.d.ts.map +1 -1
  17. package/dist/core/BaseMindooDB.js +890 -692
  18. package/dist/core/BaseMindooDB.js.map +1 -1
  19. package/dist/core/BaseMindooTenant.d.ts +59 -7
  20. package/dist/core/BaseMindooTenant.d.ts.map +1 -1
  21. package/dist/core/BaseMindooTenant.js +407 -86
  22. package/dist/core/BaseMindooTenant.js.map +1 -1
  23. package/dist/core/BaseMindooTenantDirectory.d.ts +61 -9
  24. package/dist/core/BaseMindooTenantDirectory.d.ts.map +1 -1
  25. package/dist/core/BaseMindooTenantDirectory.js +363 -35
  26. package/dist/core/BaseMindooTenantDirectory.js.map +1 -1
  27. package/dist/core/BaseMindooTenantFactory.d.ts +17 -1
  28. package/dist/core/BaseMindooTenantFactory.d.ts.map +1 -1
  29. package/dist/core/BaseMindooTenantFactory.js +286 -17
  30. package/dist/core/BaseMindooTenantFactory.js.map +1 -1
  31. package/dist/core/MindooDBServerAdmin.d.ts +44 -22
  32. package/dist/core/MindooDBServerAdmin.d.ts.map +1 -1
  33. package/dist/core/MindooDBServerAdmin.js +45 -21
  34. package/dist/core/MindooDBServerAdmin.js.map +1 -1
  35. package/dist/core/accesscontrol/DirectoryStateNode.d.ts +68 -1
  36. package/dist/core/accesscontrol/DirectoryStateNode.d.ts.map +1 -1
  37. package/dist/core/accesscontrol/DirectoryStateNode.js +157 -4
  38. package/dist/core/accesscontrol/DirectoryStateNode.js.map +1 -1
  39. package/dist/core/accesscontrol/directoryProjection.d.ts.map +1 -1
  40. package/dist/core/accesscontrol/directoryProjection.js +14 -5
  41. package/dist/core/accesscontrol/directoryProjection.js.map +1 -1
  42. package/dist/core/accesscontrol/grantKeys.d.ts +17 -14
  43. package/dist/core/accesscontrol/grantKeys.d.ts.map +1 -1
  44. package/dist/core/accesscontrol/grantKeys.js +51 -21
  45. package/dist/core/accesscontrol/grantKeys.js.map +1 -1
  46. package/dist/core/accesscontrol/types.d.ts +2 -1
  47. package/dist/core/accesscontrol/types.d.ts.map +1 -1
  48. package/dist/core/accesscontrol/types.js +3 -2
  49. package/dist/core/accesscontrol/types.js.map +1 -1
  50. package/dist/core/appendonlystores/network/AuthenticationService.d.ts +7 -0
  51. package/dist/core/appendonlystores/network/AuthenticationService.d.ts.map +1 -1
  52. package/dist/core/appendonlystores/network/AuthenticationService.js +70 -14
  53. package/dist/core/appendonlystores/network/AuthenticationService.js.map +1 -1
  54. package/dist/core/appendonlystores/network/entryWireCodec.d.ts +51 -0
  55. package/dist/core/appendonlystores/network/entryWireCodec.d.ts.map +1 -0
  56. package/dist/core/appendonlystores/network/entryWireCodec.js +93 -0
  57. package/dist/core/appendonlystores/network/entryWireCodec.js.map +1 -0
  58. package/dist/core/appendonlystores/syncStores.d.ts +147 -0
  59. package/dist/core/appendonlystores/syncStores.d.ts.map +1 -0
  60. package/dist/core/appendonlystores/syncStores.js +612 -0
  61. package/dist/core/appendonlystores/syncStores.js.map +1 -0
  62. package/dist/core/builtinDbInvariants.d.ts +83 -0
  63. package/dist/core/builtinDbInvariants.d.ts.map +1 -0
  64. package/dist/core/builtinDbInvariants.js +244 -0
  65. package/dist/core/builtinDbInvariants.js.map +1 -0
  66. package/dist/core/copy/feasibility.d.ts.map +1 -1
  67. package/dist/core/copy/feasibility.js +3 -0
  68. package/dist/core/copy/feasibility.js.map +1 -1
  69. package/dist/core/copy/flatten.d.ts.map +1 -1
  70. package/dist/core/copy/flatten.js +6 -1
  71. package/dist/core/copy/flatten.js.map +1 -1
  72. package/dist/core/copy/host.d.ts +5 -0
  73. package/dist/core/copy/host.d.ts.map +1 -1
  74. package/dist/core/copy/preflight.d.ts.map +1 -1
  75. package/dist/core/copy/preflight.js +7 -1
  76. package/dist/core/copy/preflight.js.map +1 -1
  77. package/dist/core/copy/types.d.ts +7 -0
  78. package/dist/core/copy/types.d.ts.map +1 -1
  79. package/dist/core/crypto/EntrySignature.d.ts +8 -2
  80. package/dist/core/crypto/EntrySignature.d.ts.map +1 -1
  81. package/dist/core/crypto/EntrySignature.js +14 -5
  82. package/dist/core/crypto/EntrySignature.js.map +1 -1
  83. package/dist/core/crypto/MindooDocSigner.d.ts.map +1 -1
  84. package/dist/core/crypto/MindooDocSigner.js +11 -2
  85. package/dist/core/crypto/MindooDocSigner.js.map +1 -1
  86. package/dist/core/crypto/RSAEncryption.d.ts +19 -0
  87. package/dist/core/crypto/RSAEncryption.d.ts.map +1 -1
  88. package/dist/core/crypto/RSAEncryption.js +36 -3
  89. package/dist/core/crypto/RSAEncryption.js.map +1 -1
  90. package/dist/core/crypto/privateKeyEncryption.js +1 -1
  91. package/dist/core/crypto/privateKeyEncryption.js.map +1 -1
  92. package/dist/core/crypto/rsaOaep3072.d.ts +12 -0
  93. package/dist/core/crypto/rsaOaep3072.d.ts.map +1 -0
  94. package/dist/core/crypto/rsaOaep3072.js +30 -0
  95. package/dist/core/crypto/rsaOaep3072.js.map +1 -0
  96. package/dist/core/index.d.ts +7 -3
  97. package/dist/core/index.d.ts.map +1 -1
  98. package/dist/core/index.js +33 -4
  99. package/dist/core/index.js.map +1 -1
  100. package/dist/core/join/deviceJoinBootstrap.d.ts +23 -0
  101. package/dist/core/join/deviceJoinBootstrap.d.ts.map +1 -0
  102. package/dist/core/join/deviceJoinBootstrap.js +37 -0
  103. package/dist/core/join/deviceJoinBootstrap.js.map +1 -0
  104. package/dist/core/keys/KeyBag.d.ts +20 -2
  105. package/dist/core/keys/KeyBag.d.ts.map +1 -1
  106. package/dist/core/keys/KeyBag.js +22 -10
  107. package/dist/core/keys/KeyBag.js.map +1 -1
  108. package/dist/core/logging/MindooLogger.d.ts.map +1 -1
  109. package/dist/core/logging/MindooLogger.js +65 -40
  110. package/dist/core/logging/MindooLogger.js.map +1 -1
  111. package/dist/core/tenantIdValidation.d.ts.map +1 -1
  112. package/dist/core/tenantIdValidation.js +1 -1
  113. package/dist/core/tenantIdValidation.js.map +1 -1
  114. package/dist/core/tenantSetup.d.ts +94 -9
  115. package/dist/core/tenantSetup.d.ts.map +1 -1
  116. package/dist/core/tenantSetup.js +208 -20
  117. package/dist/core/tenantSetup.js.map +1 -1
  118. package/dist/core/types.d.ts +313 -33
  119. package/dist/core/types.d.ts.map +1 -1
  120. package/dist/core/types.js +14 -1
  121. package/dist/core/types.js.map +1 -1
  122. package/dist/core/uri/joinRequestUri.d.ts.map +1 -1
  123. package/dist/core/uri/joinRequestUri.js +12 -3
  124. package/dist/core/uri/joinRequestUri.js.map +1 -1
  125. package/dist/core/userid/canonicalUsername.d.ts +66 -0
  126. package/dist/core/userid/canonicalUsername.d.ts.map +1 -0
  127. package/dist/core/userid/canonicalUsername.js +235 -0
  128. package/dist/core/userid/canonicalUsername.js.map +1 -0
  129. package/dist/core/userid/index.d.ts +9 -0
  130. package/dist/core/userid/index.d.ts.map +1 -1
  131. package/dist/core/userid/index.js +14 -0
  132. package/dist/core/userid/index.js.map +1 -1
  133. package/dist/core/userkeys/UserKeyDocument.d.ts +50 -0
  134. package/dist/core/userkeys/UserKeyDocument.d.ts.map +1 -0
  135. package/dist/core/userkeys/UserKeyDocument.js +145 -0
  136. package/dist/core/userkeys/UserKeyDocument.js.map +1 -0
  137. package/dist/core/userkeys/UserKeyManager.d.ts +170 -0
  138. package/dist/core/userkeys/UserKeyManager.d.ts.map +1 -0
  139. package/dist/core/userkeys/UserKeyManager.js +1277 -0
  140. package/dist/core/userkeys/UserKeyManager.js.map +1 -0
  141. package/dist/core/userkeys/fingerprint.d.ts +17 -0
  142. package/dist/core/userkeys/fingerprint.d.ts.map +1 -0
  143. package/dist/core/userkeys/fingerprint.js +59 -0
  144. package/dist/core/userkeys/fingerprint.js.map +1 -0
  145. package/dist/core/userkeys/index.d.ts +11 -0
  146. package/dist/core/userkeys/index.d.ts.map +1 -0
  147. package/dist/core/userkeys/index.js +49 -0
  148. package/dist/core/userkeys/index.js.map +1 -0
  149. package/dist/core/userkeys/recipients.d.ts +51 -0
  150. package/dist/core/userkeys/recipients.d.ts.map +1 -0
  151. package/dist/core/userkeys/recipients.js +292 -0
  152. package/dist/core/userkeys/recipients.js.map +1 -0
  153. package/dist/core/userkeys/resolveUserKeyDocument.d.ts +21 -0
  154. package/dist/core/userkeys/resolveUserKeyDocument.d.ts.map +1 -0
  155. package/dist/core/userkeys/resolveUserKeyDocument.js +98 -0
  156. package/dist/core/userkeys/resolveUserKeyDocument.js.map +1 -0
  157. package/dist/core/userkeys/sealedCrypto.d.ts +21 -0
  158. package/dist/core/userkeys/sealedCrypto.d.ts.map +1 -0
  159. package/dist/core/userkeys/sealedCrypto.js +79 -0
  160. package/dist/core/userkeys/sealedCrypto.js.map +1 -0
  161. package/dist/core/userkeys/sealedTypes.d.ts +72 -0
  162. package/dist/core/userkeys/sealedTypes.d.ts.map +1 -0
  163. package/dist/core/userkeys/sealedTypes.js +14 -0
  164. package/dist/core/userkeys/sealedTypes.js.map +1 -0
  165. package/dist/core/userkeys/types.d.ts +70 -0
  166. package/dist/core/userkeys/types.d.ts.map +1 -0
  167. package/dist/core/userkeys/types.js +48 -0
  168. package/dist/core/userkeys/types.js.map +1 -0
  169. package/dist/core/userkeys/validateUserKeyDocument.d.ts +19 -0
  170. package/dist/core/userkeys/validateUserKeyDocument.d.ts.map +1 -0
  171. package/dist/core/userkeys/validateUserKeyDocument.js +42 -0
  172. package/dist/core/userkeys/validateUserKeyDocument.js.map +1 -0
  173. package/dist/node/server/CapabilityMatcher.d.ts +7 -2
  174. package/dist/node/server/CapabilityMatcher.d.ts.map +1 -1
  175. package/dist/node/server/CapabilityMatcher.js +12 -3
  176. package/dist/node/server/CapabilityMatcher.js.map +1 -1
  177. package/dist/node/server/DeviceDiscoveryService.d.ts +36 -0
  178. package/dist/node/server/DeviceDiscoveryService.d.ts.map +1 -0
  179. package/dist/node/server/DeviceDiscoveryService.js +165 -0
  180. package/dist/node/server/DeviceDiscoveryService.js.map +1 -0
  181. package/dist/node/server/MindooDBServer.d.ts +27 -1
  182. package/dist/node/server/MindooDBServer.d.ts.map +1 -1
  183. package/dist/node/server/MindooDBServer.js +265 -220
  184. package/dist/node/server/MindooDBServer.js.map +1 -1
  185. package/dist/node/server/SyncEventBus.d.ts +10 -0
  186. package/dist/node/server/SyncEventBus.d.ts.map +1 -1
  187. package/dist/node/server/SyncEventBus.js.map +1 -1
  188. package/dist/node/server/SystemAdminAuth.d.ts +7 -0
  189. package/dist/node/server/SystemAdminAuth.d.ts.map +1 -1
  190. package/dist/node/server/SystemAdminAuth.js +4 -1
  191. package/dist/node/server/SystemAdminAuth.js.map +1 -1
  192. package/dist/node/server/TenantManager.d.ts +86 -5
  193. package/dist/node/server/TenantManager.d.ts.map +1 -1
  194. package/dist/node/server/TenantManager.js +235 -71
  195. package/dist/node/server/TenantManager.js.map +1 -1
  196. package/dist/node/server/add-to-network.d.ts.map +1 -1
  197. package/dist/node/server/add-to-network.js +33 -15
  198. package/dist/node/server/add-to-network.js.map +1 -1
  199. package/dist/node/server/canonicalSetupName.d.ts +22 -0
  200. package/dist/node/server/canonicalSetupName.d.ts.map +1 -0
  201. package/dist/node/server/canonicalSetupName.js +38 -0
  202. package/dist/node/server/canonicalSetupName.js.map +1 -0
  203. package/dist/node/server/config.d.ts.map +1 -1
  204. package/dist/node/server/config.js +33 -1
  205. package/dist/node/server/config.js.map +1 -1
  206. package/dist/node/server/peer/ClusterAuditLog.d.ts +64 -0
  207. package/dist/node/server/peer/ClusterAuditLog.d.ts.map +1 -0
  208. package/dist/node/server/peer/ClusterAuditLog.js +99 -0
  209. package/dist/node/server/peer/ClusterAuditLog.js.map +1 -0
  210. package/dist/node/server/peer/ClusterJobRunner.d.ts +43 -0
  211. package/dist/node/server/peer/ClusterJobRunner.d.ts.map +1 -0
  212. package/dist/node/server/peer/ClusterJobRunner.js +119 -0
  213. package/dist/node/server/peer/ClusterJobRunner.js.map +1 -0
  214. package/dist/node/server/peer/ClusterManager.d.ts +116 -0
  215. package/dist/node/server/peer/ClusterManager.d.ts.map +1 -0
  216. package/dist/node/server/peer/ClusterManager.js +274 -0
  217. package/dist/node/server/peer/ClusterManager.js.map +1 -0
  218. package/dist/node/server/peer/PeerAuthService.d.ts +69 -0
  219. package/dist/node/server/peer/PeerAuthService.d.ts.map +1 -0
  220. package/dist/node/server/peer/PeerAuthService.js +208 -0
  221. package/dist/node/server/peer/PeerAuthService.js.map +1 -0
  222. package/dist/node/server/peer/PeerReplicator.d.ts +232 -0
  223. package/dist/node/server/peer/PeerReplicator.d.ts.map +1 -0
  224. package/dist/node/server/peer/PeerReplicator.js +879 -0
  225. package/dist/node/server/peer/PeerReplicator.js.map +1 -0
  226. package/dist/node/server/peer/routes.d.ts +66 -0
  227. package/dist/node/server/peer/routes.d.ts.map +1 -0
  228. package/dist/node/server/peer/routes.js +322 -0
  229. package/dist/node/server/peer/routes.js.map +1 -0
  230. package/dist/node/server/peer/types.d.ts +244 -0
  231. package/dist/node/server/peer/types.d.ts.map +1 -0
  232. package/dist/node/server/peer/types.js +189 -0
  233. package/dist/node/server/peer/types.js.map +1 -0
  234. package/dist/node/server/server.d.ts +1 -1
  235. package/dist/node/server/server.js +18 -37
  236. package/dist/node/server/server.js.map +1 -1
  237. package/dist/node/server/serverinit.d.ts +2 -1
  238. package/dist/node/server/serverinit.d.ts.map +1 -1
  239. package/dist/node/server/serverinit.js +34 -12
  240. package/dist/node/server/serverinit.js.map +1 -1
  241. package/dist/node/server/types.d.ts +42 -30
  242. package/dist/node/server/types.d.ts.map +1 -1
  243. package/dist/node/server/types.js +1 -7
  244. package/dist/node/server/types.js.map +1 -1
  245. package/dist/node/server/validation.d.ts +0 -6
  246. package/dist/node/server/validation.d.ts.map +1 -1
  247. package/dist/node/server/validation.js +1 -7
  248. package/dist/node/server/validation.js.map +1 -1
  249. package/package.json +5 -5
@@ -9,7 +9,7 @@ const storeEntryTime_1 = require("./storeEntryTime");
9
9
  const uuid_1 = require("uuid");
10
10
  const types_1 = require("./types");
11
11
  const types_2 = require("./appendonlystores/types");
12
- const bloom_1 = require("./appendonlystores/bloom");
12
+ const syncStores_1 = require("./appendonlystores/syncStores");
13
13
  const types_3 = require("./appendonlystores/network/types");
14
14
  const MaterializationPlanner_1 = require("./appendonlystores/MaterializationPlanner");
15
15
  const DocumentDagAnalysis_1 = require("./DocumentDagAnalysis");
@@ -24,8 +24,12 @@ const copyDocuments_1 = require("./copy/copyDocuments");
24
24
  const materializationGuard_1 = require("./accesscontrol/materializationGuard");
25
25
  const Ed25519WitnessProvider_1 = require("./accesscontrol/timestamp/Ed25519WitnessProvider");
26
26
  const AccessDeniedError_1 = require("./accesscontrol/AccessDeniedError");
27
+ const DirectoryStateNode_1 = require("./accesscontrol/DirectoryStateNode");
27
28
  const logging_1 = require("./logging");
28
29
  const databaseIdValidation_1 = require("./databaseIdValidation");
30
+ const builtinDbInvariants_1 = require("./builtinDbInvariants");
31
+ const recipients_1 = require("./userkeys/recipients");
32
+ const sealedTypes_1 = require("./userkeys/sealedTypes");
29
33
  const DocumentSummaryStore_1 = require("./indexing/summary/DocumentSummaryStore");
30
34
  const types_4 = require("./indexing/summary/types");
31
35
  const DocumentFullTextIndex_1 = require("./indexing/fulltext/DocumentFullTextIndex");
@@ -219,12 +223,13 @@ class BaseMindooDB {
219
223
  */
220
224
  this.aclActiveCache = null;
221
225
  /**
222
- * Per-document cache of the original creator's signing public key (the
223
- * `doc_create` entry's `createdByPublicKey`). Used by the client write
224
- * prechecks to resolve `$author` (`isAuthor`) without re-scanning metadata on
225
- * every change/delete. Populated on create and lazily on first lookup.
226
+ * Per-document cache of the original creator's signing public key and the
227
+ * trusted time of the `doc_create` entry. Used by the client write prechecks
228
+ * to resolve `$author` (`isAuthor`) at grant level without re-scanning
229
+ * metadata on every change/delete. Populated on create and lazily on first
230
+ * lookup.
226
231
  */
227
- this.creatorKeyCache = new Map();
232
+ this.creatorInfoCache = new Map();
228
233
  // Internal changefeed index: sorted by (changeSeq, docId) for deterministic iteration.
229
234
  // lastModified remains available for UX metadata but is not the primary cursor key.
230
235
  this.index = [];
@@ -347,6 +352,35 @@ class BaseMindooDB {
347
352
  * from the cached materialization and seeds the pending map from them.
348
353
  */
349
354
  this.legacyCheckpointNeedsAuthorTrustScan = false;
355
+ /**
356
+ * Ids of documents that exist in the local store but cannot be read with
357
+ * the current KeyBag, so they are absent from the index and from every
358
+ * document listing.
359
+ *
360
+ * The index alone cannot answer "how much is hidden from me": a document
361
+ * that was never readable on this device gets no index entry at all (see
362
+ * {@link reconcileKeyVisibility}), and one that lost its key becomes an
363
+ * `"inaccessible"` tombstone that listings filter out. This set is the
364
+ * only place that remembers both cases, which is what
365
+ * {@link getInaccessibleDocumentCount} reports so a UI can tell an empty
366
+ * database apart from one whose keys have not arrived yet.
367
+ *
368
+ * Counts documents regardless of their deletion state - the question it
369
+ * answers is "is it here and unreadable", which is knowable without the
370
+ * key, unlike a reliable lifecycle state for a doc we cannot decrypt.
371
+ *
372
+ * Persisted in the metadata checkpoint: a warm start with an unchanged
373
+ * KeyBag skips the visibility scan, so an in-memory-only tally would
374
+ * silently reset to zero on every restart.
375
+ */
376
+ this.inaccessibleDocIds = new Set();
377
+ /**
378
+ * Set while restoring a metadata checkpoint that predates
379
+ * {@link inaccessibleDocIds}. Forces one visibility scan at open so the
380
+ * tally starts out complete instead of counting only what happens to
381
+ * arrive in this session.
382
+ */
383
+ this.legacyCheckpointNeedsVisibilityTally = false;
350
384
  // Local cache support
351
385
  this.cacheManager = null;
352
386
  this.cachePrefix = null;
@@ -453,6 +487,12 @@ class BaseMindooDB {
453
487
  * the warmer settled can still display its outcome.
454
488
  */
455
489
  this.warmerProgress = null;
490
+ /** `username_hash` per userdirectory document, filled from creates. */
491
+ this.userdirectoryHashByDocId = new Map();
492
+ /** Grant `username_hash` for a signing public key at "now". */
493
+ this.signerUsernameHashByKey = new Map();
494
+ /** Owning person per personal userdirectory document (its create signer). */
495
+ this.personalDocCreatorHashByDocId = new Map();
456
496
  // ---------------------------------------------------------------------------
457
497
  // Author-trust reconciliation: heal documents whose entries were skipped
458
498
  // because their author was not yet known to the tenant directory.
@@ -528,6 +568,171 @@ class BaseMindooDB {
528
568
  getAdminPublicKey() {
529
569
  return this.tenant.getAdministrationPublicKey();
530
570
  }
571
+ /**
572
+ * Database id used by the builtin-write registry. Admin-only databases that
573
+ * are not `userdirectory` are treated like `directory` (admin-only writes).
574
+ */
575
+ effectiveBuiltinDbId() {
576
+ const dbId = this.store.getId();
577
+ if (this._isAdminOnlyDb && dbId !== types_1.USER_DIRECTORY_DB_ID) {
578
+ return types_1.DIRECTORY_DB_ID;
579
+ }
580
+ return dbId;
581
+ }
582
+ usernameHashFromRecord(payload) {
583
+ if (!payload || typeof payload !== "object")
584
+ return null;
585
+ const value = payload.username_hash;
586
+ return typeof value === "string" && value.length > 0 ? value : null;
587
+ }
588
+ usernameHashFromInternalDoc(internalDoc) {
589
+ return this.usernameHashFromRecord(this.convertAutomergeToJS(internalDoc.doc));
590
+ }
591
+ async resolveSignerUsernameHash(signerKey) {
592
+ try {
593
+ const directory = await this.tenant.openDirectory();
594
+ if (typeof directory.resolveUsernameHashForSigningKey !== "function") {
595
+ return null;
596
+ }
597
+ return directory.resolveUsernameHashForSigningKey(signerKey, Number.MAX_SAFE_INTEGER);
598
+ }
599
+ catch {
600
+ return null;
601
+ }
602
+ }
603
+ async resolveSignerPublicKey(signingKeyPair) {
604
+ if (signingKeyPair)
605
+ return signingKeyPair.publicKey;
606
+ return (await this.tenant.getCurrentUserId()).userSigningPublicKey;
607
+ }
608
+ /**
609
+ * Hard-wired write gate for `directory` / `userdirectory` (and other
610
+ * admin-only databases). Independent of the ACL master switch.
611
+ */
612
+ async assertBuiltinWriteAllowed(op, signerPublicKey, documentUsernameHash, docId) {
613
+ const dbId = this.effectiveBuiltinDbId();
614
+ if (!(0, builtinDbInvariants_1.hasBuiltinWriteInvariant)(dbId) && !this._isAdminOnlyDb) {
615
+ return;
616
+ }
617
+ let signerUsernameHash = null;
618
+ let creatorUsernameHash = null;
619
+ if (dbId === types_1.USER_DIRECTORY_DB_ID) {
620
+ signerUsernameHash = await this.resolveSignerUsernameHash(signerPublicKey);
621
+ this.signerUsernameHashByKey.set(signerPublicKey, signerUsernameHash);
622
+ if ((0, builtinDbInvariants_1.isPersonalUserdirectoryDocId)(docId)) {
623
+ creatorUsernameHash =
624
+ op === "doc_create"
625
+ ? signerUsernameHash
626
+ : await this.ensurePersonalDocCreatorHash(docId);
627
+ }
628
+ }
629
+ const decision = (0, builtinDbInvariants_1.evaluateBuiltinWrite)({
630
+ dbId,
631
+ op,
632
+ signerKey: signerPublicKey,
633
+ adminPublicKey: this.getAdminPublicKey(),
634
+ documentUsernameHash,
635
+ signerUsernameHash,
636
+ docId,
637
+ creatorUsernameHash,
638
+ });
639
+ if (!decision.allowed) {
640
+ throw new Error(decision.reason);
641
+ }
642
+ }
643
+ /**
644
+ * Load-path counterpart: drop entries that the builtin invariant forbids.
645
+ * Username-hash matching for userdirectory creates is enforced on write and
646
+ * on the server. Admin-signed changes load only when the signer is also the
647
+ * person named by the document (the tenant founder wrapping their own key).
648
+ */
649
+ async shouldSkipLoadedEntryForBuiltin(entry) {
650
+ const dbId = this.effectiveBuiltinDbId();
651
+ const entryType = entry.entryType ?? "doc_change";
652
+ let documentUsernameHash = null;
653
+ let signerUsernameHash = null;
654
+ let creatorUsernameHash = null;
655
+ if (dbId === types_1.USER_DIRECTORY_DB_ID && (0, builtinDbInvariants_1.isPersonalUserdirectoryDocId)(entry.docId)) {
656
+ if (entryType !== "doc_create") {
657
+ creatorUsernameHash = await this.ensurePersonalDocCreatorHash(entry.docId);
658
+ signerUsernameHash = await this.ensureSignerUsernameHash(entry.createdByPublicKey);
659
+ }
660
+ }
661
+ else if (dbId === types_1.USER_DIRECTORY_DB_ID &&
662
+ (entryType === "doc_change" || entryType === "doc_snapshot") &&
663
+ entry.docId) {
664
+ documentUsernameHash = await this.ensureUserdirectoryDocHash(entry.docId);
665
+ signerUsernameHash = await this.ensureSignerUsernameHash(entry.createdByPublicKey);
666
+ }
667
+ return (0, builtinDbInvariants_1.shouldSkipLoadedEntry)({
668
+ dbId,
669
+ entryType,
670
+ signerKey: entry.createdByPublicKey,
671
+ adminPublicKey: this.getAdminPublicKey(),
672
+ documentUsernameHash,
673
+ signerUsernameHash,
674
+ docId: entry.docId,
675
+ creatorUsernameHash,
676
+ });
677
+ }
678
+ async ensureSignerUsernameHash(signerKey) {
679
+ if (this.signerUsernameHashByKey.has(signerKey)) {
680
+ return this.signerUsernameHashByKey.get(signerKey) ?? null;
681
+ }
682
+ const hash = await this.resolveSignerUsernameHash(signerKey);
683
+ this.signerUsernameHashByKey.set(signerKey, hash);
684
+ return hash;
685
+ }
686
+ /**
687
+ * Owner of a personal `userdirectory` document: the person who signed its
688
+ * `doc_create`, resolved through grants rather than the payload. A sealed
689
+ * document is opaque to everyone but its owner, so the payload is not an
690
+ * option — and grants map every device of a person to one hash, which is
691
+ * exactly the granularity ownership needs.
692
+ */
693
+ async ensurePersonalDocCreatorHash(docId) {
694
+ const cached = this.personalDocCreatorHashByDocId.get(docId);
695
+ if (cached !== undefined)
696
+ return cached;
697
+ let hash = null;
698
+ try {
699
+ const metas = await this.scanAllMetadata(this.store, { docId });
700
+ const create = metas.find((meta) => meta.entryType === "doc_create");
701
+ if (create) {
702
+ hash = await this.ensureSignerUsernameHash(create.createdByPublicKey);
703
+ }
704
+ }
705
+ catch {
706
+ hash = null;
707
+ }
708
+ // Only a resolved owner is worth caching: a miss usually means the create
709
+ // entry has not arrived yet, and the next write must ask again.
710
+ if (hash)
711
+ this.personalDocCreatorHashByDocId.set(docId, hash);
712
+ return hash;
713
+ }
714
+ async ensureUserdirectoryDocHash(docId) {
715
+ const cached = this.userdirectoryHashByDocId.get(docId);
716
+ if (cached)
717
+ return cached;
718
+ try {
719
+ const metas = await this.scanAllMetadata(this.store, { docId });
720
+ const create = metas.find((meta) => meta.entryType === "doc_create");
721
+ if (!create)
722
+ return null;
723
+ const [entry] = await this.store.getEntries([create.id]);
724
+ if (!entry)
725
+ return null;
726
+ const decrypted = await this.tenant.decryptPayload(entry.encryptedData, entry.decryptionKeyId);
727
+ const hash = (0, builtinDbInvariants_1.usernameHashFromCreateChangeBytes)(decrypted);
728
+ if (hash)
729
+ this.userdirectoryHashByDocId.set(docId, hash);
730
+ return hash;
731
+ }
732
+ catch {
733
+ return null;
734
+ }
735
+ }
531
736
  isAdminOnlyDb() {
532
737
  return this._isAdminOnlyDb;
533
738
  }
@@ -715,7 +920,7 @@ class BaseMindooDB {
715
920
  // indexing (DirectoryTimeTravelIndex) and its change events interleave
716
921
  // with access-control evaluation; admin-only databases hold no user
717
922
  // data worth summarizing. Explicit getSummaryStore() calls still work.
718
- if (this._isAdminOnlyDb || this.store.getId() === "directory") {
923
+ if (this._isAdminOnlyDb || this.store.getId() === types_1.DIRECTORY_DB_ID || this.store.getId() === types_1.USER_DIRECTORY_DB_ID) {
719
924
  return;
720
925
  }
721
926
  const hasSetupChange = changes.some((change) => change.docId === types_4.DB_SETUP_DOC_ID);
@@ -950,7 +1155,7 @@ class BaseMindooDB {
950
1155
  return;
951
1156
  }
952
1157
  // Same system-database exclusion as the summary auto-activation.
953
- if (this._isAdminOnlyDb || this.store.getId() === "directory") {
1158
+ if (this._isAdminOnlyDb || this.store.getId() === types_1.DIRECTORY_DB_ID || this.store.getId() === types_1.USER_DIRECTORY_DB_ID) {
954
1159
  return;
955
1160
  }
956
1161
  const hasSetupChange = changes.some((change) => change.docId === types_4.DB_SETUP_DOC_ID);
@@ -1344,6 +1549,11 @@ class BaseMindooDB {
1344
1549
  }
1345
1550
  }
1346
1551
  checkpoint.pendingUntrustedAuthors = pendingUntrusted;
1552
+ // Persist the unreadable-document tally (additive field). Written even
1553
+ // when empty, for the same reason as above: the field's PRESENCE marks
1554
+ // the checkpoint as tally-aware, so restore can tell "nothing hidden"
1555
+ // apart from "written before this existed, count unknown".
1556
+ checkpoint.inaccessibleDocIds = Array.from(this.inaccessibleDocIds);
1347
1557
  return new TextEncoder().encode(JSON.stringify(checkpoint));
1348
1558
  }
1349
1559
  /**
@@ -1390,6 +1600,22 @@ class BaseMindooDB {
1390
1600
  else {
1391
1601
  this.legacyCheckpointNeedsAuthorTrustScan = true;
1392
1602
  }
1603
+ // Unreadable-document tally. Missing field means the checkpoint predates
1604
+ // it: the count would otherwise stay at zero for as long as the KeyBag
1605
+ // is unchanged, because that is exactly when the visibility scan is
1606
+ // skipped. Force one scan instead.
1607
+ this.inaccessibleDocIds.clear();
1608
+ if (Array.isArray(checkpoint.inaccessibleDocIds)) {
1609
+ this.legacyCheckpointNeedsVisibilityTally = false;
1610
+ for (const docId of checkpoint.inaccessibleDocIds) {
1611
+ if (typeof docId === "string") {
1612
+ this.inaccessibleDocIds.add(docId);
1613
+ }
1614
+ }
1615
+ }
1616
+ else {
1617
+ this.legacyCheckpointNeedsVisibilityTally = true;
1618
+ }
1393
1619
  this.syncScanCursors.clear();
1394
1620
  if (checkpoint.syncScanCursors && typeof checkpoint.syncScanCursors === "object") {
1395
1621
  for (const [key, value] of Object.entries(checkpoint.syncScanCursors)) {
@@ -1557,27 +1783,34 @@ class BaseMindooDB {
1557
1783
  /**
1558
1784
  * Get or import a CryptoKey for signature verification, with caching.
1559
1785
  * This avoids re-importing the same public key multiple times.
1786
+ *
1787
+ * Trust is evaluated at `trustedTime` when provided (grant membership in the
1788
+ * directory node covering that time, or the tenant admin key). Callers that
1789
+ * omit it keep the historical HEAD check via `validatePublicSigningKey`.
1560
1790
  */
1561
- async getOrImportPublicKey(publicKey) {
1562
- // Check cache first
1563
- if (this.publicKeyCache.has(publicKey)) {
1564
- return this.publicKeyCache.get(publicKey);
1565
- }
1566
- // Validate the public key is trusted
1791
+ async getOrImportPublicKey(publicKey, trustedTime) {
1567
1792
  const directory = await this.tenant.openDirectory();
1568
- const isTrusted = await directory.validatePublicSigningKey(publicKey);
1793
+ let isTrusted = false;
1794
+ if (trustedTime !== undefined && typeof directory.getDirectoryStateAt === "function") {
1795
+ const node = await directory.getDirectoryStateAt(trustedTime);
1796
+ isTrusted =
1797
+ !!(0, DirectoryStateNode_1.grantForSigningKey)(node, publicKey) || publicKey === this.getAdminPublicKey();
1798
+ }
1799
+ else {
1800
+ isTrusted = await directory.validatePublicSigningKey(publicKey);
1801
+ }
1569
1802
  if (!isTrusted) {
1570
1803
  this.logger.warn(`Public key not trusted: ${publicKey}`);
1571
1804
  return null;
1572
1805
  }
1573
- // Import the key
1806
+ const cached = this.publicKeyCache.get(publicKey);
1807
+ if (cached)
1808
+ return cached;
1574
1809
  const subtle = this.tenant.getCryptoAdapter().getSubtle();
1575
1810
  const publicKeyBuffer = this.tenant.pemToArrayBuffer(publicKey);
1576
1811
  const cryptoKey = await subtle.importKey("spki", publicKeyBuffer, {
1577
1812
  name: "Ed25519",
1578
- }, false, // not extractable
1579
- ["verify"]);
1580
- // Cache the imported key
1813
+ }, false, ["verify"]);
1581
1814
  this.publicKeyCache.set(publicKey, cryptoKey);
1582
1815
  return cryptoKey;
1583
1816
  }
@@ -1736,6 +1969,17 @@ class BaseMindooDB {
1736
1969
  // Every path that writes entries for a document lands here, so this is
1737
1970
  // where retained metadata for it goes stale (see scannedEntryMetadata).
1738
1971
  this.forgetScannedEntryMetadata(docId);
1972
+ // Same reasoning for the unreadable-document tally: every reveal and every
1973
+ // inaccessibility tombstone passes through here, so keeping it in step
1974
+ // needs no bookkeeping at the individual call sites. The one case this
1975
+ // misses is a document that was never readable, which produces no index
1976
+ // entry at all - those call `noteInaccessibleDocument` directly.
1977
+ if (accessState === "visible") {
1978
+ this.forgetInaccessibleDocument(docId);
1979
+ }
1980
+ else {
1981
+ this.noteInaccessibleDocument(docId);
1982
+ }
1739
1983
  const assignedSeq = this.nextChangeSeq;
1740
1984
  const newEntry = {
1741
1985
  docId,
@@ -2003,7 +2247,8 @@ class BaseMindooDB {
2003
2247
  // transition.
2004
2248
  const currentFingerprint = await this.computeCurrentKeyBagFingerprint();
2005
2249
  const fingerprintMatches = currentFingerprint !== null && currentFingerprint === this.lastReconciledKeyBagFingerprint;
2006
- if (!fingerprintMatches) {
2250
+ if (!fingerprintMatches || this.legacyCheckpointNeedsVisibilityTally) {
2251
+ this.legacyCheckpointNeedsVisibilityTally = false;
2007
2252
  await this.reconcileKeyVisibility();
2008
2253
  }
2009
2254
  this.markKeyBagFingerprintReconciled(currentFingerprint);
@@ -2055,7 +2300,7 @@ class BaseMindooDB {
2055
2300
  if (!this.summaryAutoUpdateEnabled || this.summarySetupProbed || this.summaryStore) {
2056
2301
  return;
2057
2302
  }
2058
- if (this._isAdminOnlyDb || this.store.getId() === "directory") {
2303
+ if (this._isAdminOnlyDb || this.store.getId() === types_1.DIRECTORY_DB_ID || this.store.getId() === types_1.USER_DIRECTORY_DB_ID) {
2059
2304
  return;
2060
2305
  }
2061
2306
  this.summarySetupProbed = true;
@@ -2089,7 +2334,7 @@ class BaseMindooDB {
2089
2334
  if (!this.fulltextAutoUpdateEnabled || this.fulltextSetupProbed || this.fulltextIndex) {
2090
2335
  return;
2091
2336
  }
2092
- if (this._isAdminOnlyDb || this.store.getId() === "directory") {
2337
+ if (this._isAdminOnlyDb || this.store.getId() === types_1.DIRECTORY_DB_ID || this.store.getId() === types_1.USER_DIRECTORY_DB_ID) {
2093
2338
  return;
2094
2339
  }
2095
2340
  if (this.isTimeTravelMode()) {
@@ -2122,6 +2367,39 @@ class BaseMindooDB {
2122
2367
  this.cacheMetaDirty = true;
2123
2368
  this.cacheManager?.markDirty();
2124
2369
  }
2370
+ /**
2371
+ * Remember that `docId` is present in the store but unreadable. Idempotent:
2372
+ * the same document is re-observed on every sync batch that carries another
2373
+ * entry for it.
2374
+ */
2375
+ noteInaccessibleDocument(docId) {
2376
+ if (this.inaccessibleDocIds.has(docId)) {
2377
+ return;
2378
+ }
2379
+ this.inaccessibleDocIds.add(docId);
2380
+ this.cacheMetaDirty = true;
2381
+ this.cacheManager?.markDirty();
2382
+ }
2383
+ /** Counterpart of {@link noteInaccessibleDocument} for a doc that became readable. */
2384
+ forgetInaccessibleDocument(docId) {
2385
+ if (!this.inaccessibleDocIds.delete(docId)) {
2386
+ return;
2387
+ }
2388
+ this.cacheMetaDirty = true;
2389
+ this.cacheManager?.markDirty();
2390
+ }
2391
+ /**
2392
+ * How many documents this database holds locally that the current KeyBag
2393
+ * cannot open. They appear in no listing and in no view, so this count is
2394
+ * the only signal that a database looks emptier than it is - typically on a
2395
+ * device whose user key is still waiting for approval.
2396
+ *
2397
+ * Includes deleted documents, and counts what has arrived on this device -
2398
+ * not what the tenant holds.
2399
+ */
2400
+ getInaccessibleDocumentCount() {
2401
+ return this.inaccessibleDocIds.size;
2402
+ }
2125
2403
  /**
2126
2404
  * Derive the per-doc visibility summary needed to reconcile the index
2127
2405
  * against the current KeyBag state.
@@ -2254,12 +2532,12 @@ class BaseMindooDB {
2254
2532
  }
2255
2533
  const existingIndex = this.getDocIndexPosition(docId);
2256
2534
  const existing = existingIndex === undefined ? undefined : this.index[existingIndex];
2257
- // Visibility is governed by key possession alone: documents whose
2258
- // decryption key is in the bag are visible; the rest are hidden. Which
2259
- // keys a user holds is governed by the key distribution model
2260
- // (docs/accesscontrol.md §13), not by a server/client read gate.
2535
+ await this.tenant.ingestSealedFromEntries?.(visibility.decryptionKeyId, metadataByDoc.get(docId));
2261
2536
  const canRead = await this.tenant.hasDecryptionKey(visibility.decryptionKeyId);
2262
2537
  if (canRead) {
2538
+ // This scan is the authority on visibility, so it also heals a tally
2539
+ // that a visible->visible short-circuit below would leave untouched.
2540
+ this.forgetInaccessibleDocument(docId);
2263
2541
  // Reveal-on-add only triggers when the previous state was
2264
2542
  // missing or inaccessible; visible->visible transitions stay
2265
2543
  // idempotent via the existing `updateIndex` short-circuit.
@@ -2279,8 +2557,11 @@ class BaseMindooDB {
2279
2557
  changed = true;
2280
2558
  }
2281
2559
  else {
2282
- // Already inaccessible (or never seen). Scrub defensively in
2283
- // case a previous session left an L2 record behind.
2560
+ // Already inaccessible (or never seen, which is the common case on a
2561
+ // device whose keys have not arrived). No index entry is written for
2562
+ // it, so the tally is the only record that it is here at all.
2563
+ this.noteInaccessibleDocument(docId);
2564
+ // Scrub defensively in case a previous session left an L2 record behind.
2284
2565
  await this.purgeMaterializedDocument(docId);
2285
2566
  }
2286
2567
  }
@@ -2407,7 +2688,7 @@ class BaseMindooDB {
2407
2688
  * directory was synced in a session that never opened this DB).
2408
2689
  */
2409
2690
  async reconcileAuthorTrustAtOpen() {
2410
- if (this.isTimeTravelMode() || this._isAdminOnlyDb || this.store.getId() === "directory") {
2691
+ if (this.isTimeTravelMode() || this._isAdminOnlyDb || this.store.getId() === types_1.DIRECTORY_DB_ID || this.store.getId() === types_1.USER_DIRECTORY_DB_ID) {
2411
2692
  return;
2412
2693
  }
2413
2694
  if (this.legacyCheckpointNeedsAuthorTrustScan) {
@@ -2601,6 +2882,10 @@ class BaseMindooDB {
2601
2882
  const cachedDoc = this.getCachedDocument(docId);
2602
2883
  let updatedDoc = null;
2603
2884
  if (cachedDoc) {
2885
+ if (await this.dropSealedAccessIfUnwrapFailed(cachedDoc)) {
2886
+ this.logger.debug(`Dropping cached sealed document ${docId} — newest wrap is not for this user`);
2887
+ return;
2888
+ }
2604
2889
  // Document is cached - try incremental update
2605
2890
  this.logger.debug(`Document ${docId} found in cache, attempting incremental update`);
2606
2891
  try {
@@ -2646,9 +2931,22 @@ class BaseMindooDB {
2646
2931
  // Before indexing, verify the user has the decryption key so that
2647
2932
  // documents the user cannot access do not appear in getAllDocumentIds.
2648
2933
  const representativeEntry = docLifecycleEntries[0];
2934
+ await this.tenant.ingestSealedFromEntries?.(representativeEntry.decryptionKeyId, docLifecycleEntries);
2649
2935
  const keyAvailable = await this.tenant.hasDecryptionKey(representativeEntry.decryptionKeyId);
2650
2936
  if (!keyAvailable) {
2651
2937
  this.logger.debug(`Skipping metadata-first index for doc ${docId} — decryption key "${representativeEntry.decryptionKeyId}" not available`);
2938
+ // This is where a device that is still waiting for its keys meets
2939
+ // most of the tenant: the doc is skipped before it ever reaches
2940
+ // the index, so record it for `getInaccessibleDocumentCount`.
2941
+ this.noteInaccessibleDocument(docId);
2942
+ const existingIndex = this.getDocIndexPosition(docId);
2943
+ const existing = existingIndex === undefined ? undefined : this.index[existingIndex];
2944
+ if (existing?.accessState === "visible") {
2945
+ await this.purgeMaterializedDocument(docId);
2946
+ this.updateIndex(docId, existing.lastModified, true, representativeEntry.decryptionKeyId, "inaccessible");
2947
+ this.cacheMetaDirty = true;
2948
+ this.cacheManager?.markDirty();
2949
+ }
2652
2950
  }
2653
2951
  else {
2654
2952
  // Cold sync: entryMetadataList is already the full doc history from
@@ -2695,6 +2993,7 @@ class BaseMindooDB {
2695
2993
  // doc.
2696
2994
  if (error instanceof errors_1.SymmetricKeyNotFoundError) {
2697
2995
  this.logger.debug(`Skipping document ${docId} - missing key: ${error.keyId}`);
2996
+ this.noteInaccessibleDocument(docId);
2698
2997
  // Avoid a per-doc scanAllMetadata round-trip here: everything
2699
2998
  // we need is already in this batch's entryMetadataList plus the
2700
2999
  // existing index entry (if any). We do NOT need a second pass
@@ -3005,54 +3304,8 @@ class BaseMindooDB {
3005
3304
  nextCursor: cursor,
3006
3305
  };
3007
3306
  }
3008
- /**
3009
- * Fetch a store's bloom filter summary, returning null if unsupported or on
3010
- * error. Used on the target for missing-id pre-screening (callers fall back
3011
- * to exact checks) and on the source for the total-entry-count progress
3012
- * denominator.
3013
- */
3014
- async getStoreBloomSummary(store) {
3015
- if (typeof store.getIdBloomSummary !== "function") {
3016
- return null;
3017
- }
3018
- try {
3019
- return await store.getIdBloomSummary();
3020
- }
3021
- catch (error) {
3022
- this.logger.warn("Failed to get bloom summary from store, falling back to exact checks", error);
3023
- return null;
3024
- }
3025
- }
3026
- static setSyncAbortSignalOnStore(store, signal) {
3027
- if ('setSyncAbortSignal' in store && typeof store.setSyncAbortSignal === 'function') {
3028
- store.setSyncAbortSignal(signal);
3029
- }
3030
- }
3031
- /**
3032
- * Fetch a store's head descriptor (`{ epoch, maxReceiptOrder }`), returning
3033
- * null when the store (or the remote server behind it) does not support it
3034
- * or the request fails. A null head simply disables the persisted-cursor
3035
- * fast path for this sync.
3036
- */
3037
- async getStoreHeadSafe(store) {
3038
- if (typeof store.getStoreHead !== "function") {
3039
- return null;
3040
- }
3041
- try {
3042
- return await store.getStoreHead();
3043
- }
3044
- catch (error) {
3045
- this.logger.debug("Store head unavailable, falling back to full scan", error);
3046
- return null;
3047
- }
3048
- }
3049
- /** Stable identity for a store in the persisted sync-cursor map. */
3050
- static syncStoreIdentity(store) {
3051
- return store.getCacheIdentity?.() ?? `${store.getId()}/${store.getStoreKind()}`;
3052
- }
3053
- /** Key for {@link syncScanCursors}: one record per (source, target) pair. */
3054
- syncScanCursorKey(sourceStore, targetStore) {
3055
- return `${BaseMindooDB.syncStoreIdentity(sourceStore)}->${BaseMindooDB.syncStoreIdentity(targetStore)}`;
3307
+ getStoreBloomSummary(store) {
3308
+ return (0, syncStores_1.getStoreBloomSummary)(store, this.logger);
3056
3309
  }
3057
3310
  /** Persist a sync scan cursor and schedule a checkpoint flush. */
3058
3311
  saveSyncScanCursor(key, record) {
@@ -3068,171 +3321,8 @@ class BaseMindooDB {
3068
3321
  this.cacheMetaDirty = true;
3069
3322
  this.cacheManager?.markDirty();
3070
3323
  }
3071
- /**
3072
- * From a list of candidate IDs, return only those the target store is
3073
- * missing. Uses bloom-filter pre-screening when available, then falls
3074
- * back to exact `hasEntries` for the uncertain set.
3075
- */
3076
- async filterMissingIds(targetStore, candidateIds, bloom) {
3077
- let definitelyMissing = [];
3078
- let maybeExisting = candidateIds;
3079
- if (bloom) {
3080
- definitelyMissing = [];
3081
- maybeExisting = [];
3082
- for (const id of candidateIds) {
3083
- if ((0, bloom_1.bloomMightContainId)(bloom, id)) {
3084
- maybeExisting.push(id);
3085
- }
3086
- else {
3087
- definitelyMissing.push(id);
3088
- }
3089
- }
3090
- }
3091
- let missingIds = definitelyMissing;
3092
- if (maybeExisting.length > 0) {
3093
- const existing = await targetStore.hasEntries(maybeExisting);
3094
- const existingSet = new Set(existing);
3095
- missingIds = missingIds.concat(maybeExisting.filter((id) => !existingSet.has(id)));
3096
- }
3097
- return missingIds;
3098
- }
3099
- resolveTransferBatchSize(options) {
3100
- if (options?.transferBatchSize && options.transferBatchSize > 0) {
3101
- return options.transferBatchSize;
3102
- }
3103
- if (options?.storeKind === types_2.StoreKind.attachments) {
3104
- return 100;
3105
- }
3106
- return Math.min(options?.pageSize ?? 1000, BaseMindooDB.DEFAULT_DOCS_TRANSFER_BATCH_SIZE);
3107
- }
3108
- /**
3109
- * Transfer a set of entry IDs from source to target in fixed-size batches,
3110
- * emitting progress and checking for cancellation between each batch.
3111
- *
3112
- * Callers (cursor-scan path and legacy path) collect the IDs that need
3113
- * transferring, then delegate to this method instead of issuing one
3114
- * monolithic `getEntries`. This gives three benefits:
3115
- *
3116
- * 1. The UI receives frequent progress updates with batch metadata so long
3117
- * transfers no longer look frozen.
3118
- * 2. Cancellation can interrupt work between batches rather than waiting
3119
- * for one large HTTP response to complete.
3120
- * 3. Each server-side `getEntries` + RSA encryption unit is smaller,
3121
- * reducing the risk of socket timeouts on large payloads.
3122
- *
3123
- * Returns partial progress on cancellation so callers can report how much
3124
- * was actually transferred before the abort.
3125
- */
3126
- async transferEntriesInBatches(sourceStore, targetStore, entryIds, options, state) {
3127
- if (entryIds.length === 0) {
3128
- return { transferred: state.transferred, transferredBytes: state.transferredBytes, cancelled: false, rejected: [] };
3129
- }
3130
- const onProgress = options?.onProgress;
3131
- const signal = options?.signal;
3132
- const transferBatchSize = this.resolveTransferBatchSize(options);
3133
- const totalTransferBatches = Math.max(1, Math.ceil(entryIds.length / transferBatchSize));
3134
- // Parallel transfer window (sync-v5, phase 4): each batch is one
3135
- // getEntries + putEntries round trip; a small window of concurrent
3136
- // batches overlaps network latency and server-side crypto. Witness
3137
- // receipts are applied per batch and are order-independent.
3138
- const maxConcurrent = Math.min(Math.max(1, Math.floor(options?.maxConcurrentBatches ?? BaseMindooDB.DEFAULT_MAX_CONCURRENT_TRANSFER_BATCHES)), totalTransferBatches);
3139
- let transferred = state.transferred;
3140
- let transferredBytes = state.transferredBytes;
3141
- let cancelled = false;
3142
- let firstError = null;
3143
- let nextBatchIndex = 0;
3144
- // Per-entry rejections reported by a witnessing target (sync-v5): the
3145
- // push continues, the rejected entries are surfaced to the caller.
3146
- const rejected = [];
3147
- const runWorker = async () => {
3148
- while (true) {
3149
- if (firstError || cancelled || signal?.aborted) {
3150
- if (signal?.aborted)
3151
- cancelled = true;
3152
- return;
3153
- }
3154
- const batchIndex = nextBatchIndex++;
3155
- const offset = batchIndex * transferBatchSize;
3156
- if (offset >= entryIds.length) {
3157
- return;
3158
- }
3159
- const currentTransferBatch = batchIndex + 1;
3160
- const batchIds = entryIds.slice(offset, offset + transferBatchSize);
3161
- const pageSummary = state.currentPage ? `page ${state.currentPage}, ` : "";
3162
- onProgress?.({
3163
- phase: "transferring",
3164
- message: `Transferring batch ${currentTransferBatch}/${totalTransferBatches} (${batchIds.length} entries, ${pageSummary}scanned ${state.scanned})...`,
3165
- transferredEntries: transferred,
3166
- transferredBytes,
3167
- scannedEntries: state.scanned,
3168
- totalSourceEntries: state.totalSourceEntries,
3169
- currentPage: state.currentPage,
3170
- currentTransferBatch,
3171
- totalTransferBatches,
3172
- transferBatchSize,
3173
- });
3174
- try {
3175
- const batchEntries = await sourceStore.getEntries(batchIds);
3176
- if (signal?.aborted) {
3177
- cancelled = true;
3178
- return;
3179
- }
3180
- const putResult = await targetStore.putEntries(batchEntries);
3181
- // On push, the remote (witnessing) target returns receipts for accepted
3182
- // entries; persist them back onto the local source so the revision feed
3183
- // re-anchors them from the provisional head to their committed
3184
- // `receivedAt` (docs/accesscontrol.md §5.3). Pull targets return void.
3185
- // sync-v5 targets return a structured ack that additionally carries
3186
- // per-entry rejections (signature-class failures the remote skipped).
3187
- const { receipts, batchRejected } = BaseMindooDB.normalizePutResult(putResult);
3188
- if (receipts.length > 0 && sourceStore.applyWitnessReceipts) {
3189
- await sourceStore.applyWitnessReceipts(receipts);
3190
- }
3191
- const rejectedIds = batchRejected.length > 0 ? new Set(batchRejected.map((entry) => entry.id)) : null;
3192
- if (rejectedIds) {
3193
- rejected.push(...batchRejected);
3194
- this.logger.warn(`Target rejected ${batchRejected.length} of ${batchEntries.length} entries in transfer batch ${currentTransferBatch}/${totalTransferBatches}`);
3195
- }
3196
- for (const entry of batchEntries) {
3197
- if (rejectedIds?.has(entry.id)) {
3198
- continue;
3199
- }
3200
- transferredBytes += entry.encryptedSize ?? entry.encryptedData.length;
3201
- }
3202
- transferred += batchEntries.length - batchRejected.length;
3203
- }
3204
- catch (error) {
3205
- if (signal?.aborted) {
3206
- cancelled = true;
3207
- return;
3208
- }
3209
- if (!firstError) {
3210
- firstError = error;
3211
- }
3212
- return;
3213
- }
3214
- onProgress?.({
3215
- phase: "transferring",
3216
- message: `Transferred ${transferred} entries after batch ${currentTransferBatch}/${totalTransferBatches}`,
3217
- transferredEntries: transferred,
3218
- transferredBytes,
3219
- scannedEntries: state.scanned,
3220
- totalSourceEntries: state.totalSourceEntries,
3221
- currentPage: state.currentPage,
3222
- currentTransferBatch,
3223
- totalTransferBatches,
3224
- transferBatchSize,
3225
- });
3226
- }
3227
- };
3228
- await Promise.all(Array.from({ length: maxConcurrent }, () => runWorker()));
3229
- if (signal?.aborted || cancelled) {
3230
- return { transferred, transferredBytes, cancelled: true, rejected };
3231
- }
3232
- if (firstError) {
3233
- throw firstError;
3234
- }
3235
- return { transferred, transferredBytes, cancelled: false, rejected };
3324
+ filterMissingIds(targetStore, candidateIds, bloom) {
3325
+ return (0, syncStores_1.filterMissingIds)(targetStore, candidateIds, bloom);
3236
3326
  }
3237
3327
  /**
3238
3328
  * Normalize the polymorphic putEntries result (`void`, receipts array, or
@@ -3250,15 +3340,39 @@ class BaseMindooDB {
3250
3340
  }
3251
3341
  return { receipts: [], batchRejected: [] };
3252
3342
  }
3343
+ /**
3344
+ * Expose this database's persisted {@link syncScanCursors} map to the shared
3345
+ * store-transfer helper. The map lives in the metadata checkpoint, so saving
3346
+ * a cursor also marks the cache dirty.
3347
+ */
3348
+ syncCursorStore() {
3349
+ return {
3350
+ get: (key) => this.syncScanCursors.get(key) ?? null,
3351
+ save: (key, record) => this.saveSyncScanCursor(key, record),
3352
+ delete: (key) => {
3353
+ this.syncScanCursors.delete(key);
3354
+ },
3355
+ };
3356
+ }
3253
3357
  async syncEntriesFromStore(sourceStore, targetStore, options) {
3254
3358
  const signal = options?.signal;
3255
- BaseMindooDB.setSyncAbortSignalOnStore(sourceStore, signal);
3256
- BaseMindooDB.setSyncAbortSignalOnStore(targetStore, signal);
3359
+ (0, syncStores_1.setSyncAbortSignalOnStore)(sourceStore, signal);
3360
+ (0, syncStores_1.setSyncAbortSignalOnStore)(targetStore, signal);
3257
3361
  try {
3258
3362
  if (options?.mode === "dense") {
3259
3363
  return await this.syncEntriesFromStoreDense(sourceStore, targetStore, options);
3260
3364
  }
3261
- return await this.syncEntriesFromStoreImpl(sourceStore, targetStore, options);
3365
+ // Client rejection semantics: the scan cursor advances past a rejected
3366
+ // entry so a single corrupt or forged entry cannot block this database's
3367
+ // push sync forever (docs/network-sync-protocol.md §5.7.6). The
3368
+ // server-to-server replicator passes `"hold"` instead, because there a
3369
+ // rejection usually means "the peer has not replicated the directory
3370
+ // yet" and skipping would drop the entry permanently.
3371
+ return await (0, syncStores_1.syncEntriesBetweenStores)(sourceStore, targetStore, options, {
3372
+ logger: this.logger,
3373
+ cursors: this.syncCursorStore(),
3374
+ rejectionPolicy: "advance",
3375
+ });
3262
3376
  }
3263
3377
  catch (error) {
3264
3378
  if (signal?.aborted) {
@@ -3268,258 +3382,10 @@ class BaseMindooDB {
3268
3382
  throw error;
3269
3383
  }
3270
3384
  finally {
3271
- BaseMindooDB.setSyncAbortSignalOnStore(sourceStore, undefined);
3272
- BaseMindooDB.setSyncAbortSignalOnStore(targetStore, undefined);
3385
+ (0, syncStores_1.setSyncAbortSignalOnStore)(sourceStore, undefined);
3386
+ (0, syncStores_1.setSyncAbortSignalOnStore)(targetStore, undefined);
3273
3387
  }
3274
3388
  }
3275
- async syncEntriesFromStoreImpl(sourceStore, targetStore, options) {
3276
- let transferred = 0;
3277
- let transferredBytes = 0;
3278
- let scanned = 0;
3279
- // Per-entry rejections reported by a witnessing target (sync-v5): the
3280
- // sync completes; rejected entries are surfaced to the caller as warnings.
3281
- const rejected = [];
3282
- const onProgress = options?.onProgress;
3283
- const pageSize = options?.pageSize ?? 1000;
3284
- const signal = options?.signal;
3285
- if (this.supportsCursorScan(sourceStore)) {
3286
- // Persisted-cursor fast path (sync-v5, phase 1): resume the metadata
3287
- // scan where the previous sync between this (source, target) pair left
3288
- // off — or skip the sync entirely when the source head shows nothing
3289
- // new. The heads are fetched up front (cheap; one request per side for
3290
- // network stores) and also anchor the epochs persisted with the cursor.
3291
- const cursorKey = this.syncScanCursorKey(sourceStore, targetStore);
3292
- const persisted = options?.forceFullScan
3293
- ? null
3294
- : this.syncScanCursors.get(cursorKey) ?? null;
3295
- const [sourceHead, targetHead] = await Promise.all([
3296
- this.getStoreHeadSafe(sourceStore),
3297
- this.getStoreHeadSafe(targetStore),
3298
- ]);
3299
- let cursor = null;
3300
- if (persisted && sourceHead && targetHead) {
3301
- if (persisted.sourceEpoch === sourceHead.epoch &&
3302
- persisted.targetEpoch === targetHead.epoch) {
3303
- if (sourceHead.maxReceiptOrder <= persisted.cursor.receiptOrder) {
3304
- this.logger.debug(`Sync skip: source head ${sourceHead.maxReceiptOrder} already covered by persisted cursor (${cursorKey})`);
3305
- onProgress?.({
3306
- phase: 'preparing',
3307
- message: 'Source unchanged since last sync, nothing to scan',
3308
- transferredEntries: 0,
3309
- scannedEntries: 0,
3310
- });
3311
- return { transferred: 0, scanned: 0, cancelled: false };
3312
- }
3313
- cursor = persisted.cursor;
3314
- this.logger.debug(`Resuming sync scan from persisted cursor receiptOrder=${cursor.receiptOrder} (${cursorKey})`);
3315
- }
3316
- else {
3317
- // Epoch change on either side: the cursor lineage is broken
3318
- // (store reset / receipt-order migration) — full rescan.
3319
- this.logger.info(`Sync cursor epoch changed for ${cursorKey}, discarding persisted cursor and re-scanning`);
3320
- this.syncScanCursors.delete(cursorKey);
3321
- }
3322
- }
3323
- const persistScanCursor = (finalCursor) => {
3324
- if (finalCursor && sourceHead && targetHead) {
3325
- this.saveSyncScanCursor(cursorKey, {
3326
- sourceEpoch: sourceHead.epoch,
3327
- targetEpoch: targetHead.epoch,
3328
- cursor: finalCursor,
3329
- });
3330
- }
3331
- };
3332
- const targetBloom = await this.getStoreBloomSummary(targetStore);
3333
- // Fixed progress denominator: the SOURCE's total entry count. The cursor
3334
- // scan below examines every source entry, so `scannedEntries/totalSourceEntries`
3335
- // is a real completion ratio. (Local stores serve this from a cached bloom
3336
- // summary; network stores answer with one extra request.) The target bloom
3337
- // stays dedicated to missing-id pre-screening.
3338
- const sourceBloom = await this.getStoreBloomSummary(sourceStore);
3339
- const totalSourceEstimate = sourceBloom?.totalIds;
3340
- onProgress?.({
3341
- phase: 'preparing',
3342
- message: 'Preparing to sync entries...',
3343
- transferredEntries: 0,
3344
- scannedEntries: 0,
3345
- totalSourceEntries: totalSourceEstimate,
3346
- });
3347
- // Upper scan bound: the source head captured BEFORE the scan started.
3348
- // Pushing to a witnessing target re-anchors the just-transferred
3349
- // entries to a fresh receiptOrder on the source (applyWitnessReceipts),
3350
- // i.e. past the running cursor — without this bound the scan would
3351
- // re-discover its own transfers and never terminate (first-sync loop).
3352
- // Entries beyond the bound (re-anchored or written concurrently) are
3353
- // picked up by the next sync.
3354
- const scanUpperBound = sourceHead?.maxReceiptOrder;
3355
- // IDs already transferred in this sync session. Guards against
3356
- // re-pushing entries the scan re-discovers when the target bloom
3357
- // snapshot is stale (fetched once up front; empty on a first sync, so
3358
- // it would classify every re-discovered id as "definitely missing"
3359
- // and skip the exact hasEntries check).
3360
- const transferredThisSync = new Set();
3361
- let currentPage = 0;
3362
- // Scan-page pipelining (sync-v5, phase 4): while page N is being
3363
- // filtered and transferred, page N+1 is already being fetched.
3364
- let nextPagePromise = null;
3365
- const discardPrefetch = () => {
3366
- // Swallow errors of an in-flight prefetch we will never consume
3367
- // (abort/cancel paths) to avoid unhandled rejections.
3368
- nextPagePromise?.catch(() => { });
3369
- nextPagePromise = null;
3370
- };
3371
- while (true) {
3372
- if (signal?.aborted) {
3373
- discardPrefetch();
3374
- persistScanCursor(cursor);
3375
- return { transferred, transferredBytes, scanned, cancelled: true, rejected };
3376
- }
3377
- const page = nextPagePromise
3378
- ? await nextPagePromise
3379
- : await sourceStore.scanEntriesSince(cursor, pageSize);
3380
- nextPagePromise = page.hasMore
3381
- ? sourceStore.scanEntriesSince(page.nextCursor, pageSize)
3382
- : null;
3383
- currentPage++;
3384
- // Clamp the page to the scan bound. Pages are ordered by
3385
- // (receiptOrder, id), so everything past the first out-of-bound
3386
- // entry is out of bound as well.
3387
- let pageEntries = page.entries;
3388
- let reachedScanBound = false;
3389
- if (scanUpperBound !== undefined && pageEntries.length > 0) {
3390
- const inBound = pageEntries.filter((m) => (m.receiptOrder ?? 0) <= scanUpperBound);
3391
- if (inBound.length < pageEntries.length) {
3392
- reachedScanBound = true;
3393
- pageEntries = inBound;
3394
- }
3395
- }
3396
- scanned += pageEntries.length;
3397
- if (signal?.aborted) {
3398
- discardPrefetch();
3399
- persistScanCursor(cursor);
3400
- return { transferred, transferredBytes, scanned, cancelled: true, rejected };
3401
- }
3402
- if (pageEntries.length > 0) {
3403
- onProgress?.({
3404
- phase: 'transferring',
3405
- message: `Scanned ${scanned} entries, checking for changes (page ${currentPage})...`,
3406
- transferredEntries: transferred,
3407
- scannedEntries: scanned,
3408
- totalSourceEntries: totalSourceEstimate,
3409
- currentPage,
3410
- });
3411
- const ids = pageEntries
3412
- .map((m) => m.id)
3413
- .filter((id) => !transferredThisSync.has(id));
3414
- const missingIds = ids.length > 0
3415
- ? await this.filterMissingIds(targetStore, ids, targetBloom)
3416
- : [];
3417
- if (signal?.aborted) {
3418
- discardPrefetch();
3419
- persistScanCursor(cursor);
3420
- return { transferred, transferredBytes, scanned, cancelled: true, rejected };
3421
- }
3422
- if (missingIds.length > 0) {
3423
- const transferResult = await this.transferEntriesInBatches(sourceStore, targetStore, missingIds, options, {
3424
- transferred,
3425
- transferredBytes,
3426
- scanned,
3427
- totalSourceEntries: totalSourceEstimate,
3428
- currentPage,
3429
- });
3430
- transferred = transferResult.transferred;
3431
- transferredBytes = transferResult.transferredBytes;
3432
- rejected.push(...transferResult.rejected);
3433
- if (transferResult.cancelled) {
3434
- // The current page's transfer did not complete: persist the
3435
- // boundary of the last fully transferred page instead.
3436
- discardPrefetch();
3437
- persistScanCursor(cursor);
3438
- return { transferred, transferredBytes, scanned, cancelled: true, rejected };
3439
- }
3440
- for (const id of missingIds) {
3441
- transferredThisSync.add(id);
3442
- }
3443
- }
3444
- }
3445
- onProgress?.({
3446
- phase: 'transferring',
3447
- message: `Transferred ${transferred} entries (page ${currentPage}, scanned ${scanned})`,
3448
- transferredEntries: transferred,
3449
- transferredBytes,
3450
- scannedEntries: scanned,
3451
- totalSourceEntries: totalSourceEstimate,
3452
- currentPage,
3453
- });
3454
- if (reachedScanBound) {
3455
- // Persist the last in-bound position (NOT page.nextCursor, which
3456
- // already points into the out-of-bound tail): a concurrent write
3457
- // that landed between the bound and the re-anchored tail must be
3458
- // scanned by the next sync.
3459
- const lastInBound = pageEntries.length > 0 ? pageEntries[pageEntries.length - 1] : null;
3460
- if (lastInBound) {
3461
- cursor = { receiptOrder: lastInBound.receiptOrder ?? 0, id: lastInBound.id };
3462
- }
3463
- discardPrefetch();
3464
- break;
3465
- }
3466
- cursor = page.nextCursor;
3467
- if (!page.hasMore) {
3468
- break;
3469
- }
3470
- }
3471
- persistScanCursor(cursor);
3472
- return { transferred, transferredBytes, scanned, cancelled: false, rejected };
3473
- }
3474
- onProgress?.({
3475
- phase: 'preparing',
3476
- message: 'Finding new entries...',
3477
- transferredEntries: 0,
3478
- scannedEntries: 0,
3479
- });
3480
- const targetIds = await targetStore.getAllIds();
3481
- const sourceNewMetadata = await sourceStore.findNewEntries(targetIds);
3482
- if (sourceNewMetadata.length === 0) {
3483
- return { transferred: 0, scanned: 0, cancelled: false };
3484
- }
3485
- if (options?.signal?.aborted) {
3486
- return { transferred: 0, scanned: 0, cancelled: true };
3487
- }
3488
- onProgress?.({
3489
- phase: 'transferring',
3490
- message: `Transferring ${sourceNewMetadata.length} entries...`,
3491
- transferredEntries: transferred,
3492
- scannedEntries: sourceNewMetadata.length,
3493
- totalSourceEntries: sourceNewMetadata.length,
3494
- });
3495
- const transferResult = await this.transferEntriesInBatches(sourceStore, targetStore, sourceNewMetadata.map((m) => m.id), options, {
3496
- transferred,
3497
- transferredBytes,
3498
- scanned: sourceNewMetadata.length,
3499
- totalSourceEntries: sourceNewMetadata.length,
3500
- });
3501
- transferred = transferResult.transferred;
3502
- transferredBytes = transferResult.transferredBytes;
3503
- rejected.push(...transferResult.rejected);
3504
- if (transferResult.cancelled) {
3505
- return { transferred, transferredBytes, scanned: sourceNewMetadata.length, cancelled: true, rejected };
3506
- }
3507
- onProgress?.({
3508
- phase: 'transferring',
3509
- message: `Transferred ${transferred} entries`,
3510
- transferredEntries: transferred,
3511
- transferredBytes,
3512
- scannedEntries: sourceNewMetadata.length,
3513
- totalSourceEntries: sourceNewMetadata.length,
3514
- });
3515
- return {
3516
- transferred,
3517
- transferredBytes,
3518
- scanned: sourceNewMetadata.length,
3519
- cancelled: false,
3520
- rejected,
3521
- };
3522
- }
3523
3389
  /**
3524
3390
  * Dense sync: transfer only the entries required to reconstruct the latest
3525
3391
  * state of each document, using the batch materialization planner to skip
@@ -3899,7 +3765,6 @@ class BaseMindooDB {
3899
3765
  throw new Error("createDocument: signingKeyPair and signingKeyPassword must be provided together");
3900
3766
  }
3901
3767
  const useCustomSigningKey = signingKeyPair !== undefined && signingKeyPassword !== undefined;
3902
- const keyId = await this.resolveCreateKeyId(options);
3903
3768
  const useCustomDocId = options.id !== undefined;
3904
3769
  this.validateIdPrefixOption("createDocument", options);
3905
3770
  this.validateAssumeUniqueIdOption("createDocument", options);
@@ -3931,18 +3796,17 @@ class BaseMindooDB {
3931
3796
  throw new Error("createDocument: initialValues is not supported together with a custom id; " +
3932
3797
  "create the document, then apply values with changeDoc().");
3933
3798
  }
3934
- }
3935
- // Admin-only validation: only admin key can modify data in admin-only databases
3936
- if (this._isAdminOnlyDb) {
3937
- const adminPublicKey = this.getAdminPublicKey();
3938
- const signerPublicKey = useCustomSigningKey
3939
- ? signingKeyPair.publicKey
3940
- : (await this.tenant.getCurrentUserId()).userSigningPublicKey;
3941
- if (signerPublicKey !== adminPublicKey) {
3942
- throw new Error("Admin-only database: only the admin key can modify data");
3799
+ if (useDeterministicSeed && options.recipients !== undefined) {
3800
+ throw new Error("createDocument: recipients is not supported together with a convergent custom id; " +
3801
+ "pass assumeUniqueId for random ids.");
3943
3802
  }
3944
3803
  }
3804
+ const signerPublicKey = await this.resolveSignerPublicKey(useCustomSigningKey ? signingKeyPair : undefined);
3805
+ const createUsernameHash = this.usernameHashFromRecord(Object.fromEntries(initialValueEntries));
3806
+ // The id is settled before the write gate because it decides which rule
3807
+ // applies in `userdirectory`: a personal document is recognized by its id.
3945
3808
  const docId = useCustomDocId ? options.id : this.generateUnusedDocId(options.idPrefix);
3809
+ await this.assertBuiltinWriteAllowed("doc_create", signerPublicKey, createUsernameHash, docId);
3946
3810
  // Idempotent create: when a caller-provided id already exists locally,
3947
3811
  // return the existing document instead of producing a duplicate doc_create.
3948
3812
  if (useCustomDocId) {
@@ -3961,6 +3825,7 @@ class BaseMindooDB {
3961
3825
  return this.wrapDocument(existing);
3962
3826
  }
3963
3827
  }
3828
+ const keyId = await this.resolveCreateKeyId(options, docId);
3964
3829
  this.logger.debug(`Creating document ${docId} with key ${keyId}${useCustomSigningKey ? ' using custom signing key' : ''}${useCustomDocId ? ' with caller-provided id' : ''}`);
3965
3830
  // Build the initial Automerge document and its first change bytes.
3966
3831
  //
@@ -3970,6 +3835,7 @@ class BaseMindooDB {
3970
3835
  // replicas using the same id produce the same Automerge hash and
3971
3836
  // `doc_create` entry id, allowing later changes to merge.
3972
3837
  const now = (0, timeSource_1.semanticNow)();
3838
+ const sealedCreate = await this.prepareSealedCreate(options, docId, keyId, now, signerPublicKey);
3973
3839
  let newDoc;
3974
3840
  let changeBytes;
3975
3841
  if (useDeterministicSeed) {
@@ -3995,6 +3861,9 @@ class BaseMindooDB {
3995
3861
  // Store metadata in the document payload
3996
3862
  // We need to modify the document to ensure a change is created
3997
3863
  doc._attachments = [];
3864
+ if (sealedCreate) {
3865
+ (0, recipients_1.applyEncryptForCreate)(doc, sealedCreate.encryptFor);
3866
+ }
3998
3867
  // Seed caller-provided initial values within the same doc_create
3999
3868
  // change so a doc_create Tier 2 rule can evaluate them in the
4000
3869
  // "after" state (docs/accesscontrol.md §6.3, §9).
@@ -4074,9 +3943,12 @@ class BaseMindooDB {
4074
3943
  createdByPublicKey = currentUser.userSigningPublicKey;
4075
3944
  }
4076
3945
  this.logger.debug(`Signed payload, signature length: ${signature.length} bytes`);
4077
- // Remember the creator's signing key so $author prechecks on subsequent
4078
- // change/delete operations don't need a metadata scan.
4079
- this.creatorKeyCache.set(docId, createdByPublicKey);
3946
+ // Remember the creator so $author prechecks on subsequent change/delete
3947
+ // operations don't need a metadata scan. `now` is the local createdAt;
3948
+ // a later witness may stamp receivedAt, which resolveCreatorInfo picks up
3949
+ // on a cache miss. For the local writer, createdAt is sufficient: the
3950
+ // creating device is still granted.
3951
+ this.creatorInfoCache.set(docId, { signingKey: createdByPublicKey, trustedTime: now });
4080
3952
  // Snapshot of attachments referenced by the freshly created doc (normally
4081
3953
  // empty; the public create API does not add attachments inline).
4082
3954
  const createAttachmentRefs = this.collectAttachmentRefs(newDoc);
@@ -4094,6 +3966,7 @@ class BaseMindooDB {
4094
3966
  originalSize: changeBytes.length,
4095
3967
  encryptedSize: encryptedPayload.length,
4096
3968
  attachmentRefs: createAttachmentRefs.length > 0 ? createAttachmentRefs : undefined,
3969
+ recipients: sealedCreate?.recipients,
4097
3970
  entryVersion: types_1.CURRENT_STORE_ENTRY_VERSION,
4098
3971
  };
4099
3972
  // Bind the metadata with the author signature (audit finding #5).
@@ -4123,6 +3996,7 @@ class BaseMindooDB {
4123
3996
  isDeleted: false,
4124
3997
  awaitingWitness,
4125
3998
  witnessed,
3999
+ sealedRecipients: sealedCreate?.recipients,
4126
4000
  };
4127
4001
  // Update cache and index
4128
4002
  await this.storeCachedDocument(internalDoc);
@@ -4149,7 +4023,6 @@ class BaseMindooDB {
4149
4023
  throw new Error("createDocuments: signingKeyPair and signingKeyPassword must be provided together");
4150
4024
  }
4151
4025
  const useCustomSigningKey = signingKeyPair !== undefined && signingKeyPassword !== undefined;
4152
- const keyId = await this.resolveCreateKeyId(options);
4153
4026
  const useCustomDocId = options.id !== undefined;
4154
4027
  this.validateIdPrefixOption("createDocuments", options);
4155
4028
  this.validateAssumeUniqueIdOption("createDocuments", options);
@@ -4157,24 +4030,22 @@ class BaseMindooDB {
4157
4030
  // out of the deterministic seed, so initialValues fold into doc_create
4158
4031
  // and the document costs a single store entry.
4159
4032
  const useDeterministicSeed = useCustomDocId && !options.assumeUniqueId;
4033
+ if (useDeterministicSeed && options.recipients !== undefined) {
4034
+ throw new Error("createDocuments: recipients is not supported together with a convergent custom id; pass assumeUniqueId");
4035
+ }
4160
4036
  // Sanitize caller-provided initial values: internal/reserved fields are
4161
4037
  // managed by MindooDB and must not be seeded by callers. `undefined`
4162
4038
  // values are treated as "not set" (Automerge cannot represent undefined).
4163
4039
  const initialValueEntries = options.initialValues
4164
4040
  ? Object.entries(options.initialValues).filter(([k, v]) => !k.startsWith("_") && v !== undefined)
4165
4041
  : [];
4166
- // Admin-only validation: only admin key can modify data in admin-only databases
4167
- if (this._isAdminOnlyDb) {
4168
- const adminPublicKey = this.getAdminPublicKey();
4169
- const signerPublicKey = useCustomSigningKey
4170
- ? signingKeyPair.publicKey
4171
- : (await this.tenant.getCurrentUserId()).userSigningPublicKey;
4172
- if (signerPublicKey !== adminPublicKey) {
4173
- throw new Error("Admin-only database: only the admin key can modify data");
4174
- }
4175
- }
4042
+ const signerPublicKey = await this.resolveSignerPublicKey(useCustomSigningKey ? signingKeyPair : undefined);
4043
+ const createUsernameHash = this.usernameHashFromRecord(Object.fromEntries(initialValueEntries));
4176
4044
  const docId = useCustomDocId ? options.id : this.generateUnusedDocId(options.idPrefix);
4045
+ await this.assertBuiltinWriteAllowed("doc_create", signerPublicKey, createUsernameHash, docId);
4177
4046
  const now = (0, timeSource_1.semanticNow)();
4047
+ const keyId = await this.resolveCreateKeyId(options, docId);
4048
+ const sealedCreate = await this.prepareSealedCreate(options, docId, keyId, now, signerPublicKey);
4178
4049
  // Build the initial Automerge document and its first change bytes,
4179
4050
  // following the same seeding rules as the single-create path.
4180
4051
  let seedDoc;
@@ -4189,6 +4060,9 @@ class BaseMindooDB {
4189
4060
  const initialDoc = automerge_adapter_1.Automerge.init();
4190
4061
  seedDoc = automerge_adapter_1.Automerge.change(initialDoc, (doc) => {
4191
4062
  doc._attachments = [];
4063
+ if (sealedCreate) {
4064
+ (0, recipients_1.applyEncryptForCreate)(doc, sealedCreate.encryptFor);
4065
+ }
4192
4066
  // Seed caller-provided initial values within the same doc_create change
4193
4067
  // so a doc_create Tier 2 rule can evaluate them in the "after" state.
4194
4068
  for (const [key, value] of initialValueEntries) {
@@ -4254,9 +4128,12 @@ class BaseMindooDB {
4254
4128
  signature = await this.tenant.signPayload(encryptedPayload);
4255
4129
  createdByPublicKey = currentUser.userSigningPublicKey;
4256
4130
  }
4257
- // Remember the creator's signing key so $author prechecks on subsequent
4258
- // change/delete operations don't need a metadata scan.
4259
- this.creatorKeyCache.set(docId, createdByPublicKey);
4131
+ // Remember the creator so $author prechecks on subsequent change/delete
4132
+ // operations don't need a metadata scan. `now` is the local createdAt;
4133
+ // a later witness may stamp receivedAt, which resolveCreatorInfo picks up
4134
+ // on a cache miss. For the local writer, createdAt is sufficient: the
4135
+ // creating device is still granted.
4136
+ this.creatorInfoCache.set(docId, { signingKey: createdByPublicKey, trustedTime: now });
4260
4137
  const createAttachmentRefs = this.collectAttachmentRefs(finalDoc);
4261
4138
  const entryMetadata = {
4262
4139
  entryType: "doc_create",
@@ -4271,6 +4148,7 @@ class BaseMindooDB {
4271
4148
  originalSize: createChangeBytes.length,
4272
4149
  encryptedSize: encryptedPayload.length,
4273
4150
  attachmentRefs: createAttachmentRefs.length > 0 ? createAttachmentRefs : undefined,
4151
+ recipients: sealedCreate?.recipients,
4274
4152
  entryVersion: types_1.CURRENT_STORE_ENTRY_VERSION,
4275
4153
  };
4276
4154
  // Bind the metadata with the author signature (audit finding #5).
@@ -4317,6 +4195,7 @@ class BaseMindooDB {
4317
4195
  isDeleted: false,
4318
4196
  awaitingWitness,
4319
4197
  witnessed,
4198
+ sealedRecipients: sealedCreate?.recipients,
4320
4199
  };
4321
4200
  return { internalDoc, entries };
4322
4201
  }
@@ -4335,6 +4214,9 @@ class BaseMindooDB {
4335
4214
  this.logger.debug(`Getting document ${docId} at timestamp ${timestamp}`);
4336
4215
  // Get all entry metadata for this document
4337
4216
  const allEntryMetadata = await this.scanAllMetadata(this.store, { docId });
4217
+ if (allEntryMetadata.length > 0) {
4218
+ await this.ingestSealedKeyFromEntries(allEntryMetadata);
4219
+ }
4338
4220
  // Filter to document replay metadata plus snapshots up to the timestamp.
4339
4221
  // Attachment chunks are not part of the Automerge replay DAG and must not
4340
4222
  // participate in historical materialization.
@@ -4380,7 +4262,7 @@ class BaseMindooDB {
4380
4262
  if (snapshotEntries.length > 0) {
4381
4263
  const snapshotData = snapshotEntries[0];
4382
4264
  let isValid = false;
4383
- if (this._isAdminOnlyDb && snapshotData.createdByPublicKey !== this.getAdminPublicKey()) {
4265
+ if (await this.shouldSkipLoadedEntryForBuiltin(snapshotData)) {
4384
4266
  this.logger.warn(`Admin-only DB: skipping snapshot ${snapshotData.id} not signed by admin key`);
4385
4267
  }
4386
4268
  else {
@@ -4427,7 +4309,7 @@ class BaseMindooDB {
4427
4309
  this.logger.warn(`Entry ${entryMeta.id} not found in store, skipping`);
4428
4310
  continue;
4429
4311
  }
4430
- if (this._isAdminOnlyDb && entryData.createdByPublicKey !== this.getAdminPublicKey()) {
4312
+ if (await this.shouldSkipLoadedEntryForBuiltin(entryData)) {
4431
4313
  this.logger.warn(`Admin-only DB: skipping entry ${entryData.id} not signed by admin key`);
4432
4314
  continue;
4433
4315
  }
@@ -4470,10 +4352,62 @@ class BaseMindooDB {
4470
4352
  });
4471
4353
  return this.wrapDocument(internalDoc);
4472
4354
  }
4355
+ /**
4356
+ * Unwrap a `$sealed:` DEK from entry recipient wraps into the tenant session
4357
+ * cache. `loadDocumentInternal` already does this; history / timestamp reads
4358
+ * must as well — otherwise Haven's revision list throws SymmetricKeyNotFoundError
4359
+ * on a cold tenant even when this device is in the wrap list.
4360
+ */
4361
+ async ingestSealedKeyFromEntries(entries) {
4362
+ if (entries.length === 0)
4363
+ return;
4364
+ const keyId = entries.find((entry) => entry.entryType === "doc_create")?.decryptionKeyId
4365
+ ?? entries[0].decryptionKeyId;
4366
+ await this.tenant.ingestSealedFromEntries?.(keyId, entries);
4367
+ }
4368
+ /**
4369
+ * Re-ingest the newest sealed wrap list. When this replica can no longer
4370
+ * unwrap it (recipient removal), drop the leftover session DEK and any
4371
+ * materialized plaintext so later honest-client writes cannot reuse it.
4372
+ *
4373
+ * @returns true when access was revoked
4374
+ */
4375
+ async dropSealedAccessIfUnwrapFailed(internalDoc) {
4376
+ if (!(0, sealedTypes_1.isSealedKeyId)(internalDoc.decryptionKeyId)) {
4377
+ return false;
4378
+ }
4379
+ const metas = await this.scanAllMetadata(this.store, { docId: internalDoc.id });
4380
+ await this.ingestSealedKeyFromEntries(metas);
4381
+ if (await this.tenant.hasDecryptionKey(internalDoc.decryptionKeyId)) {
4382
+ return false;
4383
+ }
4384
+ await this.purgeMaterializedDocument(internalDoc.id);
4385
+ const existingIndex = this.getDocIndexPosition(internalDoc.id);
4386
+ const existing = existingIndex === undefined ? undefined : this.index[existingIndex];
4387
+ if (existing?.accessState === "visible") {
4388
+ this.updateIndex(internalDoc.id, existing.lastModified, true, internalDoc.decryptionKeyId, "inaccessible");
4389
+ this.cacheMetaDirty = true;
4390
+ this.cacheManager?.markDirty();
4391
+ }
4392
+ return true;
4393
+ }
4394
+ /**
4395
+ * Patch paths (`applyTextPatch`, rich-text, Automerge) used to persist with
4396
+ * a leftover session DEK after the newest wrap excluded this user.
4397
+ * `changeDoc` already refused those writes; this is the shared chokepoint.
4398
+ */
4399
+ async assertCurrentSealedAccess(internalDoc) {
4400
+ if (await this.dropSealedAccessIfUnwrapFailed(internalDoc)) {
4401
+ throw new errors_1.DocumentNotFoundError(internalDoc.id);
4402
+ }
4403
+ }
4473
4404
  async *iterateDocumentHistory(docId) {
4474
4405
  this.logger.debug(`Iterating document history for ${docId}`);
4475
4406
  // Get all entry metadata for this document
4476
4407
  const allEntryMetadata = await this.scanAllMetadata(this.store, { docId });
4408
+ if (allEntryMetadata.length > 0) {
4409
+ await this.ingestSealedKeyFromEntries(allEntryMetadata);
4410
+ }
4477
4411
  // Filter to document replay entries (exclude snapshots) and order them
4478
4412
  // causally: a plain chronological sort can place an effect before its
4479
4413
  // cause when several entries share the same millisecond (or when replica
@@ -4486,6 +4420,7 @@ class BaseMindooDB {
4486
4420
  }
4487
4421
  // Load all entries
4488
4422
  const entries = await this.store.getEntries(relevantEntries.map(em => em.id));
4423
+ await this.ingestSealedKeyFromEntries(entries.length > 0 ? entries : relevantEntries);
4489
4424
  // Build a map for quick lookup
4490
4425
  const entryMap = new Map(entries.map(e => [e.id, e]));
4491
4426
  // Apply changes in order
@@ -4500,7 +4435,7 @@ class BaseMindooDB {
4500
4435
  continue;
4501
4436
  }
4502
4437
  // Admin-only mode: only accept entries signed by the admin key
4503
- if (this._isAdminOnlyDb && entryData.createdByPublicKey !== this.getAdminPublicKey()) {
4438
+ if (await this.shouldSkipLoadedEntryForBuiltin(entryData)) {
4504
4439
  this.logger.warn(`Admin-only DB: skipping entry ${entryData.id} not signed by admin key`);
4505
4440
  continue;
4506
4441
  }
@@ -4660,14 +4595,14 @@ class BaseMindooDB {
4660
4595
  // leading witnessed prefix.
4661
4596
  let stableCursor = cursor;
4662
4597
  let sawProvisional = false;
4663
- const consider = (em) => {
4598
+ const consider = async (em) => {
4664
4599
  if (!this.isDocumentReplayEntry(em)) {
4665
4600
  return;
4666
4601
  }
4667
4602
  // Admin-only mode: only accept entries signed by the admin key (mirrors
4668
4603
  // materialization / iterateDocumentHistory). Non-admin entries are
4669
4604
  // permanently ignored and do not gate the stable cursor.
4670
- if (this._isAdminOnlyDb && em.createdByPublicKey !== this.getAdminPublicKey()) {
4605
+ if (await this.shouldSkipLoadedEntryForBuiltin(em)) {
4671
4606
  return;
4672
4607
  }
4673
4608
  newEntries.push(em);
@@ -4699,7 +4634,7 @@ class BaseMindooDB {
4699
4634
  while (true) {
4700
4635
  const page = await this.store.scanEntriesSince(scanCursor, 1000, scanFilters);
4701
4636
  for (const em of page.entries) {
4702
- consider(em);
4637
+ await consider(em);
4703
4638
  }
4704
4639
  scanCursor = page.nextCursor;
4705
4640
  if (!page.hasMore) {
@@ -4720,7 +4655,7 @@ class BaseMindooDB {
4720
4655
  if (cursor && !this.isAfterReceiptCursor(em, cursor)) {
4721
4656
  continue;
4722
4657
  }
4723
- consider(em);
4658
+ await consider(em);
4724
4659
  }
4725
4660
  }
4726
4661
  return { newEntries, resumeCursor: stableCursor };
@@ -4749,10 +4684,12 @@ class BaseMindooDB {
4749
4684
  // All metadata for this doc (metadata only, cheap). Includes snapshots, used
4750
4685
  // for cache-miss seed reconstruction.
4751
4686
  const docAllMeta = await this.scanAllMetadata(this.store, { docId });
4752
- let replayMeta = docAllMeta.filter((em) => this.isDocumentReplayEntry(em));
4753
- if (this._isAdminOnlyDb) {
4754
- const adminKey = this.getAdminPublicKey();
4755
- replayMeta = replayMeta.filter((em) => em.createdByPublicKey === adminKey);
4687
+ const replayCandidates = docAllMeta.filter((em) => this.isDocumentReplayEntry(em));
4688
+ const replayMeta = [];
4689
+ for (const em of replayCandidates) {
4690
+ if (!(await this.shouldSkipLoadedEntryForBuiltin(em))) {
4691
+ replayMeta.push(em);
4692
+ }
4756
4693
  }
4757
4694
  if (replayMeta.length === 0) {
4758
4695
  return;
@@ -4951,7 +4888,7 @@ class BaseMindooDB {
4951
4888
  return null;
4952
4889
  }
4953
4890
  const snapshotData = entries[0];
4954
- if (this._isAdminOnlyDb && snapshotData.createdByPublicKey !== this.getAdminPublicKey()) {
4891
+ if (await this.shouldSkipLoadedEntryForBuiltin(snapshotData)) {
4955
4892
  this.logger.warn(`Admin-only DB: skipping snapshot ${snapshotData.id} not signed by admin key`);
4956
4893
  return null;
4957
4894
  }
@@ -5120,7 +5057,7 @@ class BaseMindooDB {
5120
5057
  result.set(metadata.id, null);
5121
5058
  continue;
5122
5059
  }
5123
- if (this._isAdminOnlyDb && entry.createdByPublicKey !== this.getAdminPublicKey()) {
5060
+ if (await this.shouldSkipLoadedEntryForBuiltin(entry)) {
5124
5061
  result.set(metadata.id, null);
5125
5062
  continue;
5126
5063
  }
@@ -5286,7 +5223,7 @@ class BaseMindooDB {
5286
5223
  const entry = entries[0];
5287
5224
  if (entry) {
5288
5225
  let isValid = false;
5289
- if (this._isAdminOnlyDb && entry.createdByPublicKey !== this.getAdminPublicKey()) {
5226
+ if (await this.shouldSkipLoadedEntryForBuiltin(entry)) {
5290
5227
  this.logger.warn(`Admin-only DB: skipping DAG details for ${entry.id} not signed by admin key`);
5291
5228
  }
5292
5229
  else {
@@ -5983,7 +5920,7 @@ class BaseMindooDB {
5983
5920
  this.logger.warn(`Conflict analysis: entry ${metadata.id} not found in store, skipping`);
5984
5921
  continue;
5985
5922
  }
5986
- if (this._isAdminOnlyDb && entry.createdByPublicKey !== this.getAdminPublicKey()) {
5923
+ if (await this.shouldSkipLoadedEntryForBuiltin(entry)) {
5987
5924
  this.logger.warn(`Admin-only DB: skipping conflict analysis entry ${entry.id} not signed by admin key`);
5988
5925
  continue;
5989
5926
  }
@@ -6533,7 +6470,17 @@ class BaseMindooDB {
6533
6470
  if (docIds.length === 0) {
6534
6471
  return;
6535
6472
  }
6536
- await this.assertLifecycleMutationAllowed(options.signingKeyPair, options.signingKeyPassword);
6473
+ // One gate call per document id: inside `userdirectory` the applicable rule
6474
+ // depends on the id (personal documents are owned by their creator), so a
6475
+ // single batch-wide check would deny an owner deleting their own data.
6476
+ if ((0, builtinDbInvariants_1.hasBuiltinWriteInvariant)(this.effectiveBuiltinDbId()) || this._isAdminOnlyDb) {
6477
+ for (const docId of docIds) {
6478
+ await this.assertLifecycleMutationAllowed("doc_delete", options.signingKeyPair, options.signingKeyPassword, undefined, docId);
6479
+ }
6480
+ }
6481
+ else {
6482
+ await this.assertLifecycleMutationAllowed("doc_delete", options.signingKeyPair, options.signingKeyPassword);
6483
+ }
6537
6484
  this.logger.info(`Bulk-deleting ${docIds.length} documents`);
6538
6485
  // Unlike creation, a delete must first materialize the CURRENT document
6539
6486
  // (the doc_delete entry is an Automerge change on its heads), which costs
@@ -6580,21 +6527,14 @@ class BaseMindooDB {
6580
6527
  async undeleteDocument(docId, options = {}) {
6581
6528
  return this.undeleteDocInternal(docId, options.signingKeyPair, options.signingKeyPassword, options.bypassAccessControlPrecheck);
6582
6529
  }
6583
- async assertLifecycleMutationAllowed(signingKeyPair, signingKeyPassword) {
6530
+ async assertLifecycleMutationAllowed(op, signingKeyPair, signingKeyPassword, documentUsernameHash, docId) {
6584
6531
  this.assertWritable("document lifecycle mutation");
6585
6532
  const useCustomSigningKey = signingKeyPair !== undefined && signingKeyPassword !== undefined;
6586
6533
  if ((signingKeyPair !== undefined) !== (signingKeyPassword !== undefined)) {
6587
6534
  throw new Error("Lifecycle mutation requires both signingKeyPair and signingKeyPassword");
6588
6535
  }
6589
- if (this._isAdminOnlyDb) {
6590
- const adminPublicKey = this.getAdminPublicKey();
6591
- const signerPublicKey = useCustomSigningKey
6592
- ? signingKeyPair.publicKey
6593
- : (await this.tenant.getCurrentUserId()).userSigningPublicKey;
6594
- if (signerPublicKey !== adminPublicKey) {
6595
- throw new Error("Admin-only database: only the admin key can modify data");
6596
- }
6597
- }
6536
+ const signerPublicKey = await this.resolveSignerPublicKey(useCustomSigningKey ? signingKeyPair : undefined);
6537
+ await this.assertBuiltinWriteAllowed(op, signerPublicKey, documentUsernameHash, docId);
6598
6538
  }
6599
6539
  /**
6600
6540
  * Write a delete or undelete lifecycle entry for an existing document.
@@ -6700,12 +6640,11 @@ class BaseMindooDB {
6700
6640
  // default to the "before" state; both states are supplied so either `when`
6701
6641
  // resolves. Throws AccessDeniedError if denied.
6702
6642
  {
6703
- const creatorKey = await this.resolveCreatorSigningKey(docId);
6704
6643
  const beforeState = internalDoc.doc;
6705
6644
  await this.assertWriteAllowed({
6706
6645
  op: entryType,
6707
6646
  signerKey: createdByPublicKey,
6708
- isAuthor: creatorKey !== null && createdByPublicKey === creatorKey,
6647
+ isAuthor: await this.resolveIsAuthor(docId, createdByPublicKey, Number.MAX_SAFE_INTEGER),
6709
6648
  bypass: bypassPrecheck,
6710
6649
  getBeforeDoc: () => this.convertAutomergeToJS(beforeState),
6711
6650
  getAfterDoc: () => this.convertAutomergeToJS(newDoc),
@@ -6769,7 +6708,7 @@ class BaseMindooDB {
6769
6708
  async deleteDocInternal(docId, signingKeyPair, signingKeyPassword, bypassPrecheck) {
6770
6709
  const useCustomSigningKey = signingKeyPair !== undefined && signingKeyPassword !== undefined;
6771
6710
  this.logger.debug(`Deleting document ${docId}${useCustomSigningKey ? ' using custom signing key' : ''}`);
6772
- await this.assertLifecycleMutationAllowed(signingKeyPair, signingKeyPassword);
6711
+ await this.assertLifecycleMutationAllowed("doc_delete", signingKeyPair, signingKeyPassword, undefined, docId);
6773
6712
  // Get current document
6774
6713
  const internalDoc = await this.loadDocumentInternal(docId);
6775
6714
  if (!internalDoc || internalDoc.isDeleted) {
@@ -6781,11 +6720,11 @@ class BaseMindooDB {
6781
6720
  async undeleteDocInternal(docId, signingKeyPair, signingKeyPassword, bypassPrecheck) {
6782
6721
  const useCustomSigningKey = signingKeyPair !== undefined && signingKeyPassword !== undefined;
6783
6722
  this.logger.debug(`Undeleting document ${docId}${useCustomSigningKey ? ' using custom signing key' : ''}`);
6784
- await this.assertLifecycleMutationAllowed(signingKeyPair, signingKeyPassword);
6785
6723
  const internalDoc = await this.loadDocumentInternal(docId);
6786
6724
  if (!internalDoc) {
6787
6725
  throw new errors_1.DocumentNotFoundError(docId);
6788
6726
  }
6727
+ await this.assertLifecycleMutationAllowed("doc_undelete", signingKeyPair, signingKeyPassword, this.usernameHashFromInternalDoc(internalDoc), docId);
6789
6728
  if (!internalDoc.isDeleted) {
6790
6729
  this.logger.debug(`Document ${docId} is already alive; undelete is a no-op`);
6791
6730
  return;
@@ -6803,13 +6742,6 @@ class BaseMindooDB {
6803
6742
  this.assertWritable("applyTextPatch");
6804
6743
  const docId = doc.getId();
6805
6744
  this.logger.debug(`===== applyTextPatch called for document ${docId} =====`);
6806
- if (this._isAdminOnlyDb) {
6807
- const adminPublicKey = this.getAdminPublicKey();
6808
- const currentUser = await this.tenant.getCurrentUserId();
6809
- if (currentUser.userSigningPublicKey !== adminPublicKey) {
6810
- throw new Error("Admin-only database: only the admin key can modify data");
6811
- }
6812
- }
6813
6745
  let internalDoc = this.getCachedDocument(docId);
6814
6746
  if (!internalDoc) {
6815
6747
  const loadedDoc = await this.loadDocumentInternal(docId);
@@ -6867,13 +6799,6 @@ class BaseMindooDB {
6867
6799
  this.assertWritable("applyRichTextPatch");
6868
6800
  const docId = doc.getId();
6869
6801
  this.logger.debug(`===== applyRichTextPatch called for document ${docId} =====`);
6870
- if (this._isAdminOnlyDb) {
6871
- const adminPublicKey = this.getAdminPublicKey();
6872
- const currentUser = await this.tenant.getCurrentUserId();
6873
- if (currentUser.userSigningPublicKey !== adminPublicKey) {
6874
- throw new Error("Admin-only database: only the admin key can modify data");
6875
- }
6876
- }
6877
6802
  let internalDoc = this.getCachedDocument(docId);
6878
6803
  if (!internalDoc) {
6879
6804
  const loadedDoc = await this.loadDocumentInternal(docId);
@@ -6942,13 +6867,6 @@ class BaseMindooDB {
6942
6867
  this.assertWritable("applyRichTextStepsPatch");
6943
6868
  const docId = doc.getId();
6944
6869
  this.logger.debug(`===== applyRichTextStepsPatch called for document ${docId} =====`);
6945
- if (this._isAdminOnlyDb) {
6946
- const adminPublicKey = this.getAdminPublicKey();
6947
- const currentUser = await this.tenant.getCurrentUserId();
6948
- if (currentUser.userSigningPublicKey !== adminPublicKey) {
6949
- throw new Error("Admin-only database: only the admin key can modify data");
6950
- }
6951
- }
6952
6870
  let internalDoc = this.getCachedDocument(docId);
6953
6871
  if (!internalDoc) {
6954
6872
  const loadedDoc = await this.loadDocumentInternal(docId);
@@ -7061,13 +6979,6 @@ class BaseMindooDB {
7061
6979
  this.assertWritable("applyAutomergeChanges");
7062
6980
  const docId = doc.getId();
7063
6981
  this.logger.debug(`===== applyAutomergeChanges called for document ${docId} =====`);
7064
- if (this._isAdminOnlyDb) {
7065
- const adminPublicKey = this.getAdminPublicKey();
7066
- const currentUser = await this.tenant.getCurrentUserId();
7067
- if (currentUser.userSigningPublicKey !== adminPublicKey) {
7068
- throw new Error("Admin-only database: only the admin key can modify data");
7069
- }
7070
- }
7071
6982
  if (!Array.isArray(patch.changes) || patch.changes.length === 0) {
7072
6983
  throw new Error("Automerge changes patch must include at least one change byte sequence");
7073
6984
  }
@@ -7171,13 +7082,6 @@ class BaseMindooDB {
7171
7082
  this.assertWritable("applyJsonPatch");
7172
7083
  const docId = doc.getId();
7173
7084
  this.logger.debug(`===== applyJsonPatch called for document ${docId} =====`);
7174
- if (this._isAdminOnlyDb) {
7175
- const adminPublicKey = this.getAdminPublicKey();
7176
- const currentUser = await this.tenant.getCurrentUserId();
7177
- if (currentUser.userSigningPublicKey !== adminPublicKey) {
7178
- throw new Error("Admin-only database: only the admin key can modify data");
7179
- }
7180
- }
7181
7085
  let internalDoc = this.getCachedDocument(docId);
7182
7086
  if (!internalDoc) {
7183
7087
  const loadedDoc = await this.loadDocumentInternal(docId);
@@ -7235,16 +7139,6 @@ class BaseMindooDB {
7235
7139
  }
7236
7140
  const useCustomKey = signingKeyPair !== undefined && signingKeyPassword !== undefined;
7237
7141
  this.logger.debug(`===== ${useCustomKey ? 'changeDocWithSigningKey' : 'changeDoc'} called for document ${docId} =====`);
7238
- // Admin-only validation: only admin key can modify data in admin-only databases
7239
- if (this._isAdminOnlyDb) {
7240
- const adminPublicKey = this.getAdminPublicKey();
7241
- const signerPublicKey = useCustomKey
7242
- ? signingKeyPair.publicKey
7243
- : (await this.tenant.getCurrentUserId()).userSigningPublicKey;
7244
- if (signerPublicKey !== adminPublicKey) {
7245
- throw new Error("Admin-only database: only the admin key can modify data");
7246
- }
7247
- }
7248
7142
  // Get internal document from cache or load it
7249
7143
  let internalDoc = this.getCachedDocument(docId);
7250
7144
  if (!internalDoc) {
@@ -7262,6 +7156,7 @@ class BaseMindooDB {
7262
7156
  if (internalDoc.isDeleted) {
7263
7157
  throw new errors_1.DocumentDeletedError(docId);
7264
7158
  }
7159
+ await this.assertCurrentSealedAccess(internalDoc);
7265
7160
  // Apply the change function
7266
7161
  const now = (0, timeSource_1.semanticNow)();
7267
7162
  this.logger.debug(`Applying change function to document ${docId}`);
@@ -7304,6 +7199,11 @@ class BaseMindooDB {
7304
7199
  isAccessible: () => true,
7305
7200
  isAwaitingWitness: () => internalDoc.awaitingWitness ?? false,
7306
7201
  isWitnessed: () => internalDoc.witnessed ?? false,
7202
+ isSealed: () => (0, sealedTypes_1.isSealedKeyId)(internalDoc.decryptionKeyId),
7203
+ getRecipients: () => (0, recipients_1.resolveRecipientsFromPayload)(internalDoc.doc, internalDoc.sealedRecipients),
7204
+ getRecipientEpoch: () => internalDoc.sealedRecipients?.epoch ?? 0,
7205
+ isEncryptedFor: (users) => (0, sealedTypes_1.isSealedKeyId)(internalDoc.decryptionKeyId)
7206
+ && (0, recipients_1.isPayloadEncryptedFor)(internalDoc.doc, internalDoc.sealedRecipients, Array.isArray(users) ? users : [users]),
7307
7207
  getHeads: () => automerge_adapter_1.Automerge.getHeads(internalDoc.doc),
7308
7208
  getData: () => {
7309
7209
  // Return a proxy that collects property assignments and deletions
@@ -7584,6 +7484,7 @@ class BaseMindooDB {
7584
7484
  successMessage: useCustomKey ? "changed with custom signing key" : "changed",
7585
7485
  attachmentIds: pendingAttachmentAdditions.map((ref) => ref.attachmentId),
7586
7486
  bypassPrecheck,
7487
+ recipients: internalDoc.sealedRecipients,
7587
7488
  });
7588
7489
  }
7589
7490
  catch (error) {
@@ -8087,7 +7988,7 @@ class BaseMindooDB {
8087
7988
  }
8088
7989
  }
8089
7990
  async persistDocumentChange(options) {
8090
- const { internalDoc, newDoc, now, headsBeforeChange, useCustomKey, signingKeyPair, signingKeyPassword, successMessage, attachmentIds, bypassPrecheck } = options;
7991
+ const { internalDoc, newDoc, now, headsBeforeChange, useCustomKey, signingKeyPair, signingKeyPassword, successMessage, attachmentIds, bypassPrecheck, recipients } = options;
8091
7992
  const docId = internalDoc.id;
8092
7993
  this.logger.debug(`Getting change bytes from document ${docId}`);
8093
7994
  const changesSincePreviousHeads = headsBeforeChange
@@ -8111,17 +8012,21 @@ class BaseMindooDB {
8111
8012
  successMessage,
8112
8013
  attachmentIds,
8113
8014
  bypassPrecheck,
8015
+ recipients: options.recipients,
8114
8016
  });
8115
8017
  }
8116
8018
  async persistAutomergeDocumentChanges(options) {
8117
- const { internalDoc, newDoc, now, changeBytesList, headsBeforeChange, useCustomKey, signingKeyPair, signingKeyPassword, successMessage, attachmentIds, bypassPrecheck, } = options;
8019
+ const { internalDoc, newDoc, now, changeBytesList, headsBeforeChange, useCustomKey, signingKeyPair, signingKeyPassword, successMessage, attachmentIds, bypassPrecheck, recipients, } = options;
8118
8020
  const docId = internalDoc.id;
8021
+ await this.assertCurrentSealedAccess(internalDoc);
8119
8022
  const resolvedChangeBytesList = changeBytesList ?? (headsBeforeChange
8120
8023
  ? automerge_adapter_1.Automerge.getChangesSince(newDoc, headsBeforeChange)
8121
8024
  : []);
8122
8025
  if (resolvedChangeBytesList.length === 0) {
8123
8026
  throw new Error("No Automerge changes to persist");
8124
8027
  }
8028
+ const signerPublicKey = await this.resolveSignerPublicKey(useCustomKey ? signingKeyPair : undefined);
8029
+ await this.assertBuiltinWriteAllowed("doc_change", signerPublicKey, this.usernameHashFromInternalDoc(internalDoc), docId);
8125
8030
  // Signed snapshot of the document's attachments after this change, computed
8126
8031
  // once from the resulting document state so EVERY change path (changeDoc,
8127
8032
  // text patches, merges, ...) carries it consistently on each produced entry.
@@ -8140,6 +8045,7 @@ class BaseMindooDB {
8140
8045
  // The full attachment snapshot describes the document's resulting state,
8141
8046
  // so it goes on every produced entry (unlike the add-only attachmentIds).
8142
8047
  attachmentRefs,
8048
+ recipients: index === 0 ? recipients : undefined,
8143
8049
  });
8144
8050
  entries.push(entry);
8145
8051
  const decodedChange = automerge_adapter_1.Automerge.decodeChange(changeBytes);
@@ -8153,12 +8059,11 @@ class BaseMindooDB {
8153
8059
  const signerKey = useCustomKey
8154
8060
  ? signingKeyPair.publicKey
8155
8061
  : (await this.tenant.getCurrentUserId()).userSigningPublicKey;
8156
- const creatorKey = await this.resolveCreatorSigningKey(docId);
8157
8062
  const beforeState = internalDoc.doc;
8158
8063
  await this.assertWriteAllowed({
8159
8064
  op: "doc_change",
8160
8065
  signerKey,
8161
- isAuthor: creatorKey !== null && signerKey === creatorKey,
8066
+ isAuthor: await this.resolveIsAuthor(docId, signerKey, Number.MAX_SAFE_INTEGER),
8162
8067
  bypass: bypassPrecheck,
8163
8068
  getBeforeDoc: () => this.convertAutomergeToJS(beforeState),
8164
8069
  getAfterDoc: () => this.convertAutomergeToJS(newDoc),
@@ -8181,6 +8086,9 @@ class BaseMindooDB {
8181
8086
  // changefeed must re-emit the doc even when `lastModified` collides with
8182
8087
  // the previous change in the same millisecond.
8183
8088
  this.updateIndex(docId, internalDoc.lastModified, internalDoc.isDeleted, internalDoc.decryptionKeyId, "visible", internalDoc.awaitingWitness, internalDoc.witnessed, true);
8089
+ if (recipients) {
8090
+ internalDoc.sealedRecipients = recipients;
8091
+ }
8184
8092
  this.markDocDirty(docId);
8185
8093
  this.logger.info(`Document ${docId} ${successMessage} successfully`);
8186
8094
  const createdByPublicKey = useCustomKey
@@ -8191,7 +8099,7 @@ class BaseMindooDB {
8191
8099
  : { createdByPublicKey: createdByPublicKey ?? "" });
8192
8100
  }
8193
8101
  async buildDocChangeStoreEntry(options) {
8194
- const { internalDoc, changeBytes, now, useCustomKey, signingKeyPair, signingKeyPassword, attachmentIds, attachmentRefs } = options;
8102
+ const { internalDoc, changeBytes, now, useCustomKey, signingKeyPair, signingKeyPassword, attachmentIds, attachmentRefs, recipients } = options;
8195
8103
  const docId = internalDoc.id;
8196
8104
  this.logger.debug(`Got change bytes: ${changeBytes.length} bytes`);
8197
8105
  const decodedChange = automerge_adapter_1.Automerge.decodeChange(changeBytes);
@@ -8230,6 +8138,7 @@ class BaseMindooDB {
8230
8138
  encryptedSize: encryptedPayload.length,
8231
8139
  attachmentIds: attachmentIds && attachmentIds.length > 0 ? attachmentIds : undefined,
8232
8140
  attachmentRefs: attachmentRefs && attachmentRefs.length > 0 ? attachmentRefs : undefined,
8141
+ recipients,
8233
8142
  entryVersion: types_1.CURRENT_STORE_ENTRY_VERSION,
8234
8143
  };
8235
8144
  // Bind the metadata with the author signature (audit finding #5).
@@ -8741,7 +8650,7 @@ class BaseMindooDB {
8741
8650
  if (snapshotEntries.length > 0) {
8742
8651
  const snapshotData = snapshotEntries[0];
8743
8652
  let isValid = false;
8744
- if (this._isAdminOnlyDb && snapshotData.createdByPublicKey !== this.getAdminPublicKey()) {
8653
+ if (await this.shouldSkipLoadedEntryForBuiltin(snapshotData)) {
8745
8654
  this.logger.warn(`Admin-only DB: skipping snapshot ${snapshotData.id} not signed by admin key`);
8746
8655
  }
8747
8656
  else {
@@ -8786,7 +8695,7 @@ class BaseMindooDB {
8786
8695
  this.logger.warn(`Entry ${entryMeta.id} not found in store, skipping`);
8787
8696
  continue;
8788
8697
  }
8789
- if (this._isAdminOnlyDb && entryData.createdByPublicKey !== this.getAdminPublicKey()) {
8698
+ if (await this.shouldSkipLoadedEntryForBuiltin(entryData)) {
8790
8699
  this.logger.warn(`Admin-only DB: skipping entry ${entryData.id} not signed by admin key`);
8791
8700
  continue;
8792
8701
  }
@@ -9035,13 +8944,14 @@ class BaseMindooDB {
9035
8944
  // Load entries from store
9036
8945
  const entries = await this.store.getEntries(orderedEntryIds);
9037
8946
  // Filter entries for admin-only mode first
9038
- const validEntries = entries.filter(entryData => {
9039
- if (this._isAdminOnlyDb && entryData.createdByPublicKey !== this.getAdminPublicKey()) {
8947
+ const validEntries = [];
8948
+ for (const entryData of entries) {
8949
+ if (await this.shouldSkipLoadedEntryForBuiltin(entryData)) {
9040
8950
  this.logger.warn(`Admin-only DB: skipping entry ${entryData.id} not signed by admin key`);
9041
- return false;
8951
+ continue;
9042
8952
  }
9043
- return true;
9044
- });
8953
+ validEntries.push(entryData);
8954
+ }
9045
8955
  if (validEntries.length === 0) {
9046
8956
  this.logger.debug(`No valid entries to process for cached document ${docId}`);
9047
8957
  return null;
@@ -9592,7 +9502,13 @@ class BaseMindooDB {
9592
9502
  * server. Directory ACL writes always pass an explicit key, so this never
9593
9503
  * re-enters the directory for them.
9594
9504
  */
9595
- async resolveCreateKeyId(options) {
9505
+ async resolveCreateKeyId(options, docId) {
9506
+ if (options.recipients !== undefined && options.decryptionKeyId !== undefined) {
9507
+ throw new Error("createDocument: recipients and decryptionKeyId are mutually exclusive");
9508
+ }
9509
+ if (options.recipients !== undefined) {
9510
+ return (0, sealedTypes_1.sealedKeyId)(docId ?? options.id ?? "pending");
9511
+ }
9596
9512
  if (options.decryptionKeyId !== undefined)
9597
9513
  return options.decryptionKeyId;
9598
9514
  try {
@@ -9609,6 +9525,34 @@ class BaseMindooDB {
9609
9525
  }
9610
9526
  return "default";
9611
9527
  }
9528
+ async prepareSealedCreate(options, docId, keyId, now, signerPublicKey) {
9529
+ if (options.recipients === undefined)
9530
+ return undefined;
9531
+ const tenant = this.tenant;
9532
+ const resolved = await (0, recipients_1.resolveRecipientSpecs)({
9533
+ tenant,
9534
+ specs: options.recipients,
9535
+ options: options.recipientOptions,
9536
+ addedBy: signerPublicKey,
9537
+ now,
9538
+ });
9539
+ const dek = tenant.getCryptoAdapter().getRandomValues(new Uint8Array(32));
9540
+ console.log("[sealed] prepareSealedCreate", {
9541
+ docId,
9542
+ keyId,
9543
+ includeSelf: options.recipientOptions?.includeSelf !== false,
9544
+ userTargets: resolved.targets.map((target) => ({ kind: target.kind, id: target.id })),
9545
+ skipped: resolved.skipped,
9546
+ });
9547
+ const recipients = await (0, recipients_1.sealToTargets)({
9548
+ tenant,
9549
+ generations: [dek],
9550
+ targets: resolved.targets,
9551
+ epoch: 1,
9552
+ });
9553
+ tenant.rememberSealedGenerations(keyId, [dek]);
9554
+ return { encryptFor: resolved.encryptFor, recipients };
9555
+ }
9612
9556
  /**
9613
9557
  * Evaluate the full client-side (Tier 1 + Tier 2) write ruleset for a
9614
9558
  * candidate write, returning the {@link AccessDecision}. Returns `null` only
@@ -9701,17 +9645,203 @@ class BaseMindooDB {
9701
9645
  const signerKey = signingKeyPair
9702
9646
  ? signingKeyPair.publicKey
9703
9647
  : (await this.tenant.getCurrentUserId()).userSigningPublicKey;
9704
- const creatorKey = await this.resolveCreatorSigningKey(docId);
9705
9648
  const before = doc.getData();
9706
9649
  const decision = await this.evaluateClientWriteAccess({
9707
9650
  op: "doc_change",
9708
9651
  signerKey,
9709
- isAuthor: creatorKey !== null && signerKey === creatorKey,
9652
+ isAuthor: await this.resolveIsAuthor(docId, signerKey, Number.MAX_SAFE_INTEGER),
9710
9653
  getBeforeDoc: () => before,
9711
9654
  getAfterDoc: () => candidateAfter,
9712
9655
  });
9713
9656
  return decision ?? BaseMindooDB.ACL_NOT_ENFORCED_DECISION;
9714
9657
  }
9658
+ async addRecipients(doc, recipients, options) {
9659
+ return this.mutateRecipients(doc, "add", recipients, options);
9660
+ }
9661
+ async removeRecipients(doc, recipients, options) {
9662
+ return this.mutateRecipients(doc, "remove", recipients, options);
9663
+ }
9664
+ async setRecipients(doc, recipients, options) {
9665
+ return this.mutateRecipients(doc, "set", recipients, options);
9666
+ }
9667
+ async canChangeRecipients(doc, next) {
9668
+ const after = { ...doc.getData() };
9669
+ after[sealedTypes_1.ENCRYPT_FOR_FIELD] = { ...(0, recipients_1.readEncryptFor)(after) };
9670
+ return this.canChange(doc, after);
9671
+ }
9672
+ async mutateRecipients(doc, mode, specs, options) {
9673
+ this.assertWritable("changeDoc");
9674
+ if (!(0, sealedTypes_1.isSealedKeyId)(doc.getDecryptionKeyId())) {
9675
+ throw new Error("Recipient mutators require a document created with `recipients`");
9676
+ }
9677
+ const tenant = this.tenant;
9678
+ const docId = doc.getId();
9679
+ let internalDoc = this.getCachedDocument(docId) ?? (await this.loadDocumentInternal(docId));
9680
+ if (!internalDoc || internalDoc.isDeleted) {
9681
+ throw new errors_1.DocumentNotFoundError(docId);
9682
+ }
9683
+ const now = (0, timeSource_1.semanticNow)();
9684
+ const signer = options?.signingKeyPair?.publicKey
9685
+ ?? (await this.tenant.getCurrentUserId()).userSigningPublicKey;
9686
+ const resolved = await (0, recipients_1.resolveRecipientSpecs)({
9687
+ tenant,
9688
+ specs,
9689
+ options: {
9690
+ includeSelf: mode === "set" ? options?.includeSelf !== false : false,
9691
+ strict: options?.strict,
9692
+ },
9693
+ addedBy: signer,
9694
+ now,
9695
+ });
9696
+ const currentMap = (0, recipients_1.readEncryptFor)(internalDoc.doc);
9697
+ const activeIds = new Set(Object.entries(currentMap).filter(([, e]) => !e.removedAt).map(([id]) => id.split("#")[0]));
9698
+ const addedTargets = [];
9699
+ const removedStableIds = [];
9700
+ const matchesTarget = (id, entry, target) => {
9701
+ const base = id.split("#")[0];
9702
+ return (target.stableId === base
9703
+ || target.id === base
9704
+ || (!!entry.label && target.id === entry.label)
9705
+ || (!!entry.keyFingerprint && target.keyFingerprint === entry.keyFingerprint));
9706
+ };
9707
+ if (mode === "add" || mode === "set") {
9708
+ for (const target of resolved.targets) {
9709
+ if (!activeIds.has(target.stableId) && !activeIds.has(target.id))
9710
+ addedTargets.push(target);
9711
+ }
9712
+ }
9713
+ if (mode === "remove") {
9714
+ for (const [id, entry] of Object.entries(currentMap)) {
9715
+ if (entry.removedAt)
9716
+ continue;
9717
+ if (resolved.targets.some((target) => matchesTarget(id, entry, target))) {
9718
+ removedStableIds.push(id.split("#")[0]);
9719
+ }
9720
+ }
9721
+ }
9722
+ else if (mode === "set") {
9723
+ const nextIds = new Set(resolved.targets.flatMap((t) => [t.stableId, t.id]));
9724
+ for (const [id, entry] of Object.entries(currentMap)) {
9725
+ if (entry.removedAt)
9726
+ continue;
9727
+ const base = id.split("#")[0];
9728
+ const keep = resolved.targets.some((target) => matchesTarget(id, entry, target));
9729
+ if (!keep && !nextIds.has(base))
9730
+ removedStableIds.push(base);
9731
+ }
9732
+ }
9733
+ const rotate = removedStableIds.length > 0;
9734
+ const currentGens = tenant.getSealedGenerations(internalDoc.decryptionKeyId);
9735
+ if (currentGens.length === 0) {
9736
+ throw new Error("Cannot change recipients: the current document key is not in this session");
9737
+ }
9738
+ let generations = currentGens;
9739
+ let epoch = internalDoc.sealedRecipients?.epoch ?? currentGens.length;
9740
+ if (rotate) {
9741
+ const nextDek = tenant.getCryptoAdapter().getRandomValues(new Uint8Array(32));
9742
+ generations = [nextDek, ...currentGens];
9743
+ epoch += 1;
9744
+ }
9745
+ const remainingByStable = new Map();
9746
+ const existingSpecs = Object.entries(currentMap)
9747
+ .filter(([, e]) => !e.removedAt)
9748
+ .map(([id, e]) => e.kind === "device"
9749
+ ? { device: e.keyFingerprint ?? id, label: e.label }
9750
+ : (e.label ?? id));
9751
+ if (existingSpecs.length > 0) {
9752
+ const existingResolved = await (0, recipients_1.resolveRecipientSpecs)({
9753
+ tenant,
9754
+ specs: existingSpecs,
9755
+ options: { includeSelf: false, strict: false },
9756
+ addedBy: signer,
9757
+ now,
9758
+ });
9759
+ for (const target of existingResolved.targets)
9760
+ remainingByStable.set(target.stableId, target);
9761
+ }
9762
+ for (const target of addedTargets)
9763
+ remainingByStable.set(target.stableId, target);
9764
+ for (const id of removedStableIds) {
9765
+ remainingByStable.delete(id);
9766
+ for (const [stableId, target] of [...remainingByStable.entries()]) {
9767
+ if (target.id === id || target.stableId === id || target.keyFingerprint === id) {
9768
+ remainingByStable.delete(stableId);
9769
+ }
9770
+ }
9771
+ }
9772
+ const remaining = [...remainingByStable.values()];
9773
+ if (remaining.length === 0) {
9774
+ throw new Error("Refusing to leave a sealed document with no recipients");
9775
+ }
9776
+ const recipientsBlock = await (0, recipients_1.sealToTargets)({
9777
+ tenant,
9778
+ generations,
9779
+ targets: remaining,
9780
+ epoch,
9781
+ });
9782
+ tenant.rememberSealedGenerations(internalDoc.decryptionKeyId, generations);
9783
+ const headsBeforeChange = automerge_adapter_1.Automerge.getHeads(internalDoc.doc);
9784
+ const newDoc = automerge_adapter_1.Automerge.change(internalDoc.doc, (draft) => {
9785
+ if (addedTargets.length > 0) {
9786
+ const addMap = {};
9787
+ for (const target of addedTargets) {
9788
+ addMap[target.stableId] = {
9789
+ kind: target.kind,
9790
+ addedAt: now,
9791
+ addedBy: signer,
9792
+ keyFingerprint: target.keyFingerprint,
9793
+ ...(target.label ? { label: target.label } : {}),
9794
+ };
9795
+ }
9796
+ (0, recipients_1.applyEncryptForAdds)(draft, addMap, epoch);
9797
+ }
9798
+ if (removedStableIds.length > 0) {
9799
+ (0, recipients_1.applyEncryptForRemoves)(draft, removedStableIds, now, signer);
9800
+ }
9801
+ if (options?.change) {
9802
+ // Content edits from the caller happen in a follow-up changeDoc; the
9803
+ // recipient field itself is the carrier entry.
9804
+ }
9805
+ draft._lastModified = now;
9806
+ });
9807
+ await this.persistDocumentChange({
9808
+ internalDoc,
9809
+ newDoc,
9810
+ now,
9811
+ headsBeforeChange,
9812
+ useCustomKey: !!(options?.signingKeyPair && options.signingKeyPassword),
9813
+ signingKeyPair: options?.signingKeyPair,
9814
+ signingKeyPassword: options?.signingKeyPassword,
9815
+ successMessage: "recipients updated",
9816
+ bypassPrecheck: options?.bypassAccessControlPrecheck,
9817
+ recipients: recipientsBlock,
9818
+ });
9819
+ if (options?.change) {
9820
+ await this.changeDoc(this.wrapDocument(internalDoc), options.change, {
9821
+ signingKeyPair: options.signingKeyPair,
9822
+ signingKeyPassword: options.signingKeyPassword,
9823
+ bypassAccessControlPrecheck: options.bypassAccessControlPrecheck,
9824
+ });
9825
+ }
9826
+ const wrapped = this.wrapDocument(internalDoc);
9827
+ return {
9828
+ epoch,
9829
+ rotated: rotate,
9830
+ added: wrapped.getRecipients().filter((r) => addedTargets.some((t) => t.keyFingerprint === r.keyFingerprint)),
9831
+ removed: Object.entries((0, recipients_1.readEncryptFor)(internalDoc.doc))
9832
+ .filter(([id, e]) => e.removedAt && removedStableIds.includes(id.split("#")[0]))
9833
+ .map(([id, e]) => ({
9834
+ kind: e.kind === "device" ? "device" : "user",
9835
+ id: e.kind === "device" ? (e.keyFingerprint ?? id) : id,
9836
+ keyFingerprint: e.keyFingerprint ?? "",
9837
+ label: e.label,
9838
+ addedInEpoch: epoch,
9839
+ sealed: false,
9840
+ })),
9841
+ skipped: resolved.skipped,
9842
+ entryId: "",
9843
+ };
9844
+ }
9715
9845
  /**
9716
9846
  * Predict whether deleting `doc` via {@link deleteDocument} would be allowed
9717
9847
  * by the active write policy, without writing anything.
@@ -9721,12 +9851,11 @@ class BaseMindooDB {
9721
9851
  const signerKey = signingKeyPair
9722
9852
  ? signingKeyPair.publicKey
9723
9853
  : (await this.tenant.getCurrentUserId()).userSigningPublicKey;
9724
- const creatorKey = await this.resolveCreatorSigningKey(docId);
9725
9854
  const current = doc.getData();
9726
9855
  const decision = await this.evaluateClientWriteAccess({
9727
9856
  op: "doc_delete",
9728
9857
  signerKey,
9729
- isAuthor: creatorKey !== null && signerKey === creatorKey,
9858
+ isAuthor: await this.resolveIsAuthor(docId, signerKey, Number.MAX_SAFE_INTEGER),
9730
9859
  getBeforeDoc: () => current,
9731
9860
  getAfterDoc: () => current,
9732
9861
  });
@@ -9741,40 +9870,71 @@ class BaseMindooDB {
9741
9870
  const signerKey = signingKeyPair
9742
9871
  ? signingKeyPair.publicKey
9743
9872
  : (await this.tenant.getCurrentUserId()).userSigningPublicKey;
9744
- const creatorKey = await this.resolveCreatorSigningKey(docId);
9745
9873
  const current = doc.getData();
9746
9874
  const decision = await this.evaluateClientWriteAccess({
9747
9875
  op: "doc_undelete",
9748
9876
  signerKey,
9749
- isAuthor: creatorKey !== null && signerKey === creatorKey,
9877
+ isAuthor: await this.resolveIsAuthor(docId, signerKey, Number.MAX_SAFE_INTEGER),
9750
9878
  getBeforeDoc: () => current,
9751
9879
  getAfterDoc: () => current,
9752
9880
  });
9753
9881
  return decision ?? BaseMindooDB.ACL_NOT_ENFORCED_DECISION;
9754
9882
  }
9755
9883
  /**
9756
- * Resolve the signing public key of the document's original creator (the
9757
- * `doc_create` entry's `createdByPublicKey`), used to set `$author`
9758
- * (`isAuthor`) for the client write prechecks on change/delete/undelete.
9759
- * Cached per document; returns `null` if the create entry isn't present yet
9760
- * (in which case `isAuthor` is treated as false, matching materialization).
9884
+ * Resolve the signing public key of the document's original creator and the
9885
+ * trusted time of the `doc_create` entry, used to set `$author` (`isAuthor`)
9886
+ * at grant level. Cached per document; returns `null` if the create entry
9887
+ * isn't present yet (in which case `isAuthor` is treated as false).
9761
9888
  */
9762
- async resolveCreatorSigningKey(docId) {
9763
- const cached = this.creatorKeyCache.get(docId);
9889
+ async resolveCreatorInfo(docId) {
9890
+ const cached = this.creatorInfoCache.get(docId);
9764
9891
  if (cached)
9765
9892
  return cached;
9766
9893
  try {
9767
9894
  const metas = await this.scanAllMetadata(this.store, { docId });
9768
- const createKey = metas.find((m) => m.entryType === "doc_create")?.createdByPublicKey ?? null;
9769
- if (createKey)
9770
- this.creatorKeyCache.set(docId, createKey);
9771
- return createKey;
9895
+ const createMeta = metas.find((m) => m.entryType === "doc_create");
9896
+ const createKey = createMeta?.createdByPublicKey ?? null;
9897
+ if (!createMeta || !createKey)
9898
+ return null;
9899
+ const trustedTime = createMeta.receivedAt ?? createMeta.createdAt;
9900
+ const info = { signingKey: createKey, trustedTime };
9901
+ this.creatorInfoCache.set(docId, info);
9902
+ return info;
9772
9903
  }
9773
9904
  catch (error) {
9774
9905
  this.logger.debug(`[ACL] creator-key resolution failed for ${docId}: ${error}`);
9775
9906
  return null;
9776
9907
  }
9777
9908
  }
9909
+ /**
9910
+ * Whether `signerKey` is the document author at grant level: the creator is
9911
+ * resolved at the `doc_create` trusted time, the signer at `signerTrustedTime`.
9912
+ */
9913
+ async resolveIsAuthor(docId, signerKey, signerTrustedTime) {
9914
+ const creator = await this.resolveCreatorInfo(docId);
9915
+ if (!creator)
9916
+ return false;
9917
+ try {
9918
+ const directory = await this.tenant.openDirectory();
9919
+ if (typeof directory.isSamePerson === "function") {
9920
+ return directory.isSamePerson({
9921
+ creatorSigningKey: creator.signingKey,
9922
+ signerSigningKey: signerKey,
9923
+ creatorTrustedTime: creator.trustedTime,
9924
+ signerTrustedTime,
9925
+ });
9926
+ }
9927
+ }
9928
+ catch (error) {
9929
+ this.logger.debug(`[ACL] grant-level $author resolution failed for ${docId}: ${error}`);
9930
+ }
9931
+ return creator.signingKey === signerKey;
9932
+ }
9933
+ /** @deprecated kept as a thin wrapper for any remaining device-key callers. */
9934
+ async resolveCreatorSigningKey(docId) {
9935
+ const info = await this.resolveCreatorInfo(docId);
9936
+ return info?.signingKey ?? null;
9937
+ }
9778
9938
  /** Append a record to the quarantine/audit log (and log it). */
9779
9939
  recordQuarantine(rec) {
9780
9940
  this.quarantineLog.push(rec);
@@ -9835,7 +9995,12 @@ class BaseMindooDB {
9835
9995
  : (a.receivedAt ?? a.createdAt) - (b.receivedAt ?? b.createdAt));
9836
9996
  if (dataEntries.length === 0)
9837
9997
  return { doc: null, cacheable };
9838
- const creatorKey = dataEntries.find((m) => m.entryType === "doc_create")?.createdByPublicKey ?? null;
9998
+ const createMeta = dataEntries.find((m) => m.entryType === "doc_create");
9999
+ const creatorKey = createMeta?.createdByPublicKey ?? null;
10000
+ const creatorTrustedTime = createMeta
10001
+ ? (createMeta.receivedAt ?? createMeta.createdAt)
10002
+ : 0;
10003
+ const isSamePersonFn = directory.isSamePerson?.bind(directory);
9839
10004
  const fetched = await this.store.getEntries(dataEntries.map((m) => m.id));
9840
10005
  const entryById = new Map(fetched.map((e) => [e.id, e]));
9841
10006
  let currentDoc = null;
@@ -9869,8 +10034,13 @@ class BaseMindooDB {
9869
10034
  quarantine(meta, "cascade_dependent", "causally depends on a quarantined entry");
9870
10035
  continue;
9871
10036
  }
9872
- // Verify the author signature over the encrypted payload.
9873
- const cryptoKey = await this.getOrImportPublicKey(entry.createdByPublicKey);
10037
+ // Verify the author signature over the encrypted payload. Trust the
10038
+ // signing key at the entry's trusted time (not HEAD) so a later revoke
10039
+ // does not rewrite history.
10040
+ const trustedTimeForTrust = (0, storeEntryTime_1.isProvisional)(meta)
10041
+ ? Number.MAX_SAFE_INTEGER
10042
+ : (meta.receivedAt ?? meta.createdAt);
10043
+ const cryptoKey = await this.getOrImportPublicKey(entry.createdByPublicKey, trustedTimeForTrust);
9874
10044
  if (!cryptoKey) {
9875
10045
  // Author key not (yet) trusted by the directory. Remember it so a
9876
10046
  // later grantaccess sync can re-materialize the doc (reconcileAuthorTrust).
@@ -9931,20 +10101,29 @@ class BaseMindooDB {
9931
10101
  ? this.convertAutomergeToJS(currentDoc)
9932
10102
  : null;
9933
10103
  const afterJS = this.convertAutomergeToJS(afterDoc);
9934
- const isAuthor = creatorKey !== null && entry.createdByPublicKey === creatorKey;
9935
- // Trusted-time rule (docs/accesscontrol.md §8, version-aware): a witnessed
9936
- // entry is judged against the directory as it provably was at its
9937
- // `receivedAt`. A versioned un-witnessed entry (provisional) has no
9938
- // provable position, so it is judged against the current directory HEAD —
9939
- // `MAX_SAFE_INTEGER` selects the head node deterministically regardless of
9940
- // where the directory's own provisional (un-witnessed) entries currently
9941
- // float, which a wall-clock `now` would not (those entries can sit at a
9942
- // later `now`). A legacy un-witnessed entry predates witnessing and is
9943
- // stable at its `createdAt`, so it is judged against the historical
9944
- // directory state in effect then. See {@link isProvisional}.
9945
10104
  const trustedTime = (0, storeEntryTime_1.isProvisional)(meta)
9946
10105
  ? Number.MAX_SAFE_INTEGER
9947
10106
  : (meta.receivedAt ?? meta.createdAt);
10107
+ let isAuthor = false;
10108
+ if (creatorKey !== null) {
10109
+ if (isSamePersonFn) {
10110
+ try {
10111
+ isAuthor = await isSamePersonFn({
10112
+ creatorSigningKey: creatorKey,
10113
+ signerSigningKey: entry.createdByPublicKey,
10114
+ creatorTrustedTime,
10115
+ signerTrustedTime: trustedTime,
10116
+ });
10117
+ }
10118
+ catch (error) {
10119
+ this.logger.debug(`[ACL] grant-level $author resolution failed during materialization: ${error}`);
10120
+ isAuthor = entry.createdByPublicKey === creatorKey;
10121
+ }
10122
+ }
10123
+ else {
10124
+ isAuthor = entry.createdByPublicKey === creatorKey;
10125
+ }
10126
+ }
9948
10127
  let decision;
9949
10128
  try {
9950
10129
  decision = await evaluateClientAccess({
@@ -10107,6 +10286,22 @@ class BaseMindooDB {
10107
10286
  // Log all entry types
10108
10287
  const entryTypes = allEntryMetadata.map(em => `${em.entryType}@${em.createdAt}`).join(', ');
10109
10288
  this.logger.debug(`Entry types for ${docId}: ${entryTypes}`);
10289
+ await this.tenant.ingestSealedFromEntries?.(allEntryMetadata.find((em) => em.entryType === "doc_create")?.decryptionKeyId
10290
+ ?? allEntryMetadata[0].decryptionKeyId, allEntryMetadata);
10291
+ const loadKeyId = allEntryMetadata.find((em) => em.entryType === "doc_create")?.decryptionKeyId
10292
+ ?? allEntryMetadata[0].decryptionKeyId;
10293
+ if ((0, sealedTypes_1.isSealedKeyId)(loadKeyId) && !(await this.tenant.hasDecryptionKey(loadKeyId))) {
10294
+ this.logger.debug(`Document ${docId} is sealed and not unwrapable; treating as missing`);
10295
+ const existingIndex = this.getDocIndexPosition(docId);
10296
+ const existing = existingIndex === undefined ? undefined : this.index[existingIndex];
10297
+ if (existing?.accessState === "visible") {
10298
+ await this.purgeMaterializedDocument(docId);
10299
+ this.updateIndex(docId, existing.lastModified, true, loadKeyId, "inaccessible");
10300
+ this.cacheMetaDirty = true;
10301
+ this.cacheManager?.markDirty();
10302
+ }
10303
+ return null;
10304
+ }
10110
10305
  // Access-control materialization (docs/accesscontrol.md §10): when the
10111
10306
  // tenant has access control active, materialize through the Tier 2
10112
10307
  // quarantine path (deterministic per-entry evaluation + cascade) instead of
@@ -10184,7 +10379,7 @@ class BaseMindooDB {
10184
10379
  const snapshotData = snapshotEntries[0];
10185
10380
  // Admin-only mode: only accept snapshots signed by admin
10186
10381
  let isValid = false;
10187
- if (this._isAdminOnlyDb && snapshotData.createdByPublicKey !== this.getAdminPublicKey()) {
10382
+ if (await this.shouldSkipLoadedEntryForBuiltin(snapshotData)) {
10188
10383
  this.logger.warn(`Admin-only DB: skipping snapshot ${snapshotData.id} not signed by admin key`);
10189
10384
  }
10190
10385
  else {
@@ -10253,13 +10448,14 @@ class BaseMindooDB {
10253
10448
  if (this.isDebugEnabled())
10254
10449
  this.logger.debug(`Document state before applying entries: heads=${JSON.stringify(automerge_adapter_1.Automerge.getHeads(doc))}`);
10255
10450
  // Filter entries for admin-only mode first
10256
- const validEntries = entries.filter(entryData => {
10257
- if (this._isAdminOnlyDb && entryData.createdByPublicKey !== this.getAdminPublicKey()) {
10451
+ const validEntries = [];
10452
+ for (const entryData of entries) {
10453
+ if (await this.shouldSkipLoadedEntryForBuiltin(entryData)) {
10258
10454
  this.logger.warn(`Admin-only DB: skipping entry ${entryData.id} not signed by admin key`);
10259
- return false;
10455
+ continue;
10260
10456
  }
10261
- return true;
10262
- });
10457
+ validEntries.push(entryData);
10458
+ }
10263
10459
  if (validEntries.length === 0) {
10264
10460
  this.logger.debug(`No valid entries to process for document ${docId}`);
10265
10461
  }
@@ -10385,6 +10581,7 @@ class BaseMindooDB {
10385
10581
  isDeleted,
10386
10582
  awaitingWitness: witnessState.awaitingWitness,
10387
10583
  witnessed: witnessState.witnessed,
10584
+ sealedRecipients: (0, recipients_1.newestRecipientBlock)(allEntryMetadata),
10388
10585
  };
10389
10586
  // Update cache
10390
10587
  await this.storeCachedDocument(internalDoc);
@@ -10486,6 +10683,11 @@ class BaseMindooDB {
10486
10683
  isAccessible: () => internalDoc.accessible ?? true,
10487
10684
  isAwaitingWitness: () => internalDoc.awaitingWitness ?? false,
10488
10685
  isWitnessed: () => internalDoc.witnessed ?? false,
10686
+ isSealed: () => (0, sealedTypes_1.isSealedKeyId)(internalDoc.decryptionKeyId),
10687
+ getRecipients: () => (0, recipients_1.resolveRecipientsFromPayload)(internalDoc.doc, internalDoc.sealedRecipients),
10688
+ getRecipientEpoch: () => internalDoc.sealedRecipients?.epoch ?? 0,
10689
+ isEncryptedFor: (users) => (0, sealedTypes_1.isSealedKeyId)(internalDoc.decryptionKeyId)
10690
+ && (0, recipients_1.isPayloadEncryptedFor)(internalDoc.doc, internalDoc.sealedRecipients, Array.isArray(users) ? users : [users]),
10489
10691
  getHeads: () => automerge_adapter_1.Automerge.getHeads(internalDoc.doc),
10490
10692
  getData: () => {
10491
10693
  // Convert Automerge document to plain JS object, converting Text
@@ -10618,7 +10820,7 @@ class BaseMindooDB {
10618
10820
  });
10619
10821
  }
10620
10822
  async decryptAttachmentChunk(chunk) {
10621
- if (this._isAdminOnlyDb && chunk.createdByPublicKey !== this.getAdminPublicKey()) {
10823
+ if (await this.shouldSkipLoadedEntryForBuiltin(chunk)) {
10622
10824
  throw new Error(`Admin-only DB: chunk ${chunk.id} not signed by admin key`);
10623
10825
  }
10624
10826
  const isValid = await this.tenant.verifyEntrySignature(chunk, chunk.encryptedData);
@@ -11090,10 +11292,13 @@ class BaseMindooDB {
11090
11292
  // entries can be decrypted with the same override identity.
11091
11293
  const encryptionKeyBuffer = await this.tenant.decryptPrivateKey(override.user.userEncryptionKeyPair.privateKey, override.password, "encryption");
11092
11294
  const encryptionKey = await subtle.importKey("pkcs8", encryptionKeyBuffer, { name: "RSA-OAEP", hash: "SHA-256" }, false, ["decrypt"]);
11093
- // Activate temporary override on the remote store.
11295
+ // Activate temporary override on the remote store. Include the public PEM
11296
+ // so the auth challenge names the same device key that will sign — otherwise
11297
+ // the server rejects bootstrap pushes as an unknown device key.
11094
11298
  overrideCapableStore.setSyncAuthOverride({
11095
11299
  username: override.user.username,
11096
11300
  signingKey,
11301
+ signingPublicKey: override.user.userSigningKeyPair.publicKey,
11097
11302
  privateEncryptionKey: encryptionKey,
11098
11303
  });
11099
11304
  // Return cleanup callback so caller can always restore normal auth in finally.
@@ -11132,6 +11337,18 @@ class BaseMindooDB {
11132
11337
  encryptAttachmentPayload: (payload, keyId) => this.tenant.encryptAttachmentPayload(payload, keyId),
11133
11338
  syncStoreChanges: () => this.syncStoreChanges(),
11134
11339
  evaluateWriteAccess: (op, signerKey, isAuthor) => this.evaluateClientWriteAccess({ op, signerKey, isAuthor }),
11340
+ isSamePerson: async (creatorSigningKey, signerSigningKey, creatorTrustedTime, signerTrustedTime) => {
11341
+ const directory = await this.tenant.openDirectory();
11342
+ if (typeof directory.isSamePerson === "function") {
11343
+ return directory.isSamePerson({
11344
+ creatorSigningKey,
11345
+ signerSigningKey,
11346
+ creatorTrustedTime,
11347
+ signerTrustedTime,
11348
+ });
11349
+ }
11350
+ return creatorSigningKey === signerSigningKey;
11351
+ },
11135
11352
  hasWriteContentRules: async (op) => {
11136
11353
  const directory = await this.tenant.openDirectory();
11137
11354
  return (await directory.hasWriteContentRules?.(op, this.store.getId())) ?? false;
@@ -11306,6 +11523,9 @@ class BaseMindooDB {
11306
11523
  }
11307
11524
  if (syncResult.transferred === 0) {
11308
11525
  this.logger.debug(`No new entries to pull`);
11526
+ if (storeKind === types_2.StoreKind.docs) {
11527
+ await this.reconcileAfterDocsPull();
11528
+ }
11309
11529
  return { transferredEntries: 0, transferredBytes, scannedEntries: syncResult.scanned, cancelled: false };
11310
11530
  }
11311
11531
  options?.onProgress?.({
@@ -11328,23 +11548,7 @@ class BaseMindooDB {
11328
11548
  }
11329
11549
  if (storeKind === types_2.StoreKind.docs) {
11330
11550
  await this.syncStoreChanges();
11331
- // SDK-driven key-distribution reconcile (docs/accesscontrol.md §13).
11332
- // After the directory pulls and processes new entries, reconcile the
11333
- // local KeyBag against the directory head: bulk-remove revoked keys
11334
- // (forgetting now-inaccessible docs) and import keys newly pushed to
11335
- // this user (revealing their docs on the next content-DB pull). Runs in
11336
- // the SDK so standalone apps get it too, not just Haven. Best-effort and
11337
- // single-flight; never blocks or fails the sync.
11338
- if (this.store.getId() === "directory") {
11339
- await this.tenant.reconcileKeyDistributionsForCurrentUserSafe();
11340
- // Author-trust reconcile: refresh the directory trust caches NOW
11341
- // (instead of waiting up to DIRECTORY_SYNC_INTERVAL_MS for the next
11342
- // cache-miss validation) so grantaccess documents that just arrived
11343
- // immediately unblock entries other open databases skipped while
11344
- // the author was still unknown. Awaited so a subsequent content-DB
11345
- // sync in the same flow observes the healed state.
11346
- await this.tenant.reconcileAuthorTrustAfterDirectorySyncSafe();
11347
- }
11551
+ await this.reconcileAfterDocsPull();
11348
11552
  }
11349
11553
  if (options?.signal?.aborted) {
11350
11554
  this.logger.info(`Pull cancelled after local processing for ${storeKind} entries`);
@@ -11374,6 +11578,26 @@ class BaseMindooDB {
11374
11578
  restoreAuthOverride();
11375
11579
  }
11376
11580
  }
11581
+ /**
11582
+ * After a directory / userdirectory docs pull — including a no-op pull
11583
+ * (`transferred === 0`). Device 2 typically already has
11584
+ * `acl_keydistribution_default` from bootstrap, then later imports the
11585
+ * User-Key from `userdirectory`. A subsequent directory pull transfers
11586
+ * nothing and used to skip reconcile, so `default` never landed in the bag.
11587
+ * Re-run both passes whenever either database is fetched.
11588
+ */
11589
+ async reconcileAfterDocsPull() {
11590
+ if (this.store.getId() === "directory") {
11591
+ await this.tenant.reconcileKeyDistributionsForCurrentUserSafe();
11592
+ await this.tenant.reconcileAuthorTrustAfterDirectorySyncSafe();
11593
+ await this.tenant.reconcileUserKeysSafe?.();
11594
+ }
11595
+ if (this.store.getId() === types_1.USER_DIRECTORY_DB_ID) {
11596
+ this.tenant.noteUserDirectoryFetched?.();
11597
+ await this.tenant.reconcileUserKeysSafe?.();
11598
+ await this.tenant.reconcileKeyDistributionsForCurrentUserSafe();
11599
+ }
11600
+ }
11377
11601
  /**
11378
11602
  * Push changes to a remote content-addressed store or another MindooDB instance.
11379
11603
  *
@@ -11445,32 +11669,6 @@ class BaseMindooDB {
11445
11669
  }
11446
11670
  }
11447
11671
  exports.BaseMindooDB = BaseMindooDB;
11448
- /**
11449
- * Determine how many entry IDs to fetch per `getEntries` call during sync.
11450
- *
11451
- * This is intentionally separate from the metadata scan `pageSize` so that
11452
- * scanning can page through large ID lists quickly while the heavier
11453
- * payload downloads use a smaller batch to keep progress responsive and
11454
- * cancellation timely.
11455
- *
11456
- * Priority: explicit option > attachment default (100) > docs default (250,
11457
- * capped by pageSize).
11458
- *
11459
- * The docs default is intentionally smaller than the metadata scan pageSize
11460
- * (1000): a full scan page transferred as one `putEntries` call serializes
11461
- * to a multi-megabyte JSON body whose upload cannot report progress, risks
11462
- * the HTTP timeout on slow uplinks (each retry restarts the whole body),
11463
- * and can exceed the remote JSON body limit. Smaller batches keep progress
11464
- * events frequent and make each POST cheap to retry.
11465
- */
11466
- BaseMindooDB.DEFAULT_DOCS_TRANSFER_BATCH_SIZE = 250;
11467
- /**
11468
- * Default window of transfer batches running in parallel (sync-v5,
11469
- * phase 4). Kept small: each in-flight batch holds one decrypted page of
11470
- * entries in memory, and HTTP/2 multiplexes the requests over a single
11471
- * connection anyway. Overridable via `SyncOptions.maxConcurrentBatches`.
11472
- */
11473
- BaseMindooDB.DEFAULT_MAX_CONCURRENT_TRANSFER_BATCHES = 3;
11474
11672
  /** The "access control not enforced" allow decision returned by the
11475
11673
  * non-throwing `canCreate`/`canChange`/`canDelete` prediction helpers. */
11476
11674
  BaseMindooDB.ACL_NOT_ENFORCED_DECISION = {