@telecrypt-io/storage 0.8.0 → 0.9.2

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.
@@ -36,12 +36,16 @@ export declare class MutationOutcomeUnknownError extends StorageError {
36
36
  readonly operation: string;
37
37
  constructor(operation?: string, options?: ErrorOptions);
38
38
  }
39
- /** A multi-room mutation committed some work before a later step failed. */
39
+ /** A multi-step mutation committed some work before a later step failed. */
40
40
  export declare class MutationPartialError extends StorageError {
41
41
  readonly code: "MUTATION_PARTIAL";
42
42
  readonly operation: string;
43
43
  readonly completedIds: readonly string[];
44
- constructor(operation: string, completedIds: readonly string[], detail?: string, options?: ErrorOptions);
44
+ /** Room containing the partial mutation, when the operation can target a nested folder. */
45
+ readonly treeId?: string;
46
+ constructor(operation: string, completedIds: readonly string[], detail?: string, options?: ErrorOptions & {
47
+ treeId?: string;
48
+ });
45
49
  }
46
50
  /** A vault or folder still contains files or child folders. */
47
51
  export declare class NonEmptyTreeError extends StorageError {
@@ -58,16 +58,19 @@ export class MutationOutcomeUnknownError extends StorageError {
58
58
  this.operation = operation;
59
59
  }
60
60
  }
61
- /** A multi-room mutation committed some work before a later step failed. */
61
+ /** A multi-step mutation committed some work before a later step failed. */
62
62
  export class MutationPartialError extends StorageError {
63
63
  code = "MUTATION_PARTIAL";
64
64
  operation;
65
65
  completedIds;
66
+ /** Room containing the partial mutation, when the operation can target a nested folder. */
67
+ treeId;
66
68
  constructor(operation, completedIds, detail, options) {
67
69
  super(`${operation} failed after partial completion${detail ? `: ${detail}` : ""}; retry to reconcile current state`, options);
68
70
  this.name = "MutationPartialError";
69
71
  this.operation = operation;
70
72
  this.completedIds = [...completedIds];
73
+ this.treeId = options?.treeId;
71
74
  }
72
75
  }
73
76
  /** A vault or folder still contains files or child folders. */
@@ -50,6 +50,8 @@ export declare function deleteFolder(storage: TeleCryptIOStorage, folderId: stri
50
50
  export declare function renameFile(storage: TeleCryptIOStorage, treeId: string, fileId: string, name: string, options?: OperationOptions): Promise<RenameResult>;
51
51
  export declare function deleteFile(storage: TeleCryptIOStorage, treeId: string, fileId: string, options?: OperationOptions): Promise<DeleteResult>;
52
52
  export declare function uploadFile(storage: TeleCryptIOStorage, treeId: string, name: string, bytes: Uint8Array, mimetype: string, options?: OperationOptions): Promise<FileInfo>;
53
+ /** Discard the acknowledged stages of a failed upload using its retained IDs. */
54
+ export declare function cleanupFileUpload(storage: TeleCryptIOStorage, treeId: string, mediaId: string, fileId?: string, options?: OperationOptions): Promise<void>;
53
55
  export declare function downloadFile(storage: TeleCryptIOStorage, treeId: string, fileId: string, options?: OperationOptions): Promise<DownloadedFile>;
54
56
  export declare function setupRecovery(storage: TeleCryptIOStorage, options?: OperationOptions): Promise<RecoverySetup>;
55
57
  export declare function restoreRecovery(storage: TeleCryptIOStorage, recoveryKey: string, options?: OperationOptions): Promise<RecoveryRestore>;
@@ -12,12 +12,12 @@
12
12
  */
13
13
  import { readFileEventMetadata, withMatrixMutationAbort, withTreeMutation, } from "../TeleCryptIOStorage.js";
14
14
  import { EventType, MatrixError, RoomCreateTypeField, RoomType, UNSTABLE_MSC3088_ENABLED, UNSTABLE_MSC3088_PURPOSE, UNSTABLE_MSC3089_TREE_SUBTYPE, } from "matrix-js-sdk";
15
- import { ClientPrefix } from "matrix-js-sdk/lib/http-api/prefix.js";
16
- import { Method } from "matrix-js-sdk/lib/http-api/method.js";
15
+ import { deleteOwnedMedia } from "../media.js";
17
16
  import { validateMatrixEventId } from "./constants.js";
18
17
  import { FileTooLargeError, MutationPartialError, MutationOutcomeUnknownError, NonEmptyTreeError, RoomCreationAmbiguousError, RoomCleanupIncompleteError, StorageError, } from "./errors.js";
19
18
  import { ConditionTimeoutError, waitForCondition } from "./poll.js";
20
19
  import { validateName } from "./validation.js";
20
+ import { prepareReaderHistorySharing } from "../sharing.js";
21
21
  import { isFileDeleted, isTreeDeleted, markFileDeleted, markTreeDeleted, } from "../deletion-markers.js";
22
22
  /**
23
23
  * Production Synapse enforces the built-in rc_messages budget (per-account
@@ -100,6 +100,7 @@ function isRateLimited(error) {
100
100
  }
101
101
  function requiresMutationReconciliation(error) {
102
102
  return (error instanceof MutationOutcomeUnknownError ||
103
+ error instanceof MutationPartialError ||
103
104
  error instanceof RoomCreationAmbiguousError ||
104
105
  error instanceof RoomCleanupIncompleteError);
105
106
  }
@@ -303,7 +304,8 @@ function isActiveRelationEvent(event) {
303
304
  !Array.isArray(content) &&
304
305
  Object.keys(content).length > 0);
305
306
  }
306
- function activeTreeDirectories(client, tree) {
307
+ async function activeTreeDirectories(storage, tree, signal) {
308
+ const client = storage.getClient();
307
309
  const childEvents = readRelationEvents(client, tree.id, EventType.SpaceChild);
308
310
  if (childEvents === null)
309
311
  throw new StorageError("storage folder state is unavailable");
@@ -316,13 +318,36 @@ function activeTreeDirectories(client, tree) {
316
318
  throw new StorageError("storage folder state is inconsistent");
317
319
  activeChildIds.add(childId);
318
320
  }
319
- const knownChildren = new Map(tree.getDirectories().map((child) => [child.id, child]));
320
- return [...activeChildIds].map((childId) => {
321
- const child = knownChildren.get(childId);
322
- if (!child)
323
- throw new StorageError("storage folder state is inconsistent");
324
- return child;
325
- });
321
+ const directories = await Promise.all([...activeChildIds].map(async (childId) => {
322
+ if (isMarkedTreeDeleted(storage, childId))
323
+ return null;
324
+ try {
325
+ // The parent room's relationship state can arrive before Matrix SDK's
326
+ // MSC3089 TreeSpace projection includes that child. Resolve the exact
327
+ // room named by the active relation instead of treating that brief
328
+ // projection lag as corrupt folder state.
329
+ let joined = false;
330
+ return await waitForCondition(async () => {
331
+ const directory = storage.getTree(childId);
332
+ if (directory)
333
+ return directory;
334
+ if (!joined && client.getRoom?.(childId)?.getMyMembership?.() === "invite") {
335
+ await joinTreeRoom(storage, childId, signal ?? new AbortController().signal);
336
+ joined = true;
337
+ }
338
+ return null;
339
+ }, { timeoutMs: 15000, signal });
340
+ }
341
+ catch (error) {
342
+ if (signal?.aborted)
343
+ throw new StorageError("operation cancelled");
344
+ if (error instanceof ConditionTimeoutError) {
345
+ throw new StorageError("storage folder state is inconsistent");
346
+ }
347
+ throw new StorageError("storage folder state lookup failed", { cause: error });
348
+ }
349
+ }));
350
+ return directories.filter((directory) => directory !== null);
326
351
  }
327
352
  async function unlinkExternalParents(storage, rootId, externalParents, signal) {
328
353
  const client = storage.getClient();
@@ -479,9 +504,6 @@ async function deleteRoomDeterministically(storage, roomId, signal) {
479
504
  function removeRoomFromLocalStore(client, roomId) {
480
505
  client.store?.removeRoom(roomId);
481
506
  }
482
- function isActiveMembership(membership) {
483
- return membership === "join" || membership === "invite";
484
- }
485
507
  function isRevocableMembership(membership) {
486
508
  return membership === "join" || membership === "invite" || membership === "knock";
487
509
  }
@@ -497,6 +519,7 @@ function toArrayBuffer(bytes) {
497
519
  return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
498
520
  }
499
521
  export async function createVault(storage, name, options) {
522
+ await storage.keySafe.requireReady({ signal: options?.signal });
500
523
  return withOperationDeadline(options, async (signal) => {
501
524
  let tree;
502
525
  try {
@@ -516,6 +539,7 @@ export async function createVault(storage, name, options) {
516
539
  }
517
540
  /** Top-level vaults only — excludes subdirectories of an existing tree. */
518
541
  export async function listVaults(storage, options) {
542
+ await storage.keySafe.requireReady({ signal: options?.signal });
519
543
  return withOperationDeadline(options, async (signal) => {
520
544
  const trees = await storage.listTrees(signal);
521
545
  const topLevel = trees.filter((tree) => tree.isTopLevel);
@@ -533,44 +557,98 @@ export function getMyVaultRole(storage, vaultId) {
533
557
  return null;
534
558
  return tree.getPermissions(userId);
535
559
  }
536
- export async function joinVault(storage, vaultId, options) {
537
- return withOperationDeadline(options, async (signal) => {
538
- let membership;
560
+ async function joinTreeRoom(storage, vaultId, signal) {
561
+ let membership;
562
+ try {
563
+ membership = await storage.getRoomMembership(vaultId, undefined, { signal });
564
+ }
565
+ catch (err) {
566
+ if (signal.aborted)
567
+ throw new StorageError("operation cancelled");
568
+ // Synapse refuses GET /rooms/:room/members for an invited user with
569
+ // M_FORBIDDEN. That response means the membership preflight cannot
570
+ // inspect the room yet; it is not evidence that joining is forbidden.
571
+ // Attempt the idempotent join and let its own response decide access.
572
+ if (err instanceof MatrixError && err.errcode === "M_FORBIDDEN") {
573
+ membership = null;
574
+ }
575
+ else {
576
+ throw new StorageError("join failed", { cause: err });
577
+ }
578
+ }
579
+ if (membership === "join")
580
+ return;
581
+ const client = storage.getClient();
582
+ const userId = client.getUserId();
583
+ if (userId && client.getRoom(vaultId)?.getMember(userId)?.membership !== "invite") {
584
+ // MatrixClient.joinRoom reads the inviter from its local invite-room
585
+ // state. A to-device history bundle can arrive first in the initial
586
+ // sync, before that stripped invite state has reached the room store;
587
+ // joining without it skips Matrix JS SDK's pending-bundle acceptance.
539
588
  try {
540
- membership = await storage.getRoomMembership(vaultId, undefined, { signal });
589
+ await waitForCondition(() => client.getRoom(vaultId)?.getMember(userId)?.membership === "invite" ? true : null, { timeoutMs: 15000, signal });
541
590
  }
542
591
  catch (err) {
543
592
  if (signal.aborted)
544
593
  throw new StorageError("operation cancelled");
545
- // Synapse refuses GET /rooms/:room/members for an invited user with
546
- // M_FORBIDDEN. That response means the membership preflight cannot
547
- // inspect the room yet; it is not evidence that joining is forbidden.
548
- // Attempt the idempotent join and let its own response decide access.
549
- if (err instanceof MatrixError && err.errcode === "M_FORBIDDEN") {
550
- membership = null;
551
- }
552
- else {
553
- throw new StorageError("join failed", { cause: err });
594
+ if (err instanceof ConditionTimeoutError) {
595
+ throw new StorageError("the room invitation has not reached this login yet; retry joining", { cause: err });
554
596
  }
597
+ throw err;
555
598
  }
556
- if (membership === "join")
557
- return { vaultId, joined: true };
599
+ }
600
+ try {
601
+ await withRateLimitRetry(() => withMatrixMutationAbort(() => client.joinRoom(vaultId), signal), signal);
602
+ }
603
+ catch (err) {
604
+ if (err instanceof MutationOutcomeUnknownError)
605
+ throw err;
606
+ if (err instanceof MatrixError && err.errcode === "M_FORBIDDEN") {
607
+ const afterForbidden = await storage.getRoomMembership(vaultId, undefined, { signal });
608
+ if (afterForbidden === "join")
609
+ return;
610
+ }
611
+ if (signal.aborted)
612
+ throw new StorageError("operation cancelled");
613
+ throw new StorageError("join failed", { cause: err });
614
+ }
615
+ }
616
+ export async function joinVault(storage, vaultId, options) {
617
+ await storage.keySafe.requireReady({ signal: options?.signal });
618
+ return withOperationDeadline(options, async (signal) => {
619
+ const joined = new Set();
620
+ const pending = [vaultId];
558
621
  try {
559
- await withRateLimitRetry(() => withMatrixMutationAbort(() => storage.getClient().joinRoom(vaultId), signal), signal);
622
+ while (pending.length > 0) {
623
+ ensureOperationActive(signal);
624
+ const roomId = pending.shift();
625
+ if (joined.has(roomId) || isMarkedTreeDeleted(storage, roomId))
626
+ continue;
627
+ // Keep native joinRoom's inviter/history-bundle handling for every
628
+ // child. Merely joining the root does not accept its child invites.
629
+ await joinTreeRoom(storage, roomId, signal);
630
+ joined.add(roomId);
631
+ await resolveTree(storage, roomId, signal);
632
+ await storage.refreshRoomState(roomId, { signal });
633
+ const children = readRelationEvents(storage.getClient(), roomId, EventType.SpaceChild);
634
+ if (children === null)
635
+ throw new StorageError("storage folder state is unavailable");
636
+ for (const child of children) {
637
+ if (!isActiveRelationEvent(child))
638
+ continue;
639
+ const childId = relationStateKey(child);
640
+ if (!childId)
641
+ throw new StorageError("storage folder state is inconsistent");
642
+ pending.push(childId);
643
+ }
644
+ }
560
645
  }
561
- catch (err) {
562
- if (err instanceof MutationOutcomeUnknownError)
563
- throw err;
564
- if (err instanceof MatrixError && err.errcode === "M_FORBIDDEN") {
565
- const afterForbidden = await storage.getRoomMembership(vaultId, undefined, { signal });
566
- if (afterForbidden === "join")
567
- return { vaultId, joined: true };
646
+ catch (error) {
647
+ if (joined.size > 0) {
648
+ throw new MutationPartialError("join", [...joined], "some folders were joined; retry accepting the same vault to finish", { cause: error });
568
649
  }
569
- if (signal.aborted)
570
- throw new StorageError("operation cancelled");
571
- throw new StorageError("join failed", { cause: err });
650
+ throw error;
572
651
  }
573
- ensureOperationActive(signal);
574
652
  return { vaultId, joined: true };
575
653
  }, "mutation");
576
654
  }
@@ -592,6 +670,7 @@ function isReviewedStorageTreeRoom(room) {
592
670
  }
593
671
  /** Rooms where this account is invited and the room looks like a file tree. */
594
672
  export async function listPendingInvites(storage, options) {
673
+ await storage.keySafe.requireReady({ signal: options?.signal });
595
674
  return withOperationDeadline(options, async (signal) => {
596
675
  const client = storage.getClient();
597
676
  const rooms = client.getRooms();
@@ -628,6 +707,7 @@ export async function listPendingInvites(storage, options) {
628
707
  }
629
708
  /** Decline a vault invite (same as leaving before join). */
630
709
  export async function declineInvite(storage, vaultId, options) {
710
+ await storage.keySafe.requireReady({ signal: options?.signal });
631
711
  return withOperationDeadline(options, async (signal) => {
632
712
  const client = storage.getClient();
633
713
  try {
@@ -721,6 +801,7 @@ export async function declineInvite(storage, vaultId, options) {
721
801
  }
722
802
  /** Invites `userId` to the vault as a reader. */
723
803
  export async function shareVault(storage, vaultId, userId, role, options) {
804
+ await storage.keySafe.requireReady({ signal: options?.signal });
724
805
  if (role !== "viewer") {
725
806
  throw new StorageError("storage sharing supports viewers only");
726
807
  }
@@ -743,6 +824,8 @@ export async function shareVault(storage, vaultId, userId, role, options) {
743
824
  if (currentMembers.some((member) => member.userId === userId && member.role === "owner")) {
744
825
  throw new StorageError("share will not demote an existing owner");
745
826
  }
827
+ const client = storage.getClient();
828
+ const verifyHistorySharing = await prepareReaderHistorySharing(client, userId);
746
829
  for (const [index, space] of spaces.entries()) {
747
830
  ensureOperationActive(operation.signal);
748
831
  const members = index === 0
@@ -756,28 +839,18 @@ export async function shareVault(storage, vaultId, userId, role, options) {
756
839
  : await storage.getRoomMembership(space.id, userId, {
757
840
  signal: operation.signal,
758
841
  });
759
- if (isActiveMembership(currentMembership ?? ""))
842
+ if (currentMembership === "join")
760
843
  continue;
761
- try {
762
- await withRateLimitRetry(() => withMatrixMutationAbort(() => space.invite(userId), operation.signal), operation.signal);
763
- completedRoomIds.add(space.id);
764
- }
765
- catch (error) {
766
- if (!(error instanceof MatrixError) || error.errcode !== "M_FORBIDDEN")
767
- throw error;
768
- // Synapse may race another invite/join and answer M_FORBIDDEN. Only
769
- // suppress that typed condition after re-reading authoritative
770
- // membership; never infer it from provider-controlled error text.
771
- const membership = await storage.getRoomMembership(space.id, userId, {
772
- signal: operation.signal,
773
- });
774
- if (!isActiveMembership(membership ?? ""))
775
- throw new StorageError("share failed");
776
- }
844
+ // Native invite shares the encrypted history before inviting. Retry
845
+ // pending invitations too: an earlier attempt may have sent only an
846
+ // invitation. In particular, never swallow a media-upload 403 merely
847
+ // because an invitation already exists.
848
+ await withRateLimitRetry(() => withMatrixMutationAbort(() => space.invite(userId, false), operation.signal), operation.signal);
849
+ completedRoomIds.add(space.id);
850
+ await verifyHistorySharing();
777
851
  }
778
852
  // Storage rooms have one immutable owner power level and readers use
779
- // the room's default user level. Inviting is the complete sharing
780
- // operation; changing per-user power levels would create editors and
853
+ // the room's default user level. Changing per-user power levels would create editors and
781
854
  // let metadata authorship drift away from the owner.
782
855
  }
783
856
  catch (error) {
@@ -790,6 +863,7 @@ export async function shareVault(storage, vaultId, userId, role, options) {
790
863
  (error.message === "share failed" ||
791
864
  error.message.includes("owner") ||
792
865
  error.message.includes("current user") ||
866
+ error.message.includes("Decryption Key Safe") ||
793
867
  error.message === "operation cancelled")) {
794
868
  throw error;
795
869
  }
@@ -804,6 +878,7 @@ export async function shareVault(storage, vaultId, userId, role, options) {
804
878
  }
805
879
  }
806
880
  export async function unshareVault(storage, vaultId, userId, options) {
881
+ await storage.keySafe.requireReady({ signal: options?.signal });
807
882
  const operation = createOperationDeadline(options);
808
883
  const completedRoomIds = new Set();
809
884
  try {
@@ -878,6 +953,7 @@ export async function unshareVault(storage, vaultId, userId, options) {
878
953
  }
879
954
  }
880
955
  export async function listMembers(storage, vaultId, options) {
956
+ await storage.keySafe.requireReady({ signal: options?.signal });
881
957
  return withOperationDeadline(options, async (signal) => {
882
958
  const tree = await resolveTree(storage, vaultId, signal);
883
959
  try {
@@ -891,6 +967,7 @@ export async function listMembers(storage, vaultId, options) {
891
967
  });
892
968
  }
893
969
  export async function listFiles(storage, treeId, options) {
970
+ await storage.keySafe.requireReady({ signal: options?.signal });
894
971
  return withOperationDeadline(options, async (signal) => {
895
972
  const tree = await resolveTree(storage, treeId, signal);
896
973
  try {
@@ -913,11 +990,12 @@ export async function listFiles(storage, treeId, options) {
913
990
  }
914
991
  /** Lists the direct child folders of a vault or folder. */
915
992
  export async function listSubfolders(storage, parentId, options) {
993
+ await storage.keySafe.requireReady({ signal: options?.signal });
916
994
  return withOperationDeadline(options, async (signal) => {
917
995
  const tree = await resolveTree(storage, parentId, signal);
918
996
  await storage.refreshRoomState(parentId, { signal });
919
997
  ensureOperationActive(signal);
920
- const directories = activeTreeDirectories(storage.getClient(), tree)
998
+ const directories = (await activeTreeDirectories(storage, tree, signal))
921
999
  .filter((directory) => !isMarkedTreeDeleted(storage, directory.id));
922
1000
  return Promise.all(directories.map(async (directory) => ({
923
1001
  id: directory.id,
@@ -926,6 +1004,7 @@ export async function listSubfolders(storage, parentId, options) {
926
1004
  });
927
1005
  }
928
1006
  export async function createSubfolder(storage, parentId, name, options) {
1007
+ await storage.keySafe.requireReady({ signal: options?.signal });
929
1008
  validateName(name, "name");
930
1009
  return withOperationDeadline(options, async (signal) => {
931
1010
  const tree = await resolveTree(storage, parentId, signal);
@@ -971,10 +1050,12 @@ async function renameTree(storage, treeId, name, options) {
971
1050
  }
972
1051
  /** Renames a top-level Vault. */
973
1052
  export async function renameVault(storage, vaultId, name, options) {
1053
+ await storage.keySafe.requireReady({ signal: options?.signal });
974
1054
  return renameTree(storage, vaultId, name, options);
975
1055
  }
976
1056
  /** Renames a nested folder. */
977
1057
  export async function renameFolder(storage, folderId, name, options) {
1058
+ await storage.keySafe.requireReady({ signal: options?.signal });
978
1059
  return renameTree(storage, folderId, name, options);
979
1060
  }
980
1061
  async function deleteTree(storage, treeId, options) {
@@ -1038,13 +1119,16 @@ async function deleteTree(storage, treeId, options) {
1038
1119
  }
1039
1120
  /** Deletes a top-level Vault. */
1040
1121
  export async function deleteVault(storage, vaultId, options) {
1122
+ await storage.keySafe.requireReady({ signal: options?.signal });
1041
1123
  return deleteTree(storage, vaultId, options);
1042
1124
  }
1043
1125
  /** Deletes a nested folder. */
1044
1126
  export async function deleteFolder(storage, folderId, options) {
1127
+ await storage.keySafe.requireReady({ signal: options?.signal });
1045
1128
  return deleteTree(storage, folderId, options);
1046
1129
  }
1047
1130
  export async function renameFile(storage, treeId, fileId, name, options) {
1131
+ await storage.keySafe.requireReady({ signal: options?.signal });
1048
1132
  validateName(name, "file name");
1049
1133
  return withOperationDeadline(options, async (signal) => {
1050
1134
  const tree = await resolveTree(storage, treeId, signal);
@@ -1072,18 +1156,10 @@ export async function renameFile(storage, treeId, fileId, name, options) {
1072
1156
  return { id: fileId, name };
1073
1157
  }, "mutation");
1074
1158
  }
1075
- function requireFileDeletionTransport(client) {
1076
- const http = client.http;
1077
- if (!http || typeof http.authedRequest !== "function") {
1078
- throw new StorageError("Matrix HTTP transport unavailable");
1079
- }
1080
- return http;
1081
- }
1082
1159
  async function deleteFileMedia(storage, mediaIds, signal) {
1083
1160
  const client = storage.getClient();
1084
- const http = requireFileDeletionTransport(client);
1085
1161
  try {
1086
- await withRateLimitRetry(() => withMatrixMutationAbort(() => http.authedRequest(Method.Post, "/io.telecrypt.storage/delete_media", undefined, { media_ids: [...mediaIds] }, { prefix: ClientPrefix.Unstable, rawResponseBody: true, abortSignal: signal }), signal, "delete file media"), signal);
1162
+ await withRateLimitRetry(() => withMatrixMutationAbort(() => deleteOwnedMedia(client, mediaIds, signal), signal, "delete file media"), signal);
1087
1163
  }
1088
1164
  catch (error) {
1089
1165
  if (error instanceof MutationOutcomeUnknownError)
@@ -1094,14 +1170,13 @@ async function deleteFileMedia(storage, mediaIds, signal) {
1094
1170
  }
1095
1171
  }
1096
1172
  export async function deleteFile(storage, treeId, fileId, options) {
1173
+ await storage.keySafe.requireReady({ signal: options?.signal });
1097
1174
  return withOperationDeadline(options, async (signal) => {
1098
1175
  const tree = await resolveTree(storage, treeId, signal);
1099
1176
  if (isMarkedFileDeleted(storage, tree.id, fileId))
1100
1177
  return { id: fileId, deleted: true };
1101
1178
  await storage.refreshRoomState(treeId, { signal });
1102
1179
  const branch = tree.getFile(fileId);
1103
- if (!branch)
1104
- throw new StorageError("file not found");
1105
1180
  let originalEvent;
1106
1181
  try {
1107
1182
  originalEvent = await storage.getOriginalFileEvent(tree.id, fileId, true);
@@ -1110,7 +1185,7 @@ export async function deleteFile(storage, treeId, fileId, options) {
1110
1185
  throw new StorageError("could not resolve encrypted file safely", { cause: error });
1111
1186
  }
1112
1187
  if (originalEvent.isRedacted()) {
1113
- if (branch.isActive === false) {
1188
+ if (!branch || branch.isActive === false) {
1114
1189
  markFileDeleted(storage.getClient(), tree.id, fileId);
1115
1190
  return { id: fileId, deleted: true };
1116
1191
  }
@@ -1134,7 +1209,9 @@ export async function deleteFile(storage, treeId, fileId, options) {
1134
1209
  const completedIds = [mediaId];
1135
1210
  try {
1136
1211
  const client = storage.getClient();
1137
- const metadataEventId = await storage.getFileMetadataEventId(tree.id, fileId, { signal });
1212
+ const metadataEventId = branch
1213
+ ? await storage.getFileMetadataEventId(tree.id, fileId, { signal })
1214
+ : null;
1138
1215
  if (metadataEventId && metadataEventId !== fileId) {
1139
1216
  const metadataEvent = await storage.getFileRenameMetadataEvent(tree.id, fileId, metadataEventId);
1140
1217
  if (!metadataEvent.isRedacted()) {
@@ -1145,16 +1222,17 @@ export async function deleteFile(storage, treeId, fileId, options) {
1145
1222
  const currentBranch = tree.getFile(fileId);
1146
1223
  const indexEvent = currentBranch?.indexEvent;
1147
1224
  const listingEventId = indexEvent?.getId();
1148
- if (!indexEvent || typeof listingEventId !== "string" || listingEventId.length === 0) {
1225
+ if (currentBranch && (!indexEvent || typeof listingEventId !== "string" || listingEventId.length === 0)) {
1149
1226
  throw new StorageError("encrypted file has no listing event identifier");
1150
1227
  }
1151
- if (!indexEvent.isRedacted()) {
1228
+ if (indexEvent && !indexEvent.isRedacted()) {
1152
1229
  if (indexEvent.getSender() !== client.getUserId()) {
1153
1230
  throw new StorageError("file listing was not authored by the owner");
1154
1231
  }
1155
1232
  await withRateLimitRetry(() => withMatrixMutationAbort(() => client.redactEvent(tree.id, listingEventId), signal, "delete file listing"), signal);
1156
1233
  }
1157
- completedIds.push(listingEventId);
1234
+ if (listingEventId)
1235
+ completedIds.push(listingEventId);
1158
1236
  await withRateLimitRetry(() => withMatrixMutationAbort(() => client.redactEvent(tree.id, fileId), signal, "delete file attachment event"), signal);
1159
1237
  completedIds.push(fileId);
1160
1238
  }
@@ -1184,12 +1262,19 @@ export async function deleteFile(storage, treeId, fileId, options) {
1184
1262
  }, "mutation");
1185
1263
  }
1186
1264
  export async function uploadFile(storage, treeId, name, bytes, mimetype, options) {
1187
- return withOperationDeadline(options, async (signal) => {
1265
+ await storage.keySafe.requireReady({ signal: options?.signal });
1266
+ const deadline = createOperationDeadline(options);
1267
+ try {
1268
+ const signal = deadline.signal;
1269
+ ensureOperationActive(signal);
1270
+ // Unlike a read, an upload must retain a receipt returned by an in-flight
1271
+ // mutation after cancellation. Native HTTP requests remain time-bounded;
1272
+ // the uploader stops before starting another write when this signal aborts.
1188
1273
  const tree = await resolveTree(storage, treeId, signal);
1189
1274
  const fileId = await withRateLimitRetry(() => storage.uploadFile(tree, name, toArrayBuffer(bytes), mimetype, signal), signal).catch((error) => {
1190
1275
  if (error instanceof FileTooLargeError)
1191
1276
  throw error;
1192
- if (error instanceof MutationOutcomeUnknownError)
1277
+ if (error instanceof MutationOutcomeUnknownError || error instanceof MutationPartialError)
1193
1278
  throw error;
1194
1279
  if (signal.aborted)
1195
1280
  throw new StorageError("operation cancelled");
@@ -1212,13 +1297,24 @@ export async function uploadFile(storage, treeId, name, bytes, mimetype, options
1212
1297
  catch (error) {
1213
1298
  if (error instanceof MutationOutcomeUnknownError)
1214
1299
  throw error;
1215
- throw new MutationPartialError("upload file", [fileId], "upload completed but the file could not be observed locally", { cause: error });
1300
+ throw new MutationPartialError("upload file", [fileId], "upload completed but the file could not be observed locally", { cause: error, treeId });
1216
1301
  }
1217
1302
  ensureOperationActive(signal);
1218
1303
  return { id: fileId, name, mimetype };
1304
+ }
1305
+ finally {
1306
+ deadline.close();
1307
+ }
1308
+ }
1309
+ /** Discard the acknowledged stages of a failed upload using its retained IDs. */
1310
+ export async function cleanupFileUpload(storage, treeId, mediaId, fileId, options) {
1311
+ await storage.keySafe.requireReady({ signal: options?.signal });
1312
+ return withOperationDeadline(options, async (signal) => {
1313
+ await storage.cleanupFileUpload(treeId, mediaId, fileId, { signal });
1219
1314
  }, "mutation");
1220
1315
  }
1221
1316
  export async function downloadFile(storage, treeId, fileId, options) {
1317
+ await storage.keySafe.requireReady({ signal: options?.signal });
1222
1318
  return withOperationDeadline(options, async (signal) => {
1223
1319
  const tree = await resolveTree(storage, treeId, signal);
1224
1320
  const branch = await resolveFile(storage, tree, fileId, signal);
@@ -1253,6 +1349,7 @@ function tsToIso(ts) {
1253
1349
  return new Date(ts).toISOString();
1254
1350
  }
1255
1351
  export async function getFileDetails(storage, treeId, fileId, options) {
1352
+ await storage.keySafe.requireReady({ signal: options?.signal });
1256
1353
  return withOperationDeadline(options, async (signal) => {
1257
1354
  const tree = await resolveTree(storage, treeId, signal);
1258
1355
  await resolveFile(storage, tree, fileId, signal);
@@ -1308,9 +1405,11 @@ async function getTreeDetails(storage, treeId, signal) {
1308
1405
  }
1309
1406
  /** Returns details for a top-level Vault. */
1310
1407
  export async function getVaultDetails(storage, vaultId, options) {
1408
+ await storage.keySafe.requireReady({ signal: options?.signal });
1311
1409
  return withOperationDeadline(options, (signal) => getTreeDetails(storage, vaultId, signal));
1312
1410
  }
1313
1411
  /** Returns details for a nested folder. */
1314
1412
  export async function getFolderDetails(storage, folderId, options) {
1413
+ await storage.keySafe.requireReady({ signal: options?.signal });
1315
1414
  return withOperationDeadline(options, (signal) => getTreeDetails(storage, folderId, signal));
1316
1415
  }
@@ -87,3 +87,4 @@ export interface FolderDetails {
87
87
  createdAt: string | null;
88
88
  memberCount: number | null;
89
89
  }
90
+ export type { KeySafeStatus, KeySafeOptions } from "../key-safe.js";
@@ -0,0 +1,62 @@
1
+ import { type MatrixClient } from "matrix-js-sdk";
2
+ export interface KeySafeOptions {
3
+ signal?: AbortSignal;
4
+ }
5
+ export interface KeySafeStatus {
6
+ state: "setup-required" | "confirmation-required" | "restore-required" | "ready";
7
+ recoveryKey?: string;
8
+ }
9
+ export interface KeySafePersistenceOptions {
10
+ persistentCryptoStore?: boolean;
11
+ cryptoDatabasePrefix?: string;
12
+ /** Flush platform crypto persistence before continuing a setup mutation. */
13
+ onKeySafeStateChanged?: () => Promise<void>;
14
+ }
15
+ type WithKey = <T>(key: Uint8Array<ArrayBuffer>, fn: () => Promise<T>, signal?: AbortSignal) => Promise<T>;
16
+ /** The local saved-key acknowledgement and Matrix's existing encryption setup. */
17
+ export declare class DecryptionKeySafe {
18
+ private client;
19
+ private withKey;
20
+ private options;
21
+ private startSync?;
22
+ private memory;
23
+ private tail;
24
+ private secretRecordsRefreshed;
25
+ private refreshedKeyDescriptorId;
26
+ constructor(client: MatrixClient, withKey: WithKey, options?: KeySafePersistenceOptions, startSync?: ((signal?: AbortSignal) => Promise<void>) | undefined);
27
+ private active;
28
+ private crypto;
29
+ private local;
30
+ private save;
31
+ private serial;
32
+ private refreshAccountData;
33
+ /**
34
+ * MatrixClient.getAccountDataFromServer reads its local store after initial
35
+ * sync. A resumed sync can complete before account-data events reach that
36
+ * store, so refresh the existing Safe records from the authenticated server
37
+ * API before native secret-storage code reads them. Seed those exact records
38
+ * through the public Store API used by Matrix sync itself.
39
+ */
40
+ private refreshSecretStorageAccountData;
41
+ private topology;
42
+ private serverBackup;
43
+ private signingReady;
44
+ getStatus(signal?: AbortSignal): Promise<KeySafeStatus>;
45
+ requireReady(options?: KeySafeOptions): Promise<void>;
46
+ /** Re-publish the same native signed records after an interrupted upload. */
47
+ private publishSigning;
48
+ private checkSigningCanResume;
49
+ private configure;
50
+ setup(signal?: AbortSignal): Promise<{
51
+ recoveryKey: string;
52
+ }>;
53
+ confirmSaved(signal?: AbortSignal): Promise<{
54
+ state: "ready";
55
+ }>;
56
+ restore(recoveryKey: string, signal?: AbortSignal): Promise<{
57
+ imported: number;
58
+ total: number;
59
+ state: "ready";
60
+ }>;
61
+ }
62
+ export {};