mindoodb 0.0.92 → 0.0.94
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.
- package/README-server.md +34 -0
- package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.d.ts.map +1 -1
- package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.js +12 -3
- package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.js.map +1 -1
- package/dist/browser/index.mjs +2 -0
- package/dist/cloudflare/host.d.ts.map +1 -1
- package/dist/cloudflare/host.js +3 -0
- package/dist/cloudflare/host.js.map +1 -1
- package/dist/cloudflare/types.d.ts +2 -0
- package/dist/cloudflare/types.d.ts.map +1 -1
- package/dist/core/BaseMindooDB.d.ts +38 -0
- package/dist/core/BaseMindooDB.d.ts.map +1 -1
- package/dist/core/BaseMindooDB.js +278 -22
- package/dist/core/BaseMindooDB.js.map +1 -1
- package/dist/core/BaseMindooTenant.d.ts +51 -2
- package/dist/core/BaseMindooTenant.d.ts.map +1 -1
- package/dist/core/BaseMindooTenant.js +131 -39
- package/dist/core/BaseMindooTenant.js.map +1 -1
- package/dist/core/accesscontrol/directoryProjection.d.ts.map +1 -1
- package/dist/core/accesscontrol/directoryProjection.js +9 -0
- package/dist/core/accesscontrol/directoryProjection.js.map +1 -1
- package/dist/core/accesscontrol/types.d.ts +24 -2
- package/dist/core/accesscontrol/types.d.ts.map +1 -1
- package/dist/core/accesscontrol/types.js +25 -0
- package/dist/core/accesscontrol/types.js.map +1 -1
- package/dist/core/appendonlystores/MaterializationPlanner.d.ts.map +1 -1
- package/dist/core/appendonlystores/MaterializationPlanner.js +19 -6
- package/dist/core/appendonlystores/MaterializationPlanner.js.map +1 -1
- package/dist/core/appendonlystores/network/AuthenticationService.d.ts +2 -1
- package/dist/core/appendonlystores/network/AuthenticationService.d.ts.map +1 -1
- package/dist/core/appendonlystores/network/AuthenticationService.js +2 -1
- package/dist/core/appendonlystores/network/AuthenticationService.js.map +1 -1
- package/dist/core/crypto/AuthChallenge.d.ts +27 -2
- package/dist/core/crypto/AuthChallenge.d.ts.map +1 -1
- package/dist/core/crypto/AuthChallenge.js +25 -4
- package/dist/core/crypto/AuthChallenge.js.map +1 -1
- package/dist/core/crypto/EntrySignature.d.ts +1 -1
- package/dist/core/crypto/EntrySignature.js +2 -2
- package/dist/core/crypto/EntrySignature.js.map +1 -1
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +7 -5
- package/dist/core/index.js.map +1 -1
- package/dist/core/types.d.ts +43 -2
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/core/userkeys/recipients.d.ts +12 -0
- package/dist/core/userkeys/recipients.d.ts.map +1 -1
- package/dist/core/userkeys/recipients.js +42 -9
- package/dist/core/userkeys/recipients.js.map +1 -1
- package/dist/core/userkeys/sealedCrypto.d.ts +17 -0
- package/dist/core/userkeys/sealedCrypto.d.ts.map +1 -1
- package/dist/core/userkeys/sealedCrypto.js +31 -8
- package/dist/core/userkeys/sealedCrypto.js.map +1 -1
- package/dist/core/userkeys/sealedTypes.d.ts +52 -0
- package/dist/core/userkeys/sealedTypes.d.ts.map +1 -1
- package/dist/core/userkeys/sealedTypes.js +37 -1
- package/dist/core/userkeys/sealedTypes.js.map +1 -1
- package/dist/node/server/MindooDBServer.d.ts.map +1 -1
- package/dist/node/server/MindooDBServer.js +4 -0
- package/dist/node/server/MindooDBServer.js.map +1 -1
- package/dist/server/DeviceDiscoveryService.d.ts.map +1 -1
- package/dist/server/DeviceDiscoveryService.js +3 -1
- package/dist/server/DeviceDiscoveryService.js.map +1 -1
- package/dist/server/SystemAdminAuth.d.ts.map +1 -1
- package/dist/server/SystemAdminAuth.js +3 -1
- package/dist/server/SystemAdminAuth.js.map +1 -1
- package/dist/server/legacyChallengeSignatures.d.ts +11 -0
- package/dist/server/legacyChallengeSignatures.d.ts.map +1 -0
- package/dist/server/legacyChallengeSignatures.js +50 -0
- package/dist/server/legacyChallengeSignatures.js.map +1 -0
- package/dist/server/peer/PeerAuthService.d.ts.map +1 -1
- package/dist/server/peer/PeerAuthService.js +3 -1
- package/dist/server/peer/PeerAuthService.js.map +1 -1
- package/dist/server/types.d.ts +8 -0
- package/dist/server/types.d.ts.map +1 -1
- package/dist/server/types.js +8 -0
- package/dist/server/types.js.map +1 -1
- 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.
|
|
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
|
-
|
|
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.
|
|
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 (
|
|
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
|
|
11649
|
-
|
|
11650
|
-
|
|
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
|
-
|
|
11654
|
-
|
|
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,
|
|
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 = {
|