@telecrypt-io/storage 0.7.2 → 0.8.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.
package/README.md CHANGED
@@ -62,6 +62,15 @@ The SDK maps the file tree to Matrix primitives defined by MSC3089:
62
62
  Vault, folder, and file IDs are opaque Matrix room and event IDs. Names are display labels: two
63
63
  objects with the same name are still distinct, and a name is not an idempotency key.
64
64
 
65
+ Vault and folder names, and file names, are stored in encrypted room messages. Matrix room names
66
+ remain the generic `Encrypted storage`; file-listing state stores only an event pointer, and media
67
+ uploads omit the original filename. Use the asynchronous operations in `@telecrypt-io/storage/core`
68
+ to read names. The synchronous Matrix SDK tree helper cannot decrypt on demand and exposes a generic
69
+ file label until an async operation resolves the name. This is a TeleCrypt metadata format: standard
70
+ MSC3089 clients will not display the private names or understand these file indexes. SDK 0.8 starts
71
+ this format without a reader or migration for trees written by older SDK versions; those old trees
72
+ may be ignored or removed.
73
+
65
74
  File bytes are encrypted in the client before upload with Matrix attachment encryption (AES-CTR
66
75
  and a per-file key distributed through the room's Megolm session). The server stores encrypted
67
76
  media and Matrix events, but encryption does not hide all room, event, or other metadata from the
@@ -26,8 +26,9 @@ export interface TreeSpace {
26
26
  }
27
27
  export interface FileBranch {
28
28
  readonly id: string;
29
+ readonly roomId: string;
29
30
  /** Current MSC3089 branch/listing event. Its event ID is distinct from `id` (the state key). */
30
- readonly indexEvent?: Pick<MatrixEvent, "getId">;
31
+ readonly indexEvent?: Pick<MatrixEvent, "getId" | "getContent" | "getSender" | "isRedacted">;
31
32
  /** Matrix SDK keeps a redacted branch in state as an inactive object. */
32
33
  readonly isActive?: boolean;
33
34
  getName(): string;
@@ -144,9 +145,32 @@ export declare function withTreeMutation<T>(client: MatrixClient, operation: (si
144
145
  export declare class TeleCryptIOStorage {
145
146
  private client;
146
147
  private readonly decoratedTreeSpaces;
148
+ private readonly decoratedFileBranches;
149
+ private readonly treeNames;
150
+ private readonly fileNames;
147
151
  constructor(client: MatrixClient);
148
152
  /** The underlying matrix-js-sdk client (e.g. to stop it, or for advanced/interop use). */
149
153
  getClient(): MatrixClient;
154
+ private fileNameKey;
155
+ private getStateEvent;
156
+ private getTreeOwnerId;
157
+ private sendEncryptedMetadataMessage;
158
+ private readEncryptedMetadataMessage;
159
+ private writeTreeName;
160
+ /** Resolves an encrypted vault/folder name through its pointer state event. */
161
+ getTreeName(roomId: string, options?: MatrixRequestOptions): Promise<string>;
162
+ private writeFileName;
163
+ /** Resolves an encrypted file name from the MSC3089 index pointer. */
164
+ getFileName(treeId: string, fileId: string, options?: MatrixRequestOptions & {
165
+ refreshState?: boolean;
166
+ }): Promise<string>;
167
+ /** Returns the current file metadata pointer, including on inactive branches. */
168
+ getFileMetadataEventId(treeId: string, fileId: string, options?: MatrixRequestOptions): Promise<string | null>;
169
+ /** Fetches and validates a current encrypted rename event, allowing retries after redaction. */
170
+ getFileRenameMetadataEvent(treeId: string, fileId: string, metadataEventId: string): Promise<MatrixEvent>;
171
+ /** Fetches the original encrypted file message directly by event ID. */
172
+ getOriginalFileEvent(treeId: string, fileId: string, allowRedacted?: boolean): Promise<MatrixEvent>;
173
+ private createEncryptedFile;
150
174
  /**
151
175
  * Refreshes a room's current state from the homeserver. The background sync
152
176
  * loop is intentionally asynchronous, so a short-lived CLI process can
@@ -249,6 +273,7 @@ export declare class TeleCryptIOStorage {
249
273
  * which refreshes the exact parent/child state before reporting success.
250
274
  */
251
275
  private decorateTreeSpace;
276
+ private decorateFileBranch;
252
277
  /** Cleans up only a room created by this operation and reports incomplete cleanup. */
253
278
  private cleanupCreatedRoom;
254
279
  private removeRelationLink;
@@ -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,10 @@ 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
+ const STORAGE_METADATA_EVENT_TYPE = "io.telecrypt.storage.metadata";
13
+ const STORAGE_METADATA_MSGTYPE = "io.telecrypt.storage.metadata";
14
+ const GENERIC_TREE_NAME = "Encrypted storage";
15
+ const GENERIC_FILE_NAME = "Encrypted file";
12
16
  const recoveryMutationQueues = new WeakMap();
13
17
  const treeMutationQueues = new WeakMap();
14
18
  const TREE_SYNC_TIMEOUT_MS = 15000;
@@ -235,12 +239,6 @@ export function readFileEventMetadata(content) {
235
239
  function isRecord(value) {
236
240
  return typeof value === "object" && value !== null && !Array.isArray(value);
237
241
  }
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
242
  function validateSecretStorageStatus(value) {
245
243
  if (!isRecord(value))
246
244
  throw new RecoverySetupAmbiguousError();
@@ -409,6 +407,9 @@ export async function withTreeMutation(client, operation, signal) {
409
407
  export class TeleCryptIOStorage {
410
408
  client;
411
409
  decoratedTreeSpaces = new WeakSet();
410
+ decoratedFileBranches = new WeakMap();
411
+ treeNames = new Map();
412
+ fileNames = new Map();
412
413
  constructor(client) {
413
414
  this.client = client;
414
415
  // Advanced callers may construct a MatrixClient themselves. The SDK does
@@ -420,6 +421,229 @@ export class TeleCryptIOStorage {
420
421
  getClient() {
421
422
  return this.client;
422
423
  }
424
+ fileNameKey(treeId, fileId) {
425
+ return `${treeId}\u0000${fileId}`;
426
+ }
427
+ getStateEvent(roomId, eventType, stateKey) {
428
+ const state = this.client.getRoom(roomId)?.currentState;
429
+ const raw = state?.getStateEvents(eventType, stateKey);
430
+ if (Array.isArray(raw))
431
+ return raw[0] ?? null;
432
+ return raw ?? null;
433
+ }
434
+ getTreeOwnerId(roomId) {
435
+ const create = this.getStateEvent(roomId, EventType.RoomCreate, "");
436
+ const ownerId = create?.getSender();
437
+ if (!ownerId)
438
+ throw new StorageError("storage owner is unavailable");
439
+ return ownerId;
440
+ }
441
+ async sendEncryptedMetadataMessage(roomId, content) {
442
+ const response = await this.client.sendMessage(roomId, content);
443
+ return validateMatrixEventId(response?.event_id, "storage metadata response event ID");
444
+ }
445
+ async readEncryptedMetadataMessage(roomId, eventId, allowRedacted = false) {
446
+ const rawEvent = await this.client.fetchRoomEvent(roomId, eventId);
447
+ const event = this.client.getEventMapper()(rawEvent);
448
+ await this.client.decryptEventIfNeeded(event);
449
+ if (event.isDecryptionFailure())
450
+ throw new UndecryptableFileError();
451
+ if (allowRedacted &&
452
+ event.getId() === eventId &&
453
+ event.getRoomId() === roomId &&
454
+ event.getSender() === this.getTreeOwnerId(roomId) &&
455
+ event.isRedacted()) {
456
+ return event;
457
+ }
458
+ if (event.getId() !== eventId ||
459
+ event.getRoomId() !== roomId ||
460
+ event.getSender() !== this.getTreeOwnerId(roomId) ||
461
+ event.getWireType() !== EventType.RoomMessageEncrypted ||
462
+ event.getType() !== EventType.RoomMessage) {
463
+ throw new StorageError("encrypted storage metadata is invalid or unavailable");
464
+ }
465
+ return event;
466
+ }
467
+ async writeTreeName(roomId, name) {
468
+ validateName(name, "name");
469
+ const metadataEventId = await this.sendEncryptedMetadataMessage(roomId, {
470
+ msgtype: STORAGE_METADATA_MSGTYPE,
471
+ body: name,
472
+ });
473
+ await this.client.sendStateEvent(roomId, STORAGE_METADATA_EVENT_TYPE, { event_id: metadataEventId }, "");
474
+ this.treeNames.set(roomId, name);
475
+ }
476
+ /** Resolves an encrypted vault/folder name through its pointer state event. */
477
+ async getTreeName(roomId, options) {
478
+ if (options?.signal?.aborted)
479
+ throw new StorageError("operation cancelled");
480
+ await this.refreshRoomState(roomId, options);
481
+ const pointer = this.getStateEvent(roomId, STORAGE_METADATA_EVENT_TYPE, "");
482
+ const pointerContent = pointer?.getContent();
483
+ if (!pointer ||
484
+ pointer.getSender() !== this.getTreeOwnerId(roomId) ||
485
+ !isRecord(pointerContent) ||
486
+ typeof pointerContent.event_id !== "string") {
487
+ throw new StorageError("encrypted storage name is unavailable");
488
+ }
489
+ const metadataEventId = validateMatrixEventId(pointerContent.event_id, "storage metadata event ID");
490
+ let event;
491
+ try {
492
+ event = await this.readEncryptedMetadataMessage(roomId, metadataEventId);
493
+ }
494
+ catch (error) {
495
+ // A new device can list storage before restoring room keys. Keep the
496
+ // room visible with a generic label; a later call retries decryption.
497
+ if (error instanceof UndecryptableFileError)
498
+ return GENERIC_TREE_NAME;
499
+ throw error;
500
+ }
501
+ 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
+ this.treeNames.set(roomId, content.body);
510
+ return content.body;
511
+ }
512
+ async writeFileName(treeId, fileId, name) {
513
+ validateName(name, "file name");
514
+ validateMatrixEventId(fileId, "file event ID");
515
+ const metadataEventId = await this.sendEncryptedMetadataMessage(treeId, {
516
+ msgtype: STORAGE_METADATA_MSGTYPE,
517
+ body: name,
518
+ file_event_id: fileId,
519
+ });
520
+ await this.client.sendStateEvent(treeId, UNSTABLE_MSC3089_BRANCH.name, { active: true, metadata_event_id: metadataEventId }, fileId);
521
+ this.fileNames.set(this.fileNameKey(treeId, fileId), name);
522
+ }
523
+ /** Resolves an encrypted file name from the MSC3089 index pointer. */
524
+ async getFileName(treeId, fileId, options) {
525
+ if (options?.signal?.aborted)
526
+ throw new StorageError("operation cancelled");
527
+ if (options?.refreshState !== false)
528
+ await this.refreshRoomState(treeId, options);
529
+ const tree = this.getTree(treeId);
530
+ const branch = tree?.getFile(fileId);
531
+ if (!branch || branch.isActive === false)
532
+ throw new StorageError("file not found");
533
+ const indexEvent = branch.indexEvent;
534
+ const indexContent = indexEvent?.getContent();
535
+ if (!indexEvent ||
536
+ indexEvent.getSender() !== this.getTreeOwnerId(treeId) ||
537
+ !isRecord(indexContent) ||
538
+ indexContent.active !== true ||
539
+ typeof indexContent.metadata_event_id !== "string") {
540
+ throw new StorageError("encrypted file name is unavailable");
541
+ }
542
+ const metadataEventId = validateMatrixEventId(indexContent.metadata_event_id, "file metadata event ID");
543
+ let event;
544
+ try {
545
+ event = await this.readEncryptedMetadataMessage(treeId, metadataEventId);
546
+ }
547
+ catch (error) {
548
+ // File IDs remain listable on a fresh device while its room keys are
549
+ // being restored. The caller can resolve the private name on retry.
550
+ if (error instanceof UndecryptableFileError)
551
+ return GENERIC_FILE_NAME;
552
+ throw error;
553
+ }
554
+ const content = event.getContent();
555
+ if (!isRecord(content) || typeof content.body !== "string") {
556
+ throw new StorageError("encrypted file name is invalid or unavailable");
557
+ }
558
+ if (content.msgtype === MsgType.File) {
559
+ if (metadataEventId !== fileId || !isRecord(content.file)) {
560
+ throw new StorageError("encrypted file metadata does not match its listing");
561
+ }
562
+ }
563
+ else if (content.msgtype !== STORAGE_METADATA_MSGTYPE ||
564
+ content.file_event_id !== fileId) {
565
+ throw new StorageError("encrypted file metadata does not match its listing");
566
+ }
567
+ validateName(content.body, "file name");
568
+ this.fileNames.set(this.fileNameKey(treeId, fileId), content.body);
569
+ return content.body;
570
+ }
571
+ /** Returns the current file metadata pointer, including on inactive branches. */
572
+ async getFileMetadataEventId(treeId, fileId, options) {
573
+ if (options?.signal?.aborted)
574
+ throw new StorageError("operation cancelled");
575
+ await this.refreshRoomState(treeId, options);
576
+ const branch = this.getTree(treeId)?.getFile(fileId);
577
+ if (!branch)
578
+ throw new StorageError("file not found");
579
+ const indexEvent = branch.indexEvent;
580
+ const content = indexEvent?.getContent();
581
+ if (!indexEvent || indexEvent.getSender() !== this.getTreeOwnerId(treeId)) {
582
+ throw new StorageError("encrypted file listing is unavailable");
583
+ }
584
+ if (!isRecord(content) ||
585
+ typeof content.metadata_event_id !== "string") {
586
+ if (branch.isActive === false || indexEvent.isRedacted())
587
+ return null;
588
+ throw new StorageError("encrypted file name is unavailable");
589
+ }
590
+ return validateMatrixEventId(content.metadata_event_id, "file metadata event ID");
591
+ }
592
+ /** Fetches and validates a current encrypted rename event, allowing retries after redaction. */
593
+ async getFileRenameMetadataEvent(treeId, fileId, metadataEventId) {
594
+ const event = await this.readEncryptedMetadataMessage(treeId, metadataEventId, true);
595
+ if (event.isRedacted())
596
+ return event;
597
+ const content = event.getContent();
598
+ if (!isRecord(content) ||
599
+ content.msgtype !== STORAGE_METADATA_MSGTYPE ||
600
+ content.file_event_id !== fileId ||
601
+ typeof content.body !== "string") {
602
+ throw new StorageError("encrypted file metadata does not match its listing");
603
+ }
604
+ validateName(content.body, "file name");
605
+ return event;
606
+ }
607
+ /** Fetches the original encrypted file message directly by event ID. */
608
+ async getOriginalFileEvent(treeId, fileId, allowRedacted = false) {
609
+ const event = await this.readEncryptedMetadataMessage(treeId, fileId, allowRedacted);
610
+ if (event.isRedacted() && allowRedacted)
611
+ return event;
612
+ const content = event.getContent();
613
+ if (!isRecord(content) ||
614
+ content.msgtype !== MsgType.File ||
615
+ typeof content.body !== "string" ||
616
+ !isRecord(content.file) ||
617
+ typeof content.file.url !== "string") {
618
+ throw new StorageError("encrypted file message is invalid or unavailable");
619
+ }
620
+ return event;
621
+ }
622
+ async createEncryptedFile(treeId, name, encryptedContents, info, additionalContent) {
623
+ validateFileName(name);
624
+ const bytes = encryptedContents instanceof Uint8Array
625
+ ? encryptedContents
626
+ : new Uint8Array(encryptedContents);
627
+ 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 };
646
+ }
423
647
  /**
424
648
  * Refreshes a room's current state from the homeserver. The background sync
425
649
  * loop is intentionally asynchronous, so a short-lived CLI process can
@@ -1030,7 +1254,7 @@ export class TeleCryptIOStorage {
1030
1254
  if (!userId)
1031
1255
  throw new Error("createTree: Matrix client has no user ID");
1032
1256
  return {
1033
- name,
1257
+ name: GENERIC_TREE_NAME,
1034
1258
  preset: Preset.PrivateChat,
1035
1259
  power_level_content_override: {
1036
1260
  invite: 100,
@@ -1052,6 +1276,7 @@ export class TeleCryptIOStorage {
1052
1276
  [EventType.SpaceChild]: 100,
1053
1277
  [EventType.SpaceParent]: 100,
1054
1278
  [UNSTABLE_MSC3089_BRANCH.name]: 100,
1279
+ [STORAGE_METADATA_EVENT_TYPE]: 100,
1055
1280
  },
1056
1281
  users: { [userId]: 100 },
1057
1282
  },
@@ -1119,10 +1344,46 @@ export class TeleCryptIOStorage {
1119
1344
  return tree;
1120
1345
  this.decoratedTreeSpaces.add(tree);
1121
1346
  const originalGetDirectories = tree.getDirectories.bind(tree);
1347
+ const originalListFiles = typeof tree.listFiles === "function"
1348
+ ? tree.listFiles.bind(tree)
1349
+ : () => [];
1350
+ const originalGetFile = typeof tree.getFile === "function"
1351
+ ? tree.getFile.bind(tree)
1352
+ : () => null;
1122
1353
  tree.getDirectories = () => originalGetDirectories().map((child) => this.decorateTreeSpace(child));
1123
1354
  tree.createDirectory = (name) => this.createSubtree(tree, name);
1355
+ tree.setName = (name) => this.writeTreeName(tree.id, name);
1356
+ tree.listFiles = () => originalListFiles().map((branch) => this.decorateFileBranch(tree.id, branch));
1357
+ tree.getFile = (fileId) => {
1358
+ const branch = originalGetFile(fileId);
1359
+ return branch ? this.decorateFileBranch(tree.id, branch) : null;
1360
+ };
1361
+ tree.createFile = (name, encryptedContents, info, additionalContent) => this.createEncryptedFile(tree.id, name, encryptedContents, info, additionalContent);
1124
1362
  return tree;
1125
1363
  }
1364
+ decorateFileBranch(treeId, branch) {
1365
+ const existing = this.decoratedFileBranches.get(branch);
1366
+ if (existing)
1367
+ return existing;
1368
+ const decorated = Object.create(branch);
1369
+ const nameKey = this.fileNameKey(treeId, branch.id);
1370
+ Object.defineProperties(decorated, {
1371
+ getName: {
1372
+ value: () => this.fileNames.get(nameKey) ?? GENERIC_FILE_NAME,
1373
+ },
1374
+ setName: {
1375
+ value: (name) => this.writeFileName(treeId, branch.id, name),
1376
+ },
1377
+ delete: {
1378
+ value: async () => {
1379
+ const { deleteFile } = await import("./core/operations.js");
1380
+ await deleteFile(this, treeId, branch.id);
1381
+ },
1382
+ },
1383
+ });
1384
+ this.decoratedFileBranches.set(branch, decorated);
1385
+ return decorated;
1386
+ }
1126
1387
  /** Cleans up only a room created by this operation and reports incomplete cleanup. */
1127
1388
  async cleanupCreatedRoom(roomId) {
1128
1389
  // Cleanup is compensating work. It must not inherit the caller's already
@@ -1203,9 +1464,26 @@ export class TeleCryptIOStorage {
1203
1464
  throw new RoomCreationAmbiguousError(operation, error);
1204
1465
  }
1205
1466
  try {
1206
- return await this.waitForTreeSpace(roomId, operation, signal);
1467
+ const tree = await this.waitForTreeSpace(roomId, operation, signal);
1468
+ try {
1469
+ await this.writeTreeName(roomId, name);
1470
+ }
1471
+ catch (error) {
1472
+ try {
1473
+ await this.cleanupCreatedRoom(roomId);
1474
+ }
1475
+ catch (cleanupError) {
1476
+ throwWithCleanupDetail(error, roomId, cleanupError);
1477
+ }
1478
+ throw new StorageError(`${operation}: encrypted name initialization failed`, { cause: error });
1479
+ }
1480
+ return tree;
1207
1481
  }
1208
1482
  catch (error) {
1483
+ if (error instanceof StorageError &&
1484
+ error.message === `${operation}: encrypted name initialization failed`) {
1485
+ throw error;
1486
+ }
1209
1487
  try {
1210
1488
  await this.cleanupCreatedRoom(roomId);
1211
1489
  }
@@ -1409,7 +1687,9 @@ export class TeleCryptIOStorage {
1409
1687
  const members = parseMatrixMembersResponse(membersBody);
1410
1688
  const pls = parseMatrixPowerLevels(plsRaw);
1411
1689
  const viewLevel = pls.users_default ?? 0;
1412
- const editLevel = pls.events_default ?? 50;
1690
+ // Match matrix-js-sdk's MSC3089TreeSpace.getPermissions() default. An
1691
+ // explicit zero still means regular users lack the editor threshold.
1692
+ const editLevel = pls.events_default || 50;
1413
1693
  const adminLevel = pls.events?.["m.room.power_levels"] ?? 100;
1414
1694
  const roleFor = (userId) => {
1415
1695
  const userLevel = pls.users?.[userId] ?? viewLevel;
@@ -1479,27 +1759,17 @@ export class TeleCryptIOStorage {
1479
1759
  if (signal?.aborted)
1480
1760
  throw new StorageError("operation cancelled");
1481
1761
  let info;
1762
+ let fileEvent;
1482
1763
  try {
1483
- ({ info } = await branch.getFileInfo());
1764
+ fileEvent = await this.getOriginalFileEvent(branch.roomId, branch.id);
1765
+ const content = fileEvent.getContent();
1766
+ info = isRecord(content) && isRecord(content.file) ? content.file : undefined;
1484
1767
  }
1485
1768
  catch (error) {
1486
1769
  if (signal?.aborted)
1487
1770
  throw new StorageError("operation cancelled");
1488
1771
  if (error instanceof StorageError || error instanceof MatrixError)
1489
1772
  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
1773
  throw error;
1504
1774
  }
1505
1775
  // Missing metadata alone does not establish a decryption failure.
@@ -1509,7 +1779,7 @@ export class TeleCryptIOStorage {
1509
1779
  // `info` is the encrypted attachment descriptor. Its shape is owned by
1510
1780
  // matrix-encrypt-attachment and does not carry the plaintext size. The
1511
1781
  // event's `info` block is the sole source of user-facing file metadata.
1512
- const eventMetadata = readFileEventMetadata((await branch.getFileEvent()).getContent());
1782
+ const eventMetadata = readFileEventMetadata(fileEvent.getContent());
1513
1783
  if (signal?.aborted)
1514
1784
  throw new StorageError("operation cancelled");
1515
1785
  const declaredSize = eventMetadata.size;
@@ -11,9 +11,10 @@
11
11
  * platform-specific and stays with the caller.
12
12
  */
13
13
  import { readFileEventMetadata, withMatrixMutationAbort, withTreeMutation, } from "../TeleCryptIOStorage.js";
14
- import { EventType, MatrixError, RoomCreateTypeField, RoomType, UNSTABLE_MSC3089_BRANCH, UNSTABLE_MSC3088_ENABLED, UNSTABLE_MSC3088_PURPOSE, UNSTABLE_MSC3089_TREE_SUBTYPE, } from "matrix-js-sdk";
14
+ import { EventType, MatrixError, RoomCreateTypeField, RoomType, UNSTABLE_MSC3088_ENABLED, UNSTABLE_MSC3088_PURPOSE, UNSTABLE_MSC3089_TREE_SUBTYPE, } from "matrix-js-sdk";
15
15
  import { ClientPrefix } from "matrix-js-sdk/lib/http-api/prefix.js";
16
16
  import { Method } from "matrix-js-sdk/lib/http-api/method.js";
17
+ import { validateMatrixEventId } from "./constants.js";
17
18
  import { FileTooLargeError, MutationPartialError, MutationOutcomeUnknownError, NonEmptyTreeError, RoomCreationAmbiguousError, RoomCleanupIncompleteError, StorageError, } from "./errors.js";
18
19
  import { ConditionTimeoutError, waitForCondition } from "./poll.js";
19
20
  import { validateName } from "./validation.js";
@@ -237,8 +238,6 @@ async function refreshDeletionRooms(storage, rootId, signal) {
237
238
  throw new StorageError("could not enumerate storage parent safely");
238
239
  const parentIds = new Set();
239
240
  for (const event of parentEvents) {
240
- if (!isActiveRelationEvent(event))
241
- continue;
242
241
  const parentId = relationStateKey(event);
243
242
  if (!parentId)
244
243
  throw new StorageError("could not enumerate storage parent safely");
@@ -250,11 +249,6 @@ async function refreshDeletionRooms(storage, rootId, signal) {
250
249
  const parents = [...parentIds];
251
250
  if (parents[0]) {
252
251
  await storage.refreshRoomState(parents[0], { signal });
253
- const childEvents = readRelationEvents(client, parents[0], EventType.SpaceChild, rootId);
254
- if (childEvents === null ||
255
- !childEvents.some((event) => isActiveRelationEvent(event) && relationStateKey(event, rootId) === rootId)) {
256
- throw new StorageError("storage parent relation is inconsistent");
257
- }
258
252
  }
259
253
  if (signal?.aborted)
260
254
  throw new StorageError("operation cancelled");
@@ -332,25 +326,46 @@ function activeTreeDirectories(client, tree) {
332
326
  }
333
327
  async function unlinkExternalParents(storage, rootId, externalParents, signal) {
334
328
  const client = storage.getClient();
335
- let mutationAttempted = false;
329
+ const completedRelationEventIds = [];
330
+ const redactRelation = async (roomId, eventType, stateKey) => {
331
+ await storage.refreshRoomState(roomId, { signal });
332
+ if (signal?.aborted)
333
+ throw new StorageError("operation cancelled");
334
+ const events = readRelationEvents(client, roomId, eventType, stateKey);
335
+ if (events === null)
336
+ throw new StorageError("storage folder state is unavailable");
337
+ const event = events[0];
338
+ if (!event || !isActiveRelationEvent(event))
339
+ return;
340
+ const currentUserId = client.getUserId();
341
+ if (!currentUserId || event.getSender?.() !== currentUserId) {
342
+ throw new StorageError("storage folder relation was not authored by the owner");
343
+ }
344
+ const eventId = validateMatrixEventId(event.getId?.(), "storage folder relation event ID");
345
+ await withRateLimitRetry(() => withMatrixMutationAbort(() => client.redactEvent(roomId, eventId), signal), signal);
346
+ if (!completedRelationEventIds.includes(eventId))
347
+ completedRelationEventIds.push(eventId);
348
+ await storage.refreshRoomState(roomId, { signal });
349
+ const remaining = readRelationEvents(client, roomId, eventType, stateKey);
350
+ if (remaining === null || remaining.some((candidate) => isActiveRelationEvent(candidate))) {
351
+ throw new StorageError("delete graph unlink could not be verified");
352
+ }
353
+ };
336
354
  try {
337
355
  for (const parentId of externalParents) {
338
356
  if (signal?.aborted)
339
357
  throw new StorageError("operation cancelled");
340
- // Empty state-event content is the MSC3089-supported unlink form. Redaction
341
- // is not used here because it can be delayed or rejected independently of
342
- // the relation update.
343
- mutationAttempted = true;
344
- await withRateLimitRetry(() => withMatrixMutationAbort(() => client.sendStateEvent(parentId, EventType.SpaceChild, {}, rootId), signal), signal);
345
- if (signal?.aborted)
346
- throw new StorageError("operation cancelled");
347
- await withRateLimitRetry(() => withMatrixMutationAbort(() => client.sendStateEvent(rootId, EventType.SpaceParent, {}, parentId), signal), signal);
358
+ // Redact owner-authored relations one room at a time. If the process stops
359
+ // after the parent-side redaction, the redacted state key remains visible
360
+ // and the next call continues with the child-side relation.
361
+ await redactRelation(parentId, EventType.SpaceChild, rootId);
362
+ await redactRelation(rootId, EventType.SpaceParent, parentId);
348
363
  await storage.refreshRoomState(parentId, { signal });
349
364
  await storage.refreshRoomState(rootId, { signal });
350
365
  const childLink = readRelationEvents(client, parentId, EventType.SpaceChild, rootId);
351
366
  const parentLink = readRelationEvents(client, rootId, EventType.SpaceParent, parentId);
352
- if (!childLink ||
353
- !parentLink ||
367
+ if (childLink === null ||
368
+ parentLink === null ||
354
369
  childLink.some((event) => isActiveRelationEvent(event)) ||
355
370
  parentLink.some((event) => isActiveRelationEvent(event))) {
356
371
  throw new StorageError("delete graph unlink could not be verified");
@@ -358,45 +373,14 @@ async function unlinkExternalParents(storage, rootId, externalParents, signal) {
358
373
  }
359
374
  }
360
375
  catch (error) {
361
- if (!mutationAttempted && error instanceof StorageError && error.message === "operation cancelled") {
376
+ if (error instanceof MutationOutcomeUnknownError || error instanceof MutationPartialError)
362
377
  throw error;
363
- }
364
- // Unlinking is a two-room transaction. A failed second state update must
365
- // not leave a still-live vault detached from its external parent. Best
366
- // effort is insufficient here: surface a distinct failure if the repair
367
- // itself cannot be completed so callers never mistake a partial mutation
368
- // for a successful delete.
369
- try {
370
- await relinkExternalParents(storage, rootId, externalParents, new AbortController().signal);
371
- }
372
- catch (cleanupError) {
373
- throw new StorageError("delete graph unlink cleanup is incomplete", {
374
- cause: new AggregateError([error, cleanupError], "delete graph unlink and relink both failed", { cause: error }),
375
- });
378
+ if (completedRelationEventIds.length > 0) {
379
+ throw new MutationPartialError("delete folder links", completedRelationEventIds, "some parent links were removed; retry deleting the same folder to finish unlinking", { cause: error });
376
380
  }
377
381
  throw error;
378
382
  }
379
383
  }
380
- async function relinkExternalParents(storage, rootId, externalParents, signal) {
381
- const client = storage.getClient();
382
- const via = client.getDomain();
383
- if (!via)
384
- throw new StorageError("delete graph relink is unavailable");
385
- for (const parentId of externalParents) {
386
- if (signal?.aborted)
387
- throw new StorageError("operation cancelled");
388
- await withRateLimitRetry(() => withMatrixMutationAbort(() => client.sendStateEvent(parentId, EventType.SpaceChild, { via: [via] }, rootId), signal), signal);
389
- await withRateLimitRetry(() => withMatrixMutationAbort(() => client.sendStateEvent(rootId, EventType.SpaceParent, { via: [via] }, parentId), signal), signal);
390
- await storage.refreshRoomState(parentId, { signal });
391
- await storage.refreshRoomState(rootId, { signal });
392
- const childLink = readRelationEvents(client, parentId, EventType.SpaceChild, rootId);
393
- const parentLink = readRelationEvents(client, rootId, EventType.SpaceParent, parentId);
394
- if (!childLink?.some((event) => isActiveRelationEvent(event)) ||
395
- !parentLink?.some((event) => isActiveRelationEvent(event))) {
396
- throw new StorageError("delete graph relink could not be verified");
397
- }
398
- }
399
- }
400
384
  function isGoneError(error) {
401
385
  return (error instanceof MatrixError &&
402
386
  (error.errcode === "M_NOT_FOUND" || error.errcode === "M_UNKNOWN"));
@@ -409,12 +393,25 @@ async function deleteRoomDeterministically(storage, roomId, signal) {
409
393
  };
410
394
  try {
411
395
  const client = storage.getClient();
412
- const tree = storage.getTree(roomId);
413
- if (!tree)
414
- throw new StorageError("storage room is unavailable for deletion");
415
396
  const self = client.getUserId();
416
397
  if (!self)
417
398
  throw new StorageError("Matrix user identity is unavailable");
399
+ const ownMembership = await storage.getRoomMembership(roomId, undefined, { signal });
400
+ if (ownMembership === "leave" || ownMembership === "ban") {
401
+ try {
402
+ await withRateLimitRetry(() => withMatrixMutationAbort(() => client.forget(roomId), signal), signal);
403
+ }
404
+ catch (error) {
405
+ if (!isGoneError(error))
406
+ throw error;
407
+ }
408
+ removeRoomFromLocalStore(client, roomId);
409
+ markTreeDeleted(client, roomId);
410
+ return;
411
+ }
412
+ const tree = storage.getTree(roomId);
413
+ if (!tree)
414
+ throw new StorageError("storage room is unavailable for deletion");
418
415
  const members = await storage.listMembers(tree, { signal });
419
416
  if (members.some((member) => member.userId !== self && member.role === "owner" &&
420
417
  (member.membership === "join" || member.membership === "invite" || member.membership === "knock"))) {
@@ -444,7 +441,6 @@ async function deleteRoomDeterministically(storage, roomId, signal) {
444
441
  throw error;
445
442
  }
446
443
  }
447
- const ownMembership = await storage.getRoomMembership(roomId, undefined, { signal });
448
444
  if (ownMembership === "join" || ownMembership === "invite" || ownMembership === "knock") {
449
445
  try {
450
446
  await withRateLimitRetry(() => withMatrixMutationAbort(() => client.leave(roomId), signal), signal);
@@ -522,7 +518,11 @@ export async function createVault(storage, name, options) {
522
518
  export async function listVaults(storage, options) {
523
519
  return withOperationDeadline(options, async (signal) => {
524
520
  const trees = await storage.listTrees(signal);
525
- return trees.filter((t) => t.isTopLevel).map((t) => ({ id: t.id, name: t.room.name }));
521
+ const topLevel = trees.filter((tree) => tree.isTopLevel);
522
+ return Promise.all(topLevel.map(async (tree) => ({
523
+ id: tree.id,
524
+ name: await storage.getTreeName(tree.id, { signal }),
525
+ })));
526
526
  });
527
527
  }
528
528
  /** Current account's effective role for a vault, or null while it is unavailable. */
@@ -574,17 +574,8 @@ export async function joinVault(storage, vaultId, options) {
574
574
  return { vaultId, joined: true };
575
575
  }, "mutation");
576
576
  }
577
- function roomDisplayName(storage, roomId, fallbackName) {
578
- const client = storage.getClient();
579
- const room = client.getRoom(roomId);
580
- if (fallbackName?.trim())
581
- return fallbackName.trim();
582
- if (room?.name)
583
- return room.name;
584
- const userId = client.getUserId();
585
- if (room && userId)
586
- return room.getDefaultRoomName(userId);
587
- return roomId;
577
+ function roomDisplayName(_storage, _roomId, _fallbackName) {
578
+ return "Encrypted storage";
588
579
  }
589
580
  function hasActiveSpaceParent(room) {
590
581
  const result = room.currentState?.getStateEvents?.(EventType.SpaceParent);
@@ -908,7 +899,10 @@ export async function listFiles(storage, treeId, options) {
908
899
  const files = tree
909
900
  .listFiles()
910
901
  .filter((file) => !isMarkedFileDeleted(storage, tree.id, file.id));
911
- return files.map((f) => ({ id: f.id, name: f.getName() }));
902
+ return await Promise.all(files.map(async (file) => ({
903
+ id: file.id,
904
+ name: await storage.getFileName(tree.id, file.id, { signal, refreshState: false }),
905
+ })));
912
906
  }
913
907
  catch (error) {
914
908
  if (signal.aborted)
@@ -925,7 +919,10 @@ export async function listSubfolders(storage, parentId, options) {
925
919
  ensureOperationActive(signal);
926
920
  const directories = activeTreeDirectories(storage.getClient(), tree)
927
921
  .filter((directory) => !isMarkedTreeDeleted(storage, directory.id));
928
- return directories.map((d) => ({ id: d.id, name: d.room.name }));
922
+ return Promise.all(directories.map(async (directory) => ({
923
+ id: directory.id,
924
+ name: await storage.getTreeName(directory.id, { signal }),
925
+ })));
929
926
  });
930
927
  }
931
928
  export async function createSubfolder(storage, parentId, name, options) {
@@ -957,11 +954,9 @@ async function renameTree(storage, treeId, name, options) {
957
954
  await waitForCondition(async () => {
958
955
  ensureOperationActive(signal);
959
956
  const current = storage.getTree(treeId);
960
- if (current?.room.name === name)
961
- return current;
962
- await storage.refreshRoomState(treeId, { signal });
963
- const refreshed = storage.getTree(treeId);
964
- return refreshed?.room.name === name ? refreshed : null;
957
+ if (!current)
958
+ return null;
959
+ return (await storage.getTreeName(treeId, { signal })) === name ? current : null;
965
960
  }, { timeoutMs: 15000, signal });
966
961
  }
967
962
  catch (error) {
@@ -990,6 +985,20 @@ async function deleteTree(storage, treeId, options) {
990
985
  const pending = withTreeMutation(client, async () => {
991
986
  if (isTreeDeleted(client, treeId))
992
987
  return { id: treeId, deleted: true };
988
+ const ownMembership = await storage.getRoomMembership(treeId, undefined, {
989
+ signal: operation.signal,
990
+ });
991
+ if (ownMembership === "leave" || ownMembership === "ban") {
992
+ try {
993
+ await deleteRoomDeterministically(storage, treeId, operation.signal);
994
+ }
995
+ catch (error) {
996
+ if (error instanceof StorageError)
997
+ throw error;
998
+ throw new StorageError("delete cleanup failed", { cause: error });
999
+ }
1000
+ return { id: treeId, deleted: true };
1001
+ }
993
1002
  const tree = await resolveTree(storage, treeId, operation.signal);
994
1003
  let externalParents;
995
1004
  try {
@@ -1015,14 +1024,6 @@ async function deleteTree(storage, treeId, options) {
1015
1024
  await deleteRoomDeterministically(storage, tree.id, operation.signal);
1016
1025
  }
1017
1026
  catch (error) {
1018
- try {
1019
- await relinkExternalParents(storage, tree.id, externalParents, new AbortController().signal);
1020
- }
1021
- catch (cleanupError) {
1022
- throw new StorageError("delete cleanup is incomplete", {
1023
- cause: new AggregateError([error, cleanupError], "delete and external-parent relink both failed", { cause: error }),
1024
- });
1025
- }
1026
1027
  if (error instanceof StorageError)
1027
1028
  throw error;
1028
1029
  throw new StorageError("delete failed", { cause: error });
@@ -1054,9 +1055,11 @@ export async function renameFile(storage, treeId, fileId, name, options) {
1054
1055
  // fresh CLI/UI process can still read the previous local room state for a
1055
1056
  // short time. Do not report success until this client has observed the new
1056
1057
  // name through its normal sync loop.
1057
- await waitForCondition(() => {
1058
+ await waitForCondition(async () => {
1058
1059
  const current = tree.getFile(fileId);
1059
- return current?.getName() === name ? current : null;
1060
+ if (!current)
1061
+ return null;
1062
+ return (await storage.getFileName(treeId, fileId, { signal })) === name ? current : null;
1060
1063
  }, { timeoutMs: 15000, signal });
1061
1064
  }
1062
1065
  catch (error) {
@@ -1093,51 +1096,80 @@ async function deleteFileMedia(storage, mediaIds, signal) {
1093
1096
  export async function deleteFile(storage, treeId, fileId, options) {
1094
1097
  return withOperationDeadline(options, async (signal) => {
1095
1098
  const tree = await resolveTree(storage, treeId, signal);
1096
- const branch = await resolveFile(storage, tree, fileId, signal);
1097
- let fileInfo;
1099
+ if (isMarkedFileDeleted(storage, tree.id, fileId))
1100
+ return { id: fileId, deleted: true };
1101
+ await storage.refreshRoomState(treeId, { signal });
1102
+ const branch = tree.getFile(fileId);
1103
+ if (!branch)
1104
+ throw new StorageError("file not found");
1105
+ let originalEvent;
1098
1106
  try {
1099
- fileInfo = await branch.getFileInfo();
1107
+ originalEvent = await storage.getOriginalFileEvent(tree.id, fileId, true);
1100
1108
  }
1101
1109
  catch (error) {
1102
1110
  throw new StorageError("could not resolve encrypted file safely", { cause: error });
1103
1111
  }
1104
- const mediaId = fileInfo?.info?.url;
1112
+ if (originalEvent.isRedacted()) {
1113
+ if (branch.isActive === false) {
1114
+ markFileDeleted(storage.getClient(), tree.id, fileId);
1115
+ return { id: fileId, deleted: true };
1116
+ }
1117
+ throw new StorageError("file listing is active but its attachment event is redacted");
1118
+ }
1119
+ const originalContent = originalEvent.getContent();
1120
+ const fileDescriptor = typeof originalContent === "object" &&
1121
+ originalContent !== null &&
1122
+ !Array.isArray(originalContent) &&
1123
+ "file" in originalContent &&
1124
+ typeof originalContent.file === "object" &&
1125
+ originalContent.file !== null &&
1126
+ !Array.isArray(originalContent.file)
1127
+ ? originalContent.file
1128
+ : undefined;
1129
+ const mediaId = fileDescriptor?.url;
1105
1130
  if (typeof mediaId !== "string" || mediaId.length === 0) {
1106
1131
  throw new StorageError("encrypted file has no media identifier");
1107
1132
  }
1108
1133
  await deleteFileMedia(storage, [mediaId], signal);
1109
- const completedIds = [];
1134
+ const completedIds = [mediaId];
1110
1135
  try {
1111
1136
  const client = storage.getClient();
1112
- // MSC3089 uses the file event ID as the branch state key, but the current
1113
- // listing is a separate state event. Redact that current listing after
1114
- // clearing the branch state; redacting only the file event leaves the
1115
- // listing visible and is especially harmful when a user is suspended.
1116
- const listingEventId = branch.indexEvent?.getId?.() ?? branch.id;
1117
- if (typeof listingEventId !== "string" || listingEventId.length === 0) {
1137
+ const metadataEventId = await storage.getFileMetadataEventId(tree.id, fileId, { signal });
1138
+ if (metadataEventId && metadataEventId !== fileId) {
1139
+ const metadataEvent = await storage.getFileRenameMetadataEvent(tree.id, fileId, metadataEventId);
1140
+ if (!metadataEvent.isRedacted()) {
1141
+ await withRateLimitRetry(() => withMatrixMutationAbort(() => client.redactEvent(tree.id, metadataEventId), signal, "delete file rename metadata"), signal);
1142
+ }
1143
+ completedIds.push(metadataEventId);
1144
+ }
1145
+ const currentBranch = tree.getFile(fileId);
1146
+ const indexEvent = currentBranch?.indexEvent;
1147
+ const listingEventId = indexEvent?.getId();
1148
+ if (!indexEvent || typeof listingEventId !== "string" || listingEventId.length === 0) {
1118
1149
  throw new StorageError("encrypted file has no listing event identifier");
1119
1150
  }
1120
- await withRateLimitRetry(() => withMatrixMutationAbort(() => client.sendStateEvent(tree.id, UNSTABLE_MSC3089_BRANCH.name, {}, branch.id), signal, "delete file state"), signal);
1121
- await withRateLimitRetry(() => withMatrixMutationAbort(() => client.redactEvent(tree.id, listingEventId), signal, "delete file event"), signal);
1151
+ if (!indexEvent.isRedacted()) {
1152
+ if (indexEvent.getSender() !== client.getUserId()) {
1153
+ throw new StorageError("file listing was not authored by the owner");
1154
+ }
1155
+ await withRateLimitRetry(() => withMatrixMutationAbort(() => client.redactEvent(tree.id, listingEventId), signal, "delete file listing"), signal);
1156
+ }
1122
1157
  completedIds.push(listingEventId);
1158
+ await withRateLimitRetry(() => withMatrixMutationAbort(() => client.redactEvent(tree.id, fileId), signal, "delete file attachment event"), signal);
1159
+ completedIds.push(fileId);
1123
1160
  }
1124
1161
  catch (error) {
1125
- if (error instanceof MutationOutcomeUnknownError)
1126
- throw error;
1127
- if (signal.aborted) {
1128
- throw new MutationPartialError("delete file", completedIds, "media was deleted but Matrix event cleanup was cancelled", { cause: error });
1129
- }
1130
- throw new MutationPartialError("delete file", completedIds, "media was deleted but Matrix event cleanup stopped", { cause: error });
1162
+ throw new MutationPartialError("delete file", completedIds, "file media and the listed completed steps were deleted; retry with the original file ID to finish Matrix cleanup", { cause: error });
1131
1163
  }
1132
1164
  try {
1133
1165
  await waitForCondition(async () => {
1134
1166
  await storage.refreshRoomState(tree.id, { signal });
1135
1167
  ensureOperationActive(signal);
1136
1168
  const current = tree.getFile(fileId);
1137
- // Matrix SDK keeps a redacted branch event in room state as an
1138
- // inactive branch. Treat both an absent branch and that redacted
1139
- // inactive branch as confirmed deletion.
1140
- return !current || current.isActive === false ? true : null;
1169
+ if (current && current.isActive !== false)
1170
+ return null;
1171
+ const attachment = await storage.getOriginalFileEvent(tree.id, fileId, true);
1172
+ return attachment.isRedacted() ? true : null;
1141
1173
  }, { timeoutMs: 15000, signal });
1142
1174
  }
1143
1175
  catch (error) {
@@ -1146,7 +1178,7 @@ export async function deleteFile(storage, treeId, fileId, options) {
1146
1178
  const detail = "Matrix deletion completed but the file state could not be verified";
1147
1179
  throw new MutationPartialError("delete file", completedIds, detail, { cause: error });
1148
1180
  }
1149
- markFileDeleted(storage.getClient(), tree.id, branch.id);
1181
+ markFileDeleted(storage.getClient(), tree.id, fileId);
1150
1182
  ensureOperationActive(signal);
1151
1183
  return { id: fileId, deleted: true };
1152
1184
  }, "mutation");
@@ -1205,7 +1237,7 @@ export async function downloadFile(storage, treeId, fileId, options) {
1205
1237
  return {
1206
1238
  bytes: new Uint8Array(result.data),
1207
1239
  mimetype: result.mimetype,
1208
- name: branch.getName(),
1240
+ name: await storage.getFileName(tree.id, fileId, { signal }),
1209
1241
  };
1210
1242
  });
1211
1243
  }
@@ -1223,15 +1255,15 @@ function tsToIso(ts) {
1223
1255
  export async function getFileDetails(storage, treeId, fileId, options) {
1224
1256
  return withOperationDeadline(options, async (signal) => {
1225
1257
  const tree = await resolveTree(storage, treeId, signal);
1226
- const branch = await resolveFile(storage, tree, fileId, signal);
1227
- const name = branch.getName();
1258
+ await resolveFile(storage, tree, fileId, signal);
1259
+ const name = await storage.getFileName(tree.id, fileId, { signal });
1228
1260
  let mimetype = null;
1229
1261
  let size = null;
1230
1262
  let createdAt = null;
1231
1263
  let updatedAt = null;
1232
1264
  try {
1233
1265
  ensureOperationActive(signal);
1234
- const event = await branch.getFileEvent();
1266
+ const event = await storage.getOriginalFileEvent(tree.id, fileId);
1235
1267
  const metadata = readFileEventMetadata(event.getContent());
1236
1268
  mimetype = metadata.mimetype;
1237
1269
  size = metadata.size;
@@ -1250,7 +1282,7 @@ export async function getFileDetails(storage, treeId, fileId, options) {
1250
1282
  });
1251
1283
  }
1252
1284
  async function getTreeDetails(storage, treeId, signal) {
1253
- const tree = await resolveTree(storage, treeId, signal);
1285
+ await resolveTree(storage, treeId, signal);
1254
1286
  if (signal?.aborted)
1255
1287
  throw new StorageError("operation cancelled");
1256
1288
  const client = storage.getClient();
@@ -1268,7 +1300,7 @@ async function getTreeDetails(storage, treeId, signal) {
1268
1300
  }
1269
1301
  }
1270
1302
  return {
1271
- name: tree.room.name || roomDisplayName(storage, treeId),
1303
+ name: await storage.getTreeName(treeId, { signal }),
1272
1304
  id: treeId,
1273
1305
  createdAt,
1274
1306
  memberCount,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telecrypt-io/storage",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "license": "BUSL-1.1",
5
5
  "type": "module",
6
6
  "packageManager": "npm@11.19.0",