@telecrypt-io/storage 0.7.1 → 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
@@ -2,19 +2,9 @@
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/@telecrypt-io/storage)](https://www.npmjs.com/package/@telecrypt-io/storage)
4
4
 
5
- End-to-end encrypted file storage and sharing, built on Matrix.
6
-
7
- The current public TeleCrypt architecture, responsibilities, and product limits are authoritative at
8
- [telecrypt-io.github.io/llms-authority/llms.txt](https://telecrypt-io.github.io/llms-authority/llms.txt). This README documents this SDK's package API
9
- and development workflow.
10
-
11
- Files are encrypted on the client before upload. The server stores only opaque ciphertext and
12
- never holds the decryption keys. Shared vaults let multiple people add and read files, and a
13
- Recovery Key restores your files on a new device — even if you lose the original.
14
-
15
- This repository publishes the library package from annotated version tags using npm Trusted
16
- Publishing. GitHub Releases identify the corresponding source version. The command-line client is
17
- maintained in its own repository.
5
+ A JavaScript/TypeScript SDK for encrypted Matrix file trees, based on
6
+ [MSC3089](https://github.com/matrix-org/matrix-spec-proposals/pull/3089), an open and currently
7
+ unmerged Matrix proposal.
18
8
 
19
9
  ## Install
20
10
 
@@ -22,119 +12,88 @@ maintained in its own repository.
22
12
  npm install @telecrypt-io/storage
23
13
  ```
24
14
 
25
- This gives you the `TeleCryptIOStorage` library and its browser-safe `core` API.
15
+ ## Browser quick start
26
16
 
27
- ## Quick start
28
-
29
- **Library:**
17
+ The example below starts with an authenticated OIDC session. Obtain `userId`, `accessToken`, and
18
+ `deviceId` from that completed session. `baseUrl` and `serverName` must come from trusted
19
+ application configuration for the same Matrix deployment; do not accept them from an untrusted
20
+ request.
30
21
 
31
22
  ```ts
32
23
  import { TeleCryptIOStorage } from "@telecrypt-io/storage";
33
24
  import * as core from "@telecrypt-io/storage/core";
34
25
 
26
+ // baseUrl/serverName come from trusted application configuration.
27
+ // userId/accessToken/deviceId come from completed OIDC authentication.
35
28
  const storage = await TeleCryptIOStorage.create({
36
- baseUrl, userId, accessToken, deviceId,
29
+ baseUrl, serverName, userId, accessToken, deviceId,
37
30
  });
38
- const vault = await core.createVault(storage, "Photos");
39
- await core.uploadFile(storage, vault.id, "cat.jpg", bytes, "image/jpeg");
31
+ try {
32
+ const vault = await core.createVault(storage, "Example");
33
+ const file = await core.uploadFile(
34
+ storage, vault.id, "hello.txt",
35
+ new TextEncoder().encode("Hello"), "text/plain",
36
+ );
37
+ const downloaded = await core.downloadFile(storage, vault.id, file.id);
38
+ console.log(new TextDecoder().decode(downloaded.bytes));
39
+ } finally {
40
+ storage.getClient().stopClient();
41
+ }
40
42
  ```
41
43
 
42
- The recommended constructors configure the Matrix client with the SDK's manual-redirect transport
43
- and finite request deadline. The public `new TeleCryptIOStorage(client)` constructor is
44
- an advanced escape hatch: it does not rewrite matrix-js-sdk internals. Callers using it must build
45
- the client through matrix-js-sdk's supported `createClient` options (`fetchFn` and
46
- `localTimeoutMs`) and remain responsible for the safety of other Matrix SDK requests. SDK methods
47
- that need the authenticated transport fail closed when it is unavailable; they do not silently
48
- install a private transport configuration.
49
-
50
- Same-name vault/folder creation is serialized within one `TeleCryptIOStorage` instance, but
51
- each call creates a distinct room. Display names are labels, not identities, and Matrix
52
- provides no server-side room-creation idempotency key.
53
-
54
- ## OIDC API
55
-
56
- The SDK owns the browser authorization-code PKCE context in `sessionStorage` and provides device-code,
57
- discovery, dynamic registration, identity confirmation, and refresh helpers. OAuth metadata, token
58
- scopes, Matrix user/device identities, redirects, and complete response bodies are validated before
59
- they are returned. Network-bound helpers accept `AbortSignal` where cancellation is meaningful; token
60
- refresh uses a public client and persists the resulting token pair through the caller's callback.
61
-
62
- The command-line client is sourced by
63
- [`TeleCrypt-io/storage-cli`](https://github.com/TeleCrypt-io/storage-cli). The static web
64
- application is sourced by
65
- [`TeleCrypt-io/storage.telecrypt.io`](https://github.com/TeleCrypt-io/storage.telecrypt.io).
66
-
67
- ### 0.5 OIDC migration
68
-
69
- Version 0.5 targets Matrix JS SDK 42 and the stable Matrix OAuth metadata endpoint. Existing
70
- integrations must use the current `createFromOidc` options and pass the confirmed Matrix user ID,
71
- device ID, and access token returned by the login flow. Replace old Matrix SDK OIDC imports and
72
- private HTTP discovery with `discoverOidcIssuer`, `registerClient`, the device-code or PKCE flow
73
- helpers, and `whoAmI` from `core/oidc`; the discovery URL is the stable
74
- `/_matrix/client/v1/auth_metadata` endpoint. Refresh integration uses
75
- `buildTokenRefreshFunction` as the current `tokenRefreshFunction` callback and must persist the
76
- returned token pair before reporting success. The SDK no longer carries pre-0.5 compatibility
77
- paths, so no adapter-side shim is required or supported. Discovery accepts providers that omit
78
- `revocation_endpoint`; revocation is only an opportunistic cleanup path after refresh persistence
79
- fails. Device-code sessions retain their requested device ID in-process; callers that reconstruct a
80
- session must pass its expected device ID to `waitForDeviceCodeLogin`. `buildTokenRefreshFunction`
81
- always requires the expected device ID because a reconstructed refresh callback has no safe implicit
82
- binding. Returned OAuth scopes are rejected unless they match that intended Matrix device exactly.
83
-
84
- ## How it works
85
-
86
- Built on [MSC3089](https://github.com/matrix-org/matrix-spec-proposals/pull/3089), which models
87
- a file tree using Matrix primitives:
88
-
89
- | Storage concept | Matrix concept |
90
- |---|---|
91
- | Vault | A Space (room marked as a file tree) |
92
- | Folder | A child Space |
93
- | File | An event pointing at encrypted uploaded content |
94
- | Sharing | Room invitation |
95
- | Permissions | Power levels |
96
-
97
- ### Deletion
98
-
99
- Vaults and folders are deleted only when they are empty. Delete each file first; deleting a file
100
- also removes its encrypted media object before redacting its Matrix event. Delete empty child
101
- folders before deleting their parent. A nonempty delete
102
- fails with `NonEmptyTreeError`, so a shared or nested tree is never removed implicitly.
103
-
104
- Encryption uses the same scheme as Matrix attachments (AES-CTR with a per-file key, keys
105
- distributed via the room's Megolm session). File deletion uses TeleCrypt's authenticated Synapse
106
- storage extension to remove the local media object before Matrix redaction; stock
107
- Synapse without that extension cannot provide the SDK deletion contract.
108
-
109
- ## Development
44
+ `TeleCryptIOStorage.create` initializes the Matrix client and its persistent browser crypto store
45
+ by default. Browser persistence uses IndexedDB and the Matrix Rust crypto WASM runtime, so this
46
+ example assumes a browser environment. OIDC discovery, login, refresh, and the corresponding
47
+ `createFromOidc` inputs are exported from `@telecrypt-io/storage/core`.
110
48
 
111
- ```bash
112
- npm ci --ignore-scripts --no-fund --no-audit
113
- npm run test:unit # unit-only checks; does not start Podman
114
- npm run synapse:up # disposable local Synapse/MAS fixture
115
- npm test # full SDK suite, including fixture-backed functional tests
116
- ```
49
+ The example creates retained Matrix rooms and encrypted media. Delete its file and then its empty
50
+ vault when finished; cleanup is part of the operation, not an automatic consequence of stopping
51
+ the client.
52
+
53
+ ## Storage model and behavior
54
+
55
+ The SDK maps the file tree to Matrix primitives defined by MSC3089:
56
+
57
+ - A vault is a Matrix Space room marked as a file tree.
58
+ - A folder is a child Space in that tree.
59
+ - A file is a Matrix event referring to encrypted uploaded content.
60
+ - Sharing uses Matrix room invitations and permissions use Matrix power levels.
117
61
 
118
- The full suite uses the real local Synapse/MAS/Postgres fixture in Podman at `localhost:8008`, never
119
- a production server. This is the shared fixture for the SDK, CLI, and Web functional/e2e suites;
120
- start it from this checkout and reuse it for the sibling repositories. Unit tests may use mocks for
121
- isolated boundaries, but they do not replace the real-stack checks.
62
+ Vault, folder, and file IDs are opaque Matrix room and event IDs. Names are display labels: two
63
+ objects with the same name are still distinct, and a name is not an idempotency key.
122
64
 
123
- If setup or tests fail, inspect the relevant logs, then run `npm run synapse:down` when finished.
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.
124
73
 
125
- After a successful run, use `npm run synapse:down` for scoped teardown. Use
126
- `./throwaway_synapse/down.sh --wipe` only when a complete fixture reset is explicitly required.
74
+ File bytes are encrypted in the client before upload with Matrix attachment encryption (AES-CTR
75
+ and a per-file key distributed through the room's Megolm session). The server stores encrypted
76
+ media and Matrix events, but encryption does not hide all room, event, or other metadata from the
77
+ server or users who can read the room.
127
78
 
128
- See [RELEASING.md](./RELEASING.md) for the npm release procedure.
79
+ Recovery is explicit. Use `core.setupRecovery(storage)` to create a recovery key and keep that key
80
+ securely; use `core.restoreRecovery(storage, recoveryKey)` on a new device. Losing the recovery key
81
+ can prevent encrypted keys from being restored.
129
82
 
130
- ## Licence
83
+ Deletion has strict ordering. Delete files before their folders or vaults because folders and
84
+ vaults must be empty. File deletion removes the encrypted media object before redacting its Matrix
85
+ event and requires TeleCrypt's Synapse storage extension; stock Synapse cannot provide that
86
+ deletion contract.
131
87
 
132
- [Business Source License 1.1](./LICENSE). Non-commercial use is permitted; converts to
133
- Apache License 2.0 on 2030-07-20.
88
+ ## Source repositories
134
89
 
135
- For commercial licensing, contact TeleCrypt.io.
90
+ - Web application: [`TeleCrypt-io/storage`](https://github.com/TeleCrypt-io/storage)
91
+ - Command-line client: [`cli/`](https://github.com/TeleCrypt-io/storage/tree/main/cli)
136
92
 
137
- ## Third-party code
93
+ ## License
138
94
 
139
- - [`matrix-js-sdk`](https://github.com/matrix-org/matrix-js-sdk) — Apache-2.0 — dependency
140
- - [`matrix-encrypt-attachment`](https://github.com/matrix-org/matrix-encrypt-attachment) — Apache-2.0 — dependency
95
+ See [`LICENSE`](./LICENSE) for the complete terms. The current license is Business Source License
96
+ 1.1 with an additional grant for non-commercial use; it is not an open-source license. The stated
97
+ Change Date is 2030-07-20, and the Change License is Apache License 2.0. The license also changes
98
+ on the fourth anniversary of the first public distribution of a specific version if that occurs
99
+ before its stated Change Date. The full license controls in case of any discrepancy.
@@ -26,6 +26,9 @@ export interface TreeSpace {
26
26
  }
27
27
  export interface FileBranch {
28
28
  readonly id: string;
29
+ readonly roomId: string;
30
+ /** Current MSC3089 branch/listing event. Its event ID is distinct from `id` (the state key). */
31
+ readonly indexEvent?: Pick<MatrixEvent, "getId" | "getContent" | "getSender" | "isRedacted">;
29
32
  /** Matrix SDK keeps a redacted branch in state as an inactive object. */
30
33
  readonly isActive?: boolean;
31
34
  getName(): string;
@@ -142,9 +145,32 @@ export declare function withTreeMutation<T>(client: MatrixClient, operation: (si
142
145
  export declare class TeleCryptIOStorage {
143
146
  private client;
144
147
  private readonly decoratedTreeSpaces;
148
+ private readonly decoratedFileBranches;
149
+ private readonly treeNames;
150
+ private readonly fileNames;
145
151
  constructor(client: MatrixClient);
146
152
  /** The underlying matrix-js-sdk client (e.g. to stop it, or for advanced/interop use). */
147
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;
148
174
  /**
149
175
  * Refreshes a room's current state from the homeserver. The background sync
150
176
  * loop is intentionally asynchronous, so a short-lived CLI process can
@@ -247,6 +273,7 @@ export declare class TeleCryptIOStorage {
247
273
  * which refreshes the exact parent/child state before reporting success.
248
274
  */
249
275
  private decorateTreeSpace;
276
+ private decorateFileBranch;
250
277
  /** Cleans up only a room created by this operation and reports incomplete cleanup. */
251
278
  private cleanupCreatedRoom;
252
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_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,25 +1254,29 @@ 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,
1037
1261
  kick: 100,
1038
1262
  ban: 100,
1039
- redact: 50,
1040
- state_default: 50,
1041
- events_default: 50,
1263
+ redact: 100,
1264
+ state_default: 0,
1265
+ events_default: 0,
1042
1266
  users_default: 0,
1043
1267
  events: {
1044
1268
  [EventType.RoomPowerLevels]: 100,
1045
1269
  [EventType.RoomHistoryVisibility]: 100,
1046
1270
  [EventType.RoomTombstone]: 100,
1047
1271
  [EventType.RoomEncryption]: 100,
1048
- [EventType.RoomName]: 50,
1049
- [EventType.RoomMessage]: 50,
1050
- [EventType.RoomMessageEncrypted]: 50,
1051
- [EventType.Sticker]: 50,
1272
+ [EventType.RoomName]: 100,
1273
+ [EventType.RoomMessage]: 0,
1274
+ [EventType.RoomMessageEncrypted]: 0,
1275
+ [EventType.Sticker]: 0,
1276
+ [EventType.SpaceChild]: 100,
1277
+ [EventType.SpaceParent]: 100,
1278
+ [UNSTABLE_MSC3089_BRANCH.name]: 100,
1279
+ [STORAGE_METADATA_EVENT_TYPE]: 100,
1052
1280
  },
1053
1281
  users: { [userId]: 100 },
1054
1282
  },
@@ -1116,10 +1344,46 @@ export class TeleCryptIOStorage {
1116
1344
  return tree;
1117
1345
  this.decoratedTreeSpaces.add(tree);
1118
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;
1119
1353
  tree.getDirectories = () => originalGetDirectories().map((child) => this.decorateTreeSpace(child));
1120
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);
1121
1362
  return tree;
1122
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
+ }
1123
1387
  /** Cleans up only a room created by this operation and reports incomplete cleanup. */
1124
1388
  async cleanupCreatedRoom(roomId) {
1125
1389
  // Cleanup is compensating work. It must not inherit the caller's already
@@ -1200,9 +1464,26 @@ export class TeleCryptIOStorage {
1200
1464
  throw new RoomCreationAmbiguousError(operation, error);
1201
1465
  }
1202
1466
  try {
1203
- 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;
1204
1481
  }
1205
1482
  catch (error) {
1483
+ if (error instanceof StorageError &&
1484
+ error.message === `${operation}: encrypted name initialization failed`) {
1485
+ throw error;
1486
+ }
1206
1487
  try {
1207
1488
  await this.cleanupCreatedRoom(roomId);
1208
1489
  }
@@ -1406,7 +1687,9 @@ export class TeleCryptIOStorage {
1406
1687
  const members = parseMatrixMembersResponse(membersBody);
1407
1688
  const pls = parseMatrixPowerLevels(plsRaw);
1408
1689
  const viewLevel = pls.users_default ?? 0;
1409
- 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;
1410
1693
  const adminLevel = pls.events?.["m.room.power_levels"] ?? 100;
1411
1694
  const roleFor = (userId) => {
1412
1695
  const userLevel = pls.users?.[userId] ?? viewLevel;
@@ -1476,27 +1759,17 @@ export class TeleCryptIOStorage {
1476
1759
  if (signal?.aborted)
1477
1760
  throw new StorageError("operation cancelled");
1478
1761
  let info;
1762
+ let fileEvent;
1479
1763
  try {
1480
- ({ 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;
1481
1767
  }
1482
1768
  catch (error) {
1483
1769
  if (signal?.aborted)
1484
1770
  throw new StorageError("operation cancelled");
1485
1771
  if (error instanceof StorageError || error instanceof MatrixError)
1486
1772
  throw error;
1487
- // matrix-js-sdk's file-info accessor reads an attachment absent from
1488
- // failed-decryption events. Use its explicit decryption status, not a
1489
- // guessed content shape; preserve other SDK/transport failures.
1490
- let isPlaceholder = false;
1491
- try {
1492
- isPlaceholder = await isUndecryptableFilePlaceholder(branch);
1493
- }
1494
- catch (placeholderError) {
1495
- throw new AggregateError([error, placeholderError], "file metadata lookup and placeholder inspection both failed", { cause: error });
1496
- }
1497
- if (isPlaceholder) {
1498
- throw new UndecryptableFileError();
1499
- }
1500
1773
  throw error;
1501
1774
  }
1502
1775
  // Missing metadata alone does not establish a decryption failure.
@@ -1506,7 +1779,7 @@ export class TeleCryptIOStorage {
1506
1779
  // `info` is the encrypted attachment descriptor. Its shape is owned by
1507
1780
  // matrix-encrypt-attachment and does not carry the plaintext size. The
1508
1781
  // event's `info` block is the sole source of user-facing file metadata.
1509
- const eventMetadata = readFileEventMetadata((await branch.getFileEvent()).getContent());
1782
+ const eventMetadata = readFileEventMetadata(fileEvent.getContent());
1510
1783
  if (signal?.aborted)
1511
1784
  throw new StorageError("operation cancelled");
1512
1785
  const declaredSize = eventMetadata.size;
@@ -31,7 +31,7 @@ export declare function declineInvite(storage: TeleCryptIOStorage, vaultId: stri
31
31
  vaultId: string;
32
32
  declined: boolean;
33
33
  }>;
34
- /** Invites `userId` to the vault and applies the requested role. */
34
+ /** Invites `userId` to the vault as a reader. */
35
35
  export declare function shareVault(storage: TeleCryptIOStorage, vaultId: string, userId: string, role: string, options?: OperationOptions): Promise<ShareResult>;
36
36
  export declare function unshareVault(storage: TeleCryptIOStorage, vaultId: string, userId: string, options?: OperationOptions): Promise<UnshareResult>;
37
37
  export declare function listMembers(storage: TeleCryptIOStorage, vaultId: string, options?: OperationOptions): Promise<Member[]>;
@@ -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);
@@ -728,10 +719,10 @@ export async function declineInvite(storage, vaultId, options) {
728
719
  return { vaultId, declined: true };
729
720
  }, "mutation");
730
721
  }
731
- /** Invites `userId` to the vault and applies the requested role. */
722
+ /** Invites `userId` to the vault as a reader. */
732
723
  export async function shareVault(storage, vaultId, userId, role, options) {
733
- if (role !== "viewer" && role !== "editor") {
734
- throw new StorageError("invalid --role (must be viewer or editor)");
724
+ if (role !== "viewer") {
725
+ throw new StorageError("storage sharing supports viewers only");
735
726
  }
736
727
  const operation = createOperationDeadline(options);
737
728
  const completedRoomIds = new Set();
@@ -754,6 +745,12 @@ export async function shareVault(storage, vaultId, userId, role, options) {
754
745
  }
755
746
  for (const [index, space] of spaces.entries()) {
756
747
  ensureOperationActive(operation.signal);
748
+ const members = index === 0
749
+ ? currentMembers
750
+ : await withRateLimitRetry(() => storage.listMembers(space, { signal: operation.signal }), operation.signal);
751
+ if (members.some((member) => member.userId === userId && member.role === "owner")) {
752
+ throw new StorageError("share will not demote an existing owner");
753
+ }
757
754
  const currentMembership = index === 0
758
755
  ? currentMembers.find((member) => member.userId === userId)?.membership
759
756
  : await storage.getRoomMembership(space.id, userId, {
@@ -778,17 +775,10 @@ export async function shareVault(storage, vaultId, userId, role, options) {
778
775
  throw new StorageError("share failed");
779
776
  }
780
777
  }
781
- for (const space of spaces) {
782
- ensureOperationActive(operation.signal);
783
- const members = space.id === tree.id
784
- ? currentMembers
785
- : await withRateLimitRetry(() => storage.listMembers(space, { signal: operation.signal }), operation.signal);
786
- if (members.some((member) => member.userId === userId && member.role === "owner")) {
787
- throw new StorageError("share will not demote an existing owner");
788
- }
789
- await withRateLimitRetry(() => withMatrixMutationAbort(() => space.setPermissions(userId, role), operation.signal), operation.signal);
790
- completedRoomIds.add(space.id);
791
- }
778
+ // Storage rooms have one immutable owner power level and readers use
779
+ // the room's default user level. Inviting is the complete sharing
780
+ // operation; changing per-user power levels would create editors and
781
+ // let metadata authorship drift away from the owner.
792
782
  }
793
783
  catch (error) {
794
784
  if (error instanceof MutationOutcomeUnknownError)
@@ -805,7 +795,7 @@ export async function shareVault(storage, vaultId, userId, role, options) {
805
795
  }
806
796
  throw new StorageError("share failed", { cause: error });
807
797
  }
808
- return { vaultId, userId, role };
798
+ return { vaultId, userId, role: "viewer" };
809
799
  }, operation.signal);
810
800
  return await raceOperationDeadline(operation, pending, "mutation");
811
801
  }
@@ -909,7 +899,10 @@ export async function listFiles(storage, treeId, options) {
909
899
  const files = tree
910
900
  .listFiles()
911
901
  .filter((file) => !isMarkedFileDeleted(storage, tree.id, file.id));
912
- 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
+ })));
913
906
  }
914
907
  catch (error) {
915
908
  if (signal.aborted)
@@ -926,7 +919,10 @@ export async function listSubfolders(storage, parentId, options) {
926
919
  ensureOperationActive(signal);
927
920
  const directories = activeTreeDirectories(storage.getClient(), tree)
928
921
  .filter((directory) => !isMarkedTreeDeleted(storage, directory.id));
929
- 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
+ })));
930
926
  });
931
927
  }
932
928
  export async function createSubfolder(storage, parentId, name, options) {
@@ -958,11 +954,9 @@ async function renameTree(storage, treeId, name, options) {
958
954
  await waitForCondition(async () => {
959
955
  ensureOperationActive(signal);
960
956
  const current = storage.getTree(treeId);
961
- if (current?.room.name === name)
962
- return current;
963
- await storage.refreshRoomState(treeId, { signal });
964
- const refreshed = storage.getTree(treeId);
965
- return refreshed?.room.name === name ? refreshed : null;
957
+ if (!current)
958
+ return null;
959
+ return (await storage.getTreeName(treeId, { signal })) === name ? current : null;
966
960
  }, { timeoutMs: 15000, signal });
967
961
  }
968
962
  catch (error) {
@@ -991,6 +985,20 @@ async function deleteTree(storage, treeId, options) {
991
985
  const pending = withTreeMutation(client, async () => {
992
986
  if (isTreeDeleted(client, treeId))
993
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
+ }
994
1002
  const tree = await resolveTree(storage, treeId, operation.signal);
995
1003
  let externalParents;
996
1004
  try {
@@ -1016,14 +1024,6 @@ async function deleteTree(storage, treeId, options) {
1016
1024
  await deleteRoomDeterministically(storage, tree.id, operation.signal);
1017
1025
  }
1018
1026
  catch (error) {
1019
- try {
1020
- await relinkExternalParents(storage, tree.id, externalParents, new AbortController().signal);
1021
- }
1022
- catch (cleanupError) {
1023
- throw new StorageError("delete cleanup is incomplete", {
1024
- cause: new AggregateError([error, cleanupError], "delete and external-parent relink both failed", { cause: error }),
1025
- });
1026
- }
1027
1027
  if (error instanceof StorageError)
1028
1028
  throw error;
1029
1029
  throw new StorageError("delete failed", { cause: error });
@@ -1055,9 +1055,11 @@ export async function renameFile(storage, treeId, fileId, name, options) {
1055
1055
  // fresh CLI/UI process can still read the previous local room state for a
1056
1056
  // short time. Do not report success until this client has observed the new
1057
1057
  // name through its normal sync loop.
1058
- await waitForCondition(() => {
1058
+ await waitForCondition(async () => {
1059
1059
  const current = tree.getFile(fileId);
1060
- return current?.getName() === name ? current : null;
1060
+ if (!current)
1061
+ return null;
1062
+ return (await storage.getFileName(treeId, fileId, { signal })) === name ? current : null;
1061
1063
  }, { timeoutMs: 15000, signal });
1062
1064
  }
1063
1065
  catch (error) {
@@ -1094,43 +1096,80 @@ async function deleteFileMedia(storage, mediaIds, signal) {
1094
1096
  export async function deleteFile(storage, treeId, fileId, options) {
1095
1097
  return withOperationDeadline(options, async (signal) => {
1096
1098
  const tree = await resolveTree(storage, treeId, signal);
1097
- const branch = await resolveFile(storage, tree, fileId, signal);
1098
- 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;
1099
1106
  try {
1100
- fileInfo = await branch.getFileInfo();
1107
+ originalEvent = await storage.getOriginalFileEvent(tree.id, fileId, true);
1101
1108
  }
1102
1109
  catch (error) {
1103
1110
  throw new StorageError("could not resolve encrypted file safely", { cause: error });
1104
1111
  }
1105
- 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;
1106
1130
  if (typeof mediaId !== "string" || mediaId.length === 0) {
1107
1131
  throw new StorageError("encrypted file has no media identifier");
1108
1132
  }
1109
1133
  await deleteFileMedia(storage, [mediaId], signal);
1110
- const completedIds = [];
1134
+ const completedIds = [mediaId];
1111
1135
  try {
1112
1136
  const client = storage.getClient();
1113
- await withRateLimitRetry(() => withMatrixMutationAbort(() => client.sendStateEvent(tree.id, UNSTABLE_MSC3089_BRANCH.name, {}, branch.id), signal, "delete file state"), signal);
1114
- await withRateLimitRetry(() => withMatrixMutationAbort(() => client.redactEvent(tree.id, branch.id), signal, "delete file event"), signal);
1115
- completedIds.push(branch.id);
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) {
1149
+ throw new StorageError("encrypted file has no listing event identifier");
1150
+ }
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
+ }
1157
+ completedIds.push(listingEventId);
1158
+ await withRateLimitRetry(() => withMatrixMutationAbort(() => client.redactEvent(tree.id, fileId), signal, "delete file attachment event"), signal);
1159
+ completedIds.push(fileId);
1116
1160
  }
1117
1161
  catch (error) {
1118
- if (error instanceof MutationOutcomeUnknownError)
1119
- throw error;
1120
- if (signal.aborted) {
1121
- throw new MutationPartialError("delete file", completedIds, "media was deleted but Matrix event cleanup was cancelled", { cause: error });
1122
- }
1123
- 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 });
1124
1163
  }
1125
1164
  try {
1126
1165
  await waitForCondition(async () => {
1127
1166
  await storage.refreshRoomState(tree.id, { signal });
1128
1167
  ensureOperationActive(signal);
1129
1168
  const current = tree.getFile(fileId);
1130
- // Matrix SDK keeps a redacted branch event in room state as an
1131
- // inactive branch. Treat both an absent branch and that redacted
1132
- // inactive branch as confirmed deletion.
1133
- 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;
1134
1173
  }, { timeoutMs: 15000, signal });
1135
1174
  }
1136
1175
  catch (error) {
@@ -1139,7 +1178,7 @@ export async function deleteFile(storage, treeId, fileId, options) {
1139
1178
  const detail = "Matrix deletion completed but the file state could not be verified";
1140
1179
  throw new MutationPartialError("delete file", completedIds, detail, { cause: error });
1141
1180
  }
1142
- markFileDeleted(storage.getClient(), tree.id, branch.id);
1181
+ markFileDeleted(storage.getClient(), tree.id, fileId);
1143
1182
  ensureOperationActive(signal);
1144
1183
  return { id: fileId, deleted: true };
1145
1184
  }, "mutation");
@@ -1198,7 +1237,7 @@ export async function downloadFile(storage, treeId, fileId, options) {
1198
1237
  return {
1199
1238
  bytes: new Uint8Array(result.data),
1200
1239
  mimetype: result.mimetype,
1201
- name: branch.getName(),
1240
+ name: await storage.getFileName(tree.id, fileId, { signal }),
1202
1241
  };
1203
1242
  });
1204
1243
  }
@@ -1216,15 +1255,15 @@ function tsToIso(ts) {
1216
1255
  export async function getFileDetails(storage, treeId, fileId, options) {
1217
1256
  return withOperationDeadline(options, async (signal) => {
1218
1257
  const tree = await resolveTree(storage, treeId, signal);
1219
- const branch = await resolveFile(storage, tree, fileId, signal);
1220
- const name = branch.getName();
1258
+ await resolveFile(storage, tree, fileId, signal);
1259
+ const name = await storage.getFileName(tree.id, fileId, { signal });
1221
1260
  let mimetype = null;
1222
1261
  let size = null;
1223
1262
  let createdAt = null;
1224
1263
  let updatedAt = null;
1225
1264
  try {
1226
1265
  ensureOperationActive(signal);
1227
- const event = await branch.getFileEvent();
1266
+ const event = await storage.getOriginalFileEvent(tree.id, fileId);
1228
1267
  const metadata = readFileEventMetadata(event.getContent());
1229
1268
  mimetype = metadata.mimetype;
1230
1269
  size = metadata.size;
@@ -1243,7 +1282,7 @@ export async function getFileDetails(storage, treeId, fileId, options) {
1243
1282
  });
1244
1283
  }
1245
1284
  async function getTreeDetails(storage, treeId, signal) {
1246
- const tree = await resolveTree(storage, treeId, signal);
1285
+ await resolveTree(storage, treeId, signal);
1247
1286
  if (signal?.aborted)
1248
1287
  throw new StorageError("operation cancelled");
1249
1288
  const client = storage.getClient();
@@ -1261,7 +1300,7 @@ async function getTreeDetails(storage, treeId, signal) {
1261
1300
  }
1262
1301
  }
1263
1302
  return {
1264
- name: tree.room.name || roomDisplayName(storage, treeId),
1303
+ name: await storage.getTreeName(treeId, { signal }),
1265
1304
  id: treeId,
1266
1305
  createdAt,
1267
1306
  memberCount,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telecrypt-io/storage",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "license": "BUSL-1.1",
5
5
  "type": "module",
6
6
  "packageManager": "npm@11.19.0",