@telecrypt-io/storage 0.9.0 → 0.9.3

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.
@@ -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
- import { validateName } from "./core/validation.js";
10
+ import { isStorageTreeRoom, isStorageTreeState, STORAGE_ROOM_ENCRYPTION_ALGORITHM, 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");
@@ -481,9 +484,16 @@ export class TeleCryptIOStorage {
481
484
  return ownerId;
482
485
  }
483
486
  async sendEncryptedMetadataMessage(roomId, content) {
487
+ await this.refreshRoomState(roomId);
488
+ this.requireEncryptedStorageRoom(roomId);
484
489
  const response = await this.client.sendMessage(roomId, content);
485
490
  return validateMatrixEventId(response?.event_id, "storage metadata response event ID");
486
491
  }
492
+ requireEncryptedStorageRoom(roomId) {
493
+ if (!isStorageTreeRoom(this.client.getRoom(roomId))) {
494
+ throw new StorageError("storage room encryption is unavailable or unsupported");
495
+ }
496
+ }
487
497
  async readEncryptedMetadataMessage(roomId, eventId, allowRedacted = false) {
488
498
  const rawEvent = await this.client.fetchRoomEvent(roomId, eventId);
489
499
  const event = this.client.getEventMapper()(rawEvent);
@@ -515,8 +525,7 @@ export class TeleCryptIOStorage {
515
525
  await this.client.sendStateEvent(roomId, STORAGE_METADATA_EVENT_TYPE, { event_id: metadataEventId }, "");
516
526
  this.treeNames.set(roomId, name);
517
527
  }
518
- /** Resolves an encrypted vault/folder name through its pointer state event. */
519
- async getTreeName(roomId, options) {
528
+ async getTreeMetadataEvent(roomId, options) {
520
529
  if (options?.signal?.aborted)
521
530
  throw new StorageError("operation cancelled");
522
531
  await this.refreshRoomState(roomId, options);
@@ -529,9 +538,22 @@ export class TeleCryptIOStorage {
529
538
  throw new StorageError("encrypted storage name is unavailable");
530
539
  }
531
540
  const metadataEventId = validateMatrixEventId(pointerContent.event_id, "storage metadata event ID");
541
+ const event = await this.readEncryptedMetadataMessage(roomId, metadataEventId);
542
+ const content = event.getContent();
543
+ if (!isRecord(content) ||
544
+ content.msgtype !== STORAGE_METADATA_MSGTYPE ||
545
+ typeof content.body !== "string" ||
546
+ Object.hasOwn(content, "file_event_id")) {
547
+ throw new StorageError("encrypted storage name is invalid or unavailable");
548
+ }
549
+ validateName(content.body, "name");
550
+ return event;
551
+ }
552
+ /** Resolves an encrypted vault/folder name through its pointer state event. */
553
+ async getTreeName(roomId, options) {
532
554
  let event;
533
555
  try {
534
- event = await this.readEncryptedMetadataMessage(roomId, metadataEventId);
556
+ event = await this.getTreeMetadataEvent(roomId, options);
535
557
  }
536
558
  catch (error) {
537
559
  // A new device can list storage before restoring room keys. Keep the
@@ -541,16 +563,78 @@ export class TeleCryptIOStorage {
541
563
  throw error;
542
564
  }
543
565
  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
566
  this.treeNames.set(roomId, content.body);
552
567
  return content.body;
553
568
  }
569
+ /**
570
+ * Waits for the current encrypted vault/folder name, or file metadata, to
571
+ * have its session in the active native key backup. Checks the exact session
572
+ * even when encryption reuses a key and the backup engine emits no new event.
573
+ */
574
+ async waitForKeyBackup(treeId, fileId, options) {
575
+ validateMatrixRoomId(treeId);
576
+ if (fileId !== undefined)
577
+ validateMatrixEventId(fileId, "file event ID");
578
+ const timeoutMs = matrixRequestTimeout({ timeoutMs: options?.timeoutMs ?? options?.localTimeoutMs ?? 20_000 });
579
+ const signal = matrixRequestSignal(options);
580
+ const crypto = this.client.getCrypto();
581
+ if (!crypto)
582
+ throw new StorageError("crypto is not initialized");
583
+ let sessionId;
584
+ try {
585
+ await waitForCondition(async (signal) => {
586
+ if (!sessionId) {
587
+ let event;
588
+ if (fileId === undefined) {
589
+ event = await this.getTreeMetadataEvent(treeId, { signal });
590
+ }
591
+ else {
592
+ const metadataId = await this.getFileMetadataEventId(treeId, fileId, { signal });
593
+ if (!metadataId)
594
+ throw new StorageError("encrypted file name is unavailable");
595
+ event = metadataId === fileId
596
+ ? await this.getOriginalFileEvent(treeId, fileId)
597
+ : await this.getFileRenameMetadataEvent(treeId, fileId, metadataId);
598
+ }
599
+ const wire = event.getWireContent();
600
+ if (event.isRedacted() || event.isDecryptionFailure() ||
601
+ wire.algorithm !== STORAGE_ROOM_ENCRYPTION_ALGORITHM || typeof wire.session_id !== "string" || !wire.session_id) {
602
+ throw new StorageError("encrypted storage session is unavailable");
603
+ }
604
+ sessionId = wire.session_id;
605
+ }
606
+ if (signal?.aborted)
607
+ throw new StorageError("operation cancelled");
608
+ const version = await crypto.getActiveSessionBackupVersion();
609
+ if (!version)
610
+ return null;
611
+ let backup;
612
+ try {
613
+ backup = await this.client.http.authedRequest(Method.Get, `/room_keys/keys/${encodeURIComponent(treeId)}/${encodeURIComponent(sessionId)}`, { version }, undefined, { prefix: ClientPrefix.V3, abortSignal: signal });
614
+ }
615
+ catch (error) {
616
+ if (error instanceof MatrixError && error.httpStatus === 404 && error.errcode === "M_NOT_FOUND")
617
+ return null;
618
+ throw error;
619
+ }
620
+ const sessionData = isRecord(backup) ? backup.session_data : undefined;
621
+ if (!isRecord(backup) || !Number.isSafeInteger(backup.first_message_index) ||
622
+ backup.first_message_index < 0 || !isRecord(sessionData) ||
623
+ ["ciphertext", "ephemeral", "mac"].some((key) => typeof sessionData[key] !== "string" || !sessionData[key])) {
624
+ throw new StorageError("server key backup session is invalid");
625
+ }
626
+ return true;
627
+ }, { timeoutMs, signal });
628
+ }
629
+ catch (error) {
630
+ if (signal?.aborted)
631
+ throw new StorageError("operation cancelled", { cause: error });
632
+ if (error instanceof ConditionTimeoutError) {
633
+ throw new StorageError("key backup did not settle before timeout; retry key backup", { cause: error });
634
+ }
635
+ throw error;
636
+ }
637
+ }
554
638
  async writeFileName(treeId, fileId, name) {
555
639
  validateName(name, "file name");
556
640
  validateMatrixEventId(fileId, "file event ID");
@@ -661,30 +745,129 @@ export class TeleCryptIOStorage {
661
745
  }
662
746
  return event;
663
747
  }
664
- async createEncryptedFile(treeId, name, encryptedContents, info, additionalContent) {
748
+ async createEncryptedFile(treeId, name, encryptedContents, info, additionalContent, signal) {
665
749
  validateFileName(name);
750
+ // Authoritative room refresh also repairs legacy owner permissions before
751
+ // publishing new encrypted content to an existing vault.
752
+ await this.refreshRoomState(treeId, { signal });
753
+ if (signal?.aborted)
754
+ throw new StorageError("operation cancelled");
755
+ this.requireEncryptedStorageRoom(treeId);
666
756
  const bytes = encryptedContents instanceof Uint8Array
667
757
  ? encryptedContents
668
758
  : new Uint8Array(encryptedContents);
669
759
  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 };
760
+ let mediaId;
761
+ let fileId;
762
+ try {
763
+ const uploaded = await this.client.uploadContent(body, { includeFilename: false });
764
+ if (typeof uploaded.content_uri !== "string" || uploaded.content_uri.length === 0) {
765
+ throw new StorageError("encrypted media upload returned no content URI");
766
+ }
767
+ mediaId = uploaded.content_uri;
768
+ if (signal?.aborted)
769
+ throw new StorageError("operation cancelled");
770
+ const fileContent = {
771
+ ...additionalContent,
772
+ msgtype: MsgType.File,
773
+ body: name,
774
+ url: mediaId,
775
+ file: { ...info, url: mediaId },
776
+ [UNSTABLE_MSC3089_LEAF.name]: {},
777
+ };
778
+ const sent = await this.client.sendMessage(treeId, fileContent);
779
+ fileId = validateMatrixEventId(sent?.event_id, "file upload response event ID");
780
+ if (signal?.aborted)
781
+ throw new StorageError("operation cancelled");
782
+ await this.client.sendStateEvent(treeId, UNSTABLE_MSC3089_BRANCH.name, { active: true, metadata_event_id: fileId }, fileId);
783
+ if (signal?.aborted)
784
+ throw new StorageError("operation cancelled");
785
+ this.fileNames.set(this.fileNameKey(treeId, fileId), name);
786
+ return { event_id: fileId };
787
+ }
788
+ catch (error) {
789
+ const rejected = error instanceof MatrixError &&
790
+ error.httpStatus !== undefined && error.httpStatus >= 400 && error.httpStatus < 500 && error.httpStatus !== 408;
791
+ if (!mediaId) {
792
+ if (rejected)
793
+ throw error;
794
+ throw new MutationOutcomeUnknownError("file upload", { cause: error });
795
+ }
796
+ let cause = error;
797
+ if (rejected) {
798
+ try {
799
+ await this.cleanupFileUpload(treeId, mediaId, fileId);
800
+ }
801
+ catch (cleanupError) {
802
+ cause = new AggregateError([error, cleanupError], "file upload failed and cleanup is incomplete");
803
+ }
804
+ if (cause === error) {
805
+ throw new StorageError("file upload was rejected; uploaded media and attachment were removed", { cause: error });
806
+ }
807
+ }
808
+ 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 });
809
+ }
810
+ }
811
+ /**
812
+ * Discards a partial upload using its MutationPartialError.completedIds:
813
+ * [mediaId, fileId?]. Explicit cleanup also covers an unacknowledged listing.
814
+ * Keep the same IDs after failure and retry this operation, not the upload.
815
+ */
816
+ async cleanupFileUpload(treeId, mediaId, fileId, options) {
817
+ validateMatrixRoomId(treeId);
818
+ if (!/^mxc:\/\/[^/]+\/[^/?#]+$/u.test(mediaId))
819
+ throw new StorageError("invalid media identifier");
820
+ if (fileId !== undefined)
821
+ validateMatrixEventId(fileId, "file upload event ID");
822
+ const controller = new AbortController();
823
+ const timeout = setTimeout(() => controller.abort(), matrixRequestTimeout(options));
824
+ const abort = () => controller.abort(options?.signal?.reason);
825
+ if (options?.signal?.aborted)
826
+ abort();
827
+ else
828
+ options?.signal?.addEventListener("abort", abort, { once: true });
829
+ try {
830
+ await raceWithAbort((async () => {
831
+ if (controller.signal.aborted)
832
+ throw new StorageError("operation cancelled");
833
+ let attachment;
834
+ let listing = null;
835
+ if (fileId) {
836
+ const raw = await this.client.fetchRoomEvent(treeId, fileId);
837
+ attachment = this.client.getEventMapper()(raw);
838
+ await this.client.decryptEventIfNeeded(attachment);
839
+ const content = attachment.getContent();
840
+ if (attachment.getId() !== fileId || attachment.getRoomId() !== treeId ||
841
+ attachment.getSender() !== this.client.getUserId() ||
842
+ (!attachment.isRedacted() && (attachment.getWireType() !== EventType.RoomMessageEncrypted ||
843
+ attachment.getType() !== EventType.RoomMessage || content.msgtype !== MsgType.File ||
844
+ !isRecord(content.file) || content.file.url !== mediaId))) {
845
+ throw new StorageError("partial upload attachment does not match the current user and media");
846
+ }
847
+ await this.refreshRoomState(treeId, { signal: controller.signal });
848
+ listing = this.getStateEvent(treeId, UNSTABLE_MSC3089_BRANCH.name, fileId);
849
+ if (listing && !listing.isRedacted() &&
850
+ (listing.getSender() !== this.client.getUserId() || listing.getContent().metadata_event_id !== fileId)) {
851
+ throw new StorageError("file changed after upload; use ordinary file deletion");
852
+ }
853
+ }
854
+ if (controller.signal.aborted)
855
+ throw new StorageError("operation cancelled");
856
+ await deleteOwnedMedia(this.client, [mediaId], controller.signal);
857
+ if (listing && !listing.isRedacted()) {
858
+ await withMatrixMutationAbort(() => this.client.redactEvent(treeId, listing.getId()), controller.signal);
859
+ }
860
+ if (attachment && !attachment.isRedacted()) {
861
+ await withMatrixMutationAbort(() => this.client.redactEvent(treeId, fileId), controller.signal);
862
+ }
863
+ if (fileId)
864
+ this.fileNames.delete(this.fileNameKey(treeId, fileId));
865
+ })(), controller.signal, () => new MutationOutcomeUnknownError("file upload cleanup"));
866
+ }
867
+ finally {
868
+ clearTimeout(timeout);
869
+ options?.signal?.removeEventListener("abort", abort);
870
+ }
688
871
  }
689
872
  /**
690
873
  * Refreshes a room's current state from the homeserver. The background sync
@@ -703,6 +886,11 @@ export class TeleCryptIOStorage {
703
886
  abortSignal: matrixRequestSignal(options),
704
887
  });
705
888
  const parsedStateEvents = parseMatrixStateResponse(stateEvents);
889
+ if (await this.repairOwnedTreePermissions(roomId, parsedStateEvents, options)) {
890
+ // Read the accepted native state back instead of fabricating a local
891
+ // permission snapshot. An interrupted repair retries on the next load.
892
+ return this.refreshRoomState(roomId, options);
893
+ }
706
894
  if (typeof room.currentState.setStateEvents === "function") {
707
895
  const refreshed = parsedStateEvents.map((event) => new MatrixEvent(event));
708
896
  // RoomState.setStateEvents overwrites matching tuples, but deliberately
@@ -730,6 +918,49 @@ export class TeleCryptIOStorage {
730
918
  room.currentState.setStateEvents(refreshed);
731
919
  }
732
920
  }
921
+ async repairOwnedTreePermissions(roomId, state, options) {
922
+ if (isTreeDeleted(this.client, roomId))
923
+ return false;
924
+ const event = (type, key = "") => state.find((item) => item.type === type && item.state_key === key);
925
+ const create = event(EventType.RoomCreate);
926
+ const purpose = event(UNSTABLE_MSC3088_PURPOSE.name, UNSTABLE_MSC3089_TREE_SUBTYPE.name);
927
+ const encryption = event(EventType.RoomEncryption);
928
+ if (!isStorageTreeState(create?.content, purpose?.content, encryption?.content))
929
+ return false;
930
+ const userId = this.client.getUserId();
931
+ if (!userId || create?.sender !== userId ||
932
+ event(EventType.RoomMember, userId)?.content?.membership !== "join") {
933
+ return false;
934
+ }
935
+ const rawPower = event(EventType.RoomPowerLevels)?.content;
936
+ const power = parseMatrixPowerLevels(rawPower);
937
+ const ownerLevel = power.events?.[EventType.RoomPowerLevels] ?? 100;
938
+ if ((power.users?.[userId] ?? power.users_default ?? 0) < ownerLevel)
939
+ return false;
940
+ const events = { ...power.events };
941
+ let changed = (power.state_default ?? 50) < 100;
942
+ for (const type of [EventType.RoomJoinRules, UNSTABLE_MSC3088_PURPOSE.name]) {
943
+ if (events[type] !== undefined && events[type] < 100) {
944
+ events[type] = 100;
945
+ changed = true;
946
+ }
947
+ }
948
+ if (changed) {
949
+ await withMatrixMutationAbort(() => this.client.sendStateEvent(roomId, EventType.RoomPowerLevels, {
950
+ ...rawPower,
951
+ state_default: Math.max(power.state_default ?? 50, 100),
952
+ events,
953
+ }, ""), matrixRequestSignal(options));
954
+ }
955
+ const joinRules = event(EventType.RoomJoinRules)?.content;
956
+ if (joinRules?.join_rule !== "invite") {
957
+ await withMatrixMutationAbort(() => this.client.sendStateEvent(roomId, EventType.RoomJoinRules, {
958
+ join_rule: JoinRule.Invite,
959
+ }, ""), matrixRequestSignal(options));
960
+ changed = true;
961
+ }
962
+ return changed;
963
+ }
733
964
  /**
734
965
  * Recommended entry point: builds the MatrixClient with a persistent crypto
735
966
  * store and the secret-storage callback wiring `keys.*` needs, starts the
@@ -1307,7 +1538,7 @@ export class TeleCryptIOStorage {
1307
1538
  kick: 100,
1308
1539
  ban: 100,
1309
1540
  redact: 100,
1310
- state_default: 0,
1541
+ state_default: 100,
1311
1542
  events_default: 0,
1312
1543
  users_default: 0,
1313
1544
  events: {
@@ -1336,7 +1567,7 @@ export class TeleCryptIOStorage {
1336
1567
  {
1337
1568
  type: EventType.RoomEncryption,
1338
1569
  state_key: "",
1339
- content: { algorithm: "m.megolm.v1.aes-sha2" },
1570
+ content: { algorithm: STORAGE_ROOM_ENCRYPTION_ALGORITHM },
1340
1571
  },
1341
1572
  ],
1342
1573
  };
@@ -1349,8 +1580,9 @@ export class TeleCryptIOStorage {
1349
1580
  throw new StorageError("operation cancelled");
1350
1581
  try {
1351
1582
  const tree = this.client.unstableGetFileTreeSpace(roomId);
1352
- if (tree)
1583
+ if (tree && isStorageTreeRoom(this.client.getRoom(roomId))) {
1353
1584
  return this.decorateTreeSpace(tree);
1585
+ }
1354
1586
  }
1355
1587
  catch (error) {
1356
1588
  lastError = error;
@@ -1390,13 +1622,29 @@ export class TeleCryptIOStorage {
1390
1622
  return tree;
1391
1623
  this.decoratedTreeSpaces.add(tree);
1392
1624
  const originalGetDirectories = tree.getDirectories.bind(tree);
1625
+ const originalGetDirectory = typeof tree.getDirectory === "function"
1626
+ ? tree.getDirectory.bind(tree)
1627
+ : undefined;
1393
1628
  const originalListFiles = typeof tree.listFiles === "function"
1394
1629
  ? tree.listFiles.bind(tree)
1395
1630
  : () => [];
1396
1631
  const originalGetFile = typeof tree.getFile === "function"
1397
1632
  ? tree.getFile.bind(tree)
1398
1633
  : () => null;
1399
- tree.getDirectories = () => originalGetDirectories().map((child) => this.decorateTreeSpace(child));
1634
+ tree.getDirectories = () => originalGetDirectories()
1635
+ .filter((child) => !isTreeDeleted(this.client, child.id) &&
1636
+ isStorageTreeRoom(this.client.getRoom(child.id)))
1637
+ .map((child) => this.decorateTreeSpace(child));
1638
+ if (originalGetDirectory) {
1639
+ tree.getDirectory = (roomId) => {
1640
+ const child = originalGetDirectory(roomId);
1641
+ return child &&
1642
+ !isTreeDeleted(this.client, child.id) &&
1643
+ isStorageTreeRoom(this.client.getRoom(child.id))
1644
+ ? this.decorateTreeSpace(child)
1645
+ : undefined;
1646
+ };
1647
+ }
1400
1648
  tree.createDirectory = (name) => this.createSubtree(tree, name);
1401
1649
  tree.setName = (name) => this.writeTreeName(tree.id, name);
1402
1650
  tree.listFiles = () => originalListFiles().map((branch) => this.decorateFileBranch(tree.id, branch));
@@ -1404,7 +1652,7 @@ export class TeleCryptIOStorage {
1404
1652
  const branch = originalGetFile(fileId);
1405
1653
  return branch ? this.decorateFileBranch(tree.id, branch) : null;
1406
1654
  };
1407
- tree.createFile = (name, encryptedContents, info, additionalContent) => this.createEncryptedFile(tree.id, name, encryptedContents, info, additionalContent);
1655
+ tree.createFile = (name, encryptedContents, info, additionalContent, signal) => this.createEncryptedFile(tree.id, name, encryptedContents, info, additionalContent, signal);
1408
1656
  return tree;
1409
1657
  }
1410
1658
  decorateFileBranch(treeId, branch) {
@@ -1559,9 +1807,17 @@ export class TeleCryptIOStorage {
1559
1807
  throw new StorageError("operation cancelled");
1560
1808
  if (isTreeDeleted(this.client, room.roomId))
1561
1809
  continue;
1562
- const tree = this.client.unstableGetFileTreeSpace(room.roomId);
1563
- if (tree)
1564
- trees.push(this.decorateTreeSpace(tree));
1810
+ let tree = this.getTree(room.roomId);
1811
+ if (tree) {
1812
+ // Existing owner sessions repair the persisted permissions on load;
1813
+ // readers never attempt this owner-only mutation.
1814
+ if (this.getStateEvent(room.roomId, EventType.RoomCreate, "")?.getSender() === this.client.getUserId()) {
1815
+ await this.refreshRoomState(room.roomId, { signal });
1816
+ tree = this.getTree(room.roomId);
1817
+ }
1818
+ if (tree)
1819
+ trees.push(tree);
1820
+ }
1565
1821
  }
1566
1822
  return trees;
1567
1823
  }
@@ -1573,6 +1829,9 @@ export class TeleCryptIOStorage {
1573
1829
  const currentParent = this.getTree(parent.id);
1574
1830
  if (!currentParent)
1575
1831
  throw new Error("createSubtree: parent is unavailable");
1832
+ const readers = (await this.listMembers(currentParent, { signal: effectiveSignal }))
1833
+ .filter((member) => member.role === "viewer" &&
1834
+ (member.membership === "join" || member.membership === "invite"));
1576
1835
  const tree = await this.createTreeSpace(name, "createSubtree", effectiveSignal);
1577
1836
  let parentLinkAttempted = false;
1578
1837
  let parentLinkEventId;
@@ -1599,7 +1858,6 @@ export class TeleCryptIOStorage {
1599
1858
  if (this.client.getRoom(tree.id)) {
1600
1859
  await this.refreshRoomState(tree.id, { signal: effectiveSignal });
1601
1860
  }
1602
- return tree;
1603
1861
  }
1604
1862
  catch (error) {
1605
1863
  // Compensation is independent of the caller's cancelled deadline.
@@ -1691,11 +1949,29 @@ export class TeleCryptIOStorage {
1691
1949
  }
1692
1950
  throw error;
1693
1951
  }
1952
+ // The folder and both links now exist. A failed invitation must leave
1953
+ // that known folder available for reconciliation instead of deleting a
1954
+ // room to which another reader may already have received access.
1955
+ try {
1956
+ for (const reader of readers) {
1957
+ if (effectiveSignal?.aborted)
1958
+ throw new StorageError("operation cancelled");
1959
+ const verifyHistorySharing = await prepareReaderHistorySharing(this.client, reader.userId);
1960
+ await withMatrixMutationAbort(() => tree.invite(reader.userId), effectiveSignal);
1961
+ await verifyHistorySharing();
1962
+ }
1963
+ }
1964
+ catch (error) {
1965
+ 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 });
1966
+ }
1967
+ return tree;
1694
1968
  }, signal);
1695
1969
  }
1696
1970
  getTree(roomId) {
1697
1971
  if (isTreeDeleted(this.client, roomId))
1698
1972
  return null;
1973
+ if (!isStorageTreeRoom(this.client.getRoom(roomId)))
1974
+ return null;
1699
1975
  const tree = this.client.unstableGetFileTreeSpace(roomId);
1700
1976
  return tree ? this.decorateTreeSpace(tree) : null;
1701
1977
  }
@@ -1779,19 +2055,19 @@ export class TeleCryptIOStorage {
1779
2055
  throw new StorageError("operation cancelled");
1780
2056
  let response;
1781
2057
  try {
1782
- response = await withMatrixMutationAbort(() => tree.createFile(name, new Uint8Array(encrypted.data), encrypted.info, { info: { mimetype, size: data.byteLength } }), signal, "file upload");
2058
+ // Let the in-flight request settle so the uploader can retain returned
2059
+ // media/event IDs on cancellation, then stop before the next write.
2060
+ response = await tree.createFile(name, new Uint8Array(encrypted.data), encrypted.info, { info: { mimetype, size: data.byteLength } }, signal);
1783
2061
  }
1784
2062
  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)
2063
+ // Preserve acknowledged upload stages after cancellation as well.
2064
+ if (error instanceof MutationOutcomeUnknownError || error instanceof MutationPartialError)
1790
2065
  throw error;
2066
+ if (error instanceof StorageError || error instanceof MatrixError)
2067
+ throw error;
2068
+ // An unclassified transport failure can still follow a committed event.
1791
2069
  throw new MutationOutcomeUnknownError("file upload", { cause: error });
1792
2070
  }
1793
- if (signal?.aborted)
1794
- throw new StorageError("operation cancelled");
1795
2071
  try {
1796
2072
  return validateMatrixEventId(response?.event_id, "file upload response event ID");
1797
2073
  }
@@ -1832,7 +2108,7 @@ export class TeleCryptIOStorage {
1832
2108
  const mimetype = eventMetadata.mimetype ?? "application/octet-stream";
1833
2109
  const mxcUrl = info.url;
1834
2110
  const clientAny = this.client;
1835
- const accessToken = clientAny.getAccessToken();
2111
+ let accessToken = clientAny.getAccessToken();
1836
2112
  validateMatrixToken(accessToken);
1837
2113
  let trustedOrigin;
1838
2114
  try {
@@ -1866,6 +2142,7 @@ export class TeleCryptIOStorage {
1866
2142
  else
1867
2143
  signal?.addEventListener("abort", abortExternal, { once: true });
1868
2144
  let ciphertext;
2145
+ let retriedAuthentication = false;
1869
2146
  try {
1870
2147
  for (;;) {
1871
2148
  const res = await raceWithAbort(fetch(currentUrl, {
@@ -1908,13 +2185,22 @@ export class TeleCryptIOStorage {
1908
2185
  if (!res.ok) {
1909
2186
  const statusFailure = new Error(`media download failed: ${res.status}`);
1910
2187
  let detail = "";
2188
+ let unknownToken = false;
1911
2189
  try {
1912
2190
  // Error responses are diagnostics, not product media. Consume the
1913
2191
  // complete body so no provider detail is discarded, then apply
1914
2192
  // the SDK's shared secret/control-character sanitizer before it
1915
2193
  // crosses the SDK boundary.
1916
2194
  const body = await readResponseBody(res, controller.signal);
1917
- detail = sanitizeDiagnosticText(new TextDecoder().decode(body.bytes));
2195
+ const responseText = new TextDecoder().decode(body.bytes);
2196
+ detail = sanitizeDiagnosticText(responseText);
2197
+ if (res.status === 401) {
2198
+ try {
2199
+ const errorBody = JSON.parse(responseText);
2200
+ unknownToken = isRecord(errorBody) && errorBody.errcode === "M_UNKNOWN_TOKEN";
2201
+ }
2202
+ catch { /* Non-Matrix errors retain the complete diagnostic below. */ }
2203
+ }
1918
2204
  }
1919
2205
  catch (bodyError) {
1920
2206
  const partial = bodyError instanceof ResponseBodyReadError
@@ -1925,6 +2211,20 @@ export class TeleCryptIOStorage {
1925
2211
  : bodyError;
1926
2212
  throw new AggregateError([statusFailure, readFailure], "media download failed while reading the response body", { cause: statusFailure });
1927
2213
  }
2214
+ if (unknownToken && !retriedAuthentication) {
2215
+ retriedAuthentication = true;
2216
+ // Matrix's authenticated transport owns token refresh, including
2217
+ // concurrent refreshes and persistence. Its media request path
2218
+ // buffers the whole body and follows redirects, so use a small
2219
+ // authenticated read to renew the session, then retry our bounded
2220
+ // streaming download once with the current token.
2221
+ await raceWithAbort(this.client.whoami(), controller.signal, () => signal?.aborted
2222
+ ? new StorageError("operation cancelled")
2223
+ : new Error("media download timed out"));
2224
+ accessToken = clientAny.getAccessToken();
2225
+ validateMatrixToken(accessToken);
2226
+ continue;
2227
+ }
1928
2228
  throw new Error(`${statusFailure.message}${detail === "" ? "" : `: ${detail}`}`);
1929
2229
  }
1930
2230
  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. */
@@ -30,9 +30,9 @@ export interface OidcRegistrationClientMetadata {
30
30
  }
31
31
  /** The issuer-bound endpoints accepted by the token refresh adapter. */
32
32
  export type OidcTokenEndpointMetadata = Pick<OidcClientConfig, "issuer" | "token_endpoint" | "revocation_endpoint">;
33
- /** Keep provider diagnostics useful without allowing credentials, customer
34
- * identifiers, or control characters from an untrusted response to cross the
35
- * SDK boundary. */
33
+ /** Redact recognized secret fields and identifiers in provider diagnostics.
34
+ * Structured JSON uses decoded property names at every depth; the text fallback
35
+ * is limited to patterns recognized in unstructured input. */
36
36
  export declare function sanitizeDiagnosticText(value: string): string;
37
37
  /** Validates an endpoint against the configured homeserver and optional issuer path. */
38
38
  export declare function assertOidcEndpoint(value: unknown, trustedHomeserver: string, name: string, issuer?: URL, allowQuery?: boolean): string;