@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.
@@ -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, UNSTABLE_MSC3089_BRANCH, UNSTABLE_MSC3088_ENABLED, UNSTABLE_MSC3088_PURPOSE, UNSTABLE_MSC3089_TREE_SUBTYPE, } from "matrix-js-sdk";
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(client, tree) {
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 knownChildren = new Map(tree.getDirectories().map((child) => [child.id, child]));
326
- return [...activeChildIds].map((childId) => {
327
- const child = knownChildren.get(childId);
328
- if (!child)
329
- throw new StorageError("storage folder state is inconsistent");
330
- return child;
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
- let mutationAttempted = false;
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
- // Empty state-event content is the MSC3089-supported unlink form. Redaction
341
- // is not used here because it can be delayed or rejected independently of
342
- // the relation update.
343
- mutationAttempted = true;
344
- await withRateLimitRetry(() => withMatrixMutationAbort(() => client.sendStateEvent(parentId, EventType.SpaceChild, {}, rootId), signal), signal);
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 (!childLink ||
353
- !parentLink ||
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 (!mutationAttempted && error instanceof StorageError && error.message === "operation cancelled") {
390
+ if (error instanceof MutationOutcomeUnknownError || error instanceof MutationPartialError)
362
391
  throw error;
363
- }
364
- // Unlinking is a two-room transaction. A failed second state update must
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
- return trees.filter((t) => t.isTopLevel).map((t) => ({ id: t.id, name: t.room.name }));
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(() => storage.getClient().joinRoom(vaultId), signal), signal);
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(storage, roomId, fallbackName) {
578
- const client = storage.getClient();
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 (isActiveMembership(currentMembership ?? ""))
804
+ if (currentMembership === "join")
769
805
  continue;
770
- try {
771
- await withRateLimitRetry(() => withMatrixMutationAbort(() => space.invite(userId), operation.signal), operation.signal);
772
- completedRoomIds.add(space.id);
773
- }
774
- catch (error) {
775
- if (!(error instanceof MatrixError) || error.errcode !== "M_FORBIDDEN")
776
- throw error;
777
- // Synapse may race another invite/join and answer M_FORBIDDEN. Only
778
- // suppress that typed condition after re-reading authoritative
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. Inviting is the complete sharing
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((f) => ({ id: f.id, name: f.getName() }));
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.getClient(), tree)
963
+ const directories = (await activeTreeDirectories(storage, tree, signal))
927
964
  .filter((directory) => !isMarkedTreeDeleted(storage, directory.id));
928
- return directories.map((d) => ({ id: d.id, name: d.room.name }));
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?.room.name === name)
961
- return current;
962
- await storage.refreshRoomState(treeId, { signal });
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
- return current?.getName() === name ? current : null;
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
- const branch = await resolveFile(storage, tree, fileId, signal);
1097
- let fileInfo;
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
- fileInfo = await branch.getFileInfo();
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
- const mediaId = fileInfo?.info?.url;
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
- // MSC3089 uses the file event ID as the branch state key, but the current
1113
- // listing is a separate state event. Redact that current listing after
1114
- // clearing the branch state; redacting only the file event leaves the
1115
- // listing visible and is especially harmful when a user is suspended.
1116
- const listingEventId = branch.indexEvent?.getId?.() ?? branch.id;
1117
- if (typeof listingEventId !== "string" || listingEventId.length === 0) {
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
- await withRateLimitRetry(() => withMatrixMutationAbort(() => client.sendStateEvent(tree.id, UNSTABLE_MSC3089_BRANCH.name, {}, branch.id), signal, "delete file state"), signal);
1121
- await withRateLimitRetry(() => withMatrixMutationAbort(() => client.redactEvent(tree.id, listingEventId), signal, "delete file event"), signal);
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
- if (error instanceof MutationOutcomeUnknownError)
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
- // Matrix SDK keeps a redacted branch event in room state as an
1138
- // inactive branch. Treat both an absent branch and that redacted
1139
- // inactive branch as confirmed deletion.
1140
- return !current || current.isActive === false ? true : null;
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, branch.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: branch.getName(),
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
- const branch = await resolveFile(storage, tree, fileId, signal);
1227
- const name = branch.getName();
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 branch.getFileEvent();
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
- const tree = await resolveTree(storage, treeId, signal);
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: tree.room.name || roomDisplayName(storage, treeId),
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
  }