mindoodb 0.0.50 → 0.0.53

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 (198) hide show
  1. package/README-server.md +11 -2
  2. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.d.ts +13 -1
  3. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.d.ts.map +1 -1
  4. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.js +45 -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 +24 -0
  11. package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.d.ts.map +1 -1
  12. package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.js +75 -24
  13. package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.js.map +1 -1
  14. package/dist/browser/index.mjs +72 -0
  15. package/dist/core/BaseMindooDB.d.ts +89 -14
  16. package/dist/core/BaseMindooDB.d.ts.map +1 -1
  17. package/dist/core/BaseMindooDB.js +692 -201
  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 +427 -90
  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 +292 -16
  30. package/dist/core/BaseMindooTenantFactory.js.map +1 -1
  31. package/dist/core/accesscontrol/DirectoryStateNode.d.ts +68 -1
  32. package/dist/core/accesscontrol/DirectoryStateNode.d.ts.map +1 -1
  33. package/dist/core/accesscontrol/DirectoryStateNode.js +157 -4
  34. package/dist/core/accesscontrol/DirectoryStateNode.js.map +1 -1
  35. package/dist/core/accesscontrol/directoryProjection.d.ts.map +1 -1
  36. package/dist/core/accesscontrol/directoryProjection.js +14 -5
  37. package/dist/core/accesscontrol/directoryProjection.js.map +1 -1
  38. package/dist/core/accesscontrol/grantKeys.d.ts +17 -14
  39. package/dist/core/accesscontrol/grantKeys.d.ts.map +1 -1
  40. package/dist/core/accesscontrol/grantKeys.js +51 -21
  41. package/dist/core/accesscontrol/grantKeys.js.map +1 -1
  42. package/dist/core/accesscontrol/types.d.ts +13 -7
  43. package/dist/core/accesscontrol/types.d.ts.map +1 -1
  44. package/dist/core/accesscontrol/types.js +16 -10
  45. package/dist/core/accesscontrol/types.js.map +1 -1
  46. package/dist/core/appendonlystores/network/AuthenticationService.d.ts +7 -0
  47. package/dist/core/appendonlystores/network/AuthenticationService.d.ts.map +1 -1
  48. package/dist/core/appendonlystores/network/AuthenticationService.js +70 -14
  49. package/dist/core/appendonlystores/network/AuthenticationService.js.map +1 -1
  50. package/dist/core/appendonlystores/network/entryWireCodec.d.ts +51 -0
  51. package/dist/core/appendonlystores/network/entryWireCodec.d.ts.map +1 -0
  52. package/dist/core/appendonlystores/network/entryWireCodec.js +93 -0
  53. package/dist/core/appendonlystores/network/entryWireCodec.js.map +1 -0
  54. package/dist/core/builtinDbInvariants.d.ts +53 -0
  55. package/dist/core/builtinDbInvariants.d.ts.map +1 -0
  56. package/dist/core/builtinDbInvariants.js +147 -0
  57. package/dist/core/builtinDbInvariants.js.map +1 -0
  58. package/dist/core/copy/feasibility.d.ts.map +1 -1
  59. package/dist/core/copy/feasibility.js +3 -0
  60. package/dist/core/copy/feasibility.js.map +1 -1
  61. package/dist/core/copy/flatten.d.ts.map +1 -1
  62. package/dist/core/copy/flatten.js +7 -2
  63. package/dist/core/copy/flatten.js.map +1 -1
  64. package/dist/core/copy/host.d.ts +5 -0
  65. package/dist/core/copy/host.d.ts.map +1 -1
  66. package/dist/core/copy/preflight.d.ts.map +1 -1
  67. package/dist/core/copy/preflight.js +7 -1
  68. package/dist/core/copy/preflight.js.map +1 -1
  69. package/dist/core/copy/types.d.ts +8 -1
  70. package/dist/core/copy/types.d.ts.map +1 -1
  71. package/dist/core/crypto/EntrySignature.d.ts +8 -2
  72. package/dist/core/crypto/EntrySignature.d.ts.map +1 -1
  73. package/dist/core/crypto/EntrySignature.js +14 -5
  74. package/dist/core/crypto/EntrySignature.js.map +1 -1
  75. package/dist/core/crypto/MindooDocSigner.d.ts.map +1 -1
  76. package/dist/core/crypto/MindooDocSigner.js +11 -2
  77. package/dist/core/crypto/MindooDocSigner.js.map +1 -1
  78. package/dist/core/crypto/RSAEncryption.d.ts +19 -0
  79. package/dist/core/crypto/RSAEncryption.d.ts.map +1 -1
  80. package/dist/core/crypto/RSAEncryption.js +36 -3
  81. package/dist/core/crypto/RSAEncryption.js.map +1 -1
  82. package/dist/core/crypto/privateKeyEncryption.js +1 -1
  83. package/dist/core/crypto/privateKeyEncryption.js.map +1 -1
  84. package/dist/core/crypto/rsaOaep3072.d.ts +12 -0
  85. package/dist/core/crypto/rsaOaep3072.d.ts.map +1 -0
  86. package/dist/core/crypto/rsaOaep3072.js +30 -0
  87. package/dist/core/crypto/rsaOaep3072.js.map +1 -0
  88. package/dist/core/databaseIdValidation.d.ts +23 -0
  89. package/dist/core/databaseIdValidation.d.ts.map +1 -1
  90. package/dist/core/databaseIdValidation.js +59 -1
  91. package/dist/core/databaseIdValidation.js.map +1 -1
  92. package/dist/core/index.d.ts +9 -2
  93. package/dist/core/index.d.ts.map +1 -1
  94. package/dist/core/index.js +40 -4
  95. package/dist/core/index.js.map +1 -1
  96. package/dist/core/join/deviceJoinBootstrap.d.ts +23 -0
  97. package/dist/core/join/deviceJoinBootstrap.d.ts.map +1 -0
  98. package/dist/core/join/deviceJoinBootstrap.js +37 -0
  99. package/dist/core/join/deviceJoinBootstrap.js.map +1 -0
  100. package/dist/core/keys/KeyBag.d.ts +20 -2
  101. package/dist/core/keys/KeyBag.d.ts.map +1 -1
  102. package/dist/core/keys/KeyBag.js +22 -10
  103. package/dist/core/keys/KeyBag.js.map +1 -1
  104. package/dist/core/logging/MindooLogger.d.ts.map +1 -1
  105. package/dist/core/logging/MindooLogger.js +65 -40
  106. package/dist/core/logging/MindooLogger.js.map +1 -1
  107. package/dist/core/tenantIdValidation.d.ts.map +1 -1
  108. package/dist/core/tenantIdValidation.js +1 -1
  109. package/dist/core/tenantIdValidation.js.map +1 -1
  110. package/dist/core/tenantSetup.d.ts +109 -0
  111. package/dist/core/tenantSetup.d.ts.map +1 -0
  112. package/dist/core/tenantSetup.js +250 -0
  113. package/dist/core/tenantSetup.js.map +1 -0
  114. package/dist/core/types.d.ts +333 -43
  115. package/dist/core/types.d.ts.map +1 -1
  116. package/dist/core/types.js +27 -9
  117. package/dist/core/types.js.map +1 -1
  118. package/dist/core/uri/joinRequestUri.d.ts +37 -0
  119. package/dist/core/uri/joinRequestUri.d.ts.map +1 -0
  120. package/dist/core/uri/joinRequestUri.js +168 -0
  121. package/dist/core/uri/joinRequestUri.js.map +1 -0
  122. package/dist/core/userid/canonicalUsername.d.ts +66 -0
  123. package/dist/core/userid/canonicalUsername.d.ts.map +1 -0
  124. package/dist/core/userid/canonicalUsername.js +235 -0
  125. package/dist/core/userid/canonicalUsername.js.map +1 -0
  126. package/dist/core/userid/index.d.ts +9 -0
  127. package/dist/core/userid/index.d.ts.map +1 -1
  128. package/dist/core/userid/index.js +14 -0
  129. package/dist/core/userid/index.js.map +1 -1
  130. package/dist/core/userkeys/UserKeyDocument.d.ts +50 -0
  131. package/dist/core/userkeys/UserKeyDocument.d.ts.map +1 -0
  132. package/dist/core/userkeys/UserKeyDocument.js +145 -0
  133. package/dist/core/userkeys/UserKeyDocument.js.map +1 -0
  134. package/dist/core/userkeys/UserKeyManager.d.ts +170 -0
  135. package/dist/core/userkeys/UserKeyManager.d.ts.map +1 -0
  136. package/dist/core/userkeys/UserKeyManager.js +1277 -0
  137. package/dist/core/userkeys/UserKeyManager.js.map +1 -0
  138. package/dist/core/userkeys/fingerprint.d.ts +17 -0
  139. package/dist/core/userkeys/fingerprint.d.ts.map +1 -0
  140. package/dist/core/userkeys/fingerprint.js +59 -0
  141. package/dist/core/userkeys/fingerprint.js.map +1 -0
  142. package/dist/core/userkeys/index.d.ts +11 -0
  143. package/dist/core/userkeys/index.d.ts.map +1 -0
  144. package/dist/core/userkeys/index.js +49 -0
  145. package/dist/core/userkeys/index.js.map +1 -0
  146. package/dist/core/userkeys/recipients.d.ts +51 -0
  147. package/dist/core/userkeys/recipients.d.ts.map +1 -0
  148. package/dist/core/userkeys/recipients.js +292 -0
  149. package/dist/core/userkeys/recipients.js.map +1 -0
  150. package/dist/core/userkeys/resolveUserKeyDocument.d.ts +21 -0
  151. package/dist/core/userkeys/resolveUserKeyDocument.d.ts.map +1 -0
  152. package/dist/core/userkeys/resolveUserKeyDocument.js +98 -0
  153. package/dist/core/userkeys/resolveUserKeyDocument.js.map +1 -0
  154. package/dist/core/userkeys/sealedCrypto.d.ts +21 -0
  155. package/dist/core/userkeys/sealedCrypto.d.ts.map +1 -0
  156. package/dist/core/userkeys/sealedCrypto.js +79 -0
  157. package/dist/core/userkeys/sealedCrypto.js.map +1 -0
  158. package/dist/core/userkeys/sealedTypes.d.ts +72 -0
  159. package/dist/core/userkeys/sealedTypes.d.ts.map +1 -0
  160. package/dist/core/userkeys/sealedTypes.js +14 -0
  161. package/dist/core/userkeys/sealedTypes.js.map +1 -0
  162. package/dist/core/userkeys/types.d.ts +70 -0
  163. package/dist/core/userkeys/types.d.ts.map +1 -0
  164. package/dist/core/userkeys/types.js +48 -0
  165. package/dist/core/userkeys/types.js.map +1 -0
  166. package/dist/core/userkeys/validateUserKeyDocument.d.ts +19 -0
  167. package/dist/core/userkeys/validateUserKeyDocument.d.ts.map +1 -0
  168. package/dist/core/userkeys/validateUserKeyDocument.js +42 -0
  169. package/dist/core/userkeys/validateUserKeyDocument.js.map +1 -0
  170. package/dist/core/utils/idGeneration.d.ts +39 -20
  171. package/dist/core/utils/idGeneration.d.ts.map +1 -1
  172. package/dist/core/utils/idGeneration.js +82 -62
  173. package/dist/core/utils/idGeneration.js.map +1 -1
  174. package/dist/core/utils/index.d.ts +1 -1
  175. package/dist/core/utils/index.d.ts.map +1 -1
  176. package/dist/core/utils/index.js +4 -1
  177. package/dist/core/utils/index.js.map +1 -1
  178. package/dist/node/server/CapabilityMatcher.d.ts +5 -0
  179. package/dist/node/server/CapabilityMatcher.d.ts.map +1 -1
  180. package/dist/node/server/CapabilityMatcher.js +10 -1
  181. package/dist/node/server/CapabilityMatcher.js.map +1 -1
  182. package/dist/node/server/DeviceDiscoveryService.d.ts +36 -0
  183. package/dist/node/server/DeviceDiscoveryService.d.ts.map +1 -0
  184. package/dist/node/server/DeviceDiscoveryService.js +165 -0
  185. package/dist/node/server/DeviceDiscoveryService.js.map +1 -0
  186. package/dist/node/server/MindooDBServer.d.ts +13 -0
  187. package/dist/node/server/MindooDBServer.d.ts.map +1 -1
  188. package/dist/node/server/MindooDBServer.js +99 -61
  189. package/dist/node/server/MindooDBServer.js.map +1 -1
  190. package/dist/node/server/SystemAdminAuth.d.ts +7 -0
  191. package/dist/node/server/SystemAdminAuth.d.ts.map +1 -1
  192. package/dist/node/server/SystemAdminAuth.js +4 -1
  193. package/dist/node/server/SystemAdminAuth.js.map +1 -1
  194. package/dist/node/server/TenantManager.d.ts +29 -0
  195. package/dist/node/server/TenantManager.d.ts.map +1 -1
  196. package/dist/node/server/TenantManager.js +146 -7
  197. package/dist/node/server/TenantManager.js.map +1 -1
  198. package/package.json +5 -5
@@ -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 = [];
@@ -453,6 +458,10 @@ class BaseMindooDB {
453
458
  * the warmer settled can still display its outcome.
454
459
  */
455
460
  this.warmerProgress = null;
461
+ /** `username_hash` per userdirectory document, filled from creates. */
462
+ this.userdirectoryHashByDocId = new Map();
463
+ /** Grant `username_hash` for a signing public key at "now". */
464
+ this.signerUsernameHashByKey = new Map();
456
465
  // ---------------------------------------------------------------------------
457
466
  // Author-trust reconciliation: heal documents whose entries were skipped
458
467
  // because their author was not yet known to the tenant directory.
@@ -528,6 +537,125 @@ class BaseMindooDB {
528
537
  getAdminPublicKey() {
529
538
  return this.tenant.getAdministrationPublicKey();
530
539
  }
540
+ /**
541
+ * Database id used by the builtin-write registry. Admin-only databases that
542
+ * are not `userdirectory` are treated like `directory` (admin-only writes).
543
+ */
544
+ effectiveBuiltinDbId() {
545
+ const dbId = this.store.getId();
546
+ if (this._isAdminOnlyDb && dbId !== types_1.USER_DIRECTORY_DB_ID) {
547
+ return types_1.DIRECTORY_DB_ID;
548
+ }
549
+ return dbId;
550
+ }
551
+ usernameHashFromRecord(payload) {
552
+ if (!payload || typeof payload !== "object")
553
+ return null;
554
+ const value = payload.username_hash;
555
+ return typeof value === "string" && value.length > 0 ? value : null;
556
+ }
557
+ usernameHashFromInternalDoc(internalDoc) {
558
+ return this.usernameHashFromRecord(this.convertAutomergeToJS(internalDoc.doc));
559
+ }
560
+ async resolveSignerUsernameHash(signerKey) {
561
+ try {
562
+ const directory = await this.tenant.openDirectory();
563
+ if (typeof directory.resolveUsernameHashForSigningKey !== "function") {
564
+ return null;
565
+ }
566
+ return directory.resolveUsernameHashForSigningKey(signerKey, Number.MAX_SAFE_INTEGER);
567
+ }
568
+ catch {
569
+ return null;
570
+ }
571
+ }
572
+ async resolveSignerPublicKey(signingKeyPair) {
573
+ if (signingKeyPair)
574
+ return signingKeyPair.publicKey;
575
+ return (await this.tenant.getCurrentUserId()).userSigningPublicKey;
576
+ }
577
+ /**
578
+ * Hard-wired write gate for `directory` / `userdirectory` (and other
579
+ * admin-only databases). Independent of the ACL master switch.
580
+ */
581
+ async assertBuiltinWriteAllowed(op, signerPublicKey, documentUsernameHash) {
582
+ const dbId = this.effectiveBuiltinDbId();
583
+ if (!(0, builtinDbInvariants_1.hasBuiltinWriteInvariant)(dbId) && !this._isAdminOnlyDb) {
584
+ return;
585
+ }
586
+ let signerUsernameHash = null;
587
+ if (dbId === types_1.USER_DIRECTORY_DB_ID) {
588
+ signerUsernameHash = await this.resolveSignerUsernameHash(signerPublicKey);
589
+ this.signerUsernameHashByKey.set(signerPublicKey, signerUsernameHash);
590
+ }
591
+ const decision = (0, builtinDbInvariants_1.evaluateBuiltinWrite)({
592
+ dbId,
593
+ op,
594
+ signerKey: signerPublicKey,
595
+ adminPublicKey: this.getAdminPublicKey(),
596
+ documentUsernameHash,
597
+ signerUsernameHash,
598
+ });
599
+ if (!decision.allowed) {
600
+ throw new Error(decision.reason);
601
+ }
602
+ }
603
+ /**
604
+ * Load-path counterpart: drop entries that the builtin invariant forbids.
605
+ * Username-hash matching for userdirectory creates is enforced on write and
606
+ * on the server. Admin-signed changes load only when the signer is also the
607
+ * person named by the document (the tenant founder wrapping their own key).
608
+ */
609
+ async shouldSkipLoadedEntryForBuiltin(entry) {
610
+ const dbId = this.effectiveBuiltinDbId();
611
+ const entryType = entry.entryType ?? "doc_change";
612
+ let documentUsernameHash = null;
613
+ let signerUsernameHash = null;
614
+ if (dbId === types_1.USER_DIRECTORY_DB_ID &&
615
+ (entryType === "doc_change" || entryType === "doc_snapshot") &&
616
+ entry.docId) {
617
+ documentUsernameHash = await this.ensureUserdirectoryDocHash(entry.docId);
618
+ signerUsernameHash = await this.ensureSignerUsernameHash(entry.createdByPublicKey);
619
+ }
620
+ return (0, builtinDbInvariants_1.shouldSkipLoadedEntry)({
621
+ dbId,
622
+ entryType,
623
+ signerKey: entry.createdByPublicKey,
624
+ adminPublicKey: this.getAdminPublicKey(),
625
+ documentUsernameHash,
626
+ signerUsernameHash,
627
+ });
628
+ }
629
+ async ensureSignerUsernameHash(signerKey) {
630
+ if (this.signerUsernameHashByKey.has(signerKey)) {
631
+ return this.signerUsernameHashByKey.get(signerKey) ?? null;
632
+ }
633
+ const hash = await this.resolveSignerUsernameHash(signerKey);
634
+ this.signerUsernameHashByKey.set(signerKey, hash);
635
+ return hash;
636
+ }
637
+ async ensureUserdirectoryDocHash(docId) {
638
+ const cached = this.userdirectoryHashByDocId.get(docId);
639
+ if (cached)
640
+ return cached;
641
+ try {
642
+ const metas = await this.scanAllMetadata(this.store, { docId });
643
+ const create = metas.find((meta) => meta.entryType === "doc_create");
644
+ if (!create)
645
+ return null;
646
+ const [entry] = await this.store.getEntries([create.id]);
647
+ if (!entry)
648
+ return null;
649
+ const decrypted = await this.tenant.decryptPayload(entry.encryptedData, entry.decryptionKeyId);
650
+ const hash = (0, builtinDbInvariants_1.usernameHashFromCreateChangeBytes)(decrypted);
651
+ if (hash)
652
+ this.userdirectoryHashByDocId.set(docId, hash);
653
+ return hash;
654
+ }
655
+ catch {
656
+ return null;
657
+ }
658
+ }
531
659
  isAdminOnlyDb() {
532
660
  return this._isAdminOnlyDb;
533
661
  }
@@ -715,7 +843,7 @@ class BaseMindooDB {
715
843
  // indexing (DirectoryTimeTravelIndex) and its change events interleave
716
844
  // with access-control evaluation; admin-only databases hold no user
717
845
  // data worth summarizing. Explicit getSummaryStore() calls still work.
718
- if (this._isAdminOnlyDb || this.store.getId() === "directory") {
846
+ if (this._isAdminOnlyDb || this.store.getId() === types_1.DIRECTORY_DB_ID || this.store.getId() === types_1.USER_DIRECTORY_DB_ID) {
719
847
  return;
720
848
  }
721
849
  const hasSetupChange = changes.some((change) => change.docId === types_4.DB_SETUP_DOC_ID);
@@ -950,7 +1078,7 @@ class BaseMindooDB {
950
1078
  return;
951
1079
  }
952
1080
  // Same system-database exclusion as the summary auto-activation.
953
- if (this._isAdminOnlyDb || this.store.getId() === "directory") {
1081
+ if (this._isAdminOnlyDb || this.store.getId() === types_1.DIRECTORY_DB_ID || this.store.getId() === types_1.USER_DIRECTORY_DB_ID) {
954
1082
  return;
955
1083
  }
956
1084
  const hasSetupChange = changes.some((change) => change.docId === types_4.DB_SETUP_DOC_ID);
@@ -1557,27 +1685,34 @@ class BaseMindooDB {
1557
1685
  /**
1558
1686
  * Get or import a CryptoKey for signature verification, with caching.
1559
1687
  * This avoids re-importing the same public key multiple times.
1688
+ *
1689
+ * Trust is evaluated at `trustedTime` when provided (grant membership in the
1690
+ * directory node covering that time, or the tenant admin key). Callers that
1691
+ * omit it keep the historical HEAD check via `validatePublicSigningKey`.
1560
1692
  */
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
1693
+ async getOrImportPublicKey(publicKey, trustedTime) {
1567
1694
  const directory = await this.tenant.openDirectory();
1568
- const isTrusted = await directory.validatePublicSigningKey(publicKey);
1695
+ let isTrusted = false;
1696
+ if (trustedTime !== undefined && typeof directory.getDirectoryStateAt === "function") {
1697
+ const node = await directory.getDirectoryStateAt(trustedTime);
1698
+ isTrusted =
1699
+ !!(0, DirectoryStateNode_1.grantForSigningKey)(node, publicKey) || publicKey === this.getAdminPublicKey();
1700
+ }
1701
+ else {
1702
+ isTrusted = await directory.validatePublicSigningKey(publicKey);
1703
+ }
1569
1704
  if (!isTrusted) {
1570
1705
  this.logger.warn(`Public key not trusted: ${publicKey}`);
1571
1706
  return null;
1572
1707
  }
1573
- // Import the key
1708
+ const cached = this.publicKeyCache.get(publicKey);
1709
+ if (cached)
1710
+ return cached;
1574
1711
  const subtle = this.tenant.getCryptoAdapter().getSubtle();
1575
1712
  const publicKeyBuffer = this.tenant.pemToArrayBuffer(publicKey);
1576
1713
  const cryptoKey = await subtle.importKey("spki", publicKeyBuffer, {
1577
1714
  name: "Ed25519",
1578
- }, false, // not extractable
1579
- ["verify"]);
1580
- // Cache the imported key
1715
+ }, false, ["verify"]);
1581
1716
  this.publicKeyCache.set(publicKey, cryptoKey);
1582
1717
  return cryptoKey;
1583
1718
  }
@@ -2055,7 +2190,7 @@ class BaseMindooDB {
2055
2190
  if (!this.summaryAutoUpdateEnabled || this.summarySetupProbed || this.summaryStore) {
2056
2191
  return;
2057
2192
  }
2058
- if (this._isAdminOnlyDb || this.store.getId() === "directory") {
2193
+ if (this._isAdminOnlyDb || this.store.getId() === types_1.DIRECTORY_DB_ID || this.store.getId() === types_1.USER_DIRECTORY_DB_ID) {
2059
2194
  return;
2060
2195
  }
2061
2196
  this.summarySetupProbed = true;
@@ -2089,7 +2224,7 @@ class BaseMindooDB {
2089
2224
  if (!this.fulltextAutoUpdateEnabled || this.fulltextSetupProbed || this.fulltextIndex) {
2090
2225
  return;
2091
2226
  }
2092
- if (this._isAdminOnlyDb || this.store.getId() === "directory") {
2227
+ if (this._isAdminOnlyDb || this.store.getId() === types_1.DIRECTORY_DB_ID || this.store.getId() === types_1.USER_DIRECTORY_DB_ID) {
2093
2228
  return;
2094
2229
  }
2095
2230
  if (this.isTimeTravelMode()) {
@@ -2254,10 +2389,7 @@ class BaseMindooDB {
2254
2389
  }
2255
2390
  const existingIndex = this.getDocIndexPosition(docId);
2256
2391
  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.
2392
+ await this.tenant.ingestSealedFromEntries?.(visibility.decryptionKeyId, metadataByDoc.get(docId));
2261
2393
  const canRead = await this.tenant.hasDecryptionKey(visibility.decryptionKeyId);
2262
2394
  if (canRead) {
2263
2395
  // Reveal-on-add only triggers when the previous state was
@@ -2407,7 +2539,7 @@ class BaseMindooDB {
2407
2539
  * directory was synced in a session that never opened this DB).
2408
2540
  */
2409
2541
  async reconcileAuthorTrustAtOpen() {
2410
- if (this.isTimeTravelMode() || this._isAdminOnlyDb || this.store.getId() === "directory") {
2542
+ if (this.isTimeTravelMode() || this._isAdminOnlyDb || this.store.getId() === types_1.DIRECTORY_DB_ID || this.store.getId() === types_1.USER_DIRECTORY_DB_ID) {
2411
2543
  return;
2412
2544
  }
2413
2545
  if (this.legacyCheckpointNeedsAuthorTrustScan) {
@@ -2601,6 +2733,10 @@ class BaseMindooDB {
2601
2733
  const cachedDoc = this.getCachedDocument(docId);
2602
2734
  let updatedDoc = null;
2603
2735
  if (cachedDoc) {
2736
+ if (await this.dropSealedAccessIfUnwrapFailed(cachedDoc)) {
2737
+ this.logger.debug(`Dropping cached sealed document ${docId} — newest wrap is not for this user`);
2738
+ return;
2739
+ }
2604
2740
  // Document is cached - try incremental update
2605
2741
  this.logger.debug(`Document ${docId} found in cache, attempting incremental update`);
2606
2742
  try {
@@ -2646,9 +2782,18 @@ class BaseMindooDB {
2646
2782
  // Before indexing, verify the user has the decryption key so that
2647
2783
  // documents the user cannot access do not appear in getAllDocumentIds.
2648
2784
  const representativeEntry = docLifecycleEntries[0];
2785
+ await this.tenant.ingestSealedFromEntries?.(representativeEntry.decryptionKeyId, docLifecycleEntries);
2649
2786
  const keyAvailable = await this.tenant.hasDecryptionKey(representativeEntry.decryptionKeyId);
2650
2787
  if (!keyAvailable) {
2651
2788
  this.logger.debug(`Skipping metadata-first index for doc ${docId} — decryption key "${representativeEntry.decryptionKeyId}" not available`);
2789
+ const existingIndex = this.getDocIndexPosition(docId);
2790
+ const existing = existingIndex === undefined ? undefined : this.index[existingIndex];
2791
+ if (existing?.accessState === "visible") {
2792
+ await this.purgeMaterializedDocument(docId);
2793
+ this.updateIndex(docId, existing.lastModified, true, representativeEntry.decryptionKeyId, "inaccessible");
2794
+ this.cacheMetaDirty = true;
2795
+ this.cacheManager?.markDirty();
2796
+ }
2652
2797
  }
2653
2798
  else {
2654
2799
  // Cold sync: entryMetadataList is already the full doc history from
@@ -3861,8 +4006,8 @@ class BaseMindooDB {
3861
4006
  return stripped.length > 0 ? stripped : undefined;
3862
4007
  }
3863
4008
  /**
3864
- * Generate a fresh document id (`<prefix>_<22-char-base62(uuidv7)>`, or the
3865
- * bare base62 part without a prefix) that does not collide with any document
4009
+ * Generate a fresh document id (`<prefix>_<24-char-objectid>`, or the
4010
+ * bare ObjectId without a prefix) that does not collide with any document
3866
4011
  * already present in the local index. A collision is practically impossible
3867
4012
  * (128-bit UUID7), so this is only a cheap O(1) safety net reusing the
3868
4013
  * existing `indexLookup` map — no extra bookkeeping.
@@ -3879,10 +4024,10 @@ class BaseMindooDB {
3879
4024
  * Internal method to create a new document.
3880
4025
  *
3881
4026
  * Handles the creation flavors expressible through `CreateOptions`:
3882
- * - generated ID (time-sortable base62-encoded UUID7), current user signs,
4027
+ * - generated ID (time-sortable MongoDB-style ObjectId), current user signs,
3883
4028
  * default tenant key
3884
4029
  * - generated ID with custom signing key (e.g. directory admin operations)
3885
- * - generated ID with a caller-provided `idPrefix` (`<prefix>_<base62>`)
4030
+ * - generated ID with a caller-provided `idPrefix` (`<prefix>_<objectId>`)
3886
4031
  * - caller-provided ID, current user signs (idempotent on existing IDs)
3887
4032
  * - caller-provided ID with custom signing key
3888
4033
  *
@@ -3899,7 +4044,6 @@ class BaseMindooDB {
3899
4044
  throw new Error("createDocument: signingKeyPair and signingKeyPassword must be provided together");
3900
4045
  }
3901
4046
  const useCustomSigningKey = signingKeyPair !== undefined && signingKeyPassword !== undefined;
3902
- const keyId = await this.resolveCreateKeyId(options);
3903
4047
  const useCustomDocId = options.id !== undefined;
3904
4048
  this.validateIdPrefixOption("createDocument", options);
3905
4049
  this.validateAssumeUniqueIdOption("createDocument", options);
@@ -3931,17 +4075,14 @@ class BaseMindooDB {
3931
4075
  throw new Error("createDocument: initialValues is not supported together with a custom id; " +
3932
4076
  "create the document, then apply values with changeDoc().");
3933
4077
  }
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");
4078
+ if (useDeterministicSeed && options.recipients !== undefined) {
4079
+ throw new Error("createDocument: recipients is not supported together with a convergent custom id; " +
4080
+ "pass assumeUniqueId for random ids.");
3943
4081
  }
3944
4082
  }
4083
+ const signerPublicKey = await this.resolveSignerPublicKey(useCustomSigningKey ? signingKeyPair : undefined);
4084
+ const createUsernameHash = this.usernameHashFromRecord(Object.fromEntries(initialValueEntries));
4085
+ await this.assertBuiltinWriteAllowed("doc_create", signerPublicKey, createUsernameHash);
3945
4086
  const docId = useCustomDocId ? options.id : this.generateUnusedDocId(options.idPrefix);
3946
4087
  // Idempotent create: when a caller-provided id already exists locally,
3947
4088
  // return the existing document instead of producing a duplicate doc_create.
@@ -3961,6 +4102,7 @@ class BaseMindooDB {
3961
4102
  return this.wrapDocument(existing);
3962
4103
  }
3963
4104
  }
4105
+ const keyId = await this.resolveCreateKeyId(options, docId);
3964
4106
  this.logger.debug(`Creating document ${docId} with key ${keyId}${useCustomSigningKey ? ' using custom signing key' : ''}${useCustomDocId ? ' with caller-provided id' : ''}`);
3965
4107
  // Build the initial Automerge document and its first change bytes.
3966
4108
  //
@@ -3970,6 +4112,7 @@ class BaseMindooDB {
3970
4112
  // replicas using the same id produce the same Automerge hash and
3971
4113
  // `doc_create` entry id, allowing later changes to merge.
3972
4114
  const now = (0, timeSource_1.semanticNow)();
4115
+ const sealedCreate = await this.prepareSealedCreate(options, docId, keyId, now, signerPublicKey);
3973
4116
  let newDoc;
3974
4117
  let changeBytes;
3975
4118
  if (useDeterministicSeed) {
@@ -3995,6 +4138,9 @@ class BaseMindooDB {
3995
4138
  // Store metadata in the document payload
3996
4139
  // We need to modify the document to ensure a change is created
3997
4140
  doc._attachments = [];
4141
+ if (sealedCreate) {
4142
+ (0, recipients_1.applyEncryptForCreate)(doc, sealedCreate.encryptFor);
4143
+ }
3998
4144
  // Seed caller-provided initial values within the same doc_create
3999
4145
  // change so a doc_create Tier 2 rule can evaluate them in the
4000
4146
  // "after" state (docs/accesscontrol.md §6.3, §9).
@@ -4074,9 +4220,12 @@ class BaseMindooDB {
4074
4220
  createdByPublicKey = currentUser.userSigningPublicKey;
4075
4221
  }
4076
4222
  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);
4223
+ // Remember the creator so $author prechecks on subsequent change/delete
4224
+ // operations don't need a metadata scan. `now` is the local createdAt;
4225
+ // a later witness may stamp receivedAt, which resolveCreatorInfo picks up
4226
+ // on a cache miss. For the local writer, createdAt is sufficient: the
4227
+ // creating device is still granted.
4228
+ this.creatorInfoCache.set(docId, { signingKey: createdByPublicKey, trustedTime: now });
4080
4229
  // Snapshot of attachments referenced by the freshly created doc (normally
4081
4230
  // empty; the public create API does not add attachments inline).
4082
4231
  const createAttachmentRefs = this.collectAttachmentRefs(newDoc);
@@ -4094,6 +4243,7 @@ class BaseMindooDB {
4094
4243
  originalSize: changeBytes.length,
4095
4244
  encryptedSize: encryptedPayload.length,
4096
4245
  attachmentRefs: createAttachmentRefs.length > 0 ? createAttachmentRefs : undefined,
4246
+ recipients: sealedCreate?.recipients,
4097
4247
  entryVersion: types_1.CURRENT_STORE_ENTRY_VERSION,
4098
4248
  };
4099
4249
  // Bind the metadata with the author signature (audit finding #5).
@@ -4123,6 +4273,7 @@ class BaseMindooDB {
4123
4273
  isDeleted: false,
4124
4274
  awaitingWitness,
4125
4275
  witnessed,
4276
+ sealedRecipients: sealedCreate?.recipients,
4126
4277
  };
4127
4278
  // Update cache and index
4128
4279
  await this.storeCachedDocument(internalDoc);
@@ -4149,7 +4300,6 @@ class BaseMindooDB {
4149
4300
  throw new Error("createDocuments: signingKeyPair and signingKeyPassword must be provided together");
4150
4301
  }
4151
4302
  const useCustomSigningKey = signingKeyPair !== undefined && signingKeyPassword !== undefined;
4152
- const keyId = await this.resolveCreateKeyId(options);
4153
4303
  const useCustomDocId = options.id !== undefined;
4154
4304
  this.validateIdPrefixOption("createDocuments", options);
4155
4305
  this.validateAssumeUniqueIdOption("createDocuments", options);
@@ -4157,24 +4307,22 @@ class BaseMindooDB {
4157
4307
  // out of the deterministic seed, so initialValues fold into doc_create
4158
4308
  // and the document costs a single store entry.
4159
4309
  const useDeterministicSeed = useCustomDocId && !options.assumeUniqueId;
4310
+ if (useDeterministicSeed && options.recipients !== undefined) {
4311
+ throw new Error("createDocuments: recipients is not supported together with a convergent custom id; pass assumeUniqueId");
4312
+ }
4160
4313
  // Sanitize caller-provided initial values: internal/reserved fields are
4161
4314
  // managed by MindooDB and must not be seeded by callers. `undefined`
4162
4315
  // values are treated as "not set" (Automerge cannot represent undefined).
4163
4316
  const initialValueEntries = options.initialValues
4164
4317
  ? Object.entries(options.initialValues).filter(([k, v]) => !k.startsWith("_") && v !== undefined)
4165
4318
  : [];
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
- }
4319
+ const signerPublicKey = await this.resolveSignerPublicKey(useCustomSigningKey ? signingKeyPair : undefined);
4320
+ const createUsernameHash = this.usernameHashFromRecord(Object.fromEntries(initialValueEntries));
4321
+ await this.assertBuiltinWriteAllowed("doc_create", signerPublicKey, createUsernameHash);
4176
4322
  const docId = useCustomDocId ? options.id : this.generateUnusedDocId(options.idPrefix);
4177
4323
  const now = (0, timeSource_1.semanticNow)();
4324
+ const keyId = await this.resolveCreateKeyId(options, docId);
4325
+ const sealedCreate = await this.prepareSealedCreate(options, docId, keyId, now, signerPublicKey);
4178
4326
  // Build the initial Automerge document and its first change bytes,
4179
4327
  // following the same seeding rules as the single-create path.
4180
4328
  let seedDoc;
@@ -4189,6 +4337,9 @@ class BaseMindooDB {
4189
4337
  const initialDoc = automerge_adapter_1.Automerge.init();
4190
4338
  seedDoc = automerge_adapter_1.Automerge.change(initialDoc, (doc) => {
4191
4339
  doc._attachments = [];
4340
+ if (sealedCreate) {
4341
+ (0, recipients_1.applyEncryptForCreate)(doc, sealedCreate.encryptFor);
4342
+ }
4192
4343
  // Seed caller-provided initial values within the same doc_create change
4193
4344
  // so a doc_create Tier 2 rule can evaluate them in the "after" state.
4194
4345
  for (const [key, value] of initialValueEntries) {
@@ -4254,9 +4405,12 @@ class BaseMindooDB {
4254
4405
  signature = await this.tenant.signPayload(encryptedPayload);
4255
4406
  createdByPublicKey = currentUser.userSigningPublicKey;
4256
4407
  }
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);
4408
+ // Remember the creator so $author prechecks on subsequent change/delete
4409
+ // operations don't need a metadata scan. `now` is the local createdAt;
4410
+ // a later witness may stamp receivedAt, which resolveCreatorInfo picks up
4411
+ // on a cache miss. For the local writer, createdAt is sufficient: the
4412
+ // creating device is still granted.
4413
+ this.creatorInfoCache.set(docId, { signingKey: createdByPublicKey, trustedTime: now });
4260
4414
  const createAttachmentRefs = this.collectAttachmentRefs(finalDoc);
4261
4415
  const entryMetadata = {
4262
4416
  entryType: "doc_create",
@@ -4271,6 +4425,7 @@ class BaseMindooDB {
4271
4425
  originalSize: createChangeBytes.length,
4272
4426
  encryptedSize: encryptedPayload.length,
4273
4427
  attachmentRefs: createAttachmentRefs.length > 0 ? createAttachmentRefs : undefined,
4428
+ recipients: sealedCreate?.recipients,
4274
4429
  entryVersion: types_1.CURRENT_STORE_ENTRY_VERSION,
4275
4430
  };
4276
4431
  // Bind the metadata with the author signature (audit finding #5).
@@ -4317,6 +4472,7 @@ class BaseMindooDB {
4317
4472
  isDeleted: false,
4318
4473
  awaitingWitness,
4319
4474
  witnessed,
4475
+ sealedRecipients: sealedCreate?.recipients,
4320
4476
  };
4321
4477
  return { internalDoc, entries };
4322
4478
  }
@@ -4335,6 +4491,9 @@ class BaseMindooDB {
4335
4491
  this.logger.debug(`Getting document ${docId} at timestamp ${timestamp}`);
4336
4492
  // Get all entry metadata for this document
4337
4493
  const allEntryMetadata = await this.scanAllMetadata(this.store, { docId });
4494
+ if (allEntryMetadata.length > 0) {
4495
+ await this.ingestSealedKeyFromEntries(allEntryMetadata);
4496
+ }
4338
4497
  // Filter to document replay metadata plus snapshots up to the timestamp.
4339
4498
  // Attachment chunks are not part of the Automerge replay DAG and must not
4340
4499
  // participate in historical materialization.
@@ -4380,7 +4539,7 @@ class BaseMindooDB {
4380
4539
  if (snapshotEntries.length > 0) {
4381
4540
  const snapshotData = snapshotEntries[0];
4382
4541
  let isValid = false;
4383
- if (this._isAdminOnlyDb && snapshotData.createdByPublicKey !== this.getAdminPublicKey()) {
4542
+ if (await this.shouldSkipLoadedEntryForBuiltin(snapshotData)) {
4384
4543
  this.logger.warn(`Admin-only DB: skipping snapshot ${snapshotData.id} not signed by admin key`);
4385
4544
  }
4386
4545
  else {
@@ -4427,7 +4586,7 @@ class BaseMindooDB {
4427
4586
  this.logger.warn(`Entry ${entryMeta.id} not found in store, skipping`);
4428
4587
  continue;
4429
4588
  }
4430
- if (this._isAdminOnlyDb && entryData.createdByPublicKey !== this.getAdminPublicKey()) {
4589
+ if (await this.shouldSkipLoadedEntryForBuiltin(entryData)) {
4431
4590
  this.logger.warn(`Admin-only DB: skipping entry ${entryData.id} not signed by admin key`);
4432
4591
  continue;
4433
4592
  }
@@ -4470,10 +4629,62 @@ class BaseMindooDB {
4470
4629
  });
4471
4630
  return this.wrapDocument(internalDoc);
4472
4631
  }
4632
+ /**
4633
+ * Unwrap a `$sealed:` DEK from entry recipient wraps into the tenant session
4634
+ * cache. `loadDocumentInternal` already does this; history / timestamp reads
4635
+ * must as well — otherwise Haven's revision list throws SymmetricKeyNotFoundError
4636
+ * on a cold tenant even when this device is in the wrap list.
4637
+ */
4638
+ async ingestSealedKeyFromEntries(entries) {
4639
+ if (entries.length === 0)
4640
+ return;
4641
+ const keyId = entries.find((entry) => entry.entryType === "doc_create")?.decryptionKeyId
4642
+ ?? entries[0].decryptionKeyId;
4643
+ await this.tenant.ingestSealedFromEntries?.(keyId, entries);
4644
+ }
4645
+ /**
4646
+ * Re-ingest the newest sealed wrap list. When this replica can no longer
4647
+ * unwrap it (recipient removal), drop the leftover session DEK and any
4648
+ * materialized plaintext so later honest-client writes cannot reuse it.
4649
+ *
4650
+ * @returns true when access was revoked
4651
+ */
4652
+ async dropSealedAccessIfUnwrapFailed(internalDoc) {
4653
+ if (!(0, sealedTypes_1.isSealedKeyId)(internalDoc.decryptionKeyId)) {
4654
+ return false;
4655
+ }
4656
+ const metas = await this.scanAllMetadata(this.store, { docId: internalDoc.id });
4657
+ await this.ingestSealedKeyFromEntries(metas);
4658
+ if (await this.tenant.hasDecryptionKey(internalDoc.decryptionKeyId)) {
4659
+ return false;
4660
+ }
4661
+ await this.purgeMaterializedDocument(internalDoc.id);
4662
+ const existingIndex = this.getDocIndexPosition(internalDoc.id);
4663
+ const existing = existingIndex === undefined ? undefined : this.index[existingIndex];
4664
+ if (existing?.accessState === "visible") {
4665
+ this.updateIndex(internalDoc.id, existing.lastModified, true, internalDoc.decryptionKeyId, "inaccessible");
4666
+ this.cacheMetaDirty = true;
4667
+ this.cacheManager?.markDirty();
4668
+ }
4669
+ return true;
4670
+ }
4671
+ /**
4672
+ * Patch paths (`applyTextPatch`, rich-text, Automerge) used to persist with
4673
+ * a leftover session DEK after the newest wrap excluded this user.
4674
+ * `changeDoc` already refused those writes; this is the shared chokepoint.
4675
+ */
4676
+ async assertCurrentSealedAccess(internalDoc) {
4677
+ if (await this.dropSealedAccessIfUnwrapFailed(internalDoc)) {
4678
+ throw new errors_1.DocumentNotFoundError(internalDoc.id);
4679
+ }
4680
+ }
4473
4681
  async *iterateDocumentHistory(docId) {
4474
4682
  this.logger.debug(`Iterating document history for ${docId}`);
4475
4683
  // Get all entry metadata for this document
4476
4684
  const allEntryMetadata = await this.scanAllMetadata(this.store, { docId });
4685
+ if (allEntryMetadata.length > 0) {
4686
+ await this.ingestSealedKeyFromEntries(allEntryMetadata);
4687
+ }
4477
4688
  // Filter to document replay entries (exclude snapshots) and order them
4478
4689
  // causally: a plain chronological sort can place an effect before its
4479
4690
  // cause when several entries share the same millisecond (or when replica
@@ -4486,6 +4697,7 @@ class BaseMindooDB {
4486
4697
  }
4487
4698
  // Load all entries
4488
4699
  const entries = await this.store.getEntries(relevantEntries.map(em => em.id));
4700
+ await this.ingestSealedKeyFromEntries(entries.length > 0 ? entries : relevantEntries);
4489
4701
  // Build a map for quick lookup
4490
4702
  const entryMap = new Map(entries.map(e => [e.id, e]));
4491
4703
  // Apply changes in order
@@ -4500,7 +4712,7 @@ class BaseMindooDB {
4500
4712
  continue;
4501
4713
  }
4502
4714
  // Admin-only mode: only accept entries signed by the admin key
4503
- if (this._isAdminOnlyDb && entryData.createdByPublicKey !== this.getAdminPublicKey()) {
4715
+ if (await this.shouldSkipLoadedEntryForBuiltin(entryData)) {
4504
4716
  this.logger.warn(`Admin-only DB: skipping entry ${entryData.id} not signed by admin key`);
4505
4717
  continue;
4506
4718
  }
@@ -4660,14 +4872,14 @@ class BaseMindooDB {
4660
4872
  // leading witnessed prefix.
4661
4873
  let stableCursor = cursor;
4662
4874
  let sawProvisional = false;
4663
- const consider = (em) => {
4875
+ const consider = async (em) => {
4664
4876
  if (!this.isDocumentReplayEntry(em)) {
4665
4877
  return;
4666
4878
  }
4667
4879
  // Admin-only mode: only accept entries signed by the admin key (mirrors
4668
4880
  // materialization / iterateDocumentHistory). Non-admin entries are
4669
4881
  // permanently ignored and do not gate the stable cursor.
4670
- if (this._isAdminOnlyDb && em.createdByPublicKey !== this.getAdminPublicKey()) {
4882
+ if (await this.shouldSkipLoadedEntryForBuiltin(em)) {
4671
4883
  return;
4672
4884
  }
4673
4885
  newEntries.push(em);
@@ -4699,7 +4911,7 @@ class BaseMindooDB {
4699
4911
  while (true) {
4700
4912
  const page = await this.store.scanEntriesSince(scanCursor, 1000, scanFilters);
4701
4913
  for (const em of page.entries) {
4702
- consider(em);
4914
+ await consider(em);
4703
4915
  }
4704
4916
  scanCursor = page.nextCursor;
4705
4917
  if (!page.hasMore) {
@@ -4720,7 +4932,7 @@ class BaseMindooDB {
4720
4932
  if (cursor && !this.isAfterReceiptCursor(em, cursor)) {
4721
4933
  continue;
4722
4934
  }
4723
- consider(em);
4935
+ await consider(em);
4724
4936
  }
4725
4937
  }
4726
4938
  return { newEntries, resumeCursor: stableCursor };
@@ -4749,10 +4961,12 @@ class BaseMindooDB {
4749
4961
  // All metadata for this doc (metadata only, cheap). Includes snapshots, used
4750
4962
  // for cache-miss seed reconstruction.
4751
4963
  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);
4964
+ const replayCandidates = docAllMeta.filter((em) => this.isDocumentReplayEntry(em));
4965
+ const replayMeta = [];
4966
+ for (const em of replayCandidates) {
4967
+ if (!(await this.shouldSkipLoadedEntryForBuiltin(em))) {
4968
+ replayMeta.push(em);
4969
+ }
4756
4970
  }
4757
4971
  if (replayMeta.length === 0) {
4758
4972
  return;
@@ -4951,7 +5165,7 @@ class BaseMindooDB {
4951
5165
  return null;
4952
5166
  }
4953
5167
  const snapshotData = entries[0];
4954
- if (this._isAdminOnlyDb && snapshotData.createdByPublicKey !== this.getAdminPublicKey()) {
5168
+ if (await this.shouldSkipLoadedEntryForBuiltin(snapshotData)) {
4955
5169
  this.logger.warn(`Admin-only DB: skipping snapshot ${snapshotData.id} not signed by admin key`);
4956
5170
  return null;
4957
5171
  }
@@ -5120,7 +5334,7 @@ class BaseMindooDB {
5120
5334
  result.set(metadata.id, null);
5121
5335
  continue;
5122
5336
  }
5123
- if (this._isAdminOnlyDb && entry.createdByPublicKey !== this.getAdminPublicKey()) {
5337
+ if (await this.shouldSkipLoadedEntryForBuiltin(entry)) {
5124
5338
  result.set(metadata.id, null);
5125
5339
  continue;
5126
5340
  }
@@ -5286,7 +5500,7 @@ class BaseMindooDB {
5286
5500
  const entry = entries[0];
5287
5501
  if (entry) {
5288
5502
  let isValid = false;
5289
- if (this._isAdminOnlyDb && entry.createdByPublicKey !== this.getAdminPublicKey()) {
5503
+ if (await this.shouldSkipLoadedEntryForBuiltin(entry)) {
5290
5504
  this.logger.warn(`Admin-only DB: skipping DAG details for ${entry.id} not signed by admin key`);
5291
5505
  }
5292
5506
  else {
@@ -5983,7 +6197,7 @@ class BaseMindooDB {
5983
6197
  this.logger.warn(`Conflict analysis: entry ${metadata.id} not found in store, skipping`);
5984
6198
  continue;
5985
6199
  }
5986
- if (this._isAdminOnlyDb && entry.createdByPublicKey !== this.getAdminPublicKey()) {
6200
+ if (await this.shouldSkipLoadedEntryForBuiltin(entry)) {
5987
6201
  this.logger.warn(`Admin-only DB: skipping conflict analysis entry ${entry.id} not signed by admin key`);
5988
6202
  continue;
5989
6203
  }
@@ -6425,7 +6639,7 @@ class BaseMindooDB {
6425
6639
  createdAt: createdAtByDocId.get(entry.docId) ?? entry.lastModified,
6426
6640
  isDeleted: entry.isDeleted,
6427
6641
  }));
6428
- // Plain comparison, NOT localeCompare: ids are base62 in ASCII order and
6642
+ // Plain comparison, NOT localeCompare: ids are ObjectIds in ASCII order and
6429
6643
  // locale collation would reorder digits against letters, so the tie-break
6430
6644
  // for same-millisecond documents would no longer follow their ids.
6431
6645
  results.sort((left, right) => left.createdAt !== right.createdAt
@@ -6533,7 +6747,7 @@ class BaseMindooDB {
6533
6747
  if (docIds.length === 0) {
6534
6748
  return;
6535
6749
  }
6536
- await this.assertLifecycleMutationAllowed(options.signingKeyPair, options.signingKeyPassword);
6750
+ await this.assertLifecycleMutationAllowed("doc_delete", options.signingKeyPair, options.signingKeyPassword);
6537
6751
  this.logger.info(`Bulk-deleting ${docIds.length} documents`);
6538
6752
  // Unlike creation, a delete must first materialize the CURRENT document
6539
6753
  // (the doc_delete entry is an Automerge change on its heads), which costs
@@ -6580,21 +6794,14 @@ class BaseMindooDB {
6580
6794
  async undeleteDocument(docId, options = {}) {
6581
6795
  return this.undeleteDocInternal(docId, options.signingKeyPair, options.signingKeyPassword, options.bypassAccessControlPrecheck);
6582
6796
  }
6583
- async assertLifecycleMutationAllowed(signingKeyPair, signingKeyPassword) {
6797
+ async assertLifecycleMutationAllowed(op, signingKeyPair, signingKeyPassword, documentUsernameHash) {
6584
6798
  this.assertWritable("document lifecycle mutation");
6585
6799
  const useCustomSigningKey = signingKeyPair !== undefined && signingKeyPassword !== undefined;
6586
6800
  if ((signingKeyPair !== undefined) !== (signingKeyPassword !== undefined)) {
6587
6801
  throw new Error("Lifecycle mutation requires both signingKeyPair and signingKeyPassword");
6588
6802
  }
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
- }
6803
+ const signerPublicKey = await this.resolveSignerPublicKey(useCustomSigningKey ? signingKeyPair : undefined);
6804
+ await this.assertBuiltinWriteAllowed(op, signerPublicKey, documentUsernameHash);
6598
6805
  }
6599
6806
  /**
6600
6807
  * Write a delete or undelete lifecycle entry for an existing document.
@@ -6700,12 +6907,11 @@ class BaseMindooDB {
6700
6907
  // default to the "before" state; both states are supplied so either `when`
6701
6908
  // resolves. Throws AccessDeniedError if denied.
6702
6909
  {
6703
- const creatorKey = await this.resolveCreatorSigningKey(docId);
6704
6910
  const beforeState = internalDoc.doc;
6705
6911
  await this.assertWriteAllowed({
6706
6912
  op: entryType,
6707
6913
  signerKey: createdByPublicKey,
6708
- isAuthor: creatorKey !== null && createdByPublicKey === creatorKey,
6914
+ isAuthor: await this.resolveIsAuthor(docId, createdByPublicKey, Number.MAX_SAFE_INTEGER),
6709
6915
  bypass: bypassPrecheck,
6710
6916
  getBeforeDoc: () => this.convertAutomergeToJS(beforeState),
6711
6917
  getAfterDoc: () => this.convertAutomergeToJS(newDoc),
@@ -6769,7 +6975,7 @@ class BaseMindooDB {
6769
6975
  async deleteDocInternal(docId, signingKeyPair, signingKeyPassword, bypassPrecheck) {
6770
6976
  const useCustomSigningKey = signingKeyPair !== undefined && signingKeyPassword !== undefined;
6771
6977
  this.logger.debug(`Deleting document ${docId}${useCustomSigningKey ? ' using custom signing key' : ''}`);
6772
- await this.assertLifecycleMutationAllowed(signingKeyPair, signingKeyPassword);
6978
+ await this.assertLifecycleMutationAllowed("doc_delete", signingKeyPair, signingKeyPassword);
6773
6979
  // Get current document
6774
6980
  const internalDoc = await this.loadDocumentInternal(docId);
6775
6981
  if (!internalDoc || internalDoc.isDeleted) {
@@ -6781,11 +6987,11 @@ class BaseMindooDB {
6781
6987
  async undeleteDocInternal(docId, signingKeyPair, signingKeyPassword, bypassPrecheck) {
6782
6988
  const useCustomSigningKey = signingKeyPair !== undefined && signingKeyPassword !== undefined;
6783
6989
  this.logger.debug(`Undeleting document ${docId}${useCustomSigningKey ? ' using custom signing key' : ''}`);
6784
- await this.assertLifecycleMutationAllowed(signingKeyPair, signingKeyPassword);
6785
6990
  const internalDoc = await this.loadDocumentInternal(docId);
6786
6991
  if (!internalDoc) {
6787
6992
  throw new errors_1.DocumentNotFoundError(docId);
6788
6993
  }
6994
+ await this.assertLifecycleMutationAllowed("doc_undelete", signingKeyPair, signingKeyPassword, this.usernameHashFromInternalDoc(internalDoc));
6789
6995
  if (!internalDoc.isDeleted) {
6790
6996
  this.logger.debug(`Document ${docId} is already alive; undelete is a no-op`);
6791
6997
  return;
@@ -6803,13 +7009,6 @@ class BaseMindooDB {
6803
7009
  this.assertWritable("applyTextPatch");
6804
7010
  const docId = doc.getId();
6805
7011
  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
7012
  let internalDoc = this.getCachedDocument(docId);
6814
7013
  if (!internalDoc) {
6815
7014
  const loadedDoc = await this.loadDocumentInternal(docId);
@@ -6867,13 +7066,6 @@ class BaseMindooDB {
6867
7066
  this.assertWritable("applyRichTextPatch");
6868
7067
  const docId = doc.getId();
6869
7068
  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
7069
  let internalDoc = this.getCachedDocument(docId);
6878
7070
  if (!internalDoc) {
6879
7071
  const loadedDoc = await this.loadDocumentInternal(docId);
@@ -6942,13 +7134,6 @@ class BaseMindooDB {
6942
7134
  this.assertWritable("applyRichTextStepsPatch");
6943
7135
  const docId = doc.getId();
6944
7136
  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
7137
  let internalDoc = this.getCachedDocument(docId);
6953
7138
  if (!internalDoc) {
6954
7139
  const loadedDoc = await this.loadDocumentInternal(docId);
@@ -7061,13 +7246,6 @@ class BaseMindooDB {
7061
7246
  this.assertWritable("applyAutomergeChanges");
7062
7247
  const docId = doc.getId();
7063
7248
  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
7249
  if (!Array.isArray(patch.changes) || patch.changes.length === 0) {
7072
7250
  throw new Error("Automerge changes patch must include at least one change byte sequence");
7073
7251
  }
@@ -7171,13 +7349,6 @@ class BaseMindooDB {
7171
7349
  this.assertWritable("applyJsonPatch");
7172
7350
  const docId = doc.getId();
7173
7351
  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
7352
  let internalDoc = this.getCachedDocument(docId);
7182
7353
  if (!internalDoc) {
7183
7354
  const loadedDoc = await this.loadDocumentInternal(docId);
@@ -7235,16 +7406,6 @@ class BaseMindooDB {
7235
7406
  }
7236
7407
  const useCustomKey = signingKeyPair !== undefined && signingKeyPassword !== undefined;
7237
7408
  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
7409
  // Get internal document from cache or load it
7249
7410
  let internalDoc = this.getCachedDocument(docId);
7250
7411
  if (!internalDoc) {
@@ -7262,6 +7423,7 @@ class BaseMindooDB {
7262
7423
  if (internalDoc.isDeleted) {
7263
7424
  throw new errors_1.DocumentDeletedError(docId);
7264
7425
  }
7426
+ await this.assertCurrentSealedAccess(internalDoc);
7265
7427
  // Apply the change function
7266
7428
  const now = (0, timeSource_1.semanticNow)();
7267
7429
  this.logger.debug(`Applying change function to document ${docId}`);
@@ -7304,6 +7466,11 @@ class BaseMindooDB {
7304
7466
  isAccessible: () => true,
7305
7467
  isAwaitingWitness: () => internalDoc.awaitingWitness ?? false,
7306
7468
  isWitnessed: () => internalDoc.witnessed ?? false,
7469
+ isSealed: () => (0, sealedTypes_1.isSealedKeyId)(internalDoc.decryptionKeyId),
7470
+ getRecipients: () => (0, recipients_1.resolveRecipientsFromPayload)(internalDoc.doc, internalDoc.sealedRecipients),
7471
+ getRecipientEpoch: () => internalDoc.sealedRecipients?.epoch ?? 0,
7472
+ isEncryptedFor: (users) => (0, sealedTypes_1.isSealedKeyId)(internalDoc.decryptionKeyId)
7473
+ && (0, recipients_1.isPayloadEncryptedFor)(internalDoc.doc, internalDoc.sealedRecipients, Array.isArray(users) ? users : [users]),
7307
7474
  getHeads: () => automerge_adapter_1.Automerge.getHeads(internalDoc.doc),
7308
7475
  getData: () => {
7309
7476
  // Return a proxy that collects property assignments and deletions
@@ -7584,6 +7751,7 @@ class BaseMindooDB {
7584
7751
  successMessage: useCustomKey ? "changed with custom signing key" : "changed",
7585
7752
  attachmentIds: pendingAttachmentAdditions.map((ref) => ref.attachmentId),
7586
7753
  bypassPrecheck,
7754
+ recipients: internalDoc.sealedRecipients,
7587
7755
  });
7588
7756
  }
7589
7757
  catch (error) {
@@ -8087,7 +8255,7 @@ class BaseMindooDB {
8087
8255
  }
8088
8256
  }
8089
8257
  async persistDocumentChange(options) {
8090
- const { internalDoc, newDoc, now, headsBeforeChange, useCustomKey, signingKeyPair, signingKeyPassword, successMessage, attachmentIds, bypassPrecheck } = options;
8258
+ const { internalDoc, newDoc, now, headsBeforeChange, useCustomKey, signingKeyPair, signingKeyPassword, successMessage, attachmentIds, bypassPrecheck, recipients } = options;
8091
8259
  const docId = internalDoc.id;
8092
8260
  this.logger.debug(`Getting change bytes from document ${docId}`);
8093
8261
  const changesSincePreviousHeads = headsBeforeChange
@@ -8111,17 +8279,21 @@ class BaseMindooDB {
8111
8279
  successMessage,
8112
8280
  attachmentIds,
8113
8281
  bypassPrecheck,
8282
+ recipients: options.recipients,
8114
8283
  });
8115
8284
  }
8116
8285
  async persistAutomergeDocumentChanges(options) {
8117
- const { internalDoc, newDoc, now, changeBytesList, headsBeforeChange, useCustomKey, signingKeyPair, signingKeyPassword, successMessage, attachmentIds, bypassPrecheck, } = options;
8286
+ const { internalDoc, newDoc, now, changeBytesList, headsBeforeChange, useCustomKey, signingKeyPair, signingKeyPassword, successMessage, attachmentIds, bypassPrecheck, recipients, } = options;
8118
8287
  const docId = internalDoc.id;
8288
+ await this.assertCurrentSealedAccess(internalDoc);
8119
8289
  const resolvedChangeBytesList = changeBytesList ?? (headsBeforeChange
8120
8290
  ? automerge_adapter_1.Automerge.getChangesSince(newDoc, headsBeforeChange)
8121
8291
  : []);
8122
8292
  if (resolvedChangeBytesList.length === 0) {
8123
8293
  throw new Error("No Automerge changes to persist");
8124
8294
  }
8295
+ const signerPublicKey = await this.resolveSignerPublicKey(useCustomKey ? signingKeyPair : undefined);
8296
+ await this.assertBuiltinWriteAllowed("doc_change", signerPublicKey, this.usernameHashFromInternalDoc(internalDoc));
8125
8297
  // Signed snapshot of the document's attachments after this change, computed
8126
8298
  // once from the resulting document state so EVERY change path (changeDoc,
8127
8299
  // text patches, merges, ...) carries it consistently on each produced entry.
@@ -8140,6 +8312,7 @@ class BaseMindooDB {
8140
8312
  // The full attachment snapshot describes the document's resulting state,
8141
8313
  // so it goes on every produced entry (unlike the add-only attachmentIds).
8142
8314
  attachmentRefs,
8315
+ recipients: index === 0 ? recipients : undefined,
8143
8316
  });
8144
8317
  entries.push(entry);
8145
8318
  const decodedChange = automerge_adapter_1.Automerge.decodeChange(changeBytes);
@@ -8153,12 +8326,11 @@ class BaseMindooDB {
8153
8326
  const signerKey = useCustomKey
8154
8327
  ? signingKeyPair.publicKey
8155
8328
  : (await this.tenant.getCurrentUserId()).userSigningPublicKey;
8156
- const creatorKey = await this.resolveCreatorSigningKey(docId);
8157
8329
  const beforeState = internalDoc.doc;
8158
8330
  await this.assertWriteAllowed({
8159
8331
  op: "doc_change",
8160
8332
  signerKey,
8161
- isAuthor: creatorKey !== null && signerKey === creatorKey,
8333
+ isAuthor: await this.resolveIsAuthor(docId, signerKey, Number.MAX_SAFE_INTEGER),
8162
8334
  bypass: bypassPrecheck,
8163
8335
  getBeforeDoc: () => this.convertAutomergeToJS(beforeState),
8164
8336
  getAfterDoc: () => this.convertAutomergeToJS(newDoc),
@@ -8181,6 +8353,9 @@ class BaseMindooDB {
8181
8353
  // changefeed must re-emit the doc even when `lastModified` collides with
8182
8354
  // the previous change in the same millisecond.
8183
8355
  this.updateIndex(docId, internalDoc.lastModified, internalDoc.isDeleted, internalDoc.decryptionKeyId, "visible", internalDoc.awaitingWitness, internalDoc.witnessed, true);
8356
+ if (recipients) {
8357
+ internalDoc.sealedRecipients = recipients;
8358
+ }
8184
8359
  this.markDocDirty(docId);
8185
8360
  this.logger.info(`Document ${docId} ${successMessage} successfully`);
8186
8361
  const createdByPublicKey = useCustomKey
@@ -8191,7 +8366,7 @@ class BaseMindooDB {
8191
8366
  : { createdByPublicKey: createdByPublicKey ?? "" });
8192
8367
  }
8193
8368
  async buildDocChangeStoreEntry(options) {
8194
- const { internalDoc, changeBytes, now, useCustomKey, signingKeyPair, signingKeyPassword, attachmentIds, attachmentRefs } = options;
8369
+ const { internalDoc, changeBytes, now, useCustomKey, signingKeyPair, signingKeyPassword, attachmentIds, attachmentRefs, recipients } = options;
8195
8370
  const docId = internalDoc.id;
8196
8371
  this.logger.debug(`Got change bytes: ${changeBytes.length} bytes`);
8197
8372
  const decodedChange = automerge_adapter_1.Automerge.decodeChange(changeBytes);
@@ -8230,6 +8405,7 @@ class BaseMindooDB {
8230
8405
  encryptedSize: encryptedPayload.length,
8231
8406
  attachmentIds: attachmentIds && attachmentIds.length > 0 ? attachmentIds : undefined,
8232
8407
  attachmentRefs: attachmentRefs && attachmentRefs.length > 0 ? attachmentRefs : undefined,
8408
+ recipients,
8233
8409
  entryVersion: types_1.CURRENT_STORE_ENTRY_VERSION,
8234
8410
  };
8235
8411
  // Bind the metadata with the author signature (audit finding #5).
@@ -8741,7 +8917,7 @@ class BaseMindooDB {
8741
8917
  if (snapshotEntries.length > 0) {
8742
8918
  const snapshotData = snapshotEntries[0];
8743
8919
  let isValid = false;
8744
- if (this._isAdminOnlyDb && snapshotData.createdByPublicKey !== this.getAdminPublicKey()) {
8920
+ if (await this.shouldSkipLoadedEntryForBuiltin(snapshotData)) {
8745
8921
  this.logger.warn(`Admin-only DB: skipping snapshot ${snapshotData.id} not signed by admin key`);
8746
8922
  }
8747
8923
  else {
@@ -8786,7 +8962,7 @@ class BaseMindooDB {
8786
8962
  this.logger.warn(`Entry ${entryMeta.id} not found in store, skipping`);
8787
8963
  continue;
8788
8964
  }
8789
- if (this._isAdminOnlyDb && entryData.createdByPublicKey !== this.getAdminPublicKey()) {
8965
+ if (await this.shouldSkipLoadedEntryForBuiltin(entryData)) {
8790
8966
  this.logger.warn(`Admin-only DB: skipping entry ${entryData.id} not signed by admin key`);
8791
8967
  continue;
8792
8968
  }
@@ -9035,13 +9211,14 @@ class BaseMindooDB {
9035
9211
  // Load entries from store
9036
9212
  const entries = await this.store.getEntries(orderedEntryIds);
9037
9213
  // Filter entries for admin-only mode first
9038
- const validEntries = entries.filter(entryData => {
9039
- if (this._isAdminOnlyDb && entryData.createdByPublicKey !== this.getAdminPublicKey()) {
9214
+ const validEntries = [];
9215
+ for (const entryData of entries) {
9216
+ if (await this.shouldSkipLoadedEntryForBuiltin(entryData)) {
9040
9217
  this.logger.warn(`Admin-only DB: skipping entry ${entryData.id} not signed by admin key`);
9041
- return false;
9218
+ continue;
9042
9219
  }
9043
- return true;
9044
- });
9220
+ validEntries.push(entryData);
9221
+ }
9045
9222
  if (validEntries.length === 0) {
9046
9223
  this.logger.debug(`No valid entries to process for cached document ${docId}`);
9047
9224
  return null;
@@ -9592,7 +9769,13 @@ class BaseMindooDB {
9592
9769
  * server. Directory ACL writes always pass an explicit key, so this never
9593
9770
  * re-enters the directory for them.
9594
9771
  */
9595
- async resolveCreateKeyId(options) {
9772
+ async resolveCreateKeyId(options, docId) {
9773
+ if (options.recipients !== undefined && options.decryptionKeyId !== undefined) {
9774
+ throw new Error("createDocument: recipients and decryptionKeyId are mutually exclusive");
9775
+ }
9776
+ if (options.recipients !== undefined) {
9777
+ return (0, sealedTypes_1.sealedKeyId)(docId ?? options.id ?? "pending");
9778
+ }
9596
9779
  if (options.decryptionKeyId !== undefined)
9597
9780
  return options.decryptionKeyId;
9598
9781
  try {
@@ -9609,6 +9792,34 @@ class BaseMindooDB {
9609
9792
  }
9610
9793
  return "default";
9611
9794
  }
9795
+ async prepareSealedCreate(options, docId, keyId, now, signerPublicKey) {
9796
+ if (options.recipients === undefined)
9797
+ return undefined;
9798
+ const tenant = this.tenant;
9799
+ const resolved = await (0, recipients_1.resolveRecipientSpecs)({
9800
+ tenant,
9801
+ specs: options.recipients,
9802
+ options: options.recipientOptions,
9803
+ addedBy: signerPublicKey,
9804
+ now,
9805
+ });
9806
+ const dek = tenant.getCryptoAdapter().getRandomValues(new Uint8Array(32));
9807
+ console.log("[sealed] prepareSealedCreate", {
9808
+ docId,
9809
+ keyId,
9810
+ includeSelf: options.recipientOptions?.includeSelf !== false,
9811
+ userTargets: resolved.targets.map((target) => ({ kind: target.kind, id: target.id })),
9812
+ skipped: resolved.skipped,
9813
+ });
9814
+ const recipients = await (0, recipients_1.sealToTargets)({
9815
+ tenant,
9816
+ generations: [dek],
9817
+ targets: resolved.targets,
9818
+ epoch: 1,
9819
+ });
9820
+ tenant.rememberSealedGenerations(keyId, [dek]);
9821
+ return { encryptFor: resolved.encryptFor, recipients };
9822
+ }
9612
9823
  /**
9613
9824
  * Evaluate the full client-side (Tier 1 + Tier 2) write ruleset for a
9614
9825
  * candidate write, returning the {@link AccessDecision}. Returns `null` only
@@ -9701,17 +9912,203 @@ class BaseMindooDB {
9701
9912
  const signerKey = signingKeyPair
9702
9913
  ? signingKeyPair.publicKey
9703
9914
  : (await this.tenant.getCurrentUserId()).userSigningPublicKey;
9704
- const creatorKey = await this.resolveCreatorSigningKey(docId);
9705
9915
  const before = doc.getData();
9706
9916
  const decision = await this.evaluateClientWriteAccess({
9707
9917
  op: "doc_change",
9708
9918
  signerKey,
9709
- isAuthor: creatorKey !== null && signerKey === creatorKey,
9919
+ isAuthor: await this.resolveIsAuthor(docId, signerKey, Number.MAX_SAFE_INTEGER),
9710
9920
  getBeforeDoc: () => before,
9711
9921
  getAfterDoc: () => candidateAfter,
9712
9922
  });
9713
9923
  return decision ?? BaseMindooDB.ACL_NOT_ENFORCED_DECISION;
9714
9924
  }
9925
+ async addRecipients(doc, recipients, options) {
9926
+ return this.mutateRecipients(doc, "add", recipients, options);
9927
+ }
9928
+ async removeRecipients(doc, recipients, options) {
9929
+ return this.mutateRecipients(doc, "remove", recipients, options);
9930
+ }
9931
+ async setRecipients(doc, recipients, options) {
9932
+ return this.mutateRecipients(doc, "set", recipients, options);
9933
+ }
9934
+ async canChangeRecipients(doc, next) {
9935
+ const after = { ...doc.getData() };
9936
+ after[sealedTypes_1.ENCRYPT_FOR_FIELD] = { ...(0, recipients_1.readEncryptFor)(after) };
9937
+ return this.canChange(doc, after);
9938
+ }
9939
+ async mutateRecipients(doc, mode, specs, options) {
9940
+ this.assertWritable("changeDoc");
9941
+ if (!(0, sealedTypes_1.isSealedKeyId)(doc.getDecryptionKeyId())) {
9942
+ throw new Error("Recipient mutators require a document created with `recipients`");
9943
+ }
9944
+ const tenant = this.tenant;
9945
+ const docId = doc.getId();
9946
+ let internalDoc = this.getCachedDocument(docId) ?? (await this.loadDocumentInternal(docId));
9947
+ if (!internalDoc || internalDoc.isDeleted) {
9948
+ throw new errors_1.DocumentNotFoundError(docId);
9949
+ }
9950
+ const now = (0, timeSource_1.semanticNow)();
9951
+ const signer = options?.signingKeyPair?.publicKey
9952
+ ?? (await this.tenant.getCurrentUserId()).userSigningPublicKey;
9953
+ const resolved = await (0, recipients_1.resolveRecipientSpecs)({
9954
+ tenant,
9955
+ specs,
9956
+ options: {
9957
+ includeSelf: mode === "set" ? options?.includeSelf !== false : false,
9958
+ strict: options?.strict,
9959
+ },
9960
+ addedBy: signer,
9961
+ now,
9962
+ });
9963
+ const currentMap = (0, recipients_1.readEncryptFor)(internalDoc.doc);
9964
+ const activeIds = new Set(Object.entries(currentMap).filter(([, e]) => !e.removedAt).map(([id]) => id.split("#")[0]));
9965
+ const addedTargets = [];
9966
+ const removedStableIds = [];
9967
+ const matchesTarget = (id, entry, target) => {
9968
+ const base = id.split("#")[0];
9969
+ return (target.stableId === base
9970
+ || target.id === base
9971
+ || (!!entry.label && target.id === entry.label)
9972
+ || (!!entry.keyFingerprint && target.keyFingerprint === entry.keyFingerprint));
9973
+ };
9974
+ if (mode === "add" || mode === "set") {
9975
+ for (const target of resolved.targets) {
9976
+ if (!activeIds.has(target.stableId) && !activeIds.has(target.id))
9977
+ addedTargets.push(target);
9978
+ }
9979
+ }
9980
+ if (mode === "remove") {
9981
+ for (const [id, entry] of Object.entries(currentMap)) {
9982
+ if (entry.removedAt)
9983
+ continue;
9984
+ if (resolved.targets.some((target) => matchesTarget(id, entry, target))) {
9985
+ removedStableIds.push(id.split("#")[0]);
9986
+ }
9987
+ }
9988
+ }
9989
+ else if (mode === "set") {
9990
+ const nextIds = new Set(resolved.targets.flatMap((t) => [t.stableId, t.id]));
9991
+ for (const [id, entry] of Object.entries(currentMap)) {
9992
+ if (entry.removedAt)
9993
+ continue;
9994
+ const base = id.split("#")[0];
9995
+ const keep = resolved.targets.some((target) => matchesTarget(id, entry, target));
9996
+ if (!keep && !nextIds.has(base))
9997
+ removedStableIds.push(base);
9998
+ }
9999
+ }
10000
+ const rotate = removedStableIds.length > 0;
10001
+ const currentGens = tenant.getSealedGenerations(internalDoc.decryptionKeyId);
10002
+ if (currentGens.length === 0) {
10003
+ throw new Error("Cannot change recipients: the current document key is not in this session");
10004
+ }
10005
+ let generations = currentGens;
10006
+ let epoch = internalDoc.sealedRecipients?.epoch ?? currentGens.length;
10007
+ if (rotate) {
10008
+ const nextDek = tenant.getCryptoAdapter().getRandomValues(new Uint8Array(32));
10009
+ generations = [nextDek, ...currentGens];
10010
+ epoch += 1;
10011
+ }
10012
+ const remainingByStable = new Map();
10013
+ const existingSpecs = Object.entries(currentMap)
10014
+ .filter(([, e]) => !e.removedAt)
10015
+ .map(([id, e]) => e.kind === "device"
10016
+ ? { device: e.keyFingerprint ?? id, label: e.label }
10017
+ : (e.label ?? id));
10018
+ if (existingSpecs.length > 0) {
10019
+ const existingResolved = await (0, recipients_1.resolveRecipientSpecs)({
10020
+ tenant,
10021
+ specs: existingSpecs,
10022
+ options: { includeSelf: false, strict: false },
10023
+ addedBy: signer,
10024
+ now,
10025
+ });
10026
+ for (const target of existingResolved.targets)
10027
+ remainingByStable.set(target.stableId, target);
10028
+ }
10029
+ for (const target of addedTargets)
10030
+ remainingByStable.set(target.stableId, target);
10031
+ for (const id of removedStableIds) {
10032
+ remainingByStable.delete(id);
10033
+ for (const [stableId, target] of [...remainingByStable.entries()]) {
10034
+ if (target.id === id || target.stableId === id || target.keyFingerprint === id) {
10035
+ remainingByStable.delete(stableId);
10036
+ }
10037
+ }
10038
+ }
10039
+ const remaining = [...remainingByStable.values()];
10040
+ if (remaining.length === 0) {
10041
+ throw new Error("Refusing to leave a sealed document with no recipients");
10042
+ }
10043
+ const recipientsBlock = await (0, recipients_1.sealToTargets)({
10044
+ tenant,
10045
+ generations,
10046
+ targets: remaining,
10047
+ epoch,
10048
+ });
10049
+ tenant.rememberSealedGenerations(internalDoc.decryptionKeyId, generations);
10050
+ const headsBeforeChange = automerge_adapter_1.Automerge.getHeads(internalDoc.doc);
10051
+ const newDoc = automerge_adapter_1.Automerge.change(internalDoc.doc, (draft) => {
10052
+ if (addedTargets.length > 0) {
10053
+ const addMap = {};
10054
+ for (const target of addedTargets) {
10055
+ addMap[target.stableId] = {
10056
+ kind: target.kind,
10057
+ addedAt: now,
10058
+ addedBy: signer,
10059
+ keyFingerprint: target.keyFingerprint,
10060
+ ...(target.label ? { label: target.label } : {}),
10061
+ };
10062
+ }
10063
+ (0, recipients_1.applyEncryptForAdds)(draft, addMap, epoch);
10064
+ }
10065
+ if (removedStableIds.length > 0) {
10066
+ (0, recipients_1.applyEncryptForRemoves)(draft, removedStableIds, now, signer);
10067
+ }
10068
+ if (options?.change) {
10069
+ // Content edits from the caller happen in a follow-up changeDoc; the
10070
+ // recipient field itself is the carrier entry.
10071
+ }
10072
+ draft._lastModified = now;
10073
+ });
10074
+ await this.persistDocumentChange({
10075
+ internalDoc,
10076
+ newDoc,
10077
+ now,
10078
+ headsBeforeChange,
10079
+ useCustomKey: !!(options?.signingKeyPair && options.signingKeyPassword),
10080
+ signingKeyPair: options?.signingKeyPair,
10081
+ signingKeyPassword: options?.signingKeyPassword,
10082
+ successMessage: "recipients updated",
10083
+ bypassPrecheck: options?.bypassAccessControlPrecheck,
10084
+ recipients: recipientsBlock,
10085
+ });
10086
+ if (options?.change) {
10087
+ await this.changeDoc(this.wrapDocument(internalDoc), options.change, {
10088
+ signingKeyPair: options.signingKeyPair,
10089
+ signingKeyPassword: options.signingKeyPassword,
10090
+ bypassAccessControlPrecheck: options.bypassAccessControlPrecheck,
10091
+ });
10092
+ }
10093
+ const wrapped = this.wrapDocument(internalDoc);
10094
+ return {
10095
+ epoch,
10096
+ rotated: rotate,
10097
+ added: wrapped.getRecipients().filter((r) => addedTargets.some((t) => t.keyFingerprint === r.keyFingerprint)),
10098
+ removed: Object.entries((0, recipients_1.readEncryptFor)(internalDoc.doc))
10099
+ .filter(([id, e]) => e.removedAt && removedStableIds.includes(id.split("#")[0]))
10100
+ .map(([id, e]) => ({
10101
+ kind: e.kind === "device" ? "device" : "user",
10102
+ id: e.kind === "device" ? (e.keyFingerprint ?? id) : id,
10103
+ keyFingerprint: e.keyFingerprint ?? "",
10104
+ label: e.label,
10105
+ addedInEpoch: epoch,
10106
+ sealed: false,
10107
+ })),
10108
+ skipped: resolved.skipped,
10109
+ entryId: "",
10110
+ };
10111
+ }
9715
10112
  /**
9716
10113
  * Predict whether deleting `doc` via {@link deleteDocument} would be allowed
9717
10114
  * by the active write policy, without writing anything.
@@ -9721,12 +10118,11 @@ class BaseMindooDB {
9721
10118
  const signerKey = signingKeyPair
9722
10119
  ? signingKeyPair.publicKey
9723
10120
  : (await this.tenant.getCurrentUserId()).userSigningPublicKey;
9724
- const creatorKey = await this.resolveCreatorSigningKey(docId);
9725
10121
  const current = doc.getData();
9726
10122
  const decision = await this.evaluateClientWriteAccess({
9727
10123
  op: "doc_delete",
9728
10124
  signerKey,
9729
- isAuthor: creatorKey !== null && signerKey === creatorKey,
10125
+ isAuthor: await this.resolveIsAuthor(docId, signerKey, Number.MAX_SAFE_INTEGER),
9730
10126
  getBeforeDoc: () => current,
9731
10127
  getAfterDoc: () => current,
9732
10128
  });
@@ -9741,40 +10137,71 @@ class BaseMindooDB {
9741
10137
  const signerKey = signingKeyPair
9742
10138
  ? signingKeyPair.publicKey
9743
10139
  : (await this.tenant.getCurrentUserId()).userSigningPublicKey;
9744
- const creatorKey = await this.resolveCreatorSigningKey(docId);
9745
10140
  const current = doc.getData();
9746
10141
  const decision = await this.evaluateClientWriteAccess({
9747
10142
  op: "doc_undelete",
9748
10143
  signerKey,
9749
- isAuthor: creatorKey !== null && signerKey === creatorKey,
10144
+ isAuthor: await this.resolveIsAuthor(docId, signerKey, Number.MAX_SAFE_INTEGER),
9750
10145
  getBeforeDoc: () => current,
9751
10146
  getAfterDoc: () => current,
9752
10147
  });
9753
10148
  return decision ?? BaseMindooDB.ACL_NOT_ENFORCED_DECISION;
9754
10149
  }
9755
10150
  /**
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).
10151
+ * Resolve the signing public key of the document's original creator and the
10152
+ * trusted time of the `doc_create` entry, used to set `$author` (`isAuthor`)
10153
+ * at grant level. Cached per document; returns `null` if the create entry
10154
+ * isn't present yet (in which case `isAuthor` is treated as false).
9761
10155
  */
9762
- async resolveCreatorSigningKey(docId) {
9763
- const cached = this.creatorKeyCache.get(docId);
10156
+ async resolveCreatorInfo(docId) {
10157
+ const cached = this.creatorInfoCache.get(docId);
9764
10158
  if (cached)
9765
10159
  return cached;
9766
10160
  try {
9767
10161
  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;
10162
+ const createMeta = metas.find((m) => m.entryType === "doc_create");
10163
+ const createKey = createMeta?.createdByPublicKey ?? null;
10164
+ if (!createMeta || !createKey)
10165
+ return null;
10166
+ const trustedTime = createMeta.receivedAt ?? createMeta.createdAt;
10167
+ const info = { signingKey: createKey, trustedTime };
10168
+ this.creatorInfoCache.set(docId, info);
10169
+ return info;
9772
10170
  }
9773
10171
  catch (error) {
9774
10172
  this.logger.debug(`[ACL] creator-key resolution failed for ${docId}: ${error}`);
9775
10173
  return null;
9776
10174
  }
9777
10175
  }
10176
+ /**
10177
+ * Whether `signerKey` is the document author at grant level: the creator is
10178
+ * resolved at the `doc_create` trusted time, the signer at `signerTrustedTime`.
10179
+ */
10180
+ async resolveIsAuthor(docId, signerKey, signerTrustedTime) {
10181
+ const creator = await this.resolveCreatorInfo(docId);
10182
+ if (!creator)
10183
+ return false;
10184
+ try {
10185
+ const directory = await this.tenant.openDirectory();
10186
+ if (typeof directory.isSamePerson === "function") {
10187
+ return directory.isSamePerson({
10188
+ creatorSigningKey: creator.signingKey,
10189
+ signerSigningKey: signerKey,
10190
+ creatorTrustedTime: creator.trustedTime,
10191
+ signerTrustedTime,
10192
+ });
10193
+ }
10194
+ }
10195
+ catch (error) {
10196
+ this.logger.debug(`[ACL] grant-level $author resolution failed for ${docId}: ${error}`);
10197
+ }
10198
+ return creator.signingKey === signerKey;
10199
+ }
10200
+ /** @deprecated kept as a thin wrapper for any remaining device-key callers. */
10201
+ async resolveCreatorSigningKey(docId) {
10202
+ const info = await this.resolveCreatorInfo(docId);
10203
+ return info?.signingKey ?? null;
10204
+ }
9778
10205
  /** Append a record to the quarantine/audit log (and log it). */
9779
10206
  recordQuarantine(rec) {
9780
10207
  this.quarantineLog.push(rec);
@@ -9835,7 +10262,12 @@ class BaseMindooDB {
9835
10262
  : (a.receivedAt ?? a.createdAt) - (b.receivedAt ?? b.createdAt));
9836
10263
  if (dataEntries.length === 0)
9837
10264
  return { doc: null, cacheable };
9838
- const creatorKey = dataEntries.find((m) => m.entryType === "doc_create")?.createdByPublicKey ?? null;
10265
+ const createMeta = dataEntries.find((m) => m.entryType === "doc_create");
10266
+ const creatorKey = createMeta?.createdByPublicKey ?? null;
10267
+ const creatorTrustedTime = createMeta
10268
+ ? (createMeta.receivedAt ?? createMeta.createdAt)
10269
+ : 0;
10270
+ const isSamePersonFn = directory.isSamePerson?.bind(directory);
9839
10271
  const fetched = await this.store.getEntries(dataEntries.map((m) => m.id));
9840
10272
  const entryById = new Map(fetched.map((e) => [e.id, e]));
9841
10273
  let currentDoc = null;
@@ -9869,8 +10301,13 @@ class BaseMindooDB {
9869
10301
  quarantine(meta, "cascade_dependent", "causally depends on a quarantined entry");
9870
10302
  continue;
9871
10303
  }
9872
- // Verify the author signature over the encrypted payload.
9873
- const cryptoKey = await this.getOrImportPublicKey(entry.createdByPublicKey);
10304
+ // Verify the author signature over the encrypted payload. Trust the
10305
+ // signing key at the entry's trusted time (not HEAD) so a later revoke
10306
+ // does not rewrite history.
10307
+ const trustedTimeForTrust = (0, storeEntryTime_1.isProvisional)(meta)
10308
+ ? Number.MAX_SAFE_INTEGER
10309
+ : (meta.receivedAt ?? meta.createdAt);
10310
+ const cryptoKey = await this.getOrImportPublicKey(entry.createdByPublicKey, trustedTimeForTrust);
9874
10311
  if (!cryptoKey) {
9875
10312
  // Author key not (yet) trusted by the directory. Remember it so a
9876
10313
  // later grantaccess sync can re-materialize the doc (reconcileAuthorTrust).
@@ -9931,20 +10368,29 @@ class BaseMindooDB {
9931
10368
  ? this.convertAutomergeToJS(currentDoc)
9932
10369
  : null;
9933
10370
  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
10371
  const trustedTime = (0, storeEntryTime_1.isProvisional)(meta)
9946
10372
  ? Number.MAX_SAFE_INTEGER
9947
10373
  : (meta.receivedAt ?? meta.createdAt);
10374
+ let isAuthor = false;
10375
+ if (creatorKey !== null) {
10376
+ if (isSamePersonFn) {
10377
+ try {
10378
+ isAuthor = await isSamePersonFn({
10379
+ creatorSigningKey: creatorKey,
10380
+ signerSigningKey: entry.createdByPublicKey,
10381
+ creatorTrustedTime,
10382
+ signerTrustedTime: trustedTime,
10383
+ });
10384
+ }
10385
+ catch (error) {
10386
+ this.logger.debug(`[ACL] grant-level $author resolution failed during materialization: ${error}`);
10387
+ isAuthor = entry.createdByPublicKey === creatorKey;
10388
+ }
10389
+ }
10390
+ else {
10391
+ isAuthor = entry.createdByPublicKey === creatorKey;
10392
+ }
10393
+ }
9948
10394
  let decision;
9949
10395
  try {
9950
10396
  decision = await evaluateClientAccess({
@@ -10107,6 +10553,22 @@ class BaseMindooDB {
10107
10553
  // Log all entry types
10108
10554
  const entryTypes = allEntryMetadata.map(em => `${em.entryType}@${em.createdAt}`).join(', ');
10109
10555
  this.logger.debug(`Entry types for ${docId}: ${entryTypes}`);
10556
+ await this.tenant.ingestSealedFromEntries?.(allEntryMetadata.find((em) => em.entryType === "doc_create")?.decryptionKeyId
10557
+ ?? allEntryMetadata[0].decryptionKeyId, allEntryMetadata);
10558
+ const loadKeyId = allEntryMetadata.find((em) => em.entryType === "doc_create")?.decryptionKeyId
10559
+ ?? allEntryMetadata[0].decryptionKeyId;
10560
+ if ((0, sealedTypes_1.isSealedKeyId)(loadKeyId) && !(await this.tenant.hasDecryptionKey(loadKeyId))) {
10561
+ this.logger.debug(`Document ${docId} is sealed and not unwrapable; treating as missing`);
10562
+ const existingIndex = this.getDocIndexPosition(docId);
10563
+ const existing = existingIndex === undefined ? undefined : this.index[existingIndex];
10564
+ if (existing?.accessState === "visible") {
10565
+ await this.purgeMaterializedDocument(docId);
10566
+ this.updateIndex(docId, existing.lastModified, true, loadKeyId, "inaccessible");
10567
+ this.cacheMetaDirty = true;
10568
+ this.cacheManager?.markDirty();
10569
+ }
10570
+ return null;
10571
+ }
10110
10572
  // Access-control materialization (docs/accesscontrol.md §10): when the
10111
10573
  // tenant has access control active, materialize through the Tier 2
10112
10574
  // quarantine path (deterministic per-entry evaluation + cascade) instead of
@@ -10184,7 +10646,7 @@ class BaseMindooDB {
10184
10646
  const snapshotData = snapshotEntries[0];
10185
10647
  // Admin-only mode: only accept snapshots signed by admin
10186
10648
  let isValid = false;
10187
- if (this._isAdminOnlyDb && snapshotData.createdByPublicKey !== this.getAdminPublicKey()) {
10649
+ if (await this.shouldSkipLoadedEntryForBuiltin(snapshotData)) {
10188
10650
  this.logger.warn(`Admin-only DB: skipping snapshot ${snapshotData.id} not signed by admin key`);
10189
10651
  }
10190
10652
  else {
@@ -10253,13 +10715,14 @@ class BaseMindooDB {
10253
10715
  if (this.isDebugEnabled())
10254
10716
  this.logger.debug(`Document state before applying entries: heads=${JSON.stringify(automerge_adapter_1.Automerge.getHeads(doc))}`);
10255
10717
  // Filter entries for admin-only mode first
10256
- const validEntries = entries.filter(entryData => {
10257
- if (this._isAdminOnlyDb && entryData.createdByPublicKey !== this.getAdminPublicKey()) {
10718
+ const validEntries = [];
10719
+ for (const entryData of entries) {
10720
+ if (await this.shouldSkipLoadedEntryForBuiltin(entryData)) {
10258
10721
  this.logger.warn(`Admin-only DB: skipping entry ${entryData.id} not signed by admin key`);
10259
- return false;
10722
+ continue;
10260
10723
  }
10261
- return true;
10262
- });
10724
+ validEntries.push(entryData);
10725
+ }
10263
10726
  if (validEntries.length === 0) {
10264
10727
  this.logger.debug(`No valid entries to process for document ${docId}`);
10265
10728
  }
@@ -10385,6 +10848,7 @@ class BaseMindooDB {
10385
10848
  isDeleted,
10386
10849
  awaitingWitness: witnessState.awaitingWitness,
10387
10850
  witnessed: witnessState.witnessed,
10851
+ sealedRecipients: (0, recipients_1.newestRecipientBlock)(allEntryMetadata),
10388
10852
  };
10389
10853
  // Update cache
10390
10854
  await this.storeCachedDocument(internalDoc);
@@ -10486,6 +10950,11 @@ class BaseMindooDB {
10486
10950
  isAccessible: () => internalDoc.accessible ?? true,
10487
10951
  isAwaitingWitness: () => internalDoc.awaitingWitness ?? false,
10488
10952
  isWitnessed: () => internalDoc.witnessed ?? false,
10953
+ isSealed: () => (0, sealedTypes_1.isSealedKeyId)(internalDoc.decryptionKeyId),
10954
+ getRecipients: () => (0, recipients_1.resolveRecipientsFromPayload)(internalDoc.doc, internalDoc.sealedRecipients),
10955
+ getRecipientEpoch: () => internalDoc.sealedRecipients?.epoch ?? 0,
10956
+ isEncryptedFor: (users) => (0, sealedTypes_1.isSealedKeyId)(internalDoc.decryptionKeyId)
10957
+ && (0, recipients_1.isPayloadEncryptedFor)(internalDoc.doc, internalDoc.sealedRecipients, Array.isArray(users) ? users : [users]),
10489
10958
  getHeads: () => automerge_adapter_1.Automerge.getHeads(internalDoc.doc),
10490
10959
  getData: () => {
10491
10960
  // Convert Automerge document to plain JS object, converting Text
@@ -10618,7 +11087,7 @@ class BaseMindooDB {
10618
11087
  });
10619
11088
  }
10620
11089
  async decryptAttachmentChunk(chunk) {
10621
- if (this._isAdminOnlyDb && chunk.createdByPublicKey !== this.getAdminPublicKey()) {
11090
+ if (await this.shouldSkipLoadedEntryForBuiltin(chunk)) {
10622
11091
  throw new Error(`Admin-only DB: chunk ${chunk.id} not signed by admin key`);
10623
11092
  }
10624
11093
  const isValid = await this.tenant.verifyEntrySignature(chunk, chunk.encryptedData);
@@ -11090,10 +11559,13 @@ class BaseMindooDB {
11090
11559
  // entries can be decrypted with the same override identity.
11091
11560
  const encryptionKeyBuffer = await this.tenant.decryptPrivateKey(override.user.userEncryptionKeyPair.privateKey, override.password, "encryption");
11092
11561
  const encryptionKey = await subtle.importKey("pkcs8", encryptionKeyBuffer, { name: "RSA-OAEP", hash: "SHA-256" }, false, ["decrypt"]);
11093
- // Activate temporary override on the remote store.
11562
+ // Activate temporary override on the remote store. Include the public PEM
11563
+ // so the auth challenge names the same device key that will sign — otherwise
11564
+ // the server rejects bootstrap pushes as an unknown device key.
11094
11565
  overrideCapableStore.setSyncAuthOverride({
11095
11566
  username: override.user.username,
11096
11567
  signingKey,
11568
+ signingPublicKey: override.user.userSigningKeyPair.publicKey,
11097
11569
  privateEncryptionKey: encryptionKey,
11098
11570
  });
11099
11571
  // Return cleanup callback so caller can always restore normal auth in finally.
@@ -11132,6 +11604,18 @@ class BaseMindooDB {
11132
11604
  encryptAttachmentPayload: (payload, keyId) => this.tenant.encryptAttachmentPayload(payload, keyId),
11133
11605
  syncStoreChanges: () => this.syncStoreChanges(),
11134
11606
  evaluateWriteAccess: (op, signerKey, isAuthor) => this.evaluateClientWriteAccess({ op, signerKey, isAuthor }),
11607
+ isSamePerson: async (creatorSigningKey, signerSigningKey, creatorTrustedTime, signerTrustedTime) => {
11608
+ const directory = await this.tenant.openDirectory();
11609
+ if (typeof directory.isSamePerson === "function") {
11610
+ return directory.isSamePerson({
11611
+ creatorSigningKey,
11612
+ signerSigningKey,
11613
+ creatorTrustedTime,
11614
+ signerTrustedTime,
11615
+ });
11616
+ }
11617
+ return creatorSigningKey === signerSigningKey;
11618
+ },
11135
11619
  hasWriteContentRules: async (op) => {
11136
11620
  const directory = await this.tenant.openDirectory();
11137
11621
  return (await directory.hasWriteContentRules?.(op, this.store.getId())) ?? false;
@@ -11306,6 +11790,9 @@ class BaseMindooDB {
11306
11790
  }
11307
11791
  if (syncResult.transferred === 0) {
11308
11792
  this.logger.debug(`No new entries to pull`);
11793
+ if (storeKind === types_2.StoreKind.docs) {
11794
+ await this.reconcileAfterDocsPull();
11795
+ }
11309
11796
  return { transferredEntries: 0, transferredBytes, scannedEntries: syncResult.scanned, cancelled: false };
11310
11797
  }
11311
11798
  options?.onProgress?.({
@@ -11328,23 +11815,7 @@ class BaseMindooDB {
11328
11815
  }
11329
11816
  if (storeKind === types_2.StoreKind.docs) {
11330
11817
  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
- }
11818
+ await this.reconcileAfterDocsPull();
11348
11819
  }
11349
11820
  if (options?.signal?.aborted) {
11350
11821
  this.logger.info(`Pull cancelled after local processing for ${storeKind} entries`);
@@ -11374,6 +11845,26 @@ class BaseMindooDB {
11374
11845
  restoreAuthOverride();
11375
11846
  }
11376
11847
  }
11848
+ /**
11849
+ * After a directory / userdirectory docs pull — including a no-op pull
11850
+ * (`transferred === 0`). Device 2 typically already has
11851
+ * `acl_keydistribution_default` from bootstrap, then later imports the
11852
+ * User-Key from `userdirectory`. A subsequent directory pull transfers
11853
+ * nothing and used to skip reconcile, so `default` never landed in the bag.
11854
+ * Re-run both passes whenever either database is fetched.
11855
+ */
11856
+ async reconcileAfterDocsPull() {
11857
+ if (this.store.getId() === "directory") {
11858
+ await this.tenant.reconcileKeyDistributionsForCurrentUserSafe();
11859
+ await this.tenant.reconcileAuthorTrustAfterDirectorySyncSafe();
11860
+ await this.tenant.reconcileUserKeysSafe?.();
11861
+ }
11862
+ if (this.store.getId() === types_1.USER_DIRECTORY_DB_ID) {
11863
+ this.tenant.noteUserDirectoryFetched?.();
11864
+ await this.tenant.reconcileUserKeysSafe?.();
11865
+ await this.tenant.reconcileKeyDistributionsForCurrentUserSafe();
11866
+ }
11867
+ }
11377
11868
  /**
11378
11869
  * Push changes to a remote content-addressed store or another MindooDB instance.
11379
11870
  *