@telecrypt-io/storage 0.7.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { ClientEvent, createClient, EventType, MatrixEvent, MatrixError, Preset, RoomCreateTypeField, RoomType, SyncState, UNSTABLE_MSC3088_ENABLED, UNSTABLE_MSC3088_PURPOSE, UNSTABLE_MSC3089_BRANCH, UNSTABLE_MSC3089_TREE_SUBTYPE, } from "matrix-js-sdk";
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";
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";
@@ -9,6 +9,11 @@ import { cancelResponseBody, raceWithAbort, readMediaResponseBody, readResponseB
9
9
  import { sanitizeDiagnosticText } from "./core/oidc.js";
10
10
  import { validateName } from "./core/validation.js";
11
11
  import { isTreeDeleted } from "./deletion-markers.js";
12
+ import { DecryptionKeySafe } from "./key-safe.js";
13
+ const STORAGE_METADATA_EVENT_TYPE = "io.telecrypt.storage.metadata";
14
+ const STORAGE_METADATA_MSGTYPE = "io.telecrypt.storage.metadata";
15
+ const GENERIC_TREE_NAME = "Encrypted storage";
16
+ const GENERIC_FILE_NAME = "Encrypted file";
12
17
  const recoveryMutationQueues = new WeakMap();
13
18
  const treeMutationQueues = new WeakMap();
14
19
  const TREE_SYNC_TIMEOUT_MS = 15000;
@@ -235,12 +240,6 @@ export function readFileEventMetadata(content) {
235
240
  function isRecord(value) {
236
241
  return typeof value === "object" && value !== null && !Array.isArray(value);
237
242
  }
238
- async function isUndecryptableFilePlaceholder(branch) {
239
- // matrix-js-sdk replaces failed encrypted event content with m.bad.encrypted;
240
- // its public failure flag is the authoritative state and avoids guessing
241
- // from that display content (or misclassifying malformed plaintext events).
242
- return (await branch.getFileEvent()).isDecryptionFailure();
243
- }
244
243
  function validateSecretStorageStatus(value) {
245
244
  if (!isRecord(value))
246
245
  throw new RecoverySetupAmbiguousError();
@@ -408,9 +407,18 @@ export async function withTreeMutation(client, operation, signal) {
408
407
  }
409
408
  export class TeleCryptIOStorage {
410
409
  client;
410
+ syncOptions;
411
411
  decoratedTreeSpaces = new WeakSet();
412
- constructor(client) {
412
+ decoratedFileBranches = new WeakMap();
413
+ treeNames = new Map();
414
+ fileNames = new Map();
415
+ keySafe;
416
+ syncStarted = false;
417
+ syncInFlight;
418
+ constructor(client, options = {}, syncOptions = {}) {
413
419
  this.client = client;
420
+ this.syncOptions = syncOptions;
421
+ this.keySafe = new DecryptionKeySafe(client, (key, operation, signal) => this.withSecretStorageKey(key, operation, signal), options, (signal) => this.startSync(signal));
414
422
  // Advanced callers may construct a MatrixClient themselves. The SDK does
415
423
  // not mutate matrix-js-sdk internals: configure that client with the
416
424
  // supported createClient({ fetchFn, localTimeoutMs }) options, or use
@@ -420,6 +428,264 @@ export class TeleCryptIOStorage {
420
428
  getClient() {
421
429
  return this.client;
422
430
  }
431
+ /** Start Matrix room sync after crypto setup, for example after the current
432
+ * login has checked or restored its Decryption Key Safe. */
433
+ async startSync(signal) {
434
+ if (this.syncStarted)
435
+ return;
436
+ if (this.syncInFlight)
437
+ return this.syncInFlight;
438
+ const start = (async () => {
439
+ try {
440
+ TeleCryptIOStorage.throwIfAborted(signal);
441
+ const timeoutMs = this.syncOptions.syncTimeoutMs ?? 15000;
442
+ this.syncOptions.onProgress?.("Starting Matrix client…");
443
+ await TeleCryptIOStorage.withTimeout(this.client.startClient({ initialSyncLimit: this.syncOptions.initialSyncLimit ?? 10 }), timeoutMs, "client start", signal);
444
+ this.syncOptions.onProgress?.("Waiting for first sync with homeserver…");
445
+ await TeleCryptIOStorage.waitForFirstSync(this.client, timeoutMs, signal);
446
+ TeleCryptIOStorage.throwIfAborted(signal);
447
+ this.syncStarted = true;
448
+ this.syncOptions.onProgress?.("Sync complete.");
449
+ }
450
+ catch (error) {
451
+ try {
452
+ this.client.stopClient();
453
+ }
454
+ catch (stopError) {
455
+ throw new AggregateError([error, stopError], "Matrix sync startup and cleanup failed");
456
+ }
457
+ throw error;
458
+ }
459
+ finally {
460
+ this.syncInFlight = undefined;
461
+ }
462
+ })();
463
+ this.syncInFlight = start;
464
+ return start;
465
+ }
466
+ fileNameKey(treeId, fileId) {
467
+ return `${treeId}\u0000${fileId}`;
468
+ }
469
+ getStateEvent(roomId, eventType, stateKey) {
470
+ const state = this.client.getRoom(roomId)?.currentState;
471
+ const raw = state?.getStateEvents(eventType, stateKey);
472
+ if (Array.isArray(raw))
473
+ return raw[0] ?? null;
474
+ return raw ?? null;
475
+ }
476
+ getTreeOwnerId(roomId) {
477
+ const create = this.getStateEvent(roomId, EventType.RoomCreate, "");
478
+ const ownerId = create?.getSender();
479
+ if (!ownerId)
480
+ throw new StorageError("storage owner is unavailable");
481
+ return ownerId;
482
+ }
483
+ async sendEncryptedMetadataMessage(roomId, content) {
484
+ const response = await this.client.sendMessage(roomId, content);
485
+ return validateMatrixEventId(response?.event_id, "storage metadata response event ID");
486
+ }
487
+ async readEncryptedMetadataMessage(roomId, eventId, allowRedacted = false) {
488
+ const rawEvent = await this.client.fetchRoomEvent(roomId, eventId);
489
+ const event = this.client.getEventMapper()(rawEvent);
490
+ await this.client.decryptEventIfNeeded(event);
491
+ if (event.isDecryptionFailure())
492
+ throw new UndecryptableFileError();
493
+ if (allowRedacted &&
494
+ event.getId() === eventId &&
495
+ event.getRoomId() === roomId &&
496
+ event.getSender() === this.getTreeOwnerId(roomId) &&
497
+ event.isRedacted()) {
498
+ return event;
499
+ }
500
+ if (event.getId() !== eventId ||
501
+ event.getRoomId() !== roomId ||
502
+ event.getSender() !== this.getTreeOwnerId(roomId) ||
503
+ event.getWireType() !== EventType.RoomMessageEncrypted ||
504
+ event.getType() !== EventType.RoomMessage) {
505
+ throw new StorageError("encrypted storage metadata is invalid or unavailable");
506
+ }
507
+ return event;
508
+ }
509
+ async writeTreeName(roomId, name) {
510
+ validateName(name, "name");
511
+ const metadataEventId = await this.sendEncryptedMetadataMessage(roomId, {
512
+ msgtype: STORAGE_METADATA_MSGTYPE,
513
+ body: name,
514
+ });
515
+ await this.client.sendStateEvent(roomId, STORAGE_METADATA_EVENT_TYPE, { event_id: metadataEventId }, "");
516
+ this.treeNames.set(roomId, name);
517
+ }
518
+ /** Resolves an encrypted vault/folder name through its pointer state event. */
519
+ async getTreeName(roomId, options) {
520
+ if (options?.signal?.aborted)
521
+ throw new StorageError("operation cancelled");
522
+ await this.refreshRoomState(roomId, options);
523
+ const pointer = this.getStateEvent(roomId, STORAGE_METADATA_EVENT_TYPE, "");
524
+ const pointerContent = pointer?.getContent();
525
+ if (!pointer ||
526
+ pointer.getSender() !== this.getTreeOwnerId(roomId) ||
527
+ !isRecord(pointerContent) ||
528
+ typeof pointerContent.event_id !== "string") {
529
+ throw new StorageError("encrypted storage name is unavailable");
530
+ }
531
+ const metadataEventId = validateMatrixEventId(pointerContent.event_id, "storage metadata event ID");
532
+ let event;
533
+ try {
534
+ event = await this.readEncryptedMetadataMessage(roomId, metadataEventId);
535
+ }
536
+ catch (error) {
537
+ // A new device can list storage before restoring room keys. Keep the
538
+ // room visible with a generic label; a later call retries decryption.
539
+ if (error instanceof UndecryptableFileError)
540
+ return GENERIC_TREE_NAME;
541
+ throw error;
542
+ }
543
+ 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
+ this.treeNames.set(roomId, content.body);
552
+ return content.body;
553
+ }
554
+ async writeFileName(treeId, fileId, name) {
555
+ validateName(name, "file name");
556
+ validateMatrixEventId(fileId, "file event ID");
557
+ const metadataEventId = await this.sendEncryptedMetadataMessage(treeId, {
558
+ msgtype: STORAGE_METADATA_MSGTYPE,
559
+ body: name,
560
+ file_event_id: fileId,
561
+ });
562
+ await this.client.sendStateEvent(treeId, UNSTABLE_MSC3089_BRANCH.name, { active: true, metadata_event_id: metadataEventId }, fileId);
563
+ this.fileNames.set(this.fileNameKey(treeId, fileId), name);
564
+ }
565
+ /** Resolves an encrypted file name from the MSC3089 index pointer. */
566
+ async getFileName(treeId, fileId, options) {
567
+ if (options?.signal?.aborted)
568
+ throw new StorageError("operation cancelled");
569
+ if (options?.refreshState !== false)
570
+ await this.refreshRoomState(treeId, options);
571
+ const tree = this.getTree(treeId);
572
+ const branch = tree?.getFile(fileId);
573
+ if (!branch || branch.isActive === false)
574
+ throw new StorageError("file not found");
575
+ const indexEvent = branch.indexEvent;
576
+ const indexContent = indexEvent?.getContent();
577
+ if (!indexEvent ||
578
+ indexEvent.getSender() !== this.getTreeOwnerId(treeId) ||
579
+ !isRecord(indexContent) ||
580
+ indexContent.active !== true ||
581
+ typeof indexContent.metadata_event_id !== "string") {
582
+ throw new StorageError("encrypted file name is unavailable");
583
+ }
584
+ const metadataEventId = validateMatrixEventId(indexContent.metadata_event_id, "file metadata event ID");
585
+ let event;
586
+ try {
587
+ event = await this.readEncryptedMetadataMessage(treeId, metadataEventId);
588
+ }
589
+ catch (error) {
590
+ // File IDs remain listable on a fresh device while its room keys are
591
+ // being restored. The caller can resolve the private name on retry.
592
+ if (error instanceof UndecryptableFileError)
593
+ return GENERIC_FILE_NAME;
594
+ throw error;
595
+ }
596
+ const content = event.getContent();
597
+ if (!isRecord(content) || typeof content.body !== "string") {
598
+ throw new StorageError("encrypted file name is invalid or unavailable");
599
+ }
600
+ if (content.msgtype === MsgType.File) {
601
+ if (metadataEventId !== fileId || !isRecord(content.file)) {
602
+ throw new StorageError("encrypted file metadata does not match its listing");
603
+ }
604
+ }
605
+ else if (content.msgtype !== STORAGE_METADATA_MSGTYPE ||
606
+ content.file_event_id !== fileId) {
607
+ throw new StorageError("encrypted file metadata does not match its listing");
608
+ }
609
+ validateName(content.body, "file name");
610
+ this.fileNames.set(this.fileNameKey(treeId, fileId), content.body);
611
+ return content.body;
612
+ }
613
+ /** Returns the current file metadata pointer, including on inactive branches. */
614
+ async getFileMetadataEventId(treeId, fileId, options) {
615
+ if (options?.signal?.aborted)
616
+ throw new StorageError("operation cancelled");
617
+ await this.refreshRoomState(treeId, options);
618
+ const branch = this.getTree(treeId)?.getFile(fileId);
619
+ if (!branch)
620
+ throw new StorageError("file not found");
621
+ const indexEvent = branch.indexEvent;
622
+ const content = indexEvent?.getContent();
623
+ if (!indexEvent || indexEvent.getSender() !== this.getTreeOwnerId(treeId)) {
624
+ throw new StorageError("encrypted file listing is unavailable");
625
+ }
626
+ if (!isRecord(content) ||
627
+ typeof content.metadata_event_id !== "string") {
628
+ if (branch.isActive === false || indexEvent.isRedacted())
629
+ return null;
630
+ throw new StorageError("encrypted file name is unavailable");
631
+ }
632
+ return validateMatrixEventId(content.metadata_event_id, "file metadata event ID");
633
+ }
634
+ /** Fetches and validates a current encrypted rename event, allowing retries after redaction. */
635
+ async getFileRenameMetadataEvent(treeId, fileId, metadataEventId) {
636
+ const event = await this.readEncryptedMetadataMessage(treeId, metadataEventId, true);
637
+ if (event.isRedacted())
638
+ return event;
639
+ const content = event.getContent();
640
+ if (!isRecord(content) ||
641
+ content.msgtype !== STORAGE_METADATA_MSGTYPE ||
642
+ content.file_event_id !== fileId ||
643
+ typeof content.body !== "string") {
644
+ throw new StorageError("encrypted file metadata does not match its listing");
645
+ }
646
+ validateName(content.body, "file name");
647
+ return event;
648
+ }
649
+ /** Fetches the original encrypted file message directly by event ID. */
650
+ async getOriginalFileEvent(treeId, fileId, allowRedacted = false) {
651
+ const event = await this.readEncryptedMetadataMessage(treeId, fileId, allowRedacted);
652
+ if (event.isRedacted() && allowRedacted)
653
+ return event;
654
+ const content = event.getContent();
655
+ if (!isRecord(content) ||
656
+ content.msgtype !== MsgType.File ||
657
+ typeof content.body !== "string" ||
658
+ !isRecord(content.file) ||
659
+ typeof content.file.url !== "string") {
660
+ throw new StorageError("encrypted file message is invalid or unavailable");
661
+ }
662
+ return event;
663
+ }
664
+ async createEncryptedFile(treeId, name, encryptedContents, info, additionalContent) {
665
+ validateFileName(name);
666
+ const bytes = encryptedContents instanceof Uint8Array
667
+ ? encryptedContents
668
+ : new Uint8Array(encryptedContents);
669
+ 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 };
688
+ }
423
689
  /**
424
690
  * Refreshes a room's current state from the homeserver. The background sync
425
691
  * loop is intentionally asynchronous, so a short-lived CLI process can
@@ -523,10 +789,11 @@ export class TeleCryptIOStorage {
523
789
  return TeleCryptIOStorage.bootstrap(client, opts);
524
790
  }
525
791
  /** Shared post-construction bootstrap for `create()`/`createFromOidc()`:
526
- * persistent crypto store, first sync, wrap in a `TeleCryptIOStorage`. */
792
+ * persistent crypto store, optional first sync, wrap in a `TeleCryptIOStorage`. */
527
793
  static async bootstrap(client, opts) {
528
794
  const progress = opts.onProgress ?? (() => { });
529
795
  const persistent = opts.persistentCryptoStore ?? true;
796
+ let storage;
530
797
  try {
531
798
  progress("Loading encryption engine (WASM)…");
532
799
  await TeleCryptIOStorage.withTimeout(client.initRustCrypto({
@@ -534,27 +801,35 @@ export class TeleCryptIOStorage {
534
801
  cryptoDatabasePrefix: opts.cryptoDatabasePrefix ?? `telecrypt-io-storage::${opts.userId}::${opts.deviceId}`,
535
802
  }), opts.initTimeoutMs ?? 60000, "crypto init", opts.signal);
536
803
  progress("Encryption ready — opening secure store…");
537
- progress("Starting Matrix client…");
538
- // Mark this before awaiting: startClient can start its sync loop and then
539
- // reject while reporting an error from the initial request.
540
- await TeleCryptIOStorage.withTimeout(client.startClient({ initialSyncLimit: opts.initialSyncLimit ?? 10 }), opts.syncTimeoutMs ?? 15000, "client start", opts.signal);
541
- progress("Waiting for first sync with homeserver…");
542
- await TeleCryptIOStorage.waitForFirstSync(client, opts.syncTimeoutMs ?? 15000, opts.signal);
543
- TeleCryptIOStorage.throwIfAborted(opts.signal);
544
- progress("Sync complete.");
545
- return new TeleCryptIOStorage(client);
804
+ storage = new TeleCryptIOStorage(client, opts, opts);
805
+ if (opts.startClient === false) {
806
+ // Rust Crypto starts checking server-side backup state during init. A
807
+ // short-lived caller (the CLI login transaction) may close this
808
+ // client before that background check finishes, so drain it before
809
+ // returning the sync-deferred client.
810
+ const crypto = client.getCrypto();
811
+ if (crypto) {
812
+ await TeleCryptIOStorage.withTimeout(crypto.checkKeyBackupAndEnable(), opts.syncTimeoutMs ?? 15000, "crypto backup check", opts.signal);
813
+ }
814
+ }
815
+ else {
816
+ await storage.startSync(opts.signal);
817
+ }
818
+ return storage;
546
819
  }
547
820
  catch (error) {
548
821
  // stopClient is idempotent in matrix-js-sdk and is also required after
549
822
  // init/timeout failures: crypto startup can have installed listeners or
550
823
  // a sync task before the awaited operation rejects.
551
- try {
552
- client.stopClient();
553
- }
554
- catch (cleanupError) {
555
- throw new AggregateError([error, cleanupError], "storage bootstrap and client cleanup failed", {
556
- cause: error,
557
- });
824
+ if (!storage) {
825
+ try {
826
+ client.stopClient();
827
+ }
828
+ catch (cleanupError) {
829
+ throw new AggregateError([error, cleanupError], "storage bootstrap and client cleanup failed", {
830
+ cause: error,
831
+ });
832
+ }
558
833
  }
559
834
  throw error;
560
835
  }
@@ -768,12 +1043,7 @@ export class TeleCryptIOStorage {
768
1043
  if (signal?.aborted)
769
1044
  throw new StorageError("operation cancelled");
770
1045
  try {
771
- await withRecoveryCryptoDeadline(() => crypto.bootstrapCrossSigning({
772
- // No existing verified device to interactively re-authenticate against
773
- // for this account, so there is nothing to feed into `makeRequest`;
774
- // matches the working pattern already proven in keys.test.ts.
775
- authUploadDeviceSigningKeys: async () => undefined,
776
- }), signal, "cross-signing bootstrap");
1046
+ await withRecoveryCryptoDeadline(() => crypto.bootstrapCrossSigning({}), signal, "cross-signing bootstrap");
777
1047
  }
778
1048
  catch (error) {
779
1049
  // Cross-signing setup can commit server-side state before a transport
@@ -1030,7 +1300,7 @@ export class TeleCryptIOStorage {
1030
1300
  if (!userId)
1031
1301
  throw new Error("createTree: Matrix client has no user ID");
1032
1302
  return {
1033
- name,
1303
+ name: GENERIC_TREE_NAME,
1034
1304
  preset: Preset.PrivateChat,
1035
1305
  power_level_content_override: {
1036
1306
  invite: 100,
@@ -1052,6 +1322,7 @@ export class TeleCryptIOStorage {
1052
1322
  [EventType.SpaceChild]: 100,
1053
1323
  [EventType.SpaceParent]: 100,
1054
1324
  [UNSTABLE_MSC3089_BRANCH.name]: 100,
1325
+ [STORAGE_METADATA_EVENT_TYPE]: 100,
1055
1326
  },
1056
1327
  users: { [userId]: 100 },
1057
1328
  },
@@ -1119,10 +1390,46 @@ export class TeleCryptIOStorage {
1119
1390
  return tree;
1120
1391
  this.decoratedTreeSpaces.add(tree);
1121
1392
  const originalGetDirectories = tree.getDirectories.bind(tree);
1393
+ const originalListFiles = typeof tree.listFiles === "function"
1394
+ ? tree.listFiles.bind(tree)
1395
+ : () => [];
1396
+ const originalGetFile = typeof tree.getFile === "function"
1397
+ ? tree.getFile.bind(tree)
1398
+ : () => null;
1122
1399
  tree.getDirectories = () => originalGetDirectories().map((child) => this.decorateTreeSpace(child));
1123
1400
  tree.createDirectory = (name) => this.createSubtree(tree, name);
1401
+ tree.setName = (name) => this.writeTreeName(tree.id, name);
1402
+ tree.listFiles = () => originalListFiles().map((branch) => this.decorateFileBranch(tree.id, branch));
1403
+ tree.getFile = (fileId) => {
1404
+ const branch = originalGetFile(fileId);
1405
+ return branch ? this.decorateFileBranch(tree.id, branch) : null;
1406
+ };
1407
+ tree.createFile = (name, encryptedContents, info, additionalContent) => this.createEncryptedFile(tree.id, name, encryptedContents, info, additionalContent);
1124
1408
  return tree;
1125
1409
  }
1410
+ decorateFileBranch(treeId, branch) {
1411
+ const existing = this.decoratedFileBranches.get(branch);
1412
+ if (existing)
1413
+ return existing;
1414
+ const decorated = Object.create(branch);
1415
+ const nameKey = this.fileNameKey(treeId, branch.id);
1416
+ Object.defineProperties(decorated, {
1417
+ getName: {
1418
+ value: () => this.fileNames.get(nameKey) ?? GENERIC_FILE_NAME,
1419
+ },
1420
+ setName: {
1421
+ value: (name) => this.writeFileName(treeId, branch.id, name),
1422
+ },
1423
+ delete: {
1424
+ value: async () => {
1425
+ const { deleteFile } = await import("./core/operations.js");
1426
+ await deleteFile(this, treeId, branch.id);
1427
+ },
1428
+ },
1429
+ });
1430
+ this.decoratedFileBranches.set(branch, decorated);
1431
+ return decorated;
1432
+ }
1126
1433
  /** Cleans up only a room created by this operation and reports incomplete cleanup. */
1127
1434
  async cleanupCreatedRoom(roomId) {
1128
1435
  // Cleanup is compensating work. It must not inherit the caller's already
@@ -1203,9 +1510,26 @@ export class TeleCryptIOStorage {
1203
1510
  throw new RoomCreationAmbiguousError(operation, error);
1204
1511
  }
1205
1512
  try {
1206
- return await this.waitForTreeSpace(roomId, operation, signal);
1513
+ const tree = await this.waitForTreeSpace(roomId, operation, signal);
1514
+ try {
1515
+ await this.writeTreeName(roomId, name);
1516
+ }
1517
+ catch (error) {
1518
+ try {
1519
+ await this.cleanupCreatedRoom(roomId);
1520
+ }
1521
+ catch (cleanupError) {
1522
+ throwWithCleanupDetail(error, roomId, cleanupError);
1523
+ }
1524
+ throw new StorageError(`${operation}: encrypted name initialization failed`, { cause: error });
1525
+ }
1526
+ return tree;
1207
1527
  }
1208
1528
  catch (error) {
1529
+ if (error instanceof StorageError &&
1530
+ error.message === `${operation}: encrypted name initialization failed`) {
1531
+ throw error;
1532
+ }
1209
1533
  try {
1210
1534
  await this.cleanupCreatedRoom(roomId);
1211
1535
  }
@@ -1409,7 +1733,9 @@ export class TeleCryptIOStorage {
1409
1733
  const members = parseMatrixMembersResponse(membersBody);
1410
1734
  const pls = parseMatrixPowerLevels(plsRaw);
1411
1735
  const viewLevel = pls.users_default ?? 0;
1412
- const editLevel = pls.events_default ?? 50;
1736
+ // Match matrix-js-sdk's MSC3089TreeSpace.getPermissions() default. An
1737
+ // explicit zero still means regular users lack the editor threshold.
1738
+ const editLevel = pls.events_default || 50;
1413
1739
  const adminLevel = pls.events?.["m.room.power_levels"] ?? 100;
1414
1740
  const roleFor = (userId) => {
1415
1741
  const userLevel = pls.users?.[userId] ?? viewLevel;
@@ -1479,27 +1805,17 @@ export class TeleCryptIOStorage {
1479
1805
  if (signal?.aborted)
1480
1806
  throw new StorageError("operation cancelled");
1481
1807
  let info;
1808
+ let fileEvent;
1482
1809
  try {
1483
- ({ info } = await branch.getFileInfo());
1810
+ fileEvent = await this.getOriginalFileEvent(branch.roomId, branch.id);
1811
+ const content = fileEvent.getContent();
1812
+ info = isRecord(content) && isRecord(content.file) ? content.file : undefined;
1484
1813
  }
1485
1814
  catch (error) {
1486
1815
  if (signal?.aborted)
1487
1816
  throw new StorageError("operation cancelled");
1488
1817
  if (error instanceof StorageError || error instanceof MatrixError)
1489
1818
  throw error;
1490
- // matrix-js-sdk's file-info accessor reads an attachment absent from
1491
- // failed-decryption events. Use its explicit decryption status, not a
1492
- // guessed content shape; preserve other SDK/transport failures.
1493
- let isPlaceholder = false;
1494
- try {
1495
- isPlaceholder = await isUndecryptableFilePlaceholder(branch);
1496
- }
1497
- catch (placeholderError) {
1498
- throw new AggregateError([error, placeholderError], "file metadata lookup and placeholder inspection both failed", { cause: error });
1499
- }
1500
- if (isPlaceholder) {
1501
- throw new UndecryptableFileError();
1502
- }
1503
1819
  throw error;
1504
1820
  }
1505
1821
  // Missing metadata alone does not establish a decryption failure.
@@ -1509,7 +1825,7 @@ export class TeleCryptIOStorage {
1509
1825
  // `info` is the encrypted attachment descriptor. Its shape is owned by
1510
1826
  // matrix-encrypt-attachment and does not carry the plaintext size. The
1511
1827
  // event's `info` block is the sole source of user-facing file metadata.
1512
- const eventMetadata = readFileEventMetadata((await branch.getFileEvent()).getContent());
1828
+ const eventMetadata = readFileEventMetadata(fileEvent.getContent());
1513
1829
  if (signal?.aborted)
1514
1830
  throw new StorageError("operation cancelled");
1515
1831
  const declaredSize = eventMetadata.size;