@telecrypt-io/storage 0.1.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/LICENSE ADDED
@@ -0,0 +1,109 @@
1
+ Business Source License 1.1
2
+
3
+ Parameters
4
+
5
+ Licensor: TeleCrypt.io
6
+ Licensed Work: TeleCrypt.io Storage
7
+ The Licensed Work is (c) 2026 TeleCrypt.io
8
+ Additional Use Grant: You may use, copy, modify, and create derivative
9
+ works of the Licensed Work for any non-commercial
10
+ purpose, including personal, educational, research,
11
+ and experimentation use. You may not use the
12
+ Licensed Work, or any derivative work thereof, to
13
+ provide a commercial product or service to third
14
+ parties, or for any purpose intended for or
15
+ directed toward commercial advantage or monetary
16
+ compensation.
17
+
18
+ Change Date: 2030-07-20
19
+
20
+ Change License: Apache License, Version 2.0
21
+
22
+ For information about alternative licensing arrangements for the Licensed
23
+ Work, please contact TeleCrypt.io.
24
+
25
+ Notice
26
+
27
+ The Business Source License (this document, or the "License") is not an
28
+ Open Source license. However, the Licensed Work will eventually be made
29
+ available under an Open Source License, as stated in this License.
30
+
31
+ License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved.
32
+ "Business Source License" is a trademark of MariaDB Corporation Ab.
33
+
34
+ -----------------------------------------------------------------------------
35
+
36
+ Business Source License 1.1
37
+
38
+ Terms
39
+
40
+ The Licensor hereby grants you the right to copy, modify, create derivative
41
+ works, redistribute, and make non-production use of the Licensed Work. The
42
+ Licensor may make an Additional Use Grant, above, permitting limited
43
+ production use.
44
+
45
+ Effective on the Change Date, or the fourth anniversary of the first publicly
46
+ available distribution of a specific version of the Licensed Work under this
47
+ License, whichever comes first, the Licensor hereby grants you rights under
48
+ the terms of the Change License, and the rights granted in the paragraph
49
+ above terminate.
50
+
51
+ If your use of the Licensed Work does not comply with the requirements
52
+ currently in effect as described in this License, you must purchase a
53
+ commercial license from the Licensor, its affiliated entities, or authorized
54
+ resellers, or you must refrain from using the Licensed Work.
55
+
56
+ All copies of the original and modified Licensed Work, and derivative works
57
+ of the Licensed Work, are subject to this License. This License applies
58
+ separately for each version of the Licensed Work and the Change Date may vary
59
+ for each version of the Licensed Work released by Licensor.
60
+
61
+ You must conspicuously display this License on each original or modified
62
+ copy of the Licensed Work. If you receive the Licensed Work in original or
63
+ modified form from a third party, the terms and conditions set forth in this
64
+ License apply to your use of that work.
65
+
66
+ Any use of the Licensed Work in violation of this License will automatically
67
+ terminate your rights under this License for the current and all other
68
+ versions of the Licensed Work.
69
+
70
+ This License does not grant you any right in any trademark or logo of
71
+ Licensor or its affiliates (provided that you may use a trademark or logo of
72
+ Licensor as expressly required by this License).
73
+
74
+ TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
75
+ AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
76
+ EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
77
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
78
+ TITLE.
79
+
80
+ MariaDB hereby grants you permission to use this License's text to license
81
+ your works, and to refer to it using the trademark "Business Source License",
82
+ as long as you comply with the Covenants of Licensor below.
83
+
84
+ Covenants of Licensor
85
+
86
+ In consideration of the right to use this License's text and the "Business
87
+ Source License" name and trademark, Licensor covenants to MariaDB, and to all
88
+ other recipients of the licensed work to be provided by Licensor:
89
+
90
+ 1. To specify as the Change License the GPL Version 2.0 or any later version,
91
+ or a license that is compatible with GPL Version 2.0 or a later version,
92
+ where "compatible" means that software provided under the Change License can
93
+ be included in a program with software provided under GPL Version 2.0 or a
94
+ later version. Licensor may specify additional Change Licenses without
95
+ limitation.
96
+
97
+ 2. To either: (a) specify an additional grant of rights to use that does not
98
+ impose any additional restriction on the right granted in this License, as
99
+ the Additional Use Grant, or (b) insert the text "None" to specify a Change
100
+ Date of no more than four years from the date the Licensed Work is
101
+ published.
102
+
103
+ 3. To not modify this License in any other way.
104
+
105
+ Notice
106
+
107
+ The Business Source License (this document, or the "License") is not an Open
108
+ Source license. However, the Licensed Work will eventually be made available
109
+ under an Open Source License, as stated in this License.
package/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # TeleCrypt.io Storage
2
+
3
+ End-to-end encrypted file storage and sharing, built on Matrix.
4
+
5
+ Files are encrypted on the client before upload. The server stores only opaque ciphertext and
6
+ never holds the decryption keys.
7
+
8
+ **Status:** early development. Not yet usable.
9
+
10
+ ## How it works
11
+
12
+ Built on [MSC3089](https://github.com/matrix-org/matrix-spec-proposals/pull/3089), which models
13
+ a file tree using Matrix primitives:
14
+
15
+ | File-system concept | Matrix concept |
16
+ |---|---|
17
+ | Folder | A Space (room marked as a file tree) |
18
+ | Subfolder | A child Space |
19
+ | File | An event pointing at encrypted uploaded content |
20
+ | Version | A newer event superseding the old |
21
+ | Sharing | Room invitation |
22
+ | Permissions | Power levels |
23
+
24
+ Encryption uses the same scheme as Matrix attachments (AES-CTR with a per-file key, keys
25
+ distributed via the room's Megolm session). Requires no server-side changes — it runs against
26
+ stock Synapse.
27
+
28
+ ## Development
29
+
30
+ ```bash
31
+ npm install
32
+ npm run synapse:up # disposable local Synapse for tests
33
+ npm test
34
+ npm run synapse:down
35
+ ```
36
+
37
+ Tests run against a real local Synapse in Docker, never against a production server.
38
+
39
+ See [IMPLEMENTATION_PLAN.md](./IMPLEMENTATION_PLAN.md) for the phased build plan.
40
+
41
+ ## Licence
42
+
43
+ [Business Source License 1.1](./LICENSE). Non-commercial use is permitted; converts to
44
+ Apache License 2.0 on 2030-07-20.
45
+
46
+ For commercial licensing, contact TeleCrypt.io.
47
+
48
+ ## Third-party code
49
+
50
+ - [`matrix-js-sdk`](https://github.com/matrix-org/matrix-js-sdk) — Apache-2.0 — dependency
51
+ - [`matrix-files-sdk`](https://github.com/matrix-org/matrix-files-sdk) — Apache-2.0 — reference
52
+ - [`files-sdk-demo`](https://github.com/vector-im/files-sdk-demo) — AGPL-3.0 — **not used**;
53
+ incompatible with this project's licence
@@ -0,0 +1,185 @@
1
+ import { MatrixClient } from "matrix-js-sdk";
2
+ import type { CryptoCallbacks } from "matrix-js-sdk/lib/crypto-api/index.js";
3
+ export interface TreeSpace {
4
+ readonly id: string;
5
+ readonly room: {
6
+ name: string;
7
+ };
8
+ readonly isTopLevel: boolean;
9
+ setName(name: string): Promise<void>;
10
+ createDirectory(name: string): Promise<TreeSpace>;
11
+ getDirectories(): TreeSpace[];
12
+ getDirectory(roomId: string): TreeSpace | undefined;
13
+ invite(userId: string, andSubspaces?: boolean): Promise<void>;
14
+ delete(): Promise<void>;
15
+ getOrder(): number;
16
+ setOrder(index: number): Promise<void>;
17
+ getPermissions(userId: string): string;
18
+ setPermissions(userId: string, role: string): Promise<void>;
19
+ getFile(fileEventId: string): FileBranch | null;
20
+ listFiles(): FileBranch[];
21
+ listAllFiles(): FileBranch[];
22
+ createFile(name: string, encryptedContents: Buffer | ArrayBuffer | Uint8Array, info: Record<string, unknown>, additionalContent?: Record<string, unknown>): Promise<{
23
+ event_id: string;
24
+ }>;
25
+ }
26
+ export interface FileBranch {
27
+ readonly id: string;
28
+ readonly version: number;
29
+ readonly isActive: boolean;
30
+ getName(): string;
31
+ setName(name: string): Promise<void>;
32
+ delete(): Promise<void>;
33
+ getFileInfo(): Promise<{
34
+ info: Record<string, unknown>;
35
+ httpUrl: string;
36
+ }>;
37
+ getFileEvent(): Promise<{
38
+ getContent: () => Record<string, unknown>;
39
+ }>;
40
+ getVersionHistory(): Promise<FileBranch[]>;
41
+ createNewVersion(name: string, encryptedContents: Buffer | ArrayBuffer | Uint8Array, info: Record<string, unknown>, additionalContent?: Record<string, unknown>): Promise<{
42
+ event_id: string;
43
+ }>;
44
+ }
45
+ export interface CreateTeleCryptIOStorageOptions {
46
+ /** Matrix homeserver base URL, e.g. "https://matrix.example.com". */
47
+ baseUrl: string;
48
+ userId: string;
49
+ accessToken: string;
50
+ deviceId: string;
51
+ /**
52
+ * Use a persistent crypto store (IndexedDB) so keys survive a restart on this
53
+ * device. This is the default (`true`) — it is what makes both same-device
54
+ * restart recovery and (via `keys.setupRecovery`/`restoreFromRecoveryKey`)
55
+ * new-device recovery possible. Set explicitly to `false` to opt into an
56
+ * in-memory, amnesiac store; this must be a deliberate choice, e.g. for a
57
+ * short-lived process that should never persist secrets to disk.
58
+ */
59
+ persistentCryptoStore?: boolean;
60
+ /**
61
+ * Overrides the IndexedDB name prefix used for the crypto store. Defaults to
62
+ * a value scoped to (userId, deviceId) so that multiple devices/users never
63
+ * collide when they happen to share a single IndexedDB origin (e.g. in
64
+ * Node/tests, where `fake-indexeddb` is process-global).
65
+ */
66
+ cryptoDatabasePrefix?: string;
67
+ /** initialSyncLimit passed to startClient(); default 10. */
68
+ initialSyncLimit?: number;
69
+ /** How long to wait for the first sync before giving up; default 15000ms. */
70
+ syncTimeoutMs?: number;
71
+ /**
72
+ * Optional platform-supplied crypto callbacks (e.g. to source the secret
73
+ * storage key from an OS keychain instead of prompting). `keys.setupRecovery`
74
+ * and `keys.restoreFromRecoveryKey` temporarily override
75
+ * `getSecretStorageKey`/`cacheSecretStorageKey` on this object for the
76
+ * duration of the call, then restore whatever was here before.
77
+ */
78
+ cryptoCallbacks?: CryptoCallbacks;
79
+ }
80
+ export declare class TeleCryptIOStorage {
81
+ private client;
82
+ constructor(client: MatrixClient);
83
+ /** The underlying matrix-js-sdk client (e.g. to stop it, or for advanced/interop use). */
84
+ getClient(): MatrixClient;
85
+ /**
86
+ * Recommended entry point: builds the MatrixClient with a persistent crypto
87
+ * store and the secret-storage callback wiring `keys.*` needs, starts the
88
+ * client, waits for the first sync, and returns a ready TeleCryptIOStorage.
89
+ *
90
+ * The plain constructor remains available for advanced callers who need to
91
+ * build/configure the MatrixClient themselves; in that case `keys.*` only
92
+ * works if the caller wires equivalent cryptoCallbacks onto the client at
93
+ * construction time (see `cryptoCallbacks` above for why: matrix-js-sdk
94
+ * captures a single callbacks object at MatrixClient construction, so it
95
+ * must exist before `initRustCrypto` runs, not be added afterwards).
96
+ */
97
+ static create(opts: CreateTeleCryptIOStorageOptions): Promise<TeleCryptIOStorage>;
98
+ private requireCrypto;
99
+ /**
100
+ * Temporarily wires `getSecretStorageKey`/`cacheSecretStorageKey` on the
101
+ * client's cryptoCallbacks to hand back `privateKey`, runs `fn`, then
102
+ * restores whatever callbacks were there before — regardless of success or
103
+ * failure. Requires that the client was constructed with a cryptoCallbacks
104
+ * object in the first place (see `create()`'s doc comment).
105
+ */
106
+ private withSecretStorageKey;
107
+ /**
108
+ * Bootstraps cross-signing and secret storage with a brand-new server-side
109
+ * key backup, and returns the Recovery Key for the caller to show the user.
110
+ * This is what makes `restoreFromRecoveryKey` on another device possible.
111
+ */
112
+ private keysSetupRecovery;
113
+ /** Is there an active key backup and a ready secret storage right now? */
114
+ private keysIsRecoverySetup;
115
+ /**
116
+ * On a new device: unlocks secret storage with the Recovery Key, loads the
117
+ * key-backup decryption key out of secret storage, and restores the key
118
+ * backup so previously-uploaded files become decryptable again.
119
+ *
120
+ * Throws a clear error (never silently "succeeds" with zero keys) if the
121
+ * recovery key is malformed or does not unlock this account's secret
122
+ * storage / key backup.
123
+ */
124
+ private keysRestoreFromRecoveryKey;
125
+ /** Key-management API: setup, status, and restore for server-side Secure Backup. */
126
+ get keys(): {
127
+ setupRecovery: () => Promise<{
128
+ recoveryKey: string;
129
+ }>;
130
+ isRecoverySetup: () => Promise<boolean>;
131
+ restoreFromRecoveryKey: (recoveryKey: string) => Promise<{
132
+ imported: number;
133
+ total: number;
134
+ }>;
135
+ };
136
+ /**
137
+ * Creates a new top-level shared folder (tree space).
138
+ *
139
+ * Works around a genuine race in matrix-js-sdk's own
140
+ * `unstableCreateFileTree()`: it creates the room via a plain
141
+ * `createRoom()` HTTP call, then immediately wraps the result by looking
142
+ * the room up in the *local* client store (`new MSC3089TreeSpace(this,
143
+ * roomId)` calls `client.getRoom(roomId)` and throws `Error("Unknown
144
+ * room")` if it's not there yet). The local store is only populated by
145
+ * the client's background sync processing, which is asynchronous and can
146
+ * easily not have caught up in the few milliseconds between the HTTP
147
+ * response and that lookup — especially right after a client has just
148
+ * started. The room really was created server-side by that point (that's
149
+ * how we get "Unknown room" at all); this method waits for the client's
150
+ * own ongoing sync loop to surface it, then wraps it directly via
151
+ * `unstableGetFileTreeSpace`, instead of surfacing a spurious failure for
152
+ * a folder that in fact exists.
153
+ */
154
+ createTree(name: string): Promise<TreeSpace>;
155
+ listTrees(): Promise<TreeSpace[]>;
156
+ getTree(roomId: string): TreeSpace | null;
157
+ /**
158
+ * Lists the participants of a shared folder (tree space) and their role,
159
+ * derived from room membership (join/invite state) + power levels.
160
+ * Includes invited-but-not-yet-joined members as well as joined ones;
161
+ * excludes anyone who has left/been kicked/banned.
162
+ *
163
+ * Deliberately reads directly from the server's authoritative REST state
164
+ * endpoints (`GET .../members`, `GET .../state/m.room.power_levels/`)
165
+ * rather than from the client's locally synced `tree.room`/`currentState`.
166
+ * On a freshly-started client (as every CLI command is — see
167
+ * docs/CLI_SPEC.md and src/cli/cryptoSnapshot.ts), room membership and
168
+ * power-level state can take several more `/sync` round trips to fully
169
+ * converge locally after a change, even though the room itself is already
170
+ * visible — reading the same data straight from the server sidesteps that
171
+ * sync-convergence lag entirely. The role thresholds mirror
172
+ * `MSC3089TreeSpace.getPermissions()` exactly (userLevel >= adminLevel ->
173
+ * owner, >= editLevel -> editor, else viewer).
174
+ */
175
+ listMembers(tree: TreeSpace): Promise<{
176
+ userId: string;
177
+ role: string;
178
+ membership: string;
179
+ }[]>;
180
+ uploadFile(tree: TreeSpace, name: string, data: ArrayBuffer, mimetype: string): Promise<string>;
181
+ downloadFile(branch: FileBranch): Promise<{
182
+ data: ArrayBuffer;
183
+ mimetype: string;
184
+ }>;
185
+ }
@@ -0,0 +1,323 @@
1
+ import { ClientEvent, createClient, SyncState } from "matrix-js-sdk";
2
+ import { encryptAttachment, decryptAttachment } from "matrix-encrypt-attachment";
3
+ import { decodeRecoveryKey } from "matrix-js-sdk/lib/crypto-api/recovery-key.js";
4
+ export class TeleCryptIOStorage {
5
+ client;
6
+ constructor(client) {
7
+ this.client = client;
8
+ }
9
+ /** The underlying matrix-js-sdk client (e.g. to stop it, or for advanced/interop use). */
10
+ getClient() {
11
+ return this.client;
12
+ }
13
+ /**
14
+ * Recommended entry point: builds the MatrixClient with a persistent crypto
15
+ * store and the secret-storage callback wiring `keys.*` needs, starts the
16
+ * client, waits for the first sync, and returns a ready TeleCryptIOStorage.
17
+ *
18
+ * The plain constructor remains available for advanced callers who need to
19
+ * build/configure the MatrixClient themselves; in that case `keys.*` only
20
+ * works if the caller wires equivalent cryptoCallbacks onto the client at
21
+ * construction time (see `cryptoCallbacks` above for why: matrix-js-sdk
22
+ * captures a single callbacks object at MatrixClient construction, so it
23
+ * must exist before `initRustCrypto` runs, not be added afterwards).
24
+ */
25
+ static async create(opts) {
26
+ const client = createClient({
27
+ baseUrl: opts.baseUrl,
28
+ userId: opts.userId,
29
+ accessToken: opts.accessToken,
30
+ deviceId: opts.deviceId,
31
+ cryptoCallbacks: opts.cryptoCallbacks ?? {},
32
+ });
33
+ const persistent = opts.persistentCryptoStore ?? true;
34
+ await client.initRustCrypto({
35
+ useIndexedDB: persistent,
36
+ cryptoDatabasePrefix: opts.cryptoDatabasePrefix ?? `telecrypt-io-storage::${opts.userId}::${opts.deviceId}`,
37
+ });
38
+ await client.startClient({ initialSyncLimit: opts.initialSyncLimit ?? 10 });
39
+ await new Promise((resolve, reject) => {
40
+ const timeoutMs = opts.syncTimeoutMs ?? 15000;
41
+ const timeout = setTimeout(() => reject(new Error("sync timeout")), timeoutMs);
42
+ client.once(ClientEvent.Sync, (state) => {
43
+ clearTimeout(timeout);
44
+ if (state === SyncState.Prepared || state === SyncState.Syncing) {
45
+ resolve();
46
+ }
47
+ else {
48
+ reject(new Error(`unexpected sync state: ${state}`));
49
+ }
50
+ });
51
+ });
52
+ return new TeleCryptIOStorage(client);
53
+ }
54
+ requireCrypto() {
55
+ const crypto = this.client.getCrypto();
56
+ if (!crypto) {
57
+ throw new Error("TeleCryptIOStorage: this client's crypto was never initialised (call client.initRustCrypto() first, or use TeleCryptIOStorage.create())");
58
+ }
59
+ return crypto;
60
+ }
61
+ /**
62
+ * Temporarily wires `getSecretStorageKey`/`cacheSecretStorageKey` on the
63
+ * client's cryptoCallbacks to hand back `privateKey`, runs `fn`, then
64
+ * restores whatever callbacks were there before — regardless of success or
65
+ * failure. Requires that the client was constructed with a cryptoCallbacks
66
+ * object in the first place (see `create()`'s doc comment).
67
+ */
68
+ async withSecretStorageKey(privateKey, fn) {
69
+ const callbacks = this.client.cryptoCallbacks;
70
+ const prevGetKey = callbacks.getSecretStorageKey;
71
+ const prevCache = callbacks.cacheSecretStorageKey;
72
+ callbacks.getSecretStorageKey = async ({ keys }) => {
73
+ const keyId = Object.keys(keys)[0];
74
+ return [keyId, privateKey];
75
+ };
76
+ callbacks.cacheSecretStorageKey = () => { };
77
+ try {
78
+ return await fn();
79
+ }
80
+ finally {
81
+ callbacks.getSecretStorageKey = prevGetKey;
82
+ callbacks.cacheSecretStorageKey = prevCache;
83
+ }
84
+ }
85
+ /**
86
+ * Bootstraps cross-signing and secret storage with a brand-new server-side
87
+ * key backup, and returns the Recovery Key for the caller to show the user.
88
+ * This is what makes `restoreFromRecoveryKey` on another device possible.
89
+ */
90
+ async keysSetupRecovery() {
91
+ const crypto = this.requireCrypto();
92
+ await crypto.bootstrapCrossSigning({
93
+ // No existing verified device to interactively re-authenticate against
94
+ // for this account, so there is nothing to feed into `makeRequest`;
95
+ // matches the working pattern already proven in keys.test.ts.
96
+ authUploadDeviceSigningKeys: async () => undefined,
97
+ });
98
+ const generated = await crypto.createRecoveryKeyFromPassphrase();
99
+ if (!generated.encodedPrivateKey) {
100
+ throw new Error("setupRecovery: matrix-js-sdk did not return an encoded recovery key");
101
+ }
102
+ await this.withSecretStorageKey(generated.privateKey, async () => {
103
+ await crypto.bootstrapSecretStorage({
104
+ setupNewSecretStorage: true,
105
+ setupNewKeyBackup: true,
106
+ createSecretStorageKey: async () => generated,
107
+ });
108
+ await crypto.checkKeyBackupAndEnable();
109
+ });
110
+ return { recoveryKey: generated.encodedPrivateKey };
111
+ }
112
+ /** Is there an active key backup and a ready secret storage right now? */
113
+ async keysIsRecoverySetup() {
114
+ const crypto = this.client.getCrypto();
115
+ if (!crypto)
116
+ return false;
117
+ const [storageStatus, backupVersion] = await Promise.all([
118
+ crypto.getSecretStorageStatus(),
119
+ crypto.getActiveSessionBackupVersion(),
120
+ ]);
121
+ return storageStatus.ready && backupVersion !== null;
122
+ }
123
+ /**
124
+ * On a new device: unlocks secret storage with the Recovery Key, loads the
125
+ * key-backup decryption key out of secret storage, and restores the key
126
+ * backup so previously-uploaded files become decryptable again.
127
+ *
128
+ * Throws a clear error (never silently "succeeds" with zero keys) if the
129
+ * recovery key is malformed or does not unlock this account's secret
130
+ * storage / key backup.
131
+ */
132
+ async keysRestoreFromRecoveryKey(recoveryKey) {
133
+ const crypto = this.requireCrypto();
134
+ let privateKey;
135
+ try {
136
+ privateKey = decodeRecoveryKey(recoveryKey);
137
+ }
138
+ catch (err) {
139
+ throw new Error(`restoreFromRecoveryKey: not a valid recovery key (${err.message})`);
140
+ }
141
+ return this.withSecretStorageKey(privateKey, async () => {
142
+ try {
143
+ await crypto.loadSessionBackupPrivateKeyFromSecretStorage();
144
+ }
145
+ catch (err) {
146
+ throw new Error(`restoreFromRecoveryKey: recovery key did not unlock secret storage / key backup (${err.message})`);
147
+ }
148
+ try {
149
+ const result = await crypto.restoreKeyBackup();
150
+ return { imported: result.imported, total: result.total };
151
+ }
152
+ catch (err) {
153
+ throw new Error(`restoreFromRecoveryKey: failed to restore key backup (${err.message})`);
154
+ }
155
+ });
156
+ }
157
+ /** Key-management API: setup, status, and restore for server-side Secure Backup. */
158
+ get keys() {
159
+ return {
160
+ setupRecovery: () => this.keysSetupRecovery(),
161
+ isRecoverySetup: () => this.keysIsRecoverySetup(),
162
+ restoreFromRecoveryKey: (recoveryKey) => this.keysRestoreFromRecoveryKey(recoveryKey),
163
+ };
164
+ }
165
+ /**
166
+ * Creates a new top-level shared folder (tree space).
167
+ *
168
+ * Works around a genuine race in matrix-js-sdk's own
169
+ * `unstableCreateFileTree()`: it creates the room via a plain
170
+ * `createRoom()` HTTP call, then immediately wraps the result by looking
171
+ * the room up in the *local* client store (`new MSC3089TreeSpace(this,
172
+ * roomId)` calls `client.getRoom(roomId)` and throws `Error("Unknown
173
+ * room")` if it's not there yet). The local store is only populated by
174
+ * the client's background sync processing, which is asynchronous and can
175
+ * easily not have caught up in the few milliseconds between the HTTP
176
+ * response and that lookup — especially right after a client has just
177
+ * started. The room really was created server-side by that point (that's
178
+ * how we get "Unknown room" at all); this method waits for the client's
179
+ * own ongoing sync loop to surface it, then wraps it directly via
180
+ * `unstableGetFileTreeSpace`, instead of surfacing a spurious failure for
181
+ * a folder that in fact exists.
182
+ */
183
+ async createTree(name) {
184
+ const knownRoomIdsBefore = new Set(this.client.getRooms().map((r) => r.roomId));
185
+ try {
186
+ // The `await` here is load-bearing: without it, the promise's
187
+ // eventual rejection would happen after this try/catch has already
188
+ // returned, bypassing the recovery logic below entirely.
189
+ return (await this.client.unstableCreateFileTree(name));
190
+ }
191
+ catch (err) {
192
+ if (!(err instanceof Error) || err.message !== "Unknown room")
193
+ throw err;
194
+ const newRoomId = await new Promise((resolve, reject) => {
195
+ const deadline = Date.now() + 15000;
196
+ const poll = () => {
197
+ const found = this.client.getRooms().find((r) => !knownRoomIdsBefore.has(r.roomId));
198
+ if (found) {
199
+ resolve(found.roomId);
200
+ return;
201
+ }
202
+ if (Date.now() >= deadline) {
203
+ reject(new Error("createTree: room was created server-side but never appeared in this client's local sync state"));
204
+ return;
205
+ }
206
+ setTimeout(poll, 200);
207
+ };
208
+ poll();
209
+ });
210
+ const tree = this.client.unstableGetFileTreeSpace(newRoomId);
211
+ if (!tree) {
212
+ throw new Error(`createTree: room ${newRoomId} appeared but is not a valid file tree space`);
213
+ }
214
+ return tree;
215
+ }
216
+ }
217
+ async listTrees() {
218
+ const rooms = this.client.getRooms();
219
+ const trees = [];
220
+ for (const room of rooms) {
221
+ const tree = this.client.unstableGetFileTreeSpace(room.roomId);
222
+ if (tree) {
223
+ trees.push(tree);
224
+ }
225
+ }
226
+ return trees;
227
+ }
228
+ getTree(roomId) {
229
+ return this.client.unstableGetFileTreeSpace(roomId);
230
+ }
231
+ /**
232
+ * Lists the participants of a shared folder (tree space) and their role,
233
+ * derived from room membership (join/invite state) + power levels.
234
+ * Includes invited-but-not-yet-joined members as well as joined ones;
235
+ * excludes anyone who has left/been kicked/banned.
236
+ *
237
+ * Deliberately reads directly from the server's authoritative REST state
238
+ * endpoints (`GET .../members`, `GET .../state/m.room.power_levels/`)
239
+ * rather than from the client's locally synced `tree.room`/`currentState`.
240
+ * On a freshly-started client (as every CLI command is — see
241
+ * docs/CLI_SPEC.md and src/cli/cryptoSnapshot.ts), room membership and
242
+ * power-level state can take several more `/sync` round trips to fully
243
+ * converge locally after a change, even though the room itself is already
244
+ * visible — reading the same data straight from the server sidesteps that
245
+ * sync-convergence lag entirely. The role thresholds mirror
246
+ * `MSC3089TreeSpace.getPermissions()` exactly (userLevel >= adminLevel ->
247
+ * owner, >= editLevel -> editor, else viewer).
248
+ */
249
+ async listMembers(tree) {
250
+ const clientAny = this.client;
251
+ const baseUrl = clientAny.getHomeserverUrl();
252
+ const token = clientAny.getAccessToken();
253
+ const authHeaders = { Authorization: `Bearer ${token}` };
254
+ const roomPath = `${baseUrl}/_matrix/client/v3/rooms/${encodeURIComponent(tree.id)}`;
255
+ const [membersRes, powerLevelsRes] = await Promise.all([
256
+ fetch(`${roomPath}/members`, { headers: authHeaders }),
257
+ fetch(`${roomPath}/state/m.room.power_levels/`, { headers: authHeaders }),
258
+ ]);
259
+ if (!membersRes.ok) {
260
+ throw new Error(`listMembers: failed to fetch room members (${membersRes.status})`);
261
+ }
262
+ const membersBody = (await membersRes.json());
263
+ const pls = powerLevelsRes.ok
264
+ ? (await powerLevelsRes.json())
265
+ : {};
266
+ const viewLevel = pls.users_default ?? 0;
267
+ const editLevel = pls.events_default ?? 50;
268
+ const adminLevel = pls.events?.["m.room.power_levels"] ?? 100;
269
+ const roleFor = (userId) => {
270
+ const userLevel = pls.users?.[userId] ?? viewLevel;
271
+ if (userLevel >= adminLevel)
272
+ return "owner";
273
+ if (userLevel >= editLevel)
274
+ return "editor";
275
+ return "viewer";
276
+ };
277
+ return membersBody.chunk
278
+ .filter((e) => e.content.membership === "join" || e.content.membership === "invite")
279
+ .map((e) => ({
280
+ userId: e.state_key,
281
+ membership: e.content.membership,
282
+ role: roleFor(e.state_key),
283
+ }));
284
+ }
285
+ async uploadFile(tree, name, data, mimetype) {
286
+ const encrypted = await encryptAttachment(data);
287
+ const { event_id } = await tree.createFile(name, Buffer.from(encrypted.data), encrypted.info, { info: { mimetype, size: data.byteLength } });
288
+ return event_id;
289
+ }
290
+ async downloadFile(branch) {
291
+ const { info } = await branch.getFileInfo();
292
+ // If the underlying room event couldn't be decrypted (e.g. this device
293
+ // never received/recovered the megolm session for it), matrix-js-sdk
294
+ // hands back a placeholder with no usable `info` rather than throwing —
295
+ // so without this check, callers would hit an opaque
296
+ // "Cannot read properties of undefined" a few lines down instead of a
297
+ // clear, actionable error.
298
+ if (!info || typeof info.url !== "string") {
299
+ throw new Error("downloadFile: could not read file info from the event — it is likely undecryptable on this device (missing megolm session; try restoring from a Recovery Key)");
300
+ }
301
+ const mxcUrl = info.url;
302
+ const clientAny = this.client;
303
+ const downloadUrl = clientAny.mxcUrlToHttp(mxcUrl, undefined, undefined, undefined, false, true, true);
304
+ if (!downloadUrl)
305
+ throw new Error("failed to build media URL");
306
+ const res = await fetch(downloadUrl, {
307
+ headers: {
308
+ Authorization: `Bearer ${clientAny.getAccessToken()}`,
309
+ },
310
+ });
311
+ if (!res.ok) {
312
+ throw new Error(`media download failed: ${res.status}`);
313
+ }
314
+ const ciphertext = await res.arrayBuffer();
315
+ const data = await decryptAttachment(ciphertext, info);
316
+ const eventContent = (await branch.getFileEvent()).getContent();
317
+ const infoBlock = eventContent["info"];
318
+ const mimetype = typeof infoBlock?.["mimetype"] === "string"
319
+ ? infoBlock["mimetype"]
320
+ : "application/octet-stream";
321
+ return { data, mimetype };
322
+ }
323
+ }