mindoodb 0.0.92 → 0.0.93

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 (67) hide show
  1. package/README-server.md +34 -0
  2. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.d.ts.map +1 -1
  3. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.js +12 -3
  4. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.js.map +1 -1
  5. package/dist/cloudflare/host.d.ts.map +1 -1
  6. package/dist/cloudflare/host.js +3 -0
  7. package/dist/cloudflare/host.js.map +1 -1
  8. package/dist/cloudflare/types.d.ts +2 -0
  9. package/dist/cloudflare/types.d.ts.map +1 -1
  10. package/dist/core/BaseMindooDB.d.ts +38 -0
  11. package/dist/core/BaseMindooDB.d.ts.map +1 -1
  12. package/dist/core/BaseMindooDB.js +278 -22
  13. package/dist/core/BaseMindooDB.js.map +1 -1
  14. package/dist/core/BaseMindooTenant.d.ts +51 -2
  15. package/dist/core/BaseMindooTenant.d.ts.map +1 -1
  16. package/dist/core/BaseMindooTenant.js +131 -39
  17. package/dist/core/BaseMindooTenant.js.map +1 -1
  18. package/dist/core/appendonlystores/MaterializationPlanner.d.ts.map +1 -1
  19. package/dist/core/appendonlystores/MaterializationPlanner.js +19 -6
  20. package/dist/core/appendonlystores/MaterializationPlanner.js.map +1 -1
  21. package/dist/core/appendonlystores/network/AuthenticationService.d.ts +2 -1
  22. package/dist/core/appendonlystores/network/AuthenticationService.d.ts.map +1 -1
  23. package/dist/core/appendonlystores/network/AuthenticationService.js +2 -1
  24. package/dist/core/appendonlystores/network/AuthenticationService.js.map +1 -1
  25. package/dist/core/crypto/AuthChallenge.d.ts +27 -2
  26. package/dist/core/crypto/AuthChallenge.d.ts.map +1 -1
  27. package/dist/core/crypto/AuthChallenge.js +25 -4
  28. package/dist/core/crypto/AuthChallenge.js.map +1 -1
  29. package/dist/core/crypto/EntrySignature.d.ts +1 -1
  30. package/dist/core/crypto/EntrySignature.js +2 -2
  31. package/dist/core/crypto/EntrySignature.js.map +1 -1
  32. package/dist/core/types.d.ts +43 -2
  33. package/dist/core/types.d.ts.map +1 -1
  34. package/dist/core/types.js.map +1 -1
  35. package/dist/core/userkeys/recipients.d.ts +12 -0
  36. package/dist/core/userkeys/recipients.d.ts.map +1 -1
  37. package/dist/core/userkeys/recipients.js +42 -9
  38. package/dist/core/userkeys/recipients.js.map +1 -1
  39. package/dist/core/userkeys/sealedCrypto.d.ts +17 -0
  40. package/dist/core/userkeys/sealedCrypto.d.ts.map +1 -1
  41. package/dist/core/userkeys/sealedCrypto.js +31 -8
  42. package/dist/core/userkeys/sealedCrypto.js.map +1 -1
  43. package/dist/core/userkeys/sealedTypes.d.ts +52 -0
  44. package/dist/core/userkeys/sealedTypes.d.ts.map +1 -1
  45. package/dist/core/userkeys/sealedTypes.js +37 -1
  46. package/dist/core/userkeys/sealedTypes.js.map +1 -1
  47. package/dist/node/server/MindooDBServer.d.ts.map +1 -1
  48. package/dist/node/server/MindooDBServer.js +4 -0
  49. package/dist/node/server/MindooDBServer.js.map +1 -1
  50. package/dist/server/DeviceDiscoveryService.d.ts.map +1 -1
  51. package/dist/server/DeviceDiscoveryService.js +3 -1
  52. package/dist/server/DeviceDiscoveryService.js.map +1 -1
  53. package/dist/server/SystemAdminAuth.d.ts.map +1 -1
  54. package/dist/server/SystemAdminAuth.js +3 -1
  55. package/dist/server/SystemAdminAuth.js.map +1 -1
  56. package/dist/server/legacyChallengeSignatures.d.ts +11 -0
  57. package/dist/server/legacyChallengeSignatures.d.ts.map +1 -0
  58. package/dist/server/legacyChallengeSignatures.js +50 -0
  59. package/dist/server/legacyChallengeSignatures.js.map +1 -0
  60. package/dist/server/peer/PeerAuthService.d.ts.map +1 -1
  61. package/dist/server/peer/PeerAuthService.js +3 -1
  62. package/dist/server/peer/PeerAuthService.js.map +1 -1
  63. package/dist/server/types.d.ts +8 -0
  64. package/dist/server/types.d.ts.map +1 -1
  65. package/dist/server/types.js +8 -0
  66. package/dist/server/types.js.map +1 -1
  67. package/package.json +2 -1
@@ -628,6 +628,16 @@ class BaseMindooDB {
628
628
  // ---------------------------------------------------------------------------
629
629
  /** Serializes concurrent author-trust reconciles (per database). */
630
630
  this.authorTrustReconcileChain = Promise.resolve();
631
+ /**
632
+ * Key series this replica gained or lost during the current pass. Documents of
633
+ * a group document are not re-checked one by one when its wraps change; the
634
+ * next {@link reconcileGroupKeyAccess} re-runs the key visibility reconcile.
635
+ */
636
+ this.groupKeyAccessChanged = new Set();
637
+ /** Per document: whether its origin decrypted with the partial series held then. */
638
+ this.partialSeriesProbes = new Map();
639
+ /** Group key series last probed without success, by key id (ms), see ingestGroupKey. */
640
+ this.groupKeyMisses = new Map();
631
641
  (0, databaseIdValidation_1.validateDatabaseId)(store.getId(), "dbId");
632
642
  (0, databaseIdValidation_1.validateDatabaseId)(attachmentStore.getId(), "dbId");
633
643
  if (store.getId() !== attachmentStore.getId()) {
@@ -3161,7 +3171,7 @@ class BaseMindooDB {
3161
3171
  const existingIndex = this.getDocIndexPosition(docId);
3162
3172
  const existing = existingIndex === undefined ? undefined : this.index[existingIndex];
3163
3173
  await this.ingestSealed(visibility.decryptionKeyId, metadataByDoc.get(docId));
3164
- const canRead = await this.tenant.hasDecryptionKey(visibility.decryptionKeyId);
3174
+ const canRead = await this.canReadWithKey(visibility.decryptionKeyId, docId, metadataByDoc.get(docId));
3165
3175
  if (canRead) {
3166
3176
  // This scan is the authority on visibility, so it also heals a tally
3167
3177
  // that a visible->visible short-circuit below would leave untouched.
@@ -3465,6 +3475,8 @@ class BaseMindooDB {
3465
3475
  this.beginChangeNotificationHold();
3466
3476
  try {
3467
3477
  await this.syncStoreChangesInternal();
3478
+ // Joined or left a group document in this batch: its documents follow.
3479
+ await this.reconcileGroupKeyAccess();
3468
3480
  }
3469
3481
  finally {
3470
3482
  this.endChangeNotificationHold();
@@ -3523,6 +3535,26 @@ class BaseMindooDB {
3523
3535
  this.logger.debug(`Dropping cached sealed document ${docId} — newest wrap is not for this user`);
3524
3536
  return;
3525
3537
  }
3538
+ // Full rebuild: drop the cached state and materialize the document
3539
+ // again, through the access-control path when that is enforced.
3540
+ const reloadFully = async () => {
3541
+ this.docCache.delete(docId);
3542
+ updatedDoc = await this.loadDocumentInternal(docId);
3543
+ if (updatedDoc) {
3544
+ // New entries triggered this reload, so force the re-emit.
3545
+ this.updateIndex(docId, updatedDoc.lastModified, updatedDoc.isDeleted, updatedDoc.decryptionKeyId, "visible", updatedDoc.awaitingWitness ?? false, updatedDoc.witnessed ?? false, true);
3546
+ }
3547
+ };
3548
+ if (await this.isAclEnforced()) {
3549
+ // The incremental path below applies new entries without the
3550
+ // access-control gate (docs/accesscontrol.md §10). With access
3551
+ // control active, a change by someone the rules do not allow must be
3552
+ // quarantined like on a fresh load, so rebuild the document through
3553
+ // maybeMaterializeWithAccessControl instead.
3554
+ this.logger.debug(`Document ${docId} found in cache, access control enforced: full reload`);
3555
+ await reloadFully();
3556
+ return;
3557
+ }
3526
3558
  // Document is cached - try incremental update
3527
3559
  this.logger.debug(`Document ${docId} found in cache, attempting incremental update`);
3528
3560
  try {
@@ -3543,12 +3575,7 @@ class BaseMindooDB {
3543
3575
  catch (error) {
3544
3576
  // If incremental update fails, fall back to full reload
3545
3577
  this.logger.warn(`Incremental update failed for document ${docId}, falling back to full reload:`, error);
3546
- this.docCache.delete(docId);
3547
- updatedDoc = await this.loadDocumentInternal(docId);
3548
- if (updatedDoc) {
3549
- // New entries triggered this reload, so force the re-emit.
3550
- this.updateIndex(docId, updatedDoc.lastModified, updatedDoc.isDeleted, updatedDoc.decryptionKeyId, "visible", updatedDoc.awaitingWitness ?? false, updatedDoc.witnessed ?? false, true);
3551
- }
3578
+ await reloadFully();
3552
3579
  }
3553
3580
  }
3554
3581
  else {
@@ -3586,7 +3613,7 @@ class BaseMindooDB {
3586
3613
  sealedIngestEntries = fullDocMetadata;
3587
3614
  }
3588
3615
  await this.ingestSealed(representativeEntry.decryptionKeyId, sealedIngestEntries);
3589
- const keyAvailable = await this.tenant.hasDecryptionKey(representativeEntry.decryptionKeyId);
3616
+ const keyAvailable = await this.canReadWithKey(representativeEntry.decryptionKeyId, docId, coldSync ? entryMetadataList : fullDocMetadata);
3590
3617
  if (!keyAvailable) {
3591
3618
  this.logger.debug(`Skipping metadata-first index for doc ${docId} — decryption key "${representativeEntry.decryptionKeyId}" not available`);
3592
3619
  // This is where a device that is still waiting for its keys meets
@@ -4515,6 +4542,10 @@ class BaseMindooDB {
4515
4542
  throw new Error("createDocument: recipients is not supported together with a convergent custom id; " +
4516
4543
  "pass assumeUniqueId for random ids.");
4517
4544
  }
4545
+ if (useDeterministicSeed && options.groupKeyDocId !== undefined) {
4546
+ throw new Error("createDocument: groupKeyDocId is not supported together with a convergent custom id; " +
4547
+ "pass assumeUniqueId for random ids.");
4548
+ }
4518
4549
  if (useDeterministicSeed && (options.attachments?.length ?? 0) > 0) {
4519
4550
  throw new Error("createDocument: attachments are not supported together with a convergent custom id; " +
4520
4551
  "pass assumeUniqueId, or add them with changeDoc().");
@@ -4764,6 +4795,9 @@ class BaseMindooDB {
4764
4795
  if (useDeterministicSeed && options.recipients !== undefined) {
4765
4796
  throw new Error("createDocuments: recipients is not supported together with a convergent custom id; pass assumeUniqueId");
4766
4797
  }
4798
+ if (useDeterministicSeed && options.groupKeyDocId !== undefined) {
4799
+ throw new Error("createDocuments: groupKeyDocId is not supported together with a convergent custom id; pass assumeUniqueId");
4800
+ }
4767
4801
  // Sanitize caller-provided initial values: internal/reserved fields are
4768
4802
  // managed by MindooDB and must not be seeded by callers. `undefined`
4769
4803
  // values are treated as "not set" (Automerge cannot represent undefined).
@@ -5102,13 +5136,112 @@ class BaseMindooDB {
5102
5136
  async ingestSealed(keyId, entries) {
5103
5137
  if (!this.tenant.ingestSealedFromEntries)
5104
5138
  return;
5139
+ const hadKey = (0, sealedTypes_1.isSealedKeyId)(keyId) ? await this.tenant.hasDecryptionKey(keyId) : false;
5140
+ const heldBefore = this.tenant.getSealedGenerations?.(keyId).length ?? 0;
5105
5141
  this.sealedIngestDepth++;
5106
5142
  try {
5107
5143
  await this.tenant.ingestSealedFromEntries(keyId, entries);
5144
+ // A document encrypted with a group document's key series (`groupKeyDocId`)
5145
+ // carries no recipient block of its own: the wraps are on the group document.
5146
+ if ((0, sealedTypes_1.isSealedKeyId)(keyId) && !(0, recipients_1.newestRecipientBlock)(entries)) {
5147
+ await this.ingestGroupKey(keyId);
5148
+ }
5149
+ // A series that does not reach back to epoch 1 may be completed from the
5150
+ // key document's older recipient blocks; `entries` is often just a batch.
5151
+ if ((0, sealedTypes_1.isSealedKeyId)(keyId) && this.tenant.needsDeepSealedIngest?.(keyId)) {
5152
+ const keyDocId = (0, sealedTypes_1.sealedKeyDocId)(keyId);
5153
+ if (keyDocId) {
5154
+ await this.tenant.ingestSealedFromEntries(keyId, await this.scanAllMetadata(this.store, { docId: keyDocId }), { complete: true });
5155
+ }
5156
+ }
5108
5157
  }
5109
5158
  finally {
5110
5159
  this.sealedIngestDepth--;
5111
5160
  }
5161
+ if ((0, sealedTypes_1.isSealedKeyId)(keyId) &&
5162
+ (hadKey !== (await this.tenant.hasDecryptionKey(keyId)) ||
5163
+ (hadKey &&
5164
+ heldBefore !== (this.tenant.getSealedGenerations?.(keyId).length ?? 0)))) {
5165
+ this.partialSeriesProbes.clear();
5166
+ // Joined or left the key series: documents of a group document that were
5167
+ // hidden (or readable) until now have to follow (see reconcileGroupKeyAccess).
5168
+ this.groupKeyAccessChanged.add(keyId);
5169
+ }
5170
+ }
5171
+ /**
5172
+ * Whether this replica can read a document of key `keyId`. Usually the key id
5173
+ * alone decides; a reader added to a group without its history
5174
+ * (`history: "none"`) knows the key id but holds only the newer generations,
5175
+ * so the document's origin entry is test-decrypted (once per document).
5176
+ */
5177
+ async canReadWithKey(keyId, docId, metas) {
5178
+ if (!(await this.tenant.hasDecryptionKey(keyId)))
5179
+ return false;
5180
+ if (!(0, sealedTypes_1.isSealedKeyId)(keyId) || !this.tenant.isPartialSealedSeries?.(keyId))
5181
+ return true;
5182
+ const known = this.partialSeriesProbes.get(docId);
5183
+ if (known !== undefined)
5184
+ return known;
5185
+ const all = metas ?? (await this.scanAllMetadata(this.store, { docId }));
5186
+ // Loading starts from the newest snapshot when there is one (written under
5187
+ // the new key when readers were added without history), else from the create.
5188
+ const origin = all
5189
+ .filter((meta) => meta.entryType === "doc_snapshot")
5190
+ .sort((a, b) => b.createdAt - a.createdAt)[0] ??
5191
+ all.find((meta) => meta.entryType === "doc_create");
5192
+ if (!origin)
5193
+ return true;
5194
+ let ok = false;
5195
+ try {
5196
+ const [entry] = await this.store.getEntries([origin.id]);
5197
+ if (entry) {
5198
+ await this.tenant.decryptPayload(entry.encryptedData, entry.decryptionKeyId);
5199
+ ok = true;
5200
+ }
5201
+ }
5202
+ catch {
5203
+ ok = false;
5204
+ }
5205
+ this.partialSeriesProbes.set(docId, ok);
5206
+ return ok;
5207
+ }
5208
+ /**
5209
+ * Makes the key series `$sealed:<groupDocId>` available from the group
5210
+ * document's own entries when the session does not have it yet. A miss is
5211
+ * remembered briefly, so a few hundred documents of a group this replica is not
5212
+ * a member of cost one scan of the group document, not one each.
5213
+ */
5214
+ async ingestGroupKey(keyId) {
5215
+ const groupDocId = (0, sealedTypes_1.sealedKeyDocId)(keyId);
5216
+ if (!groupDocId || (await this.tenant.hasDecryptionKey(keyId)))
5217
+ return;
5218
+ const lastMiss = this.groupKeyMisses.get(keyId);
5219
+ if (lastMiss !== undefined && Date.now() - lastMiss < BaseMindooDB.GROUP_KEY_MISS_TTL_MS)
5220
+ return;
5221
+ const groupEntries = await this.scanAllMetadata(this.store, { docId: groupDocId });
5222
+ if (groupEntries.length > 0) {
5223
+ await this.tenant.ingestSealedFromEntries(keyId, groupEntries);
5224
+ }
5225
+ if (await this.tenant.hasDecryptionKey(keyId)) {
5226
+ this.groupKeyMisses.delete(keyId);
5227
+ }
5228
+ else {
5229
+ this.groupKeyMisses.set(keyId, Date.now());
5230
+ }
5231
+ }
5232
+ /**
5233
+ * After a pass that changed access to a key series (added to or removed from a
5234
+ * group document), reveal or hide the documents that use it. Runs the full key
5235
+ * visibility reconcile, which is rare enough: it only happens when this
5236
+ * replica's membership in a group changed.
5237
+ */
5238
+ async reconcileGroupKeyAccess() {
5239
+ if (this.groupKeyAccessChanged.size === 0 || this.isTimeTravelMode())
5240
+ return;
5241
+ this.groupKeyAccessChanged.clear();
5242
+ this.groupKeyMisses.clear();
5243
+ await this.reconcileKeyVisibility();
5244
+ this.groupKeyAccessChanged.clear();
5112
5245
  }
5113
5246
  /**
5114
5247
  * Unwrap a `$sealed:` DEK from entry recipient wraps into the tenant session
@@ -8527,7 +8660,9 @@ class BaseMindooDB {
8527
8660
  isAccessible: () => true,
8528
8661
  isAwaitingWitness: () => internalDoc.awaitingWitness ?? false,
8529
8662
  isWitnessed: () => internalDoc.witnessed ?? false,
8530
- isSealed: () => (0, sealedTypes_1.isSealedKeyId)(internalDoc.decryptionKeyId),
8663
+ isSealed: () => (0, sealedTypes_1.isSealedKeyId)(internalDoc.decryptionKeyId) &&
8664
+ (0, sealedTypes_1.groupKeyDocIdOf)(internalDoc.decryptionKeyId, internalDoc.id) === undefined,
8665
+ getGroupKeyDocId: () => (0, sealedTypes_1.groupKeyDocIdOf)(internalDoc.decryptionKeyId, internalDoc.id),
8531
8666
  getRecipients: () => (0, recipients_1.resolveRecipientsFromPayload)(internalDoc.doc, internalDoc.sealedRecipients),
8532
8667
  getRecipientEpoch: () => internalDoc.sealedRecipients?.epoch ?? 0,
8533
8668
  isEncryptedFor: (users) => (0, sealedTypes_1.isSealedKeyId)(internalDoc.decryptionKeyId) &&
@@ -9688,6 +9823,7 @@ class BaseMindooDB {
9688
9823
  attachmentIds,
9689
9824
  bypassPrecheck,
9690
9825
  recipients: options.recipients,
9826
+ forceSnapshot: options.forceSnapshot,
9691
9827
  });
9692
9828
  }
9693
9829
  catch (error) {
@@ -9790,8 +9926,9 @@ class BaseMindooDB {
9790
9926
  signingKeyPair,
9791
9927
  signingKeyPassword,
9792
9928
  createdByPublicKey: createdByPublicKey ?? "",
9929
+ force: options.forceSnapshot,
9793
9930
  }
9794
- : { createdByPublicKey: createdByPublicKey ?? "" });
9931
+ : { createdByPublicKey: createdByPublicKey ?? "", force: options.forceSnapshot });
9795
9932
  }
9796
9933
  async buildDocChangeStoreEntry(options) {
9797
9934
  const { internalDoc, changeBytes, now, useCustomKey, signingKeyPair, signingKeyPassword, attachmentIds, attachmentRefs, recipients, } = options;
@@ -9858,7 +9995,7 @@ class BaseMindooDB {
9858
9995
  // An absent counter (first write this session) forces one scan to learn
9859
9996
  // the real backlog, after which the counter tracks it incrementally.
9860
9997
  const knownWrites = this.writesSinceSnapshotCheck.get(docId);
9861
- if (knownWrites !== undefined) {
9998
+ if (knownWrites !== undefined && !options.force) {
9862
9999
  const writes = knownWrites + 1;
9863
10000
  if (writes < this.snapshotMinChanges) {
9864
10001
  this.writesSinceSnapshotCheck.set(docId, writes);
@@ -9874,10 +10011,11 @@ class BaseMindooDB {
9874
10011
  const latestSnapshotAt = latestSnapshot?.createdAt ?? 0;
9875
10012
  const changesSinceSnapshot = replayEntries.filter((em) => em.createdAt > latestSnapshotAt).length;
9876
10013
  this.writesSinceSnapshotCheck.set(docId, changesSinceSnapshot);
9877
- if (changesSinceSnapshot < this.snapshotMinChanges) {
10014
+ if (changesSinceSnapshot < this.snapshotMinChanges && !options.force) {
9878
10015
  return;
9879
10016
  }
9880
- if (latestSnapshot &&
10017
+ if (!options.force &&
10018
+ latestSnapshot &&
9881
10019
  Date.now() - latestSnapshot.createdAt < this.snapshotCooldownMs) {
9882
10020
  return;
9883
10021
  }
@@ -11410,6 +11548,12 @@ class BaseMindooDB {
11410
11548
  options.decryptionKeyId !== undefined) {
11411
11549
  throw new Error("createDocument: recipients and decryptionKeyId are mutually exclusive");
11412
11550
  }
11551
+ if (options.groupKeyDocId !== undefined) {
11552
+ if (options.recipients !== undefined || options.decryptionKeyId !== undefined) {
11553
+ throw new Error("createDocument: groupKeyDocId is mutually exclusive with recipients and decryptionKeyId");
11554
+ }
11555
+ return await this.requireGroupKey(options.groupKeyDocId);
11556
+ }
11413
11557
  if (options.recipients !== undefined) {
11414
11558
  return (0, sealedTypes_1.sealedKeyId)(docId ?? options.id ?? "pending");
11415
11559
  }
@@ -11429,6 +11573,27 @@ class BaseMindooDB {
11429
11573
  }
11430
11574
  return "default";
11431
11575
  }
11576
+ /**
11577
+ * The key id of a group document's key series, after checking that the group
11578
+ * document is a sealed document of this database and that the caller is a member.
11579
+ */
11580
+ async requireGroupKey(groupKeyDocId) {
11581
+ if (!types_1.CUSTOM_DOC_ID_REGEX.test(groupKeyDocId) && !/^[a-z0-9_]+$/.test(groupKeyDocId)) {
11582
+ throw new Error(`createDocument: invalid groupKeyDocId "${groupKeyDocId}"`);
11583
+ }
11584
+ const keyId = (0, sealedTypes_1.sealedKeyId)(groupKeyDocId);
11585
+ const groupEntries = await this.scanAllMetadata(this.store, { docId: groupKeyDocId });
11586
+ const groupCreate = groupEntries.find((entry) => entry.entryType === "doc_create");
11587
+ if (!groupCreate || groupCreate.decryptionKeyId !== keyId) {
11588
+ throw new Error(`createDocument: group document "${groupKeyDocId}" does not exist in this database or was not created with recipients`);
11589
+ }
11590
+ this.groupKeyMisses.delete(keyId);
11591
+ await this.ingestSealed(keyId, groupEntries);
11592
+ if (!(await this.tenant.hasDecryptionKey(keyId))) {
11593
+ throw new Error(`createDocument: you are not a member of group document "${groupKeyDocId}"`);
11594
+ }
11595
+ return keyId;
11596
+ }
11432
11597
  async prepareSealedCreate(options, docId, keyId, now, signerPublicKey) {
11433
11598
  if (options.recipients === undefined)
11434
11599
  return undefined;
@@ -11573,6 +11738,9 @@ class BaseMindooDB {
11573
11738
  async setRecipients(doc, recipients, options) {
11574
11739
  return this.mutateRecipients(doc, "set", recipients, options);
11575
11740
  }
11741
+ async revealHistory(doc, recipients, options) {
11742
+ return this.mutateRecipients(doc, "reveal", recipients, options);
11743
+ }
11576
11744
  async canChangeRecipients(doc, next) {
11577
11745
  const after = { ...doc.getData() };
11578
11746
  after[sealedTypes_1.ENCRYPT_FOR_FIELD] = {
@@ -11586,6 +11754,10 @@ class BaseMindooDB {
11586
11754
  if (!(0, sealedTypes_1.isSealedKeyId)(doc.getDecryptionKeyId())) {
11587
11755
  throw new Error("Recipient mutators require a document created with `recipients`");
11588
11756
  }
11757
+ const groupDocId = (0, sealedTypes_1.groupKeyDocIdOf)(doc.getDecryptionKeyId(), doc.getId());
11758
+ if (groupDocId !== undefined) {
11759
+ throw new Error(`Document ${doc.getId()} uses the key series of group document ${groupDocId}; change the recipients there`);
11760
+ }
11589
11761
  const tenant = this.tenant;
11590
11762
  const docId = doc.getId();
11591
11763
  let internalDoc = this.getCachedDocument(docId) ?? (await this.loadDocumentInternal(docId));
@@ -11625,6 +11797,16 @@ class BaseMindooDB {
11625
11797
  addedTargets.push(target);
11626
11798
  }
11627
11799
  }
11800
+ const revealedIds = [];
11801
+ if (mode === "reveal") {
11802
+ for (const [id, entry] of Object.entries(currentMap)) {
11803
+ if (entry.removedAt || !entry.historyFrom)
11804
+ continue;
11805
+ if (resolved.targets.some((target) => matchesTarget(id, entry, target))) {
11806
+ revealedIds.push(id);
11807
+ }
11808
+ }
11809
+ }
11628
11810
  if (mode === "remove") {
11629
11811
  for (const [id, entry] of Object.entries(currentMap)) {
11630
11812
  if (entry.removedAt)
@@ -11645,19 +11827,69 @@ class BaseMindooDB {
11645
11827
  removedStableIds.push(base);
11646
11828
  }
11647
11829
  }
11648
- const rotate = removedStableIds.length > 0;
11649
- const currentGens = tenant.getSealedGenerations(internalDoc.decryptionKeyId);
11650
- if (currentGens.length === 0) {
11830
+ const withoutHistory = (mode === "add" || mode === "set") &&
11831
+ addedTargets.length > 0 &&
11832
+ (options?.history ?? sealedTypes_1.DEFAULT_RECIPIENT_HISTORY) === "none";
11833
+ const rotate = removedStableIds.length > 0 || withoutHistory;
11834
+ const heldSeries = tenant.getSealedSeries
11835
+ ? tenant.getSealedSeries(internalDoc.decryptionKeyId)
11836
+ : tenant
11837
+ .getSealedGenerations(internalDoc.decryptionKeyId)
11838
+ .map((key, index, all) => ({ epoch: all.length - index, key }));
11839
+ if (heldSeries.length === 0) {
11651
11840
  throw new Error("Cannot change recipients: the current document key is not in this session");
11652
11841
  }
11653
- let generations = currentGens;
11654
- let epoch = internalDoc.sealedRecipients?.epoch ?? currentGens.length;
11842
+ // The epoch comes from the store, not from the cached document.
11843
+ const newestBlock = (0, recipients_1.newestRecipientBlock)(await this.scanAllMetadata(this.store, { docId })) ??
11844
+ internalDoc.sealedRecipients;
11845
+ let epoch = Math.max(newestBlock?.epoch ?? 0, heldSeries[0].epoch);
11846
+ if (heldSeries[0].epoch < epoch) {
11847
+ throw new Error("Cannot change recipients: this replica does not hold the current document key yet; sync first");
11848
+ }
11849
+ // What this replica can seal: the contiguous run of generations that ends at
11850
+ // the current epoch. A member who joined without the earlier history holds a
11851
+ // shorter run; readers entitled to more keep taking the older generations
11852
+ // from the earlier recipient blocks (see ingestSealedFromEntries).
11853
+ let series = heldSeries;
11854
+ let run = [];
11855
+ for (const entry of heldSeries) {
11856
+ if (run.length > 0 && entry.epoch !== run[run.length - 1].epoch - 1)
11857
+ break;
11858
+ run.push(entry);
11859
+ }
11655
11860
  if (rotate) {
11656
11861
  const nextDek = tenant
11657
11862
  .getCryptoAdapter()
11658
11863
  .getRandomValues(new Uint8Array(32));
11659
- generations = [nextDek, ...currentGens];
11660
11864
  epoch += 1;
11865
+ run = [{ epoch, key: nextDek }, ...run];
11866
+ series = [{ epoch, key: nextDek }, ...heldSeries];
11867
+ }
11868
+ /** Oldest epoch this replica can hand to anyone. */
11869
+ const sealFrom = run[run.length - 1].epoch;
11870
+ const generations = run.map((entry) => entry.key);
11871
+ const addedHistoryFrom = withoutHistory
11872
+ ? epoch
11873
+ : sealFrom > 1
11874
+ ? sealFrom
11875
+ : undefined;
11876
+ // Revealing reaches as far back as the caller's own history.
11877
+ const revealTo = sealFrom > 1 ? sealFrom : undefined;
11878
+ for (const id of [...revealedIds]) {
11879
+ if ((currentMap[id]?.historyFrom ?? 1) <= sealFrom) {
11880
+ revealedIds.splice(revealedIds.indexOf(id), 1);
11881
+ }
11882
+ }
11883
+ const historyFromByStable = new Map();
11884
+ for (const [id, entry] of Object.entries(currentMap)) {
11885
+ if (entry.removedAt || !entry.historyFrom)
11886
+ continue;
11887
+ const from = revealedIds.includes(id) ? revealTo : entry.historyFrom;
11888
+ if (!from)
11889
+ continue;
11890
+ historyFromByStable.set(id.split("#")[0], from);
11891
+ if (entry.keyFingerprint)
11892
+ historyFromByStable.set(entry.keyFingerprint, from);
11661
11893
  }
11662
11894
  const remainingByStable = new Map();
11663
11895
  const existingSpecs = Object.entries(currentMap)
@@ -11676,8 +11908,14 @@ class BaseMindooDB {
11676
11908
  for (const target of existingResolved.targets)
11677
11909
  remainingByStable.set(target.stableId, target);
11678
11910
  }
11911
+ for (const [stableId, target] of remainingByStable) {
11912
+ const from = historyFromByStable.get(stableId) ??
11913
+ historyFromByStable.get(target.keyFingerprint);
11914
+ if (from)
11915
+ remainingByStable.set(stableId, { ...target, historyFrom: from });
11916
+ }
11679
11917
  for (const target of addedTargets)
11680
- remainingByStable.set(target.stableId, target);
11918
+ remainingByStable.set(target.stableId, addedHistoryFrom ? { ...target, historyFrom: addedHistoryFrom } : target);
11681
11919
  for (const id of removedStableIds) {
11682
11920
  remainingByStable.delete(id);
11683
11921
  for (const [stableId, target] of [...remainingByStable.entries()]) {
@@ -11698,7 +11936,7 @@ class BaseMindooDB {
11698
11936
  targets: remaining,
11699
11937
  epoch,
11700
11938
  });
11701
- tenant.rememberSealedGenerations(internalDoc.decryptionKeyId, generations);
11939
+ tenant.rememberSealedGenerations(internalDoc.decryptionKeyId, series.map((entry) => entry.key), recipientsBlock, series.map((entry) => entry.epoch));
11702
11940
  const headsBeforeChange = automerge_adapter_1.Automerge.getHeads(internalDoc.doc);
11703
11941
  const newDoc = automerge_adapter_1.Automerge.change(internalDoc.doc, { message: changeMessage }, (draft) => {
11704
11942
  if (addedTargets.length > 0) {
@@ -11710,10 +11948,22 @@ class BaseMindooDB {
11710
11948
  addedBy: signer,
11711
11949
  keyFingerprint: target.keyFingerprint,
11712
11950
  ...(target.label ? { label: target.label } : {}),
11951
+ ...(addedHistoryFrom ? { historyFrom: addedHistoryFrom } : {}),
11713
11952
  };
11714
11953
  }
11715
11954
  (0, recipients_1.applyEncryptForAdds)(draft, addMap, epoch);
11716
11955
  }
11956
+ if (revealedIds.length > 0) {
11957
+ const map = draft[sealedTypes_1.ENCRYPT_FOR_FIELD];
11958
+ for (const id of revealedIds) {
11959
+ if (!map[id])
11960
+ continue;
11961
+ if (revealTo)
11962
+ map[id].historyFrom = revealTo;
11963
+ else
11964
+ delete map[id].historyFrom;
11965
+ }
11966
+ }
11717
11967
  if (removedStableIds.length > 0) {
11718
11968
  (0, recipients_1.applyEncryptForRemoves)(draft, removedStableIds, now, signer);
11719
11969
  }
@@ -11734,6 +11984,9 @@ class BaseMindooDB {
11734
11984
  successMessage: "recipients updated",
11735
11985
  bypassPrecheck: options?.bypassAccessControlPrecheck,
11736
11986
  recipients: recipientsBlock,
11987
+ // Readers added without history cannot decrypt the older entries of this
11988
+ // document itself: a snapshot under the new key gives them its current state.
11989
+ forceSnapshot: withoutHistory,
11737
11990
  });
11738
11991
  if (options?.change) {
11739
11992
  await this.changeDoc(this.wrapDocument(internalDoc), options.change, {
@@ -12790,7 +13043,9 @@ class BaseMindooDB {
12790
13043
  isAccessible: () => internalDoc.accessible ?? true,
12791
13044
  isAwaitingWitness: () => internalDoc.awaitingWitness ?? false,
12792
13045
  isWitnessed: () => internalDoc.witnessed ?? false,
12793
- isSealed: () => (0, sealedTypes_1.isSealedKeyId)(internalDoc.decryptionKeyId),
13046
+ isSealed: () => (0, sealedTypes_1.isSealedKeyId)(internalDoc.decryptionKeyId) &&
13047
+ (0, sealedTypes_1.groupKeyDocIdOf)(internalDoc.decryptionKeyId, internalDoc.id) === undefined,
13048
+ getGroupKeyDocId: () => (0, sealedTypes_1.groupKeyDocIdOf)(internalDoc.decryptionKeyId, internalDoc.id),
12794
13049
  getRecipients: () => (0, recipients_1.resolveRecipientsFromPayload)(internalDoc.doc, internalDoc.sealedRecipients),
12795
13050
  getRecipientEpoch: () => internalDoc.sealedRecipients?.epoch ?? 0,
12796
13051
  isEncryptedFor: (users) => (0, sealedTypes_1.isSealedKeyId)(internalDoc.decryptionKeyId) &&
@@ -13852,6 +14107,7 @@ class BaseMindooDB {
13852
14107
  }
13853
14108
  }
13854
14109
  exports.BaseMindooDB = BaseMindooDB;
14110
+ BaseMindooDB.GROUP_KEY_MISS_TTL_MS = 2000;
13855
14111
  /** The "access control not enforced" allow decision returned by the
13856
14112
  * non-throwing `canCreate`/`canChange`/`canDelete` prediction helpers. */
13857
14113
  BaseMindooDB.ACL_NOT_ENFORCED_DECISION = {