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