@telecrypt-io/storage 0.9.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.
package/LICENSE CHANGED
@@ -2,7 +2,7 @@ Business Source License 1.1
2
2
 
3
3
  Parameters
4
4
 
5
- Licensor: TeleCrypt.io
5
+ Licensor: Alexander Potemkin
6
6
  Licensed Work: TeleCrypt.io Storage
7
7
  The Licensed Work is (c) 2026 TeleCrypt.io
8
8
  Additional Use Grant: You may use, copy, modify, and create derivative
package/README.md CHANGED
@@ -83,6 +83,18 @@ and a per-file key distributed through the room's Megolm session). The server st
83
83
  media and Matrix events, but encryption does not hide all room, event, or other metadata from the
84
84
  server or users who can read the room.
85
85
 
86
+ If an upload is rejected after media was accepted, the SDK removes that media and any acknowledged
87
+ attachment. A transport failure leaves accepted work intact. An upload `MutationPartialError`
88
+ retains `treeId` and `completedIds` in the order `[mediaId, attachmentEventId?]`. Keep those IDs;
89
+ do not repeat the upload blindly. To discard the partial upload, call
90
+ `core.cleanupFileUpload(storage, error.treeId, error.completedIds[0], error.completedIds[1])`.
91
+ Retry cleanup with the same IDs if it fails. A known attachment ID can also be passed to ordinary
92
+ `core.deleteFile` even when its listing was never committed. An initial media response lost before
93
+ the server returns any identifier still has an unknown outcome and cannot provide cleanup IDs.
94
+ Upload cancellation stops before the next write and waits for the current request's bounded
95
+ settlement so returned media/event IDs are retained. Keep observing the upload promise after
96
+ cancelling a UI wait or closing a view; persist any partial receipt for the originating account.
97
+
86
98
  The Decryption Key Safe is mandatory before the public core storage operations. Inspect
87
99
  `storage.keySafe.getStatus(signal?)`. For `setup-required`, call `setup(signal?)`, offer the returned
88
100
  `recoveryKey` for copying/saving, explain that losing both this key and local decryption keys can
@@ -93,6 +105,12 @@ room keys and signs the current login using the existing account keys. Normal us
93
105
  Setup and restore never replace an existing safe or account signing identity. Resetting the account
94
106
  password cannot recover encrypted files.
95
107
 
108
+ Short-lived clients should call `storage.waitForKeyBackup(treeId, fileId?, options?)` after creating
109
+ or renaming encrypted metadata and before closing. Omit `fileId` for a vault or folder. This checks
110
+ the exact encryption session in the active server backup, including reused keys that produce no
111
+ new backup-progress event. If verification fails, retain the local profile and retry backup;
112
+ the metadata mutation has already committed.
113
+
96
114
  Pending setup state is kept beside the login's IndexedDB crypto data. A platform that snapshots
97
115
  IndexedDB to disk, such as the CLI, must supply `onKeySafeStateChanged` at creation and durably flush
98
116
  its snapshot before resolving that callback. Key Safe methods accept an optional `AbortSignal`.
@@ -105,6 +123,17 @@ the SDK checks native recipient-device eligibility afterward and reports partial
105
123
  keys could not be shared. An upload restriction can reject the encrypted history bundle upload;
106
124
  the error is preserved, including when retrying an existing invitation.
107
125
 
126
+ `joinVault` accepts the vault and its linked folder invitations, including their history keys.
127
+ New folders inherit the parent's joined and invited readers; browsing accepts their folder
128
+ invitations. If reader access fails after folder creation, the partial-completion error retains
129
+ the created folder ID. Retry sharing the parent vault to complete access to that folder.
130
+
131
+ Storage spaces are private and only their creator changes room state. Loading owned trees or
132
+ refreshing their state repairs the permissive defaults emitted by older SDKs and restores
133
+ invite-only joining. The repair preserves membership, unrelated power settings and encryption
134
+ keys; readers do not write it. Owners must use an updated client for their existing trees to be
135
+ repaired. Disabled Storage markers and deleted trees are not restored automatically.
136
+
108
137
  Deletion has strict ordering. Delete files before their folders or vaults because folders and
109
138
  vaults must be empty. File deletion removes the encrypted media object before redacting its Matrix
110
139
  event and requires TeleCrypt's Synapse storage extension; stock Synapse cannot provide that
@@ -21,7 +21,7 @@ export interface TreeSpace {
21
21
  setPermissions(userId: string, role: string): Promise<void>;
22
22
  getFile(fileEventId: string): FileBranch | null;
23
23
  listFiles(): FileBranch[];
24
- createFile(name: string, encryptedContents: ArrayBuffer | Uint8Array, info: Record<string, unknown>, additionalContent?: Record<string, unknown>): Promise<{
24
+ createFile(name: string, encryptedContents: ArrayBuffer | Uint8Array, info: Record<string, unknown>, additionalContent?: Record<string, unknown>, signal?: AbortSignal): Promise<{
25
25
  event_id: string;
26
26
  }>;
27
27
  }
@@ -172,8 +172,15 @@ export declare class TeleCryptIOStorage {
172
172
  private sendEncryptedMetadataMessage;
173
173
  private readEncryptedMetadataMessage;
174
174
  private writeTreeName;
175
+ private getTreeMetadataEvent;
175
176
  /** Resolves an encrypted vault/folder name through its pointer state event. */
176
177
  getTreeName(roomId: string, options?: MatrixRequestOptions): Promise<string>;
178
+ /**
179
+ * Waits for the current encrypted vault/folder name, or file metadata, to
180
+ * have its session in the active native key backup. Checks the exact session
181
+ * even when encryption reuses a key and the backup engine emits no new event.
182
+ */
183
+ waitForKeyBackup(treeId: string, fileId?: string, options?: MatrixRequestOptions): Promise<void>;
177
184
  private writeFileName;
178
185
  /** Resolves an encrypted file name from the MSC3089 index pointer. */
179
186
  getFileName(treeId: string, fileId: string, options?: MatrixRequestOptions & {
@@ -186,6 +193,12 @@ export declare class TeleCryptIOStorage {
186
193
  /** Fetches the original encrypted file message directly by event ID. */
187
194
  getOriginalFileEvent(treeId: string, fileId: string, allowRedacted?: boolean): Promise<MatrixEvent>;
188
195
  private createEncryptedFile;
196
+ /**
197
+ * Discards a partial upload using its MutationPartialError.completedIds:
198
+ * [mediaId, fileId?]. Explicit cleanup also covers an unacknowledged listing.
199
+ * Keep the same IDs after failure and retry this operation, not the upload.
200
+ */
201
+ cleanupFileUpload(treeId: string, mediaId: string, fileId?: string, options?: MatrixRequestOptions): Promise<void>;
189
202
  /**
190
203
  * Refreshes a room's current state from the homeserver. The background sync
191
204
  * loop is intentionally asynchronous, so a short-lived CLI process can
@@ -193,6 +206,7 @@ export declare class TeleCryptIOStorage {
193
206
  * changed a file name or branch.
194
207
  */
195
208
  refreshRoomState(roomId: string, options?: MatrixRequestOptions): Promise<void>;
209
+ private repairOwnedTreePermissions;
196
210
  /**
197
211
  * Recommended entry point: builds the MatrixClient with a persistent crypto
198
212
  * store and the secret-storage callback wiring `keys.*` needs, starts the
@@ -1,15 +1,18 @@
1
- import { ClientEvent, createClient, EventType, MatrixEvent, MatrixError, MsgType, Preset, RoomCreateTypeField, RoomType, SyncState, UNSTABLE_MSC3088_ENABLED, UNSTABLE_MSC3088_PURPOSE, UNSTABLE_MSC3089_BRANCH, UNSTABLE_MSC3089_LEAF, UNSTABLE_MSC3089_TREE_SUBTYPE, } from "matrix-js-sdk";
1
+ import { ClientEvent, createClient, EventType, JoinRule, MatrixEvent, MatrixError, MsgType, Preset, RoomCreateTypeField, RoomType, SyncState, UNSTABLE_MSC3088_ENABLED, UNSTABLE_MSC3088_PURPOSE, UNSTABLE_MSC3089_BRANCH, UNSTABLE_MSC3089_LEAF, UNSTABLE_MSC3089_TREE_SUBTYPE, } from "matrix-js-sdk";
2
2
  import { Method } from "matrix-js-sdk/lib/http-api/method.js";
3
3
  import { ClientPrefix } from "matrix-js-sdk/lib/http-api/prefix.js";
4
4
  import { encryptAttachment, decryptAttachment } from "matrix-encrypt-attachment";
5
5
  import { decodeRecoveryKey } from "matrix-js-sdk/lib/crypto-api/recovery-key.js";
6
- import { FileTooLargeError, RecoveryAlreadyConfiguredError, RecoveryRestoreError, RecoverySetupAmbiguousError, RecoverySetupError, RecoveryRestoreAmbiguousError, MutationOutcomeUnknownError, RoomCreationAmbiguousError, RoomCleanupIncompleteError, StorageError, UndecryptableFileError, } from "./core/errors.js";
6
+ import { FileTooLargeError, RecoveryAlreadyConfiguredError, RecoveryRestoreError, RecoverySetupAmbiguousError, RecoverySetupError, RecoveryRestoreAmbiguousError, MutationOutcomeUnknownError, MutationPartialError, RoomCreationAmbiguousError, RoomCleanupIncompleteError, StorageError, UndecryptableFileError, } from "./core/errors.js";
7
7
  import { MAX_MEDIA_FILE_BYTES, MAX_MATRIX_IDENTIFIER_BYTES, validateCanonicalMatrixUserId, validateMatrixDeviceId, validateMatrixEventId, validateMatrixRoomId, } from "./core/constants.js";
8
8
  import { cancelResponseBody, raceWithAbort, readMediaResponseBody, readResponseBody, ResponseBodyReadError, } from "./core/http.js";
9
9
  import { sanitizeDiagnosticText } from "./core/oidc.js";
10
10
  import { validateName } from "./core/validation.js";
11
+ import { ConditionTimeoutError, waitForCondition } from "./core/poll.js";
11
12
  import { isTreeDeleted } from "./deletion-markers.js";
12
13
  import { DecryptionKeySafe } from "./key-safe.js";
14
+ import { prepareReaderHistorySharing } from "./sharing.js";
15
+ import { deleteOwnedMedia } from "./media.js";
13
16
  const STORAGE_METADATA_EVENT_TYPE = "io.telecrypt.storage.metadata";
14
17
  const STORAGE_METADATA_MSGTYPE = "io.telecrypt.storage.metadata";
15
18
  const GENERIC_TREE_NAME = "Encrypted storage";
@@ -357,7 +360,7 @@ function parseMatrixStateResponse(value) {
357
360
  function parseMatrixPowerLevels(value) {
358
361
  if (!isRecord(value))
359
362
  throw new Error("invalid Matrix power-level response");
360
- for (const name of ["users_default", "events_default"]) {
363
+ for (const name of ["users_default", "events_default", "state_default"]) {
361
364
  const field = value[name];
362
365
  if (field !== undefined && (typeof field !== "number" || !Number.isFinite(field))) {
363
366
  throw new Error("invalid Matrix power-level response");
@@ -515,8 +518,7 @@ export class TeleCryptIOStorage {
515
518
  await this.client.sendStateEvent(roomId, STORAGE_METADATA_EVENT_TYPE, { event_id: metadataEventId }, "");
516
519
  this.treeNames.set(roomId, name);
517
520
  }
518
- /** Resolves an encrypted vault/folder name through its pointer state event. */
519
- async getTreeName(roomId, options) {
521
+ async getTreeMetadataEvent(roomId, options) {
520
522
  if (options?.signal?.aborted)
521
523
  throw new StorageError("operation cancelled");
522
524
  await this.refreshRoomState(roomId, options);
@@ -529,9 +531,22 @@ export class TeleCryptIOStorage {
529
531
  throw new StorageError("encrypted storage name is unavailable");
530
532
  }
531
533
  const metadataEventId = validateMatrixEventId(pointerContent.event_id, "storage metadata event ID");
534
+ const event = await this.readEncryptedMetadataMessage(roomId, metadataEventId);
535
+ const content = event.getContent();
536
+ if (!isRecord(content) ||
537
+ content.msgtype !== STORAGE_METADATA_MSGTYPE ||
538
+ typeof content.body !== "string" ||
539
+ Object.hasOwn(content, "file_event_id")) {
540
+ throw new StorageError("encrypted storage name is invalid or unavailable");
541
+ }
542
+ validateName(content.body, "name");
543
+ return event;
544
+ }
545
+ /** Resolves an encrypted vault/folder name through its pointer state event. */
546
+ async getTreeName(roomId, options) {
532
547
  let event;
533
548
  try {
534
- event = await this.readEncryptedMetadataMessage(roomId, metadataEventId);
549
+ event = await this.getTreeMetadataEvent(roomId, options);
535
550
  }
536
551
  catch (error) {
537
552
  // A new device can list storage before restoring room keys. Keep the
@@ -541,16 +556,78 @@ export class TeleCryptIOStorage {
541
556
  throw error;
542
557
  }
543
558
  const content = event.getContent();
544
- if (!isRecord(content) ||
545
- content.msgtype !== STORAGE_METADATA_MSGTYPE ||
546
- typeof content.body !== "string" ||
547
- Object.hasOwn(content, "file_event_id")) {
548
- throw new StorageError("encrypted storage name is invalid or unavailable");
549
- }
550
- validateName(content.body, "name");
551
559
  this.treeNames.set(roomId, content.body);
552
560
  return content.body;
553
561
  }
562
+ /**
563
+ * Waits for the current encrypted vault/folder name, or file metadata, to
564
+ * have its session in the active native key backup. Checks the exact session
565
+ * even when encryption reuses a key and the backup engine emits no new event.
566
+ */
567
+ async waitForKeyBackup(treeId, fileId, options) {
568
+ validateMatrixRoomId(treeId);
569
+ if (fileId !== undefined)
570
+ validateMatrixEventId(fileId, "file event ID");
571
+ const timeoutMs = matrixRequestTimeout({ timeoutMs: options?.timeoutMs ?? options?.localTimeoutMs ?? 20_000 });
572
+ const signal = matrixRequestSignal(options);
573
+ const crypto = this.client.getCrypto();
574
+ if (!crypto)
575
+ throw new StorageError("crypto is not initialized");
576
+ let sessionId;
577
+ try {
578
+ await waitForCondition(async (signal) => {
579
+ if (!sessionId) {
580
+ let event;
581
+ if (fileId === undefined) {
582
+ event = await this.getTreeMetadataEvent(treeId, { signal });
583
+ }
584
+ else {
585
+ const metadataId = await this.getFileMetadataEventId(treeId, fileId, { signal });
586
+ if (!metadataId)
587
+ throw new StorageError("encrypted file name is unavailable");
588
+ event = metadataId === fileId
589
+ ? await this.getOriginalFileEvent(treeId, fileId)
590
+ : await this.getFileRenameMetadataEvent(treeId, fileId, metadataId);
591
+ }
592
+ const wire = event.getWireContent();
593
+ if (event.isRedacted() || event.isDecryptionFailure() ||
594
+ wire.algorithm !== "m.megolm.v1.aes-sha2" || typeof wire.session_id !== "string" || !wire.session_id) {
595
+ throw new StorageError("encrypted storage session is unavailable");
596
+ }
597
+ sessionId = wire.session_id;
598
+ }
599
+ if (signal?.aborted)
600
+ throw new StorageError("operation cancelled");
601
+ const version = await crypto.getActiveSessionBackupVersion();
602
+ if (!version)
603
+ return null;
604
+ let backup;
605
+ try {
606
+ backup = await this.client.http.authedRequest(Method.Get, `/room_keys/keys/${encodeURIComponent(treeId)}/${encodeURIComponent(sessionId)}`, { version }, undefined, { prefix: ClientPrefix.V3, abortSignal: signal });
607
+ }
608
+ catch (error) {
609
+ if (error instanceof MatrixError && error.httpStatus === 404 && error.errcode === "M_NOT_FOUND")
610
+ return null;
611
+ throw error;
612
+ }
613
+ const sessionData = isRecord(backup) ? backup.session_data : undefined;
614
+ if (!isRecord(backup) || !Number.isSafeInteger(backup.first_message_index) ||
615
+ backup.first_message_index < 0 || !isRecord(sessionData) ||
616
+ ["ciphertext", "ephemeral", "mac"].some((key) => typeof sessionData[key] !== "string" || !sessionData[key])) {
617
+ throw new StorageError("server key backup session is invalid");
618
+ }
619
+ return true;
620
+ }, { timeoutMs, signal });
621
+ }
622
+ catch (error) {
623
+ if (signal?.aborted)
624
+ throw new StorageError("operation cancelled", { cause: error });
625
+ if (error instanceof ConditionTimeoutError) {
626
+ throw new StorageError("key backup did not settle before timeout; retry key backup", { cause: error });
627
+ }
628
+ throw error;
629
+ }
630
+ }
554
631
  async writeFileName(treeId, fileId, name) {
555
632
  validateName(name, "file name");
556
633
  validateMatrixEventId(fileId, "file event ID");
@@ -661,30 +738,128 @@ export class TeleCryptIOStorage {
661
738
  }
662
739
  return event;
663
740
  }
664
- async createEncryptedFile(treeId, name, encryptedContents, info, additionalContent) {
741
+ async createEncryptedFile(treeId, name, encryptedContents, info, additionalContent, signal) {
665
742
  validateFileName(name);
743
+ // Authoritative room refresh also repairs legacy owner permissions before
744
+ // publishing new encrypted content to an existing vault.
745
+ await this.refreshRoomState(treeId, { signal });
746
+ if (signal?.aborted)
747
+ throw new StorageError("operation cancelled");
666
748
  const bytes = encryptedContents instanceof Uint8Array
667
749
  ? encryptedContents
668
750
  : new Uint8Array(encryptedContents);
669
751
  const body = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
670
- const uploaded = await this.client.uploadContent(body, { includeFilename: false });
671
- if (typeof uploaded.content_uri !== "string" || uploaded.content_uri.length === 0) {
672
- throw new StorageError("encrypted media upload returned no content URI");
673
- }
674
- const fileInfo = { ...info, url: uploaded.content_uri };
675
- const fileContent = {
676
- ...additionalContent,
677
- msgtype: MsgType.File,
678
- body: name,
679
- url: uploaded.content_uri,
680
- file: fileInfo,
681
- [UNSTABLE_MSC3089_LEAF.name]: {},
682
- };
683
- const sent = await this.client.sendMessage(treeId, fileContent);
684
- const fileEventId = validateMatrixEventId(sent?.event_id, "file upload response event ID");
685
- await this.client.sendStateEvent(treeId, UNSTABLE_MSC3089_BRANCH.name, { active: true, metadata_event_id: fileEventId }, fileEventId);
686
- this.fileNames.set(this.fileNameKey(treeId, fileEventId), name);
687
- return { event_id: fileEventId };
752
+ let mediaId;
753
+ let fileId;
754
+ try {
755
+ const uploaded = await this.client.uploadContent(body, { includeFilename: false });
756
+ if (typeof uploaded.content_uri !== "string" || uploaded.content_uri.length === 0) {
757
+ throw new StorageError("encrypted media upload returned no content URI");
758
+ }
759
+ mediaId = uploaded.content_uri;
760
+ if (signal?.aborted)
761
+ throw new StorageError("operation cancelled");
762
+ const fileContent = {
763
+ ...additionalContent,
764
+ msgtype: MsgType.File,
765
+ body: name,
766
+ url: mediaId,
767
+ file: { ...info, url: mediaId },
768
+ [UNSTABLE_MSC3089_LEAF.name]: {},
769
+ };
770
+ const sent = await this.client.sendMessage(treeId, fileContent);
771
+ fileId = validateMatrixEventId(sent?.event_id, "file upload response event ID");
772
+ if (signal?.aborted)
773
+ throw new StorageError("operation cancelled");
774
+ await this.client.sendStateEvent(treeId, UNSTABLE_MSC3089_BRANCH.name, { active: true, metadata_event_id: fileId }, fileId);
775
+ if (signal?.aborted)
776
+ throw new StorageError("operation cancelled");
777
+ this.fileNames.set(this.fileNameKey(treeId, fileId), name);
778
+ return { event_id: fileId };
779
+ }
780
+ catch (error) {
781
+ const rejected = error instanceof MatrixError &&
782
+ error.httpStatus !== undefined && error.httpStatus >= 400 && error.httpStatus < 500 && error.httpStatus !== 408;
783
+ if (!mediaId) {
784
+ if (rejected)
785
+ throw error;
786
+ throw new MutationOutcomeUnknownError("file upload", { cause: error });
787
+ }
788
+ let cause = error;
789
+ if (rejected) {
790
+ try {
791
+ await this.cleanupFileUpload(treeId, mediaId, fileId);
792
+ }
793
+ catch (cleanupError) {
794
+ cause = new AggregateError([error, cleanupError], "file upload failed and cleanup is incomplete");
795
+ }
796
+ if (cause === error) {
797
+ throw new StorageError("file upload was rejected; uploaded media and attachment were removed", { cause: error });
798
+ }
799
+ }
800
+ throw new MutationPartialError("file upload", fileId ? [mediaId, fileId] : [mediaId], `upload outcome needs reconciliation in ${treeId}; keep these media and attachment IDs and use cleanupFileUpload before uploading again`, { cause, treeId });
801
+ }
802
+ }
803
+ /**
804
+ * Discards a partial upload using its MutationPartialError.completedIds:
805
+ * [mediaId, fileId?]. Explicit cleanup also covers an unacknowledged listing.
806
+ * Keep the same IDs after failure and retry this operation, not the upload.
807
+ */
808
+ async cleanupFileUpload(treeId, mediaId, fileId, options) {
809
+ validateMatrixRoomId(treeId);
810
+ if (!/^mxc:\/\/[^/]+\/[^/?#]+$/u.test(mediaId))
811
+ throw new StorageError("invalid media identifier");
812
+ if (fileId !== undefined)
813
+ validateMatrixEventId(fileId, "file upload event ID");
814
+ const controller = new AbortController();
815
+ const timeout = setTimeout(() => controller.abort(), matrixRequestTimeout(options));
816
+ const abort = () => controller.abort(options?.signal?.reason);
817
+ if (options?.signal?.aborted)
818
+ abort();
819
+ else
820
+ options?.signal?.addEventListener("abort", abort, { once: true });
821
+ try {
822
+ await raceWithAbort((async () => {
823
+ if (controller.signal.aborted)
824
+ throw new StorageError("operation cancelled");
825
+ let attachment;
826
+ let listing = null;
827
+ if (fileId) {
828
+ const raw = await this.client.fetchRoomEvent(treeId, fileId);
829
+ attachment = this.client.getEventMapper()(raw);
830
+ await this.client.decryptEventIfNeeded(attachment);
831
+ const content = attachment.getContent();
832
+ if (attachment.getId() !== fileId || attachment.getRoomId() !== treeId ||
833
+ attachment.getSender() !== this.client.getUserId() ||
834
+ (!attachment.isRedacted() && (attachment.getWireType() !== EventType.RoomMessageEncrypted ||
835
+ attachment.getType() !== EventType.RoomMessage || content.msgtype !== MsgType.File ||
836
+ !isRecord(content.file) || content.file.url !== mediaId))) {
837
+ throw new StorageError("partial upload attachment does not match the current user and media");
838
+ }
839
+ await this.refreshRoomState(treeId, { signal: controller.signal });
840
+ listing = this.getStateEvent(treeId, UNSTABLE_MSC3089_BRANCH.name, fileId);
841
+ if (listing && !listing.isRedacted() &&
842
+ (listing.getSender() !== this.client.getUserId() || listing.getContent().metadata_event_id !== fileId)) {
843
+ throw new StorageError("file changed after upload; use ordinary file deletion");
844
+ }
845
+ }
846
+ if (controller.signal.aborted)
847
+ throw new StorageError("operation cancelled");
848
+ await deleteOwnedMedia(this.client, [mediaId], controller.signal);
849
+ if (listing && !listing.isRedacted()) {
850
+ await withMatrixMutationAbort(() => this.client.redactEvent(treeId, listing.getId()), controller.signal);
851
+ }
852
+ if (attachment && !attachment.isRedacted()) {
853
+ await withMatrixMutationAbort(() => this.client.redactEvent(treeId, fileId), controller.signal);
854
+ }
855
+ if (fileId)
856
+ this.fileNames.delete(this.fileNameKey(treeId, fileId));
857
+ })(), controller.signal, () => new MutationOutcomeUnknownError("file upload cleanup"));
858
+ }
859
+ finally {
860
+ clearTimeout(timeout);
861
+ options?.signal?.removeEventListener("abort", abort);
862
+ }
688
863
  }
689
864
  /**
690
865
  * Refreshes a room's current state from the homeserver. The background sync
@@ -703,6 +878,11 @@ export class TeleCryptIOStorage {
703
878
  abortSignal: matrixRequestSignal(options),
704
879
  });
705
880
  const parsedStateEvents = parseMatrixStateResponse(stateEvents);
881
+ if (await this.repairOwnedTreePermissions(roomId, parsedStateEvents, options)) {
882
+ // Read the accepted native state back instead of fabricating a local
883
+ // permission snapshot. An interrupted repair retries on the next load.
884
+ return this.refreshRoomState(roomId, options);
885
+ }
706
886
  if (typeof room.currentState.setStateEvents === "function") {
707
887
  const refreshed = parsedStateEvents.map((event) => new MatrixEvent(event));
708
888
  // RoomState.setStateEvents overwrites matching tuples, but deliberately
@@ -730,6 +910,49 @@ export class TeleCryptIOStorage {
730
910
  room.currentState.setStateEvents(refreshed);
731
911
  }
732
912
  }
913
+ async repairOwnedTreePermissions(roomId, state, options) {
914
+ if (isTreeDeleted(this.client, roomId))
915
+ return false;
916
+ const event = (type, key = "") => state.find((item) => item.type === type && item.state_key === key);
917
+ const create = event(EventType.RoomCreate);
918
+ const purpose = event(UNSTABLE_MSC3088_PURPOSE.name, UNSTABLE_MSC3089_TREE_SUBTYPE.name);
919
+ if (create?.content?.[RoomCreateTypeField] !== RoomType.Space ||
920
+ purpose?.content?.[UNSTABLE_MSC3088_ENABLED.name] !== true)
921
+ return false;
922
+ const userId = this.client.getUserId();
923
+ if (!userId || create?.sender !== userId ||
924
+ event(EventType.RoomMember, userId)?.content?.membership !== "join") {
925
+ return false;
926
+ }
927
+ const rawPower = event(EventType.RoomPowerLevels)?.content;
928
+ const power = parseMatrixPowerLevels(rawPower);
929
+ const ownerLevel = power.events?.[EventType.RoomPowerLevels] ?? 100;
930
+ if ((power.users?.[userId] ?? power.users_default ?? 0) < ownerLevel)
931
+ return false;
932
+ const events = { ...power.events };
933
+ let changed = (power.state_default ?? 50) < 100;
934
+ for (const type of [EventType.RoomJoinRules, UNSTABLE_MSC3088_PURPOSE.name]) {
935
+ if (events[type] !== undefined && events[type] < 100) {
936
+ events[type] = 100;
937
+ changed = true;
938
+ }
939
+ }
940
+ if (changed) {
941
+ await withMatrixMutationAbort(() => this.client.sendStateEvent(roomId, EventType.RoomPowerLevels, {
942
+ ...rawPower,
943
+ state_default: Math.max(power.state_default ?? 50, 100),
944
+ events,
945
+ }, ""), matrixRequestSignal(options));
946
+ }
947
+ const joinRules = event(EventType.RoomJoinRules)?.content;
948
+ if (joinRules?.join_rule !== "invite") {
949
+ await withMatrixMutationAbort(() => this.client.sendStateEvent(roomId, EventType.RoomJoinRules, {
950
+ join_rule: JoinRule.Invite,
951
+ }, ""), matrixRequestSignal(options));
952
+ changed = true;
953
+ }
954
+ return changed;
955
+ }
733
956
  /**
734
957
  * Recommended entry point: builds the MatrixClient with a persistent crypto
735
958
  * store and the secret-storage callback wiring `keys.*` needs, starts the
@@ -1307,7 +1530,7 @@ export class TeleCryptIOStorage {
1307
1530
  kick: 100,
1308
1531
  ban: 100,
1309
1532
  redact: 100,
1310
- state_default: 0,
1533
+ state_default: 100,
1311
1534
  events_default: 0,
1312
1535
  users_default: 0,
1313
1536
  events: {
@@ -1404,7 +1627,7 @@ export class TeleCryptIOStorage {
1404
1627
  const branch = originalGetFile(fileId);
1405
1628
  return branch ? this.decorateFileBranch(tree.id, branch) : null;
1406
1629
  };
1407
- tree.createFile = (name, encryptedContents, info, additionalContent) => this.createEncryptedFile(tree.id, name, encryptedContents, info, additionalContent);
1630
+ tree.createFile = (name, encryptedContents, info, additionalContent, signal) => this.createEncryptedFile(tree.id, name, encryptedContents, info, additionalContent, signal);
1408
1631
  return tree;
1409
1632
  }
1410
1633
  decorateFileBranch(treeId, branch) {
@@ -1560,8 +1783,14 @@ export class TeleCryptIOStorage {
1560
1783
  if (isTreeDeleted(this.client, room.roomId))
1561
1784
  continue;
1562
1785
  const tree = this.client.unstableGetFileTreeSpace(room.roomId);
1563
- if (tree)
1786
+ if (tree) {
1787
+ // Existing owner sessions repair the persisted permissions on load;
1788
+ // readers never attempt this owner-only mutation.
1789
+ if (this.getStateEvent(room.roomId, EventType.RoomCreate, "")?.getSender() === this.client.getUserId()) {
1790
+ await this.refreshRoomState(room.roomId, { signal });
1791
+ }
1564
1792
  trees.push(this.decorateTreeSpace(tree));
1793
+ }
1565
1794
  }
1566
1795
  return trees;
1567
1796
  }
@@ -1573,6 +1802,9 @@ export class TeleCryptIOStorage {
1573
1802
  const currentParent = this.getTree(parent.id);
1574
1803
  if (!currentParent)
1575
1804
  throw new Error("createSubtree: parent is unavailable");
1805
+ const readers = (await this.listMembers(currentParent, { signal: effectiveSignal }))
1806
+ .filter((member) => member.role === "viewer" &&
1807
+ (member.membership === "join" || member.membership === "invite"));
1576
1808
  const tree = await this.createTreeSpace(name, "createSubtree", effectiveSignal);
1577
1809
  let parentLinkAttempted = false;
1578
1810
  let parentLinkEventId;
@@ -1599,7 +1831,6 @@ export class TeleCryptIOStorage {
1599
1831
  if (this.client.getRoom(tree.id)) {
1600
1832
  await this.refreshRoomState(tree.id, { signal: effectiveSignal });
1601
1833
  }
1602
- return tree;
1603
1834
  }
1604
1835
  catch (error) {
1605
1836
  // Compensation is independent of the caller's cancelled deadline.
@@ -1691,6 +1922,22 @@ export class TeleCryptIOStorage {
1691
1922
  }
1692
1923
  throw error;
1693
1924
  }
1925
+ // The folder and both links now exist. A failed invitation must leave
1926
+ // that known folder available for reconciliation instead of deleting a
1927
+ // room to which another reader may already have received access.
1928
+ try {
1929
+ for (const reader of readers) {
1930
+ if (effectiveSignal?.aborted)
1931
+ throw new StorageError("operation cancelled");
1932
+ const verifyHistorySharing = await prepareReaderHistorySharing(this.client, reader.userId);
1933
+ await withMatrixMutationAbort(() => tree.invite(reader.userId), effectiveSignal);
1934
+ await verifyHistorySharing();
1935
+ }
1936
+ }
1937
+ catch (error) {
1938
+ throw new MutationPartialError("create folder", [tree.id], "the folder was created, but reader access is incomplete; retry sharing the parent vault with its readers", { cause: error });
1939
+ }
1940
+ return tree;
1694
1941
  }, signal);
1695
1942
  }
1696
1943
  getTree(roomId) {
@@ -1779,19 +2026,19 @@ export class TeleCryptIOStorage {
1779
2026
  throw new StorageError("operation cancelled");
1780
2027
  let response;
1781
2028
  try {
1782
- response = await withMatrixMutationAbort(() => tree.createFile(name, new Uint8Array(encrypted.data), encrypted.info, { info: { mimetype, size: data.byteLength } }), signal, "file upload");
2029
+ // Let the in-flight request settle so the uploader can retain returned
2030
+ // media/event IDs on cancellation, then stop before the next write.
2031
+ response = await tree.createFile(name, new Uint8Array(encrypted.data), encrypted.info, { info: { mimetype, size: data.byteLength } }, signal);
1783
2032
  }
1784
2033
  catch (error) {
1785
- // A transport failure after the encrypted event was submitted cannot be
1786
- // distinguished from a failure before commit. Do not let a caller retry
1787
- // this operation and create a duplicate file; only the explicit typed
1788
- // mutation outcome is safe to propagate unchanged.
1789
- if (error instanceof MutationOutcomeUnknownError)
2034
+ // Preserve acknowledged upload stages after cancellation as well.
2035
+ if (error instanceof MutationOutcomeUnknownError || error instanceof MutationPartialError)
1790
2036
  throw error;
2037
+ if (error instanceof StorageError || error instanceof MatrixError)
2038
+ throw error;
2039
+ // An unclassified transport failure can still follow a committed event.
1791
2040
  throw new MutationOutcomeUnknownError("file upload", { cause: error });
1792
2041
  }
1793
- if (signal?.aborted)
1794
- throw new StorageError("operation cancelled");
1795
2042
  try {
1796
2043
  return validateMatrixEventId(response?.event_id, "file upload response event ID");
1797
2044
  }
@@ -1832,7 +2079,7 @@ export class TeleCryptIOStorage {
1832
2079
  const mimetype = eventMetadata.mimetype ?? "application/octet-stream";
1833
2080
  const mxcUrl = info.url;
1834
2081
  const clientAny = this.client;
1835
- const accessToken = clientAny.getAccessToken();
2082
+ let accessToken = clientAny.getAccessToken();
1836
2083
  validateMatrixToken(accessToken);
1837
2084
  let trustedOrigin;
1838
2085
  try {
@@ -1866,6 +2113,7 @@ export class TeleCryptIOStorage {
1866
2113
  else
1867
2114
  signal?.addEventListener("abort", abortExternal, { once: true });
1868
2115
  let ciphertext;
2116
+ let retriedAuthentication = false;
1869
2117
  try {
1870
2118
  for (;;) {
1871
2119
  const res = await raceWithAbort(fetch(currentUrl, {
@@ -1908,13 +2156,22 @@ export class TeleCryptIOStorage {
1908
2156
  if (!res.ok) {
1909
2157
  const statusFailure = new Error(`media download failed: ${res.status}`);
1910
2158
  let detail = "";
2159
+ let unknownToken = false;
1911
2160
  try {
1912
2161
  // Error responses are diagnostics, not product media. Consume the
1913
2162
  // complete body so no provider detail is discarded, then apply
1914
2163
  // the SDK's shared secret/control-character sanitizer before it
1915
2164
  // crosses the SDK boundary.
1916
2165
  const body = await readResponseBody(res, controller.signal);
1917
- detail = sanitizeDiagnosticText(new TextDecoder().decode(body.bytes));
2166
+ const responseText = new TextDecoder().decode(body.bytes);
2167
+ detail = sanitizeDiagnosticText(responseText);
2168
+ if (res.status === 401) {
2169
+ try {
2170
+ const errorBody = JSON.parse(responseText);
2171
+ unknownToken = isRecord(errorBody) && errorBody.errcode === "M_UNKNOWN_TOKEN";
2172
+ }
2173
+ catch { /* Non-Matrix errors retain the complete diagnostic below. */ }
2174
+ }
1918
2175
  }
1919
2176
  catch (bodyError) {
1920
2177
  const partial = bodyError instanceof ResponseBodyReadError
@@ -1925,6 +2182,20 @@ export class TeleCryptIOStorage {
1925
2182
  : bodyError;
1926
2183
  throw new AggregateError([statusFailure, readFailure], "media download failed while reading the response body", { cause: statusFailure });
1927
2184
  }
2185
+ if (unknownToken && !retriedAuthentication) {
2186
+ retriedAuthentication = true;
2187
+ // Matrix's authenticated transport owns token refresh, including
2188
+ // concurrent refreshes and persistence. Its media request path
2189
+ // buffers the whole body and follows redirects, so use a small
2190
+ // authenticated read to renew the session, then retry our bounded
2191
+ // streaming download once with the current token.
2192
+ await raceWithAbort(this.client.whoami(), controller.signal, () => signal?.aborted
2193
+ ? new StorageError("operation cancelled")
2194
+ : new Error("media download timed out"));
2195
+ accessToken = clientAny.getAccessToken();
2196
+ validateMatrixToken(accessToken);
2197
+ continue;
2198
+ }
1928
2199
  throw new Error(`${statusFailure.message}${detail === "" ? "" : `: ${detail}`}`);
1929
2200
  }
1930
2201
  if (!res.body) {
@@ -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
  }
@@ -325,7 +326,17 @@ async function activeTreeDirectories(storage, tree, signal) {
325
326
  // MSC3089 TreeSpace projection includes that child. Resolve the exact
326
327
  // room named by the active relation instead of treating that brief
327
328
  // projection lag as corrupt folder state.
328
- return await waitForCondition(() => storage.getTree(childId), { timeoutMs: 15000, signal });
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 });
329
340
  }
330
341
  catch (error) {
331
342
  if (signal?.aborted)
@@ -546,64 +557,98 @@ export function getMyVaultRole(storage, vaultId) {
546
557
  return null;
547
558
  return tree.getPermissions(userId);
548
559
  }
549
- export async function joinVault(storage, vaultId, options) {
550
- await storage.keySafe.requireReady({ signal: options?.signal });
551
- return withOperationDeadline(options, async (signal) => {
552
- 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.
553
588
  try {
554
- membership = await storage.getRoomMembership(vaultId, undefined, { signal });
589
+ await waitForCondition(() => client.getRoom(vaultId)?.getMember(userId)?.membership === "invite" ? true : null, { timeoutMs: 15000, signal });
555
590
  }
556
591
  catch (err) {
557
592
  if (signal.aborted)
558
593
  throw new StorageError("operation cancelled");
559
- // Synapse refuses GET /rooms/:room/members for an invited user with
560
- // M_FORBIDDEN. That response means the membership preflight cannot
561
- // inspect the room yet; it is not evidence that joining is forbidden.
562
- // Attempt the idempotent join and let its own response decide access.
563
- if (err instanceof MatrixError && err.errcode === "M_FORBIDDEN") {
564
- membership = null;
565
- }
566
- else {
567
- 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 });
568
596
  }
597
+ throw err;
569
598
  }
570
- if (membership === "join")
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
- }
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;
590
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];
591
621
  try {
592
- await withRateLimitRetry(() => withMatrixMutationAbort(() => client.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
+ }
593
645
  }
594
- catch (err) {
595
- if (err instanceof MutationOutcomeUnknownError)
596
- throw err;
597
- if (err instanceof MatrixError && err.errcode === "M_FORBIDDEN") {
598
- const afterForbidden = await storage.getRoomMembership(vaultId, undefined, { signal });
599
- if (afterForbidden === "join")
600
- 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 });
601
649
  }
602
- if (signal.aborted)
603
- throw new StorageError("operation cancelled");
604
- throw new StorageError("join failed", { cause: err });
650
+ throw error;
605
651
  }
606
- ensureOperationActive(signal);
607
652
  return { vaultId, joined: true };
608
653
  }, "mutation");
609
654
  }
@@ -780,14 +825,7 @@ export async function shareVault(storage, vaultId, userId, role, options) {
780
825
  throw new StorageError("share will not demote an existing owner");
781
826
  }
782
827
  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
- }
828
+ const verifyHistorySharing = await prepareReaderHistorySharing(client, userId);
791
829
  for (const [index, space] of spaces.entries()) {
792
830
  ensureOperationActive(operation.signal);
793
831
  const members = index === 0
@@ -807,12 +845,9 @@ export async function shareVault(storage, vaultId, userId, role, options) {
807
845
  // pending invitations too: an earlier attempt may have sent only an
808
846
  // invitation. In particular, never swallow a media-upload 403 merely
809
847
  // because an invitation already exists.
810
- await withRateLimitRetry(() => withMatrixMutationAbort(() => space.invite(userId), operation.signal), operation.signal);
848
+ await withRateLimitRetry(() => withMatrixMutationAbort(() => space.invite(userId, false), operation.signal), operation.signal);
811
849
  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");
815
- }
850
+ await verifyHistorySharing();
816
851
  }
817
852
  // Storage rooms have one immutable owner power level and readers use
818
853
  // the room's default user level. Changing per-user power levels would create editors and
@@ -1121,18 +1156,10 @@ export async function renameFile(storage, treeId, fileId, name, options) {
1121
1156
  return { id: fileId, name };
1122
1157
  }, "mutation");
1123
1158
  }
1124
- function requireFileDeletionTransport(client) {
1125
- const http = client.http;
1126
- if (!http || typeof http.authedRequest !== "function") {
1127
- throw new StorageError("Matrix HTTP transport unavailable");
1128
- }
1129
- return http;
1130
- }
1131
1159
  async function deleteFileMedia(storage, mediaIds, signal) {
1132
1160
  const client = storage.getClient();
1133
- const http = requireFileDeletionTransport(client);
1134
1161
  try {
1135
- 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);
1136
1163
  }
1137
1164
  catch (error) {
1138
1165
  if (error instanceof MutationOutcomeUnknownError)
@@ -1150,8 +1177,6 @@ export async function deleteFile(storage, treeId, fileId, options) {
1150
1177
  return { id: fileId, deleted: true };
1151
1178
  await storage.refreshRoomState(treeId, { signal });
1152
1179
  const branch = tree.getFile(fileId);
1153
- if (!branch)
1154
- throw new StorageError("file not found");
1155
1180
  let originalEvent;
1156
1181
  try {
1157
1182
  originalEvent = await storage.getOriginalFileEvent(tree.id, fileId, true);
@@ -1160,7 +1185,7 @@ export async function deleteFile(storage, treeId, fileId, options) {
1160
1185
  throw new StorageError("could not resolve encrypted file safely", { cause: error });
1161
1186
  }
1162
1187
  if (originalEvent.isRedacted()) {
1163
- if (branch.isActive === false) {
1188
+ if (!branch || branch.isActive === false) {
1164
1189
  markFileDeleted(storage.getClient(), tree.id, fileId);
1165
1190
  return { id: fileId, deleted: true };
1166
1191
  }
@@ -1184,7 +1209,9 @@ export async function deleteFile(storage, treeId, fileId, options) {
1184
1209
  const completedIds = [mediaId];
1185
1210
  try {
1186
1211
  const client = storage.getClient();
1187
- const metadataEventId = await storage.getFileMetadataEventId(tree.id, fileId, { signal });
1212
+ const metadataEventId = branch
1213
+ ? await storage.getFileMetadataEventId(tree.id, fileId, { signal })
1214
+ : null;
1188
1215
  if (metadataEventId && metadataEventId !== fileId) {
1189
1216
  const metadataEvent = await storage.getFileRenameMetadataEvent(tree.id, fileId, metadataEventId);
1190
1217
  if (!metadataEvent.isRedacted()) {
@@ -1195,16 +1222,17 @@ export async function deleteFile(storage, treeId, fileId, options) {
1195
1222
  const currentBranch = tree.getFile(fileId);
1196
1223
  const indexEvent = currentBranch?.indexEvent;
1197
1224
  const listingEventId = indexEvent?.getId();
1198
- if (!indexEvent || typeof listingEventId !== "string" || listingEventId.length === 0) {
1225
+ if (currentBranch && (!indexEvent || typeof listingEventId !== "string" || listingEventId.length === 0)) {
1199
1226
  throw new StorageError("encrypted file has no listing event identifier");
1200
1227
  }
1201
- if (!indexEvent.isRedacted()) {
1228
+ if (indexEvent && !indexEvent.isRedacted()) {
1202
1229
  if (indexEvent.getSender() !== client.getUserId()) {
1203
1230
  throw new StorageError("file listing was not authored by the owner");
1204
1231
  }
1205
1232
  await withRateLimitRetry(() => withMatrixMutationAbort(() => client.redactEvent(tree.id, listingEventId), signal, "delete file listing"), signal);
1206
1233
  }
1207
- completedIds.push(listingEventId);
1234
+ if (listingEventId)
1235
+ completedIds.push(listingEventId);
1208
1236
  await withRateLimitRetry(() => withMatrixMutationAbort(() => client.redactEvent(tree.id, fileId), signal, "delete file attachment event"), signal);
1209
1237
  completedIds.push(fileId);
1210
1238
  }
@@ -1235,12 +1263,18 @@ export async function deleteFile(storage, treeId, fileId, options) {
1235
1263
  }
1236
1264
  export async function uploadFile(storage, treeId, name, bytes, mimetype, options) {
1237
1265
  await storage.keySafe.requireReady({ signal: options?.signal });
1238
- return withOperationDeadline(options, async (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.
1239
1273
  const tree = await resolveTree(storage, treeId, signal);
1240
1274
  const fileId = await withRateLimitRetry(() => storage.uploadFile(tree, name, toArrayBuffer(bytes), mimetype, signal), signal).catch((error) => {
1241
1275
  if (error instanceof FileTooLargeError)
1242
1276
  throw error;
1243
- if (error instanceof MutationOutcomeUnknownError)
1277
+ if (error instanceof MutationOutcomeUnknownError || error instanceof MutationPartialError)
1244
1278
  throw error;
1245
1279
  if (signal.aborted)
1246
1280
  throw new StorageError("operation cancelled");
@@ -1263,10 +1297,20 @@ export async function uploadFile(storage, treeId, name, bytes, mimetype, options
1263
1297
  catch (error) {
1264
1298
  if (error instanceof MutationOutcomeUnknownError)
1265
1299
  throw error;
1266
- 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 });
1267
1301
  }
1268
1302
  ensureOperationActive(signal);
1269
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 });
1270
1314
  }, "mutation");
1271
1315
  }
1272
1316
  export async function downloadFile(storage, treeId, fileId, options) {
@@ -0,0 +1,3 @@
1
+ import type { MatrixClient } from "matrix-js-sdk";
2
+ /** The policy endpoint authorizes deletion against the uploading user. */
3
+ export declare function deleteOwnedMedia(client: MatrixClient, mediaIds: readonly string[], signal?: AbortSignal): Promise<void>;
package/dist/media.js ADDED
@@ -0,0 +1,10 @@
1
+ import { Method } from "matrix-js-sdk/lib/http-api/method.js";
2
+ import { ClientPrefix } from "matrix-js-sdk/lib/http-api/prefix.js";
3
+ import { StorageError } from "./core/errors.js";
4
+ /** The policy endpoint authorizes deletion against the uploading user. */
5
+ export async function deleteOwnedMedia(client, mediaIds, signal) {
6
+ if (!client.http || typeof client.http.authedRequest !== "function") {
7
+ throw new StorageError("Matrix HTTP transport unavailable");
8
+ }
9
+ await client.http.authedRequest(Method.Post, "/io.telecrypt.storage/delete_media", undefined, { media_ids: [...mediaIds] }, { prefix: ClientPrefix.Unstable, rawResponseBody: true, abortSignal: signal });
10
+ }
@@ -0,0 +1,3 @@
1
+ import type { MatrixClient } from "matrix-js-sdk";
2
+ /** Use the same account/device requirements for explicit and inherited sharing. */
3
+ export declare function prepareReaderHistorySharing(client: MatrixClient, userId: string): Promise<() => Promise<void>>;
@@ -0,0 +1,18 @@
1
+ import { StorageError } from "./core/errors.js";
2
+ /** Use the same account/device requirements for explicit and inherited sharing. */
3
+ export async function prepareReaderHistorySharing(client, userId) {
4
+ const crypto = client.getCrypto();
5
+ if (!crypto)
6
+ throw new StorageError("sharing requires initialized encryption");
7
+ const recipient = await client.downloadKeysForUsers([userId]);
8
+ const devices = Object.keys(recipient.device_keys?.[userId] ?? {});
9
+ if (!recipient.master_keys?.[userId] || !recipient.self_signing_keys?.[userId] || devices.length === 0) {
10
+ throw new StorageError("The reader must set up or unlock their Decryption Key Safe before sharing");
11
+ }
12
+ return async () => {
13
+ const statuses = await Promise.all(devices.map((id) => crypto.getDeviceVerificationStatus(userId, id)));
14
+ if (!statuses.some((status) => status?.signedByOwner)) {
15
+ 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");
16
+ }
17
+ };
18
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telecrypt-io/storage",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "license": "BUSL-1.1",
5
5
  "type": "module",
6
6
  "packageManager": "npm@11.19.0",