@telecrypt-io/storage 0.8.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,14 +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";
13
+ import { DecryptionKeySafe } from "./key-safe.js";
14
+ import { prepareReaderHistorySharing } from "./sharing.js";
15
+ import { deleteOwnedMedia } from "./media.js";
12
16
  const STORAGE_METADATA_EVENT_TYPE = "io.telecrypt.storage.metadata";
13
17
  const STORAGE_METADATA_MSGTYPE = "io.telecrypt.storage.metadata";
14
18
  const GENERIC_TREE_NAME = "Encrypted storage";
@@ -356,7 +360,7 @@ function parseMatrixStateResponse(value) {
356
360
  function parseMatrixPowerLevels(value) {
357
361
  if (!isRecord(value))
358
362
  throw new Error("invalid Matrix power-level response");
359
- for (const name of ["users_default", "events_default"]) {
363
+ for (const name of ["users_default", "events_default", "state_default"]) {
360
364
  const field = value[name];
361
365
  if (field !== undefined && (typeof field !== "number" || !Number.isFinite(field))) {
362
366
  throw new Error("invalid Matrix power-level response");
@@ -406,12 +410,18 @@ export async function withTreeMutation(client, operation, signal) {
406
410
  }
407
411
  export class TeleCryptIOStorage {
408
412
  client;
413
+ syncOptions;
409
414
  decoratedTreeSpaces = new WeakSet();
410
415
  decoratedFileBranches = new WeakMap();
411
416
  treeNames = new Map();
412
417
  fileNames = new Map();
413
- constructor(client) {
418
+ keySafe;
419
+ syncStarted = false;
420
+ syncInFlight;
421
+ constructor(client, options = {}, syncOptions = {}) {
414
422
  this.client = client;
423
+ this.syncOptions = syncOptions;
424
+ this.keySafe = new DecryptionKeySafe(client, (key, operation, signal) => this.withSecretStorageKey(key, operation, signal), options, (signal) => this.startSync(signal));
415
425
  // Advanced callers may construct a MatrixClient themselves. The SDK does
416
426
  // not mutate matrix-js-sdk internals: configure that client with the
417
427
  // supported createClient({ fetchFn, localTimeoutMs }) options, or use
@@ -421,6 +431,41 @@ export class TeleCryptIOStorage {
421
431
  getClient() {
422
432
  return this.client;
423
433
  }
434
+ /** Start Matrix room sync after crypto setup, for example after the current
435
+ * login has checked or restored its Decryption Key Safe. */
436
+ async startSync(signal) {
437
+ if (this.syncStarted)
438
+ return;
439
+ if (this.syncInFlight)
440
+ return this.syncInFlight;
441
+ const start = (async () => {
442
+ try {
443
+ TeleCryptIOStorage.throwIfAborted(signal);
444
+ const timeoutMs = this.syncOptions.syncTimeoutMs ?? 15000;
445
+ this.syncOptions.onProgress?.("Starting Matrix client…");
446
+ await TeleCryptIOStorage.withTimeout(this.client.startClient({ initialSyncLimit: this.syncOptions.initialSyncLimit ?? 10 }), timeoutMs, "client start", signal);
447
+ this.syncOptions.onProgress?.("Waiting for first sync with homeserver…");
448
+ await TeleCryptIOStorage.waitForFirstSync(this.client, timeoutMs, signal);
449
+ TeleCryptIOStorage.throwIfAborted(signal);
450
+ this.syncStarted = true;
451
+ this.syncOptions.onProgress?.("Sync complete.");
452
+ }
453
+ catch (error) {
454
+ try {
455
+ this.client.stopClient();
456
+ }
457
+ catch (stopError) {
458
+ throw new AggregateError([error, stopError], "Matrix sync startup and cleanup failed");
459
+ }
460
+ throw error;
461
+ }
462
+ finally {
463
+ this.syncInFlight = undefined;
464
+ }
465
+ })();
466
+ this.syncInFlight = start;
467
+ return start;
468
+ }
424
469
  fileNameKey(treeId, fileId) {
425
470
  return `${treeId}\u0000${fileId}`;
426
471
  }
@@ -473,8 +518,7 @@ export class TeleCryptIOStorage {
473
518
  await this.client.sendStateEvent(roomId, STORAGE_METADATA_EVENT_TYPE, { event_id: metadataEventId }, "");
474
519
  this.treeNames.set(roomId, name);
475
520
  }
476
- /** Resolves an encrypted vault/folder name through its pointer state event. */
477
- async getTreeName(roomId, options) {
521
+ async getTreeMetadataEvent(roomId, options) {
478
522
  if (options?.signal?.aborted)
479
523
  throw new StorageError("operation cancelled");
480
524
  await this.refreshRoomState(roomId, options);
@@ -487,9 +531,22 @@ export class TeleCryptIOStorage {
487
531
  throw new StorageError("encrypted storage name is unavailable");
488
532
  }
489
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) {
490
547
  let event;
491
548
  try {
492
- event = await this.readEncryptedMetadataMessage(roomId, metadataEventId);
549
+ event = await this.getTreeMetadataEvent(roomId, options);
493
550
  }
494
551
  catch (error) {
495
552
  // A new device can list storage before restoring room keys. Keep the
@@ -499,16 +556,78 @@ export class TeleCryptIOStorage {
499
556
  throw error;
500
557
  }
501
558
  const content = event.getContent();
502
- if (!isRecord(content) ||
503
- content.msgtype !== STORAGE_METADATA_MSGTYPE ||
504
- typeof content.body !== "string" ||
505
- Object.hasOwn(content, "file_event_id")) {
506
- throw new StorageError("encrypted storage name is invalid or unavailable");
507
- }
508
- validateName(content.body, "name");
509
559
  this.treeNames.set(roomId, content.body);
510
560
  return content.body;
511
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
+ }
512
631
  async writeFileName(treeId, fileId, name) {
513
632
  validateName(name, "file name");
514
633
  validateMatrixEventId(fileId, "file event ID");
@@ -619,30 +738,128 @@ export class TeleCryptIOStorage {
619
738
  }
620
739
  return event;
621
740
  }
622
- async createEncryptedFile(treeId, name, encryptedContents, info, additionalContent) {
741
+ async createEncryptedFile(treeId, name, encryptedContents, info, additionalContent, signal) {
623
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");
624
748
  const bytes = encryptedContents instanceof Uint8Array
625
749
  ? encryptedContents
626
750
  : new Uint8Array(encryptedContents);
627
751
  const body = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
628
- const uploaded = await this.client.uploadContent(body, { includeFilename: false });
629
- if (typeof uploaded.content_uri !== "string" || uploaded.content_uri.length === 0) {
630
- throw new StorageError("encrypted media upload returned no content URI");
631
- }
632
- const fileInfo = { ...info, url: uploaded.content_uri };
633
- const fileContent = {
634
- ...additionalContent,
635
- msgtype: MsgType.File,
636
- body: name,
637
- url: uploaded.content_uri,
638
- file: fileInfo,
639
- [UNSTABLE_MSC3089_LEAF.name]: {},
640
- };
641
- const sent = await this.client.sendMessage(treeId, fileContent);
642
- const fileEventId = validateMatrixEventId(sent?.event_id, "file upload response event ID");
643
- await this.client.sendStateEvent(treeId, UNSTABLE_MSC3089_BRANCH.name, { active: true, metadata_event_id: fileEventId }, fileEventId);
644
- this.fileNames.set(this.fileNameKey(treeId, fileEventId), name);
645
- 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
+ }
646
863
  }
647
864
  /**
648
865
  * Refreshes a room's current state from the homeserver. The background sync
@@ -661,6 +878,11 @@ export class TeleCryptIOStorage {
661
878
  abortSignal: matrixRequestSignal(options),
662
879
  });
663
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
+ }
664
886
  if (typeof room.currentState.setStateEvents === "function") {
665
887
  const refreshed = parsedStateEvents.map((event) => new MatrixEvent(event));
666
888
  // RoomState.setStateEvents overwrites matching tuples, but deliberately
@@ -688,6 +910,49 @@ export class TeleCryptIOStorage {
688
910
  room.currentState.setStateEvents(refreshed);
689
911
  }
690
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
+ }
691
956
  /**
692
957
  * Recommended entry point: builds the MatrixClient with a persistent crypto
693
958
  * store and the secret-storage callback wiring `keys.*` needs, starts the
@@ -747,10 +1012,11 @@ export class TeleCryptIOStorage {
747
1012
  return TeleCryptIOStorage.bootstrap(client, opts);
748
1013
  }
749
1014
  /** Shared post-construction bootstrap for `create()`/`createFromOidc()`:
750
- * persistent crypto store, first sync, wrap in a `TeleCryptIOStorage`. */
1015
+ * persistent crypto store, optional first sync, wrap in a `TeleCryptIOStorage`. */
751
1016
  static async bootstrap(client, opts) {
752
1017
  const progress = opts.onProgress ?? (() => { });
753
1018
  const persistent = opts.persistentCryptoStore ?? true;
1019
+ let storage;
754
1020
  try {
755
1021
  progress("Loading encryption engine (WASM)…");
756
1022
  await TeleCryptIOStorage.withTimeout(client.initRustCrypto({
@@ -758,27 +1024,35 @@ export class TeleCryptIOStorage {
758
1024
  cryptoDatabasePrefix: opts.cryptoDatabasePrefix ?? `telecrypt-io-storage::${opts.userId}::${opts.deviceId}`,
759
1025
  }), opts.initTimeoutMs ?? 60000, "crypto init", opts.signal);
760
1026
  progress("Encryption ready — opening secure store…");
761
- progress("Starting Matrix client…");
762
- // Mark this before awaiting: startClient can start its sync loop and then
763
- // reject while reporting an error from the initial request.
764
- await TeleCryptIOStorage.withTimeout(client.startClient({ initialSyncLimit: opts.initialSyncLimit ?? 10 }), opts.syncTimeoutMs ?? 15000, "client start", opts.signal);
765
- progress("Waiting for first sync with homeserver…");
766
- await TeleCryptIOStorage.waitForFirstSync(client, opts.syncTimeoutMs ?? 15000, opts.signal);
767
- TeleCryptIOStorage.throwIfAborted(opts.signal);
768
- progress("Sync complete.");
769
- return new TeleCryptIOStorage(client);
1027
+ storage = new TeleCryptIOStorage(client, opts, opts);
1028
+ if (opts.startClient === false) {
1029
+ // Rust Crypto starts checking server-side backup state during init. A
1030
+ // short-lived caller (the CLI login transaction) may close this
1031
+ // client before that background check finishes, so drain it before
1032
+ // returning the sync-deferred client.
1033
+ const crypto = client.getCrypto();
1034
+ if (crypto) {
1035
+ await TeleCryptIOStorage.withTimeout(crypto.checkKeyBackupAndEnable(), opts.syncTimeoutMs ?? 15000, "crypto backup check", opts.signal);
1036
+ }
1037
+ }
1038
+ else {
1039
+ await storage.startSync(opts.signal);
1040
+ }
1041
+ return storage;
770
1042
  }
771
1043
  catch (error) {
772
1044
  // stopClient is idempotent in matrix-js-sdk and is also required after
773
1045
  // init/timeout failures: crypto startup can have installed listeners or
774
1046
  // a sync task before the awaited operation rejects.
775
- try {
776
- client.stopClient();
777
- }
778
- catch (cleanupError) {
779
- throw new AggregateError([error, cleanupError], "storage bootstrap and client cleanup failed", {
780
- cause: error,
781
- });
1047
+ if (!storage) {
1048
+ try {
1049
+ client.stopClient();
1050
+ }
1051
+ catch (cleanupError) {
1052
+ throw new AggregateError([error, cleanupError], "storage bootstrap and client cleanup failed", {
1053
+ cause: error,
1054
+ });
1055
+ }
782
1056
  }
783
1057
  throw error;
784
1058
  }
@@ -992,12 +1266,7 @@ export class TeleCryptIOStorage {
992
1266
  if (signal?.aborted)
993
1267
  throw new StorageError("operation cancelled");
994
1268
  try {
995
- await withRecoveryCryptoDeadline(() => crypto.bootstrapCrossSigning({
996
- // No existing verified device to interactively re-authenticate against
997
- // for this account, so there is nothing to feed into `makeRequest`;
998
- // matches the working pattern already proven in keys.test.ts.
999
- authUploadDeviceSigningKeys: async () => undefined,
1000
- }), signal, "cross-signing bootstrap");
1269
+ await withRecoveryCryptoDeadline(() => crypto.bootstrapCrossSigning({}), signal, "cross-signing bootstrap");
1001
1270
  }
1002
1271
  catch (error) {
1003
1272
  // Cross-signing setup can commit server-side state before a transport
@@ -1261,7 +1530,7 @@ export class TeleCryptIOStorage {
1261
1530
  kick: 100,
1262
1531
  ban: 100,
1263
1532
  redact: 100,
1264
- state_default: 0,
1533
+ state_default: 100,
1265
1534
  events_default: 0,
1266
1535
  users_default: 0,
1267
1536
  events: {
@@ -1358,7 +1627,7 @@ export class TeleCryptIOStorage {
1358
1627
  const branch = originalGetFile(fileId);
1359
1628
  return branch ? this.decorateFileBranch(tree.id, branch) : null;
1360
1629
  };
1361
- 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);
1362
1631
  return tree;
1363
1632
  }
1364
1633
  decorateFileBranch(treeId, branch) {
@@ -1514,8 +1783,14 @@ export class TeleCryptIOStorage {
1514
1783
  if (isTreeDeleted(this.client, room.roomId))
1515
1784
  continue;
1516
1785
  const tree = this.client.unstableGetFileTreeSpace(room.roomId);
1517
- 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
+ }
1518
1792
  trees.push(this.decorateTreeSpace(tree));
1793
+ }
1519
1794
  }
1520
1795
  return trees;
1521
1796
  }
@@ -1527,6 +1802,9 @@ export class TeleCryptIOStorage {
1527
1802
  const currentParent = this.getTree(parent.id);
1528
1803
  if (!currentParent)
1529
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"));
1530
1808
  const tree = await this.createTreeSpace(name, "createSubtree", effectiveSignal);
1531
1809
  let parentLinkAttempted = false;
1532
1810
  let parentLinkEventId;
@@ -1553,7 +1831,6 @@ export class TeleCryptIOStorage {
1553
1831
  if (this.client.getRoom(tree.id)) {
1554
1832
  await this.refreshRoomState(tree.id, { signal: effectiveSignal });
1555
1833
  }
1556
- return tree;
1557
1834
  }
1558
1835
  catch (error) {
1559
1836
  // Compensation is independent of the caller's cancelled deadline.
@@ -1645,6 +1922,22 @@ export class TeleCryptIOStorage {
1645
1922
  }
1646
1923
  throw error;
1647
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;
1648
1941
  }, signal);
1649
1942
  }
1650
1943
  getTree(roomId) {
@@ -1733,19 +2026,19 @@ export class TeleCryptIOStorage {
1733
2026
  throw new StorageError("operation cancelled");
1734
2027
  let response;
1735
2028
  try {
1736
- 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);
1737
2032
  }
1738
2033
  catch (error) {
1739
- // A transport failure after the encrypted event was submitted cannot be
1740
- // distinguished from a failure before commit. Do not let a caller retry
1741
- // this operation and create a duplicate file; only the explicit typed
1742
- // mutation outcome is safe to propagate unchanged.
1743
- if (error instanceof MutationOutcomeUnknownError)
2034
+ // Preserve acknowledged upload stages after cancellation as well.
2035
+ if (error instanceof MutationOutcomeUnknownError || error instanceof MutationPartialError)
2036
+ throw error;
2037
+ if (error instanceof StorageError || error instanceof MatrixError)
1744
2038
  throw error;
2039
+ // An unclassified transport failure can still follow a committed event.
1745
2040
  throw new MutationOutcomeUnknownError("file upload", { cause: error });
1746
2041
  }
1747
- if (signal?.aborted)
1748
- throw new StorageError("operation cancelled");
1749
2042
  try {
1750
2043
  return validateMatrixEventId(response?.event_id, "file upload response event ID");
1751
2044
  }
@@ -1786,7 +2079,7 @@ export class TeleCryptIOStorage {
1786
2079
  const mimetype = eventMetadata.mimetype ?? "application/octet-stream";
1787
2080
  const mxcUrl = info.url;
1788
2081
  const clientAny = this.client;
1789
- const accessToken = clientAny.getAccessToken();
2082
+ let accessToken = clientAny.getAccessToken();
1790
2083
  validateMatrixToken(accessToken);
1791
2084
  let trustedOrigin;
1792
2085
  try {
@@ -1820,6 +2113,7 @@ export class TeleCryptIOStorage {
1820
2113
  else
1821
2114
  signal?.addEventListener("abort", abortExternal, { once: true });
1822
2115
  let ciphertext;
2116
+ let retriedAuthentication = false;
1823
2117
  try {
1824
2118
  for (;;) {
1825
2119
  const res = await raceWithAbort(fetch(currentUrl, {
@@ -1862,13 +2156,22 @@ export class TeleCryptIOStorage {
1862
2156
  if (!res.ok) {
1863
2157
  const statusFailure = new Error(`media download failed: ${res.status}`);
1864
2158
  let detail = "";
2159
+ let unknownToken = false;
1865
2160
  try {
1866
2161
  // Error responses are diagnostics, not product media. Consume the
1867
2162
  // complete body so no provider detail is discarded, then apply
1868
2163
  // the SDK's shared secret/control-character sanitizer before it
1869
2164
  // crosses the SDK boundary.
1870
2165
  const body = await readResponseBody(res, controller.signal);
1871
- 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
+ }
1872
2175
  }
1873
2176
  catch (bodyError) {
1874
2177
  const partial = bodyError instanceof ResponseBodyReadError
@@ -1879,6 +2182,20 @@ export class TeleCryptIOStorage {
1879
2182
  : bodyError;
1880
2183
  throw new AggregateError([statusFailure, readFailure], "media download failed while reading the response body", { cause: statusFailure });
1881
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
+ }
1882
2199
  throw new Error(`${statusFailure.message}${detail === "" ? "" : `: ${detail}`}`);
1883
2200
  }
1884
2201
  if (!res.body) {