@telecrypt-io/storage 0.7.2 → 0.9.0
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.md +37 -3
- package/dist/TeleCryptIOStorage.d.ts +43 -3
- package/dist/TeleCryptIOStorage.js +365 -49
- package/dist/core/operations.js +234 -147
- package/dist/core/types.d.ts +1 -0
- package/dist/key-safe.d.ts +62 -0
- package/dist/key-safe.js +349 -0
- package/package.json +2 -2
package/dist/core/operations.js
CHANGED
|
@@ -11,9 +11,10 @@
|
|
|
11
11
|
* platform-specific and stays with the caller.
|
|
12
12
|
*/
|
|
13
13
|
import { readFileEventMetadata, withMatrixMutationAbort, withTreeMutation, } from "../TeleCryptIOStorage.js";
|
|
14
|
-
import { EventType, MatrixError, RoomCreateTypeField, RoomType,
|
|
14
|
+
import { EventType, MatrixError, RoomCreateTypeField, RoomType, UNSTABLE_MSC3088_ENABLED, UNSTABLE_MSC3088_PURPOSE, UNSTABLE_MSC3089_TREE_SUBTYPE, } from "matrix-js-sdk";
|
|
15
15
|
import { ClientPrefix } from "matrix-js-sdk/lib/http-api/prefix.js";
|
|
16
16
|
import { Method } from "matrix-js-sdk/lib/http-api/method.js";
|
|
17
|
+
import { validateMatrixEventId } from "./constants.js";
|
|
17
18
|
import { FileTooLargeError, MutationPartialError, MutationOutcomeUnknownError, NonEmptyTreeError, RoomCreationAmbiguousError, RoomCleanupIncompleteError, StorageError, } from "./errors.js";
|
|
18
19
|
import { ConditionTimeoutError, waitForCondition } from "./poll.js";
|
|
19
20
|
import { validateName } from "./validation.js";
|
|
@@ -237,8 +238,6 @@ async function refreshDeletionRooms(storage, rootId, signal) {
|
|
|
237
238
|
throw new StorageError("could not enumerate storage parent safely");
|
|
238
239
|
const parentIds = new Set();
|
|
239
240
|
for (const event of parentEvents) {
|
|
240
|
-
if (!isActiveRelationEvent(event))
|
|
241
|
-
continue;
|
|
242
241
|
const parentId = relationStateKey(event);
|
|
243
242
|
if (!parentId)
|
|
244
243
|
throw new StorageError("could not enumerate storage parent safely");
|
|
@@ -250,11 +249,6 @@ async function refreshDeletionRooms(storage, rootId, signal) {
|
|
|
250
249
|
const parents = [...parentIds];
|
|
251
250
|
if (parents[0]) {
|
|
252
251
|
await storage.refreshRoomState(parents[0], { signal });
|
|
253
|
-
const childEvents = readRelationEvents(client, parents[0], EventType.SpaceChild, rootId);
|
|
254
|
-
if (childEvents === null ||
|
|
255
|
-
!childEvents.some((event) => isActiveRelationEvent(event) && relationStateKey(event, rootId) === rootId)) {
|
|
256
|
-
throw new StorageError("storage parent relation is inconsistent");
|
|
257
|
-
}
|
|
258
252
|
}
|
|
259
253
|
if (signal?.aborted)
|
|
260
254
|
throw new StorageError("operation cancelled");
|
|
@@ -309,7 +303,8 @@ function isActiveRelationEvent(event) {
|
|
|
309
303
|
!Array.isArray(content) &&
|
|
310
304
|
Object.keys(content).length > 0);
|
|
311
305
|
}
|
|
312
|
-
function activeTreeDirectories(
|
|
306
|
+
async function activeTreeDirectories(storage, tree, signal) {
|
|
307
|
+
const client = storage.getClient();
|
|
313
308
|
const childEvents = readRelationEvents(client, tree.id, EventType.SpaceChild);
|
|
314
309
|
if (childEvents === null)
|
|
315
310
|
throw new StorageError("storage folder state is unavailable");
|
|
@@ -322,35 +317,69 @@ function activeTreeDirectories(client, tree) {
|
|
|
322
317
|
throw new StorageError("storage folder state is inconsistent");
|
|
323
318
|
activeChildIds.add(childId);
|
|
324
319
|
}
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
320
|
+
const directories = await Promise.all([...activeChildIds].map(async (childId) => {
|
|
321
|
+
if (isMarkedTreeDeleted(storage, childId))
|
|
322
|
+
return null;
|
|
323
|
+
try {
|
|
324
|
+
// The parent room's relationship state can arrive before Matrix SDK's
|
|
325
|
+
// MSC3089 TreeSpace projection includes that child. Resolve the exact
|
|
326
|
+
// room named by the active relation instead of treating that brief
|
|
327
|
+
// projection lag as corrupt folder state.
|
|
328
|
+
return await waitForCondition(() => storage.getTree(childId), { timeoutMs: 15000, signal });
|
|
329
|
+
}
|
|
330
|
+
catch (error) {
|
|
331
|
+
if (signal?.aborted)
|
|
332
|
+
throw new StorageError("operation cancelled");
|
|
333
|
+
if (error instanceof ConditionTimeoutError) {
|
|
334
|
+
throw new StorageError("storage folder state is inconsistent");
|
|
335
|
+
}
|
|
336
|
+
throw new StorageError("storage folder state lookup failed", { cause: error });
|
|
337
|
+
}
|
|
338
|
+
}));
|
|
339
|
+
return directories.filter((directory) => directory !== null);
|
|
332
340
|
}
|
|
333
341
|
async function unlinkExternalParents(storage, rootId, externalParents, signal) {
|
|
334
342
|
const client = storage.getClient();
|
|
335
|
-
|
|
343
|
+
const completedRelationEventIds = [];
|
|
344
|
+
const redactRelation = async (roomId, eventType, stateKey) => {
|
|
345
|
+
await storage.refreshRoomState(roomId, { signal });
|
|
346
|
+
if (signal?.aborted)
|
|
347
|
+
throw new StorageError("operation cancelled");
|
|
348
|
+
const events = readRelationEvents(client, roomId, eventType, stateKey);
|
|
349
|
+
if (events === null)
|
|
350
|
+
throw new StorageError("storage folder state is unavailable");
|
|
351
|
+
const event = events[0];
|
|
352
|
+
if (!event || !isActiveRelationEvent(event))
|
|
353
|
+
return;
|
|
354
|
+
const currentUserId = client.getUserId();
|
|
355
|
+
if (!currentUserId || event.getSender?.() !== currentUserId) {
|
|
356
|
+
throw new StorageError("storage folder relation was not authored by the owner");
|
|
357
|
+
}
|
|
358
|
+
const eventId = validateMatrixEventId(event.getId?.(), "storage folder relation event ID");
|
|
359
|
+
await withRateLimitRetry(() => withMatrixMutationAbort(() => client.redactEvent(roomId, eventId), signal), signal);
|
|
360
|
+
if (!completedRelationEventIds.includes(eventId))
|
|
361
|
+
completedRelationEventIds.push(eventId);
|
|
362
|
+
await storage.refreshRoomState(roomId, { signal });
|
|
363
|
+
const remaining = readRelationEvents(client, roomId, eventType, stateKey);
|
|
364
|
+
if (remaining === null || remaining.some((candidate) => isActiveRelationEvent(candidate))) {
|
|
365
|
+
throw new StorageError("delete graph unlink could not be verified");
|
|
366
|
+
}
|
|
367
|
+
};
|
|
336
368
|
try {
|
|
337
369
|
for (const parentId of externalParents) {
|
|
338
370
|
if (signal?.aborted)
|
|
339
371
|
throw new StorageError("operation cancelled");
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
// the relation
|
|
343
|
-
|
|
344
|
-
await
|
|
345
|
-
if (signal?.aborted)
|
|
346
|
-
throw new StorageError("operation cancelled");
|
|
347
|
-
await withRateLimitRetry(() => withMatrixMutationAbort(() => client.sendStateEvent(rootId, EventType.SpaceParent, {}, parentId), signal), signal);
|
|
372
|
+
// Redact owner-authored relations one room at a time. If the process stops
|
|
373
|
+
// after the parent-side redaction, the redacted state key remains visible
|
|
374
|
+
// and the next call continues with the child-side relation.
|
|
375
|
+
await redactRelation(parentId, EventType.SpaceChild, rootId);
|
|
376
|
+
await redactRelation(rootId, EventType.SpaceParent, parentId);
|
|
348
377
|
await storage.refreshRoomState(parentId, { signal });
|
|
349
378
|
await storage.refreshRoomState(rootId, { signal });
|
|
350
379
|
const childLink = readRelationEvents(client, parentId, EventType.SpaceChild, rootId);
|
|
351
380
|
const parentLink = readRelationEvents(client, rootId, EventType.SpaceParent, parentId);
|
|
352
|
-
if (
|
|
353
|
-
|
|
381
|
+
if (childLink === null ||
|
|
382
|
+
parentLink === null ||
|
|
354
383
|
childLink.some((event) => isActiveRelationEvent(event)) ||
|
|
355
384
|
parentLink.some((event) => isActiveRelationEvent(event))) {
|
|
356
385
|
throw new StorageError("delete graph unlink could not be verified");
|
|
@@ -358,45 +387,14 @@ async function unlinkExternalParents(storage, rootId, externalParents, signal) {
|
|
|
358
387
|
}
|
|
359
388
|
}
|
|
360
389
|
catch (error) {
|
|
361
|
-
if (
|
|
390
|
+
if (error instanceof MutationOutcomeUnknownError || error instanceof MutationPartialError)
|
|
362
391
|
throw error;
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
// not leave a still-live vault detached from its external parent. Best
|
|
366
|
-
// effort is insufficient here: surface a distinct failure if the repair
|
|
367
|
-
// itself cannot be completed so callers never mistake a partial mutation
|
|
368
|
-
// for a successful delete.
|
|
369
|
-
try {
|
|
370
|
-
await relinkExternalParents(storage, rootId, externalParents, new AbortController().signal);
|
|
371
|
-
}
|
|
372
|
-
catch (cleanupError) {
|
|
373
|
-
throw new StorageError("delete graph unlink cleanup is incomplete", {
|
|
374
|
-
cause: new AggregateError([error, cleanupError], "delete graph unlink and relink both failed", { cause: error }),
|
|
375
|
-
});
|
|
392
|
+
if (completedRelationEventIds.length > 0) {
|
|
393
|
+
throw new MutationPartialError("delete folder links", completedRelationEventIds, "some parent links were removed; retry deleting the same folder to finish unlinking", { cause: error });
|
|
376
394
|
}
|
|
377
395
|
throw error;
|
|
378
396
|
}
|
|
379
397
|
}
|
|
380
|
-
async function relinkExternalParents(storage, rootId, externalParents, signal) {
|
|
381
|
-
const client = storage.getClient();
|
|
382
|
-
const via = client.getDomain();
|
|
383
|
-
if (!via)
|
|
384
|
-
throw new StorageError("delete graph relink is unavailable");
|
|
385
|
-
for (const parentId of externalParents) {
|
|
386
|
-
if (signal?.aborted)
|
|
387
|
-
throw new StorageError("operation cancelled");
|
|
388
|
-
await withRateLimitRetry(() => withMatrixMutationAbort(() => client.sendStateEvent(parentId, EventType.SpaceChild, { via: [via] }, rootId), signal), signal);
|
|
389
|
-
await withRateLimitRetry(() => withMatrixMutationAbort(() => client.sendStateEvent(rootId, EventType.SpaceParent, { via: [via] }, parentId), signal), signal);
|
|
390
|
-
await storage.refreshRoomState(parentId, { signal });
|
|
391
|
-
await storage.refreshRoomState(rootId, { signal });
|
|
392
|
-
const childLink = readRelationEvents(client, parentId, EventType.SpaceChild, rootId);
|
|
393
|
-
const parentLink = readRelationEvents(client, rootId, EventType.SpaceParent, parentId);
|
|
394
|
-
if (!childLink?.some((event) => isActiveRelationEvent(event)) ||
|
|
395
|
-
!parentLink?.some((event) => isActiveRelationEvent(event))) {
|
|
396
|
-
throw new StorageError("delete graph relink could not be verified");
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
398
|
function isGoneError(error) {
|
|
401
399
|
return (error instanceof MatrixError &&
|
|
402
400
|
(error.errcode === "M_NOT_FOUND" || error.errcode === "M_UNKNOWN"));
|
|
@@ -409,12 +407,25 @@ async function deleteRoomDeterministically(storage, roomId, signal) {
|
|
|
409
407
|
};
|
|
410
408
|
try {
|
|
411
409
|
const client = storage.getClient();
|
|
412
|
-
const tree = storage.getTree(roomId);
|
|
413
|
-
if (!tree)
|
|
414
|
-
throw new StorageError("storage room is unavailable for deletion");
|
|
415
410
|
const self = client.getUserId();
|
|
416
411
|
if (!self)
|
|
417
412
|
throw new StorageError("Matrix user identity is unavailable");
|
|
413
|
+
const ownMembership = await storage.getRoomMembership(roomId, undefined, { signal });
|
|
414
|
+
if (ownMembership === "leave" || ownMembership === "ban") {
|
|
415
|
+
try {
|
|
416
|
+
await withRateLimitRetry(() => withMatrixMutationAbort(() => client.forget(roomId), signal), signal);
|
|
417
|
+
}
|
|
418
|
+
catch (error) {
|
|
419
|
+
if (!isGoneError(error))
|
|
420
|
+
throw error;
|
|
421
|
+
}
|
|
422
|
+
removeRoomFromLocalStore(client, roomId);
|
|
423
|
+
markTreeDeleted(client, roomId);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
const tree = storage.getTree(roomId);
|
|
427
|
+
if (!tree)
|
|
428
|
+
throw new StorageError("storage room is unavailable for deletion");
|
|
418
429
|
const members = await storage.listMembers(tree, { signal });
|
|
419
430
|
if (members.some((member) => member.userId !== self && member.role === "owner" &&
|
|
420
431
|
(member.membership === "join" || member.membership === "invite" || member.membership === "knock"))) {
|
|
@@ -444,7 +455,6 @@ async function deleteRoomDeterministically(storage, roomId, signal) {
|
|
|
444
455
|
throw error;
|
|
445
456
|
}
|
|
446
457
|
}
|
|
447
|
-
const ownMembership = await storage.getRoomMembership(roomId, undefined, { signal });
|
|
448
458
|
if (ownMembership === "join" || ownMembership === "invite" || ownMembership === "knock") {
|
|
449
459
|
try {
|
|
450
460
|
await withRateLimitRetry(() => withMatrixMutationAbort(() => client.leave(roomId), signal), signal);
|
|
@@ -483,9 +493,6 @@ async function deleteRoomDeterministically(storage, roomId, signal) {
|
|
|
483
493
|
function removeRoomFromLocalStore(client, roomId) {
|
|
484
494
|
client.store?.removeRoom(roomId);
|
|
485
495
|
}
|
|
486
|
-
function isActiveMembership(membership) {
|
|
487
|
-
return membership === "join" || membership === "invite";
|
|
488
|
-
}
|
|
489
496
|
function isRevocableMembership(membership) {
|
|
490
497
|
return membership === "join" || membership === "invite" || membership === "knock";
|
|
491
498
|
}
|
|
@@ -501,6 +508,7 @@ function toArrayBuffer(bytes) {
|
|
|
501
508
|
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
502
509
|
}
|
|
503
510
|
export async function createVault(storage, name, options) {
|
|
511
|
+
await storage.keySafe.requireReady({ signal: options?.signal });
|
|
504
512
|
return withOperationDeadline(options, async (signal) => {
|
|
505
513
|
let tree;
|
|
506
514
|
try {
|
|
@@ -520,9 +528,14 @@ export async function createVault(storage, name, options) {
|
|
|
520
528
|
}
|
|
521
529
|
/** Top-level vaults only — excludes subdirectories of an existing tree. */
|
|
522
530
|
export async function listVaults(storage, options) {
|
|
531
|
+
await storage.keySafe.requireReady({ signal: options?.signal });
|
|
523
532
|
return withOperationDeadline(options, async (signal) => {
|
|
524
533
|
const trees = await storage.listTrees(signal);
|
|
525
|
-
|
|
534
|
+
const topLevel = trees.filter((tree) => tree.isTopLevel);
|
|
535
|
+
return Promise.all(topLevel.map(async (tree) => ({
|
|
536
|
+
id: tree.id,
|
|
537
|
+
name: await storage.getTreeName(tree.id, { signal }),
|
|
538
|
+
})));
|
|
526
539
|
});
|
|
527
540
|
}
|
|
528
541
|
/** Current account's effective role for a vault, or null while it is unavailable. */
|
|
@@ -534,6 +547,7 @@ export function getMyVaultRole(storage, vaultId) {
|
|
|
534
547
|
return tree.getPermissions(userId);
|
|
535
548
|
}
|
|
536
549
|
export async function joinVault(storage, vaultId, options) {
|
|
550
|
+
await storage.keySafe.requireReady({ signal: options?.signal });
|
|
537
551
|
return withOperationDeadline(options, async (signal) => {
|
|
538
552
|
let membership;
|
|
539
553
|
try {
|
|
@@ -555,8 +569,27 @@ export async function joinVault(storage, vaultId, options) {
|
|
|
555
569
|
}
|
|
556
570
|
if (membership === "join")
|
|
557
571
|
return { vaultId, joined: true };
|
|
572
|
+
const client = storage.getClient();
|
|
573
|
+
const userId = client.getUserId();
|
|
574
|
+
if (userId && client.getRoom(vaultId)?.getMember(userId)?.membership !== "invite") {
|
|
575
|
+
// MatrixClient.joinRoom reads the inviter from its local invite-room
|
|
576
|
+
// state. A to-device history bundle can arrive first in the initial
|
|
577
|
+
// sync, before that stripped invite state has reached the room store;
|
|
578
|
+
// joining without it skips Matrix JS SDK's pending-bundle acceptance.
|
|
579
|
+
try {
|
|
580
|
+
await waitForCondition(() => client.getRoom(vaultId)?.getMember(userId)?.membership === "invite" ? true : null, { timeoutMs: 15000, signal });
|
|
581
|
+
}
|
|
582
|
+
catch (err) {
|
|
583
|
+
if (signal.aborted)
|
|
584
|
+
throw new StorageError("operation cancelled");
|
|
585
|
+
if (err instanceof ConditionTimeoutError) {
|
|
586
|
+
throw new StorageError("the room invitation has not reached this login yet; retry joining", { cause: err });
|
|
587
|
+
}
|
|
588
|
+
throw err;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
558
591
|
try {
|
|
559
|
-
await withRateLimitRetry(() => withMatrixMutationAbort(() =>
|
|
592
|
+
await withRateLimitRetry(() => withMatrixMutationAbort(() => client.joinRoom(vaultId), signal), signal);
|
|
560
593
|
}
|
|
561
594
|
catch (err) {
|
|
562
595
|
if (err instanceof MutationOutcomeUnknownError)
|
|
@@ -574,17 +607,8 @@ export async function joinVault(storage, vaultId, options) {
|
|
|
574
607
|
return { vaultId, joined: true };
|
|
575
608
|
}, "mutation");
|
|
576
609
|
}
|
|
577
|
-
function roomDisplayName(
|
|
578
|
-
|
|
579
|
-
const room = client.getRoom(roomId);
|
|
580
|
-
if (fallbackName?.trim())
|
|
581
|
-
return fallbackName.trim();
|
|
582
|
-
if (room?.name)
|
|
583
|
-
return room.name;
|
|
584
|
-
const userId = client.getUserId();
|
|
585
|
-
if (room && userId)
|
|
586
|
-
return room.getDefaultRoomName(userId);
|
|
587
|
-
return roomId;
|
|
610
|
+
function roomDisplayName(_storage, _roomId, _fallbackName) {
|
|
611
|
+
return "Encrypted storage";
|
|
588
612
|
}
|
|
589
613
|
function hasActiveSpaceParent(room) {
|
|
590
614
|
const result = room.currentState?.getStateEvents?.(EventType.SpaceParent);
|
|
@@ -601,6 +625,7 @@ function isReviewedStorageTreeRoom(room) {
|
|
|
601
625
|
}
|
|
602
626
|
/** Rooms where this account is invited and the room looks like a file tree. */
|
|
603
627
|
export async function listPendingInvites(storage, options) {
|
|
628
|
+
await storage.keySafe.requireReady({ signal: options?.signal });
|
|
604
629
|
return withOperationDeadline(options, async (signal) => {
|
|
605
630
|
const client = storage.getClient();
|
|
606
631
|
const rooms = client.getRooms();
|
|
@@ -637,6 +662,7 @@ export async function listPendingInvites(storage, options) {
|
|
|
637
662
|
}
|
|
638
663
|
/** Decline a vault invite (same as leaving before join). */
|
|
639
664
|
export async function declineInvite(storage, vaultId, options) {
|
|
665
|
+
await storage.keySafe.requireReady({ signal: options?.signal });
|
|
640
666
|
return withOperationDeadline(options, async (signal) => {
|
|
641
667
|
const client = storage.getClient();
|
|
642
668
|
try {
|
|
@@ -730,6 +756,7 @@ export async function declineInvite(storage, vaultId, options) {
|
|
|
730
756
|
}
|
|
731
757
|
/** Invites `userId` to the vault as a reader. */
|
|
732
758
|
export async function shareVault(storage, vaultId, userId, role, options) {
|
|
759
|
+
await storage.keySafe.requireReady({ signal: options?.signal });
|
|
733
760
|
if (role !== "viewer") {
|
|
734
761
|
throw new StorageError("storage sharing supports viewers only");
|
|
735
762
|
}
|
|
@@ -752,6 +779,15 @@ export async function shareVault(storage, vaultId, userId, role, options) {
|
|
|
752
779
|
if (currentMembers.some((member) => member.userId === userId && member.role === "owner")) {
|
|
753
780
|
throw new StorageError("share will not demote an existing owner");
|
|
754
781
|
}
|
|
782
|
+
const client = storage.getClient();
|
|
783
|
+
const crypto = client.getCrypto();
|
|
784
|
+
if (!crypto)
|
|
785
|
+
throw new StorageError("sharing requires initialized encryption");
|
|
786
|
+
const recipient = await client.downloadKeysForUsers([userId]);
|
|
787
|
+
const devices = Object.keys(recipient.device_keys?.[userId] ?? {});
|
|
788
|
+
if (!recipient.master_keys?.[userId] || !recipient.self_signing_keys?.[userId] || devices.length === 0) {
|
|
789
|
+
throw new StorageError("The reader must set up or unlock their Decryption Key Safe before sharing");
|
|
790
|
+
}
|
|
755
791
|
for (const [index, space] of spaces.entries()) {
|
|
756
792
|
ensureOperationActive(operation.signal);
|
|
757
793
|
const members = index === 0
|
|
@@ -765,28 +801,21 @@ export async function shareVault(storage, vaultId, userId, role, options) {
|
|
|
765
801
|
: await storage.getRoomMembership(space.id, userId, {
|
|
766
802
|
signal: operation.signal,
|
|
767
803
|
});
|
|
768
|
-
if (
|
|
804
|
+
if (currentMembership === "join")
|
|
769
805
|
continue;
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
// membership; never infer it from provider-controlled error text.
|
|
780
|
-
const membership = await storage.getRoomMembership(space.id, userId, {
|
|
781
|
-
signal: operation.signal,
|
|
782
|
-
});
|
|
783
|
-
if (!isActiveMembership(membership ?? ""))
|
|
784
|
-
throw new StorageError("share failed");
|
|
806
|
+
// Native invite shares the encrypted history before inviting. Retry
|
|
807
|
+
// pending invitations too: an earlier attempt may have sent only an
|
|
808
|
+
// invitation. In particular, never swallow a media-upload 403 merely
|
|
809
|
+
// because an invitation already exists.
|
|
810
|
+
await withRateLimitRetry(() => withMatrixMutationAbort(() => space.invite(userId), operation.signal), operation.signal);
|
|
811
|
+
completedRoomIds.add(space.id);
|
|
812
|
+
const statuses = await Promise.all(devices.map((id) => crypto.getDeviceVerificationStatus(userId, id)));
|
|
813
|
+
if (!statuses.some((status) => status?.signedByOwner)) {
|
|
814
|
+
throw new StorageError("The invitation was sent, but history keys could not be shared; the reader must unlock their Decryption Key Safe, then retry sharing");
|
|
785
815
|
}
|
|
786
816
|
}
|
|
787
817
|
// Storage rooms have one immutable owner power level and readers use
|
|
788
|
-
// the room's default user level.
|
|
789
|
-
// operation; changing per-user power levels would create editors and
|
|
818
|
+
// the room's default user level. Changing per-user power levels would create editors and
|
|
790
819
|
// let metadata authorship drift away from the owner.
|
|
791
820
|
}
|
|
792
821
|
catch (error) {
|
|
@@ -799,6 +828,7 @@ export async function shareVault(storage, vaultId, userId, role, options) {
|
|
|
799
828
|
(error.message === "share failed" ||
|
|
800
829
|
error.message.includes("owner") ||
|
|
801
830
|
error.message.includes("current user") ||
|
|
831
|
+
error.message.includes("Decryption Key Safe") ||
|
|
802
832
|
error.message === "operation cancelled")) {
|
|
803
833
|
throw error;
|
|
804
834
|
}
|
|
@@ -813,6 +843,7 @@ export async function shareVault(storage, vaultId, userId, role, options) {
|
|
|
813
843
|
}
|
|
814
844
|
}
|
|
815
845
|
export async function unshareVault(storage, vaultId, userId, options) {
|
|
846
|
+
await storage.keySafe.requireReady({ signal: options?.signal });
|
|
816
847
|
const operation = createOperationDeadline(options);
|
|
817
848
|
const completedRoomIds = new Set();
|
|
818
849
|
try {
|
|
@@ -887,6 +918,7 @@ export async function unshareVault(storage, vaultId, userId, options) {
|
|
|
887
918
|
}
|
|
888
919
|
}
|
|
889
920
|
export async function listMembers(storage, vaultId, options) {
|
|
921
|
+
await storage.keySafe.requireReady({ signal: options?.signal });
|
|
890
922
|
return withOperationDeadline(options, async (signal) => {
|
|
891
923
|
const tree = await resolveTree(storage, vaultId, signal);
|
|
892
924
|
try {
|
|
@@ -900,6 +932,7 @@ export async function listMembers(storage, vaultId, options) {
|
|
|
900
932
|
});
|
|
901
933
|
}
|
|
902
934
|
export async function listFiles(storage, treeId, options) {
|
|
935
|
+
await storage.keySafe.requireReady({ signal: options?.signal });
|
|
903
936
|
return withOperationDeadline(options, async (signal) => {
|
|
904
937
|
const tree = await resolveTree(storage, treeId, signal);
|
|
905
938
|
try {
|
|
@@ -908,7 +941,10 @@ export async function listFiles(storage, treeId, options) {
|
|
|
908
941
|
const files = tree
|
|
909
942
|
.listFiles()
|
|
910
943
|
.filter((file) => !isMarkedFileDeleted(storage, tree.id, file.id));
|
|
911
|
-
return files.map((
|
|
944
|
+
return await Promise.all(files.map(async (file) => ({
|
|
945
|
+
id: file.id,
|
|
946
|
+
name: await storage.getFileName(tree.id, file.id, { signal, refreshState: false }),
|
|
947
|
+
})));
|
|
912
948
|
}
|
|
913
949
|
catch (error) {
|
|
914
950
|
if (signal.aborted)
|
|
@@ -919,16 +955,21 @@ export async function listFiles(storage, treeId, options) {
|
|
|
919
955
|
}
|
|
920
956
|
/** Lists the direct child folders of a vault or folder. */
|
|
921
957
|
export async function listSubfolders(storage, parentId, options) {
|
|
958
|
+
await storage.keySafe.requireReady({ signal: options?.signal });
|
|
922
959
|
return withOperationDeadline(options, async (signal) => {
|
|
923
960
|
const tree = await resolveTree(storage, parentId, signal);
|
|
924
961
|
await storage.refreshRoomState(parentId, { signal });
|
|
925
962
|
ensureOperationActive(signal);
|
|
926
|
-
const directories = activeTreeDirectories(storage
|
|
963
|
+
const directories = (await activeTreeDirectories(storage, tree, signal))
|
|
927
964
|
.filter((directory) => !isMarkedTreeDeleted(storage, directory.id));
|
|
928
|
-
return directories.map((
|
|
965
|
+
return Promise.all(directories.map(async (directory) => ({
|
|
966
|
+
id: directory.id,
|
|
967
|
+
name: await storage.getTreeName(directory.id, { signal }),
|
|
968
|
+
})));
|
|
929
969
|
});
|
|
930
970
|
}
|
|
931
971
|
export async function createSubfolder(storage, parentId, name, options) {
|
|
972
|
+
await storage.keySafe.requireReady({ signal: options?.signal });
|
|
932
973
|
validateName(name, "name");
|
|
933
974
|
return withOperationDeadline(options, async (signal) => {
|
|
934
975
|
const tree = await resolveTree(storage, parentId, signal);
|
|
@@ -957,11 +998,9 @@ async function renameTree(storage, treeId, name, options) {
|
|
|
957
998
|
await waitForCondition(async () => {
|
|
958
999
|
ensureOperationActive(signal);
|
|
959
1000
|
const current = storage.getTree(treeId);
|
|
960
|
-
if (current
|
|
961
|
-
return
|
|
962
|
-
await storage.
|
|
963
|
-
const refreshed = storage.getTree(treeId);
|
|
964
|
-
return refreshed?.room.name === name ? refreshed : null;
|
|
1001
|
+
if (!current)
|
|
1002
|
+
return null;
|
|
1003
|
+
return (await storage.getTreeName(treeId, { signal })) === name ? current : null;
|
|
965
1004
|
}, { timeoutMs: 15000, signal });
|
|
966
1005
|
}
|
|
967
1006
|
catch (error) {
|
|
@@ -976,10 +1015,12 @@ async function renameTree(storage, treeId, name, options) {
|
|
|
976
1015
|
}
|
|
977
1016
|
/** Renames a top-level Vault. */
|
|
978
1017
|
export async function renameVault(storage, vaultId, name, options) {
|
|
1018
|
+
await storage.keySafe.requireReady({ signal: options?.signal });
|
|
979
1019
|
return renameTree(storage, vaultId, name, options);
|
|
980
1020
|
}
|
|
981
1021
|
/** Renames a nested folder. */
|
|
982
1022
|
export async function renameFolder(storage, folderId, name, options) {
|
|
1023
|
+
await storage.keySafe.requireReady({ signal: options?.signal });
|
|
983
1024
|
return renameTree(storage, folderId, name, options);
|
|
984
1025
|
}
|
|
985
1026
|
async function deleteTree(storage, treeId, options) {
|
|
@@ -990,6 +1031,20 @@ async function deleteTree(storage, treeId, options) {
|
|
|
990
1031
|
const pending = withTreeMutation(client, async () => {
|
|
991
1032
|
if (isTreeDeleted(client, treeId))
|
|
992
1033
|
return { id: treeId, deleted: true };
|
|
1034
|
+
const ownMembership = await storage.getRoomMembership(treeId, undefined, {
|
|
1035
|
+
signal: operation.signal,
|
|
1036
|
+
});
|
|
1037
|
+
if (ownMembership === "leave" || ownMembership === "ban") {
|
|
1038
|
+
try {
|
|
1039
|
+
await deleteRoomDeterministically(storage, treeId, operation.signal);
|
|
1040
|
+
}
|
|
1041
|
+
catch (error) {
|
|
1042
|
+
if (error instanceof StorageError)
|
|
1043
|
+
throw error;
|
|
1044
|
+
throw new StorageError("delete cleanup failed", { cause: error });
|
|
1045
|
+
}
|
|
1046
|
+
return { id: treeId, deleted: true };
|
|
1047
|
+
}
|
|
993
1048
|
const tree = await resolveTree(storage, treeId, operation.signal);
|
|
994
1049
|
let externalParents;
|
|
995
1050
|
try {
|
|
@@ -1015,14 +1070,6 @@ async function deleteTree(storage, treeId, options) {
|
|
|
1015
1070
|
await deleteRoomDeterministically(storage, tree.id, operation.signal);
|
|
1016
1071
|
}
|
|
1017
1072
|
catch (error) {
|
|
1018
|
-
try {
|
|
1019
|
-
await relinkExternalParents(storage, tree.id, externalParents, new AbortController().signal);
|
|
1020
|
-
}
|
|
1021
|
-
catch (cleanupError) {
|
|
1022
|
-
throw new StorageError("delete cleanup is incomplete", {
|
|
1023
|
-
cause: new AggregateError([error, cleanupError], "delete and external-parent relink both failed", { cause: error }),
|
|
1024
|
-
});
|
|
1025
|
-
}
|
|
1026
1073
|
if (error instanceof StorageError)
|
|
1027
1074
|
throw error;
|
|
1028
1075
|
throw new StorageError("delete failed", { cause: error });
|
|
@@ -1037,13 +1084,16 @@ async function deleteTree(storage, treeId, options) {
|
|
|
1037
1084
|
}
|
|
1038
1085
|
/** Deletes a top-level Vault. */
|
|
1039
1086
|
export async function deleteVault(storage, vaultId, options) {
|
|
1087
|
+
await storage.keySafe.requireReady({ signal: options?.signal });
|
|
1040
1088
|
return deleteTree(storage, vaultId, options);
|
|
1041
1089
|
}
|
|
1042
1090
|
/** Deletes a nested folder. */
|
|
1043
1091
|
export async function deleteFolder(storage, folderId, options) {
|
|
1092
|
+
await storage.keySafe.requireReady({ signal: options?.signal });
|
|
1044
1093
|
return deleteTree(storage, folderId, options);
|
|
1045
1094
|
}
|
|
1046
1095
|
export async function renameFile(storage, treeId, fileId, name, options) {
|
|
1096
|
+
await storage.keySafe.requireReady({ signal: options?.signal });
|
|
1047
1097
|
validateName(name, "file name");
|
|
1048
1098
|
return withOperationDeadline(options, async (signal) => {
|
|
1049
1099
|
const tree = await resolveTree(storage, treeId, signal);
|
|
@@ -1054,9 +1104,11 @@ export async function renameFile(storage, treeId, fileId, name, options) {
|
|
|
1054
1104
|
// fresh CLI/UI process can still read the previous local room state for a
|
|
1055
1105
|
// short time. Do not report success until this client has observed the new
|
|
1056
1106
|
// name through its normal sync loop.
|
|
1057
|
-
await waitForCondition(() => {
|
|
1107
|
+
await waitForCondition(async () => {
|
|
1058
1108
|
const current = tree.getFile(fileId);
|
|
1059
|
-
|
|
1109
|
+
if (!current)
|
|
1110
|
+
return null;
|
|
1111
|
+
return (await storage.getFileName(treeId, fileId, { signal })) === name ? current : null;
|
|
1060
1112
|
}, { timeoutMs: 15000, signal });
|
|
1061
1113
|
}
|
|
1062
1114
|
catch (error) {
|
|
@@ -1091,53 +1143,83 @@ async function deleteFileMedia(storage, mediaIds, signal) {
|
|
|
1091
1143
|
}
|
|
1092
1144
|
}
|
|
1093
1145
|
export async function deleteFile(storage, treeId, fileId, options) {
|
|
1146
|
+
await storage.keySafe.requireReady({ signal: options?.signal });
|
|
1094
1147
|
return withOperationDeadline(options, async (signal) => {
|
|
1095
1148
|
const tree = await resolveTree(storage, treeId, signal);
|
|
1096
|
-
|
|
1097
|
-
|
|
1149
|
+
if (isMarkedFileDeleted(storage, tree.id, fileId))
|
|
1150
|
+
return { id: fileId, deleted: true };
|
|
1151
|
+
await storage.refreshRoomState(treeId, { signal });
|
|
1152
|
+
const branch = tree.getFile(fileId);
|
|
1153
|
+
if (!branch)
|
|
1154
|
+
throw new StorageError("file not found");
|
|
1155
|
+
let originalEvent;
|
|
1098
1156
|
try {
|
|
1099
|
-
|
|
1157
|
+
originalEvent = await storage.getOriginalFileEvent(tree.id, fileId, true);
|
|
1100
1158
|
}
|
|
1101
1159
|
catch (error) {
|
|
1102
1160
|
throw new StorageError("could not resolve encrypted file safely", { cause: error });
|
|
1103
1161
|
}
|
|
1104
|
-
|
|
1162
|
+
if (originalEvent.isRedacted()) {
|
|
1163
|
+
if (branch.isActive === false) {
|
|
1164
|
+
markFileDeleted(storage.getClient(), tree.id, fileId);
|
|
1165
|
+
return { id: fileId, deleted: true };
|
|
1166
|
+
}
|
|
1167
|
+
throw new StorageError("file listing is active but its attachment event is redacted");
|
|
1168
|
+
}
|
|
1169
|
+
const originalContent = originalEvent.getContent();
|
|
1170
|
+
const fileDescriptor = typeof originalContent === "object" &&
|
|
1171
|
+
originalContent !== null &&
|
|
1172
|
+
!Array.isArray(originalContent) &&
|
|
1173
|
+
"file" in originalContent &&
|
|
1174
|
+
typeof originalContent.file === "object" &&
|
|
1175
|
+
originalContent.file !== null &&
|
|
1176
|
+
!Array.isArray(originalContent.file)
|
|
1177
|
+
? originalContent.file
|
|
1178
|
+
: undefined;
|
|
1179
|
+
const mediaId = fileDescriptor?.url;
|
|
1105
1180
|
if (typeof mediaId !== "string" || mediaId.length === 0) {
|
|
1106
1181
|
throw new StorageError("encrypted file has no media identifier");
|
|
1107
1182
|
}
|
|
1108
1183
|
await deleteFileMedia(storage, [mediaId], signal);
|
|
1109
|
-
const completedIds = [];
|
|
1184
|
+
const completedIds = [mediaId];
|
|
1110
1185
|
try {
|
|
1111
1186
|
const client = storage.getClient();
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1187
|
+
const metadataEventId = await storage.getFileMetadataEventId(tree.id, fileId, { signal });
|
|
1188
|
+
if (metadataEventId && metadataEventId !== fileId) {
|
|
1189
|
+
const metadataEvent = await storage.getFileRenameMetadataEvent(tree.id, fileId, metadataEventId);
|
|
1190
|
+
if (!metadataEvent.isRedacted()) {
|
|
1191
|
+
await withRateLimitRetry(() => withMatrixMutationAbort(() => client.redactEvent(tree.id, metadataEventId), signal, "delete file rename metadata"), signal);
|
|
1192
|
+
}
|
|
1193
|
+
completedIds.push(metadataEventId);
|
|
1194
|
+
}
|
|
1195
|
+
const currentBranch = tree.getFile(fileId);
|
|
1196
|
+
const indexEvent = currentBranch?.indexEvent;
|
|
1197
|
+
const listingEventId = indexEvent?.getId();
|
|
1198
|
+
if (!indexEvent || typeof listingEventId !== "string" || listingEventId.length === 0) {
|
|
1118
1199
|
throw new StorageError("encrypted file has no listing event identifier");
|
|
1119
1200
|
}
|
|
1120
|
-
|
|
1121
|
-
|
|
1201
|
+
if (!indexEvent.isRedacted()) {
|
|
1202
|
+
if (indexEvent.getSender() !== client.getUserId()) {
|
|
1203
|
+
throw new StorageError("file listing was not authored by the owner");
|
|
1204
|
+
}
|
|
1205
|
+
await withRateLimitRetry(() => withMatrixMutationAbort(() => client.redactEvent(tree.id, listingEventId), signal, "delete file listing"), signal);
|
|
1206
|
+
}
|
|
1122
1207
|
completedIds.push(listingEventId);
|
|
1208
|
+
await withRateLimitRetry(() => withMatrixMutationAbort(() => client.redactEvent(tree.id, fileId), signal, "delete file attachment event"), signal);
|
|
1209
|
+
completedIds.push(fileId);
|
|
1123
1210
|
}
|
|
1124
1211
|
catch (error) {
|
|
1125
|
-
|
|
1126
|
-
throw error;
|
|
1127
|
-
if (signal.aborted) {
|
|
1128
|
-
throw new MutationPartialError("delete file", completedIds, "media was deleted but Matrix event cleanup was cancelled", { cause: error });
|
|
1129
|
-
}
|
|
1130
|
-
throw new MutationPartialError("delete file", completedIds, "media was deleted but Matrix event cleanup stopped", { cause: error });
|
|
1212
|
+
throw new MutationPartialError("delete file", completedIds, "file media and the listed completed steps were deleted; retry with the original file ID to finish Matrix cleanup", { cause: error });
|
|
1131
1213
|
}
|
|
1132
1214
|
try {
|
|
1133
1215
|
await waitForCondition(async () => {
|
|
1134
1216
|
await storage.refreshRoomState(tree.id, { signal });
|
|
1135
1217
|
ensureOperationActive(signal);
|
|
1136
1218
|
const current = tree.getFile(fileId);
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
return
|
|
1219
|
+
if (current && current.isActive !== false)
|
|
1220
|
+
return null;
|
|
1221
|
+
const attachment = await storage.getOriginalFileEvent(tree.id, fileId, true);
|
|
1222
|
+
return attachment.isRedacted() ? true : null;
|
|
1141
1223
|
}, { timeoutMs: 15000, signal });
|
|
1142
1224
|
}
|
|
1143
1225
|
catch (error) {
|
|
@@ -1146,12 +1228,13 @@ export async function deleteFile(storage, treeId, fileId, options) {
|
|
|
1146
1228
|
const detail = "Matrix deletion completed but the file state could not be verified";
|
|
1147
1229
|
throw new MutationPartialError("delete file", completedIds, detail, { cause: error });
|
|
1148
1230
|
}
|
|
1149
|
-
markFileDeleted(storage.getClient(), tree.id,
|
|
1231
|
+
markFileDeleted(storage.getClient(), tree.id, fileId);
|
|
1150
1232
|
ensureOperationActive(signal);
|
|
1151
1233
|
return { id: fileId, deleted: true };
|
|
1152
1234
|
}, "mutation");
|
|
1153
1235
|
}
|
|
1154
1236
|
export async function uploadFile(storage, treeId, name, bytes, mimetype, options) {
|
|
1237
|
+
await storage.keySafe.requireReady({ signal: options?.signal });
|
|
1155
1238
|
return withOperationDeadline(options, async (signal) => {
|
|
1156
1239
|
const tree = await resolveTree(storage, treeId, signal);
|
|
1157
1240
|
const fileId = await withRateLimitRetry(() => storage.uploadFile(tree, name, toArrayBuffer(bytes), mimetype, signal), signal).catch((error) => {
|
|
@@ -1187,6 +1270,7 @@ export async function uploadFile(storage, treeId, name, bytes, mimetype, options
|
|
|
1187
1270
|
}, "mutation");
|
|
1188
1271
|
}
|
|
1189
1272
|
export async function downloadFile(storage, treeId, fileId, options) {
|
|
1273
|
+
await storage.keySafe.requireReady({ signal: options?.signal });
|
|
1190
1274
|
return withOperationDeadline(options, async (signal) => {
|
|
1191
1275
|
const tree = await resolveTree(storage, treeId, signal);
|
|
1192
1276
|
const branch = await resolveFile(storage, tree, fileId, signal);
|
|
@@ -1205,7 +1289,7 @@ export async function downloadFile(storage, treeId, fileId, options) {
|
|
|
1205
1289
|
return {
|
|
1206
1290
|
bytes: new Uint8Array(result.data),
|
|
1207
1291
|
mimetype: result.mimetype,
|
|
1208
|
-
name:
|
|
1292
|
+
name: await storage.getFileName(tree.id, fileId, { signal }),
|
|
1209
1293
|
};
|
|
1210
1294
|
});
|
|
1211
1295
|
}
|
|
@@ -1221,17 +1305,18 @@ function tsToIso(ts) {
|
|
|
1221
1305
|
return new Date(ts).toISOString();
|
|
1222
1306
|
}
|
|
1223
1307
|
export async function getFileDetails(storage, treeId, fileId, options) {
|
|
1308
|
+
await storage.keySafe.requireReady({ signal: options?.signal });
|
|
1224
1309
|
return withOperationDeadline(options, async (signal) => {
|
|
1225
1310
|
const tree = await resolveTree(storage, treeId, signal);
|
|
1226
|
-
|
|
1227
|
-
const name =
|
|
1311
|
+
await resolveFile(storage, tree, fileId, signal);
|
|
1312
|
+
const name = await storage.getFileName(tree.id, fileId, { signal });
|
|
1228
1313
|
let mimetype = null;
|
|
1229
1314
|
let size = null;
|
|
1230
1315
|
let createdAt = null;
|
|
1231
1316
|
let updatedAt = null;
|
|
1232
1317
|
try {
|
|
1233
1318
|
ensureOperationActive(signal);
|
|
1234
|
-
const event = await
|
|
1319
|
+
const event = await storage.getOriginalFileEvent(tree.id, fileId);
|
|
1235
1320
|
const metadata = readFileEventMetadata(event.getContent());
|
|
1236
1321
|
mimetype = metadata.mimetype;
|
|
1237
1322
|
size = metadata.size;
|
|
@@ -1250,7 +1335,7 @@ export async function getFileDetails(storage, treeId, fileId, options) {
|
|
|
1250
1335
|
});
|
|
1251
1336
|
}
|
|
1252
1337
|
async function getTreeDetails(storage, treeId, signal) {
|
|
1253
|
-
|
|
1338
|
+
await resolveTree(storage, treeId, signal);
|
|
1254
1339
|
if (signal?.aborted)
|
|
1255
1340
|
throw new StorageError("operation cancelled");
|
|
1256
1341
|
const client = storage.getClient();
|
|
@@ -1268,7 +1353,7 @@ async function getTreeDetails(storage, treeId, signal) {
|
|
|
1268
1353
|
}
|
|
1269
1354
|
}
|
|
1270
1355
|
return {
|
|
1271
|
-
name:
|
|
1356
|
+
name: await storage.getTreeName(treeId, { signal }),
|
|
1272
1357
|
id: treeId,
|
|
1273
1358
|
createdAt,
|
|
1274
1359
|
memberCount,
|
|
@@ -1276,9 +1361,11 @@ async function getTreeDetails(storage, treeId, signal) {
|
|
|
1276
1361
|
}
|
|
1277
1362
|
/** Returns details for a top-level Vault. */
|
|
1278
1363
|
export async function getVaultDetails(storage, vaultId, options) {
|
|
1364
|
+
await storage.keySafe.requireReady({ signal: options?.signal });
|
|
1279
1365
|
return withOperationDeadline(options, (signal) => getTreeDetails(storage, vaultId, signal));
|
|
1280
1366
|
}
|
|
1281
1367
|
/** Returns details for a nested folder. */
|
|
1282
1368
|
export async function getFolderDetails(storage, folderId, options) {
|
|
1369
|
+
await storage.keySafe.requireReady({ signal: options?.signal });
|
|
1283
1370
|
return withOperationDeadline(options, (signal) => getTreeDetails(storage, folderId, signal));
|
|
1284
1371
|
}
|