@telecrypt-io/storage 0.1.3 → 0.2.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
@@ -8,7 +8,9 @@ Files are encrypted on the client before upload. The server stores only opaque c
8
8
  never holds the decryption keys. Shared folders let multiple people add and read files, and a
9
9
  Recovery Key restores your files on a new device — even if you lose the original.
10
10
 
11
- **Status:** published and usable — library, CLI, and a React web UI.
11
+ **Status:** library source only. The current public npm release is
12
+ `@telecrypt-io/storage@0.1.3`; no version has been released from this
13
+ repository yet.
12
14
 
13
15
  ## Install
14
16
 
@@ -16,7 +18,7 @@ Recovery Key restores your files on a new device — even if you lose the origin
16
18
  npm install @telecrypt-io/storage
17
19
  ```
18
20
 
19
- This gives you both the `TeleCryptIOStorage` library and the `telecrypt-io` CLI.
21
+ This gives you the `TeleCryptIOStorage` library and its browser-safe `core` API.
20
22
 
21
23
  ## Quick start
22
24
 
@@ -33,17 +35,10 @@ const folder = await core.createFolder(storage, "Photos");
33
35
  await core.uploadFile(storage, folder.id, "cat.jpg", bytes, "image/jpeg");
34
36
  ```
35
37
 
36
- **CLI:**
37
-
38
- ```bash
39
- telecrypt-io storage login --homeserver https://your.server --user alice --password ...
40
- telecrypt-io storage folder create Photos
41
- telecrypt-io storage file upload <folderId> ./cat.jpg
42
- telecrypt-io storage folder share <folderId> @bob:your.server --role editor
43
- telecrypt-io storage recovery setup # prints your Recovery Key — save it
44
- ```
45
-
46
- **Web UI:** a React app lives in [`ui/`](./ui) (`cd ui && npm install && npm run dev`).
38
+ The command-line client is sourced by
39
+ [`TeleCrypt-io/storage-cli`](https://github.com/TeleCrypt-io/storage-cli). The static web
40
+ application is sourced by
41
+ [`TeleCrypt-io/storage.telecrypt.io`](https://github.com/TeleCrypt-io/storage.telecrypt.io).
47
42
 
48
43
  ## How it works
49
44
 
@@ -74,8 +69,7 @@ npm run synapse:down
74
69
 
75
70
  Tests run against a real local Synapse in podman, never against a production server.
76
71
 
77
- See [CLI.md](./CLI.md) for command reference and [RELEASING.md](./RELEASING.md) for release
78
- instructions.
72
+ See [RELEASING.md](./RELEASING.md) for the guarded npm release procedure.
79
73
 
80
74
  ## Licence
81
75
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telecrypt-io/storage",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "license": "BUSL-1.1",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -15,9 +15,6 @@
15
15
  "import": "./dist/core/index.js"
16
16
  }
17
17
  },
18
- "bin": {
19
- "telecrypt-io": "dist/cli/index.js"
20
- },
21
18
  "files": [
22
19
  "dist",
23
20
  "README.md",
@@ -37,10 +34,9 @@
37
34
  "synapse:down": "./throwaway_synapse/down.sh"
38
35
  },
39
36
  "dependencies": {
40
- "commander": "15.0.0",
41
- "fake-indexeddb": "6.2.5",
42
37
  "matrix-encrypt-attachment": "1.0.3",
43
- "matrix-js-sdk": "41.9.0"
38
+ "matrix-js-sdk": "41.9.0",
39
+ "oidc-client-ts": "3.5.0"
44
40
  },
45
41
  "devDependencies": {
46
42
  "@playwright/test": "1.61.1",
@@ -54,10 +50,10 @@
54
50
  },
55
51
  "repository": {
56
52
  "type": "git",
57
- "url": "git+https://github.com/TeleCrypt-io/secure-storage.git"
53
+ "url": "git+https://github.com/TeleCrypt-io/storage-sdk.git"
58
54
  },
59
- "homepage": "https://github.com/TeleCrypt-io/secure-storage#readme",
55
+ "homepage": "https://github.com/TeleCrypt-io/storage-sdk#readme",
60
56
  "bugs": {
61
- "url": "https://github.com/TeleCrypt-io/secure-storage/issues"
57
+ "url": "https://github.com/TeleCrypt-io/storage-sdk/issues"
62
58
  }
63
59
  }
@@ -1,37 +0,0 @@
1
- interface IndexSpec {
2
- name: string;
3
- keyPath: string | string[];
4
- unique: boolean;
5
- multiEntry: boolean;
6
- }
7
- interface StoreSpec {
8
- name: string;
9
- keyPath: string | string[] | null;
10
- autoIncrement: boolean;
11
- indexes: IndexSpec[];
12
- }
13
- interface StoreRecord {
14
- key?: unknown;
15
- value: unknown;
16
- }
17
- interface DbSnapshot {
18
- name: string;
19
- version: number;
20
- stores: StoreSpec[];
21
- records: Record<string, StoreRecord[]>;
22
- }
23
- export interface CryptoSnapshot {
24
- dbs: DbSnapshot[];
25
- }
26
- /** Dumps every IndexedDB database currently visible to this process. */
27
- export declare function exportIndexedDB(): Promise<CryptoSnapshot>;
28
- /** Recreates every database/store/index/record from a snapshot into the
29
- * current (assumed empty) IndexedDB factory. */
30
- export declare function importIndexedDB(snapshot: CryptoSnapshot): Promise<void>;
31
- export declare function loadSnapshotFromDisk(path: string): CryptoSnapshot | null;
32
- export declare function saveSnapshotToDisk(path: string, snapshot: CryptoSnapshot): void;
33
- /** Loads the on-disk snapshot (if any) into the current process's fake-indexeddb. */
34
- export declare function restoreCryptoStore(path: string): Promise<void>;
35
- /** Dumps the current process's fake-indexeddb to disk for the next process. */
36
- export declare function persistCryptoStore(path: string): Promise<void>;
37
- export {};
@@ -1,187 +0,0 @@
1
- /**
2
- * Cross-process crypto persistence for the process-per-command CLI.
3
- *
4
- * A CLI runs each command as a separate OS process. `TeleCryptIOStorage.create()`
5
- * initialises rust-crypto with `useIndexedDB: true`, but in Node the only
6
- * IndexedDB available is `fake-indexeddb`, which is a pure in-memory
7
- * implementation — it evaporates when the process exits. Left alone, every
8
- * CLI invocation would start with an empty crypto store and be unable to
9
- * decrypt anything a previous invocation wrote (including its own device's
10
- * megolm sessions), which also breaks cross-user sharing: userB's upload
11
- * process sends userA an olm-encrypted room key addressed to userA's device
12
- * identity; if that identity is regenerated every run, the key is
13
- * undecryptable and userA can never read userB's file no matter what backup
14
- * scheme is layered on top.
15
- *
16
- * Fix: snapshot fake-indexeddb's databases to disk after each command and
17
- * reload them before the next one runs, keyed to the profile directory. This
18
- * is Option 1 from the spec (disk-persistent crypto store) implemented via
19
- * generic export/import over the *public* IndexedDB API (databases(), cursors,
20
- * transactions) rather than poking fake-indexeddb's internals — so it isn't
21
- * coupled to fake-indexeddb's private representation and would keep working
22
- * against any spec-compliant IndexedDB implementation. Runtime crypto
23
- * behaviour is otherwise unchanged from what the library's own tests already
24
- * exercise (keys.test.ts, tree.test.ts 4.6 both run rust-crypto on
25
- * fake-indexeddb).
26
- *
27
- * Binary values (megolm session keys etc.) don't survive JSON, so the
28
- * snapshot is serialised with Node's structured-clone-capable `node:v8`
29
- * (de)serialize rather than JSON.
30
- */
31
- import * as fs from "node:fs";
32
- import * as v8 from "node:v8";
33
- function getIndexedDB() {
34
- const idb = globalThis.indexedDB;
35
- if (!idb) {
36
- throw new Error("cryptoSnapshot: globalThis.indexedDB is not set — import 'fake-indexeddb/auto' before calling exportIndexedDB/importIndexedDB");
37
- }
38
- return idb;
39
- }
40
- function promisifyRequest(req) {
41
- return new Promise((resolve, reject) => {
42
- req.onsuccess = () => resolve(req.result);
43
- req.onerror = () => reject(req.error);
44
- });
45
- }
46
- function promisifyTxDone(tx) {
47
- return new Promise((resolve, reject) => {
48
- tx.oncomplete = () => resolve();
49
- tx.onerror = () => reject(tx.error);
50
- tx.onabort = () => reject(tx.error ?? new Error("IndexedDB transaction aborted"));
51
- });
52
- }
53
- function readAllRecords(store) {
54
- return new Promise((resolve, reject) => {
55
- const out = [];
56
- const outOfLine = store.keyPath === null;
57
- const req = store.openCursor();
58
- req.onerror = () => reject(req.error);
59
- req.onsuccess = () => {
60
- const cursor = req.result;
61
- if (cursor) {
62
- out.push(outOfLine
63
- ? { key: cursor.primaryKey, value: cursor.value }
64
- : { value: cursor.value });
65
- cursor.continue();
66
- }
67
- else {
68
- resolve(out);
69
- }
70
- };
71
- });
72
- }
73
- /** Dumps every IndexedDB database currently visible to this process. */
74
- export async function exportIndexedDB() {
75
- const idb = getIndexedDB();
76
- const infos = await idb.databases();
77
- const dbs = [];
78
- for (const info of infos) {
79
- if (!info.name)
80
- continue;
81
- const db = await promisifyRequest(idb.open(info.name, info.version));
82
- try {
83
- const storeNames = Array.from(db.objectStoreNames);
84
- const stores = [];
85
- const records = {};
86
- if (storeNames.length > 0) {
87
- const tx = db.transaction(storeNames, "readonly");
88
- for (const storeName of storeNames) {
89
- const store = tx.objectStore(storeName);
90
- const indexes = Array.from(store.indexNames).map((iname) => {
91
- const idx = store.index(iname);
92
- return {
93
- name: idx.name,
94
- keyPath: idx.keyPath,
95
- unique: idx.unique,
96
- multiEntry: idx.multiEntry,
97
- };
98
- });
99
- stores.push({
100
- name: storeName,
101
- keyPath: store.keyPath,
102
- autoIncrement: store.autoIncrement,
103
- indexes,
104
- });
105
- records[storeName] = await readAllRecords(store);
106
- }
107
- await promisifyTxDone(tx);
108
- }
109
- dbs.push({ name: info.name, version: db.version, stores, records });
110
- }
111
- finally {
112
- db.close();
113
- }
114
- }
115
- return { dbs };
116
- }
117
- /** Recreates every database/store/index/record from a snapshot into the
118
- * current (assumed empty) IndexedDB factory. */
119
- export async function importIndexedDB(snapshot) {
120
- const idb = getIndexedDB();
121
- for (const dbSnap of snapshot.dbs) {
122
- const db = await new Promise((resolve, reject) => {
123
- const req = idb.open(dbSnap.name, Math.max(dbSnap.version, 1));
124
- req.onupgradeneeded = () => {
125
- const database = req.result;
126
- for (const store of dbSnap.stores) {
127
- const os = database.createObjectStore(store.name, {
128
- keyPath: store.keyPath ?? undefined,
129
- autoIncrement: store.autoIncrement,
130
- });
131
- for (const idx of store.indexes) {
132
- os.createIndex(idx.name, idx.keyPath, {
133
- unique: idx.unique,
134
- multiEntry: idx.multiEntry,
135
- });
136
- }
137
- }
138
- };
139
- req.onsuccess = () => resolve(req.result);
140
- req.onerror = () => reject(req.error);
141
- });
142
- try {
143
- const storeNames = dbSnap.stores.map((s) => s.name);
144
- if (storeNames.length > 0) {
145
- const tx = db.transaction(storeNames, "readwrite");
146
- for (const store of dbSnap.stores) {
147
- const os = tx.objectStore(store.name);
148
- for (const rec of dbSnap.records[store.name] ?? []) {
149
- if (store.keyPath === null) {
150
- os.put(rec.value, rec.key);
151
- }
152
- else {
153
- os.put(rec.value);
154
- }
155
- }
156
- }
157
- await promisifyTxDone(tx);
158
- }
159
- }
160
- finally {
161
- db.close();
162
- }
163
- }
164
- }
165
- export function loadSnapshotFromDisk(path) {
166
- if (!fs.existsSync(path))
167
- return null;
168
- const buf = fs.readFileSync(path);
169
- if (buf.length === 0)
170
- return null;
171
- return v8.deserialize(buf);
172
- }
173
- export function saveSnapshotToDisk(path, snapshot) {
174
- fs.writeFileSync(path, v8.serialize(snapshot), { mode: 0o600 });
175
- }
176
- /** Loads the on-disk snapshot (if any) into the current process's fake-indexeddb. */
177
- export async function restoreCryptoStore(path) {
178
- const snapshot = loadSnapshotFromDisk(path);
179
- if (snapshot) {
180
- await importIndexedDB(snapshot);
181
- }
182
- }
183
- /** Dumps the current process's fake-indexeddb to disk for the next process. */
184
- export async function persistCryptoStore(path) {
185
- const snapshot = await exportIndexedDB();
186
- saveSnapshotToDisk(path, snapshot);
187
- }
@@ -1 +0,0 @@
1
- export { CliError } from "../core/errors.js";
@@ -1,3 +0,0 @@
1
- // Re-homed under src/core/errors.ts (platform-agnostic — no Node/CLI
2
- // dependencies), re-exported here so existing CLI imports keep working.
3
- export { CliError } from "../core/errors.js";
@@ -1,2 +0,0 @@
1
- #!/usr/bin/env node
2
- import "fake-indexeddb/auto";
package/dist/cli/index.js DELETED
@@ -1,443 +0,0 @@
1
- #!/usr/bin/env node
2
- import "fake-indexeddb/auto";
3
- import * as fs from "node:fs";
4
- import * as path from "node:path";
5
- import { Command } from "commander";
6
- import { createClient } from "matrix-js-sdk";
7
- import { clearProfile, readSession, writeSession } from "./profile.js";
8
- import { initStorageForNewSession, openStorage, waitForBackupSettled } from "./storage.js";
9
- import { CliError } from "./errors.js";
10
- import { runAction } from "./output.js";
11
- import * as core from "../core/operations.js";
12
- import { runDeviceCodeLogin } from "./oidc.js";
13
- // matrix-js-sdk (loglevel) and the rust-crypto WASM tracing layer write
14
- // verbose logs straight to console.log/debug/info/trace (stdout by default)
15
- // AND console.warn/error (stderr by default) — e.g. push-rule setup notices
16
- // and background-request warnings fire on totally successful runs. Left
17
- // alone, that corrupts BOTH halves of the CLI's output contract: stdout must
18
- // be exactly one line (human text or --json payload), and stderr under
19
- // --json must be exactly one `{"error": "..."}` line for a test (or script)
20
- // to parse. Silence all of them here, before TeleCryptIOStorage.create() ever
21
- // triggers rust-crypto initialisation; the CLI's own output always goes
22
- // through process.stdout.write/process.stderr.write directly (see
23
- // output.ts), never console.*, so this can't swallow anything we emit
24
- // ourselves. Set TELECRYPT_IO_STORAGE_DEBUG=1 to see the SDK's logs again
25
- // (all routed to stderr, labelled) when troubleshooting.
26
- if (!process.env.TELECRYPT_IO_STORAGE_DEBUG) {
27
- console.log = () => { };
28
- console.debug = () => { };
29
- console.info = () => { };
30
- console.trace = () => { };
31
- console.warn = () => { };
32
- console.error = () => { };
33
- }
34
- else {
35
- const toStderr = (label) => (...args) => process.stderr.write(`[${label}] ${args.map(String).join(" ")}\n`);
36
- console.log = toStderr("log");
37
- console.debug = toStderr("debug");
38
- console.info = toStderr("info");
39
- console.trace = toStderr("trace");
40
- console.warn = toStderr("warn");
41
- console.error = toStderr("error");
42
- }
43
- const EXT_MIMETYPES = {
44
- ".txt": "text/plain",
45
- ".json": "application/json",
46
- ".png": "image/png",
47
- ".jpg": "image/jpeg",
48
- ".jpeg": "image/jpeg",
49
- ".pdf": "application/pdf",
50
- ".md": "text/markdown",
51
- };
52
- function guessMimetype(filePath) {
53
- return EXT_MIMETYPES[path.extname(filePath).toLowerCase()] ?? "application/octet-stream";
54
- }
55
- const program = new Command();
56
- program
57
- .name("telecrypt-io")
58
- .description("TeleCrypt.io CLI")
59
- .option("--json", "machine-readable JSON output")
60
- .showHelpAfterError();
61
- const storage = program
62
- .command("storage")
63
- .description("End-to-end encrypted file storage on Matrix");
64
- // ---------------------------------------------------------------------------
65
- // Session
66
- // ---------------------------------------------------------------------------
67
- storage
68
- .command("login")
69
- .description("Log in and persist the session + crypto store to the profile")
70
- .requiredOption("--homeserver <url>", "Matrix homeserver base URL")
71
- .option("--user <localpart>", "Username (localpart or full MXID) — password login")
72
- .option("--password <pw>", "Password — password login")
73
- .option("--oidc", "Log in via OIDC/MAS device-code grant instead of password")
74
- .action(async (opts, command) => {
75
- await runAction(command, async () => {
76
- let session;
77
- if (opts.oidc) {
78
- session = await runDeviceCodeLogin(opts.homeserver, {
79
- onVerification: ({ verificationUri, verificationUriComplete, userCode }) => {
80
- // Progress output — stderr only, so it never corrupts the
81
- // stdout contract (--json's single JSON line / text mode's
82
- // single line), same convention as TELECRYPT_IO_STORAGE_DEBUG
83
- // SDK logging below.
84
- process.stderr.write([
85
- "",
86
- `To finish logging in, visit: ${verificationUriComplete ?? verificationUri}`,
87
- verificationUriComplete ? "" : `and enter code: ${userCode}`,
88
- "Attempting to open your browser…",
89
- "Waiting for approval…",
90
- "",
91
- ]
92
- .filter((l) => l !== "")
93
- .join("\n") + "\n");
94
- },
95
- });
96
- }
97
- else {
98
- if (!opts.user || !opts.password) {
99
- throw new CliError("--user and --password are required for password login (or pass --oidc)");
100
- }
101
- const client = createClient({ baseUrl: opts.homeserver });
102
- let res;
103
- try {
104
- res = await client.loginWithPassword(opts.user, opts.password);
105
- }
106
- catch (err) {
107
- throw new CliError(`login failed: ${err.message}`);
108
- }
109
- session = {
110
- homeserver: opts.homeserver,
111
- userId: res.user_id,
112
- deviceId: res.device_id,
113
- accessToken: res.access_token,
114
- };
115
- }
116
- writeSession(session);
117
- // Establishes this device's crypto identity and does a first sync
118
- // (proves connectivity end-to-end), and writes the initial crypto
119
- // store snapshot so later commands have something to load.
120
- const opened = await initStorageForNewSession(session);
121
- await opened.close();
122
- return {
123
- json: { userId: session.userId, deviceId: session.deviceId, homeserver: session.homeserver },
124
- text: `Logged in as ${session.userId} (device ${session.deviceId})`,
125
- };
126
- });
127
- });
128
- storage
129
- .command("register")
130
- .description("Register a new account (dev/test convenience), then log in")
131
- .requiredOption("--homeserver <url>", "Matrix homeserver base URL")
132
- .requiredOption("--user <localpart>", "Username (localpart)")
133
- .requiredOption("--password <pw>", "Password")
134
- .action(async (opts, command) => {
135
- await runAction(command, async () => {
136
- const res = await fetch(`${opts.homeserver}/_matrix/client/v3/register`, {
137
- method: "POST",
138
- headers: { "Content-Type": "application/json" },
139
- body: JSON.stringify({
140
- username: opts.user,
141
- password: opts.password,
142
- auth: { type: "m.login.dummy" },
143
- inhibit_login: false,
144
- }),
145
- });
146
- if (!res.ok) {
147
- const body = await res.text();
148
- throw new CliError(`registration failed (${res.status}): ${body}`);
149
- }
150
- const data = (await res.json());
151
- const session = {
152
- homeserver: opts.homeserver,
153
- userId: data.user_id,
154
- deviceId: data.device_id,
155
- accessToken: data.access_token,
156
- };
157
- writeSession(session);
158
- const opened = await initStorageForNewSession(session);
159
- await opened.close();
160
- return {
161
- json: { userId: session.userId, deviceId: session.deviceId, homeserver: session.homeserver },
162
- text: `Registered and logged in as ${session.userId} (device ${session.deviceId})`,
163
- };
164
- });
165
- });
166
- storage
167
- .command("whoami")
168
- .description("Print the current session identity")
169
- .action(async (_opts, command) => {
170
- await runAction(command, async () => {
171
- const session = readSession();
172
- if (!session)
173
- throw new CliError("not logged in");
174
- return {
175
- json: { userId: session.userId, deviceId: session.deviceId, homeserver: session.homeserver },
176
- text: `${session.userId} (device ${session.deviceId}) @ ${session.homeserver}`,
177
- };
178
- });
179
- });
180
- storage
181
- .command("logout")
182
- .description("Clear the local profile (session + crypto store)")
183
- .action(async (_opts, command) => {
184
- await runAction(command, async () => {
185
- const session = readSession();
186
- if (session) {
187
- // Best-effort server-side logout; local profile is cleared either way.
188
- await fetch(`${session.homeserver}/_matrix/client/v3/logout`, {
189
- method: "POST",
190
- headers: { Authorization: `Bearer ${session.accessToken}` },
191
- }).catch(() => undefined);
192
- }
193
- clearProfile();
194
- return { json: { loggedOut: true }, text: "Logged out." };
195
- });
196
- });
197
- // ---------------------------------------------------------------------------
198
- // Recovery (Layer 2)
199
- // ---------------------------------------------------------------------------
200
- const recovery = storage.command("recovery").description("Server-side key backup / recovery");
201
- recovery
202
- .command("setup")
203
- .description("Set up recovery (cross-signing + key backup) and print the Recovery Key")
204
- .action(async (_opts, command) => {
205
- await runAction(command, async () => {
206
- const opened = await openStorage();
207
- try {
208
- const result = await core.setupRecovery(opened.storage);
209
- // Give any already-known megolm sessions a chance to actually reach
210
- // the server backup before this short-lived process exits — see
211
- // waitForBackupSettled's doc comment.
212
- await waitForBackupSettled(opened.storage);
213
- return {
214
- json: { ...result },
215
- text: [
216
- "Recovery Key (SAVE THIS — it is the only way to recover your files on a new device):",
217
- "",
218
- result.recoveryKey,
219
- ].join("\n"),
220
- };
221
- }
222
- finally {
223
- await opened.close();
224
- }
225
- });
226
- });
227
- recovery
228
- .command("restore <recoveryKey>")
229
- .description("Restore keys on this device from a Recovery Key")
230
- .action(async (recoveryKey, _opts, command) => {
231
- await runAction(command, async () => {
232
- const opened = await openStorage();
233
- try {
234
- const result = await core.restoreRecovery(opened.storage, recoveryKey);
235
- return {
236
- json: { ...result },
237
- text: `Restored ${result.imported}/${result.total} keys.`,
238
- };
239
- }
240
- finally {
241
- await opened.close();
242
- }
243
- });
244
- });
245
- // ---------------------------------------------------------------------------
246
- // Folders
247
- // ---------------------------------------------------------------------------
248
- const folder = storage.command("folder").description("Shared folder operations");
249
- folder
250
- .command("create <name>")
251
- .description("Create a new shared folder")
252
- .action(async (name, _opts, command) => {
253
- await runAction(command, async () => {
254
- const opened = await openStorage();
255
- try {
256
- const result = await core.createFolder(opened.storage, name);
257
- return {
258
- json: { folderId: result.id, name: result.name },
259
- text: `Created folder "${result.name}" (${result.id})`,
260
- };
261
- }
262
- finally {
263
- await opened.close();
264
- }
265
- });
266
- });
267
- folder
268
- .command("list")
269
- .description("List folders visible to the current user")
270
- .action(async (_opts, command) => {
271
- await runAction(command, async () => {
272
- const opened = await openStorage();
273
- try {
274
- const folders = await core.listFolders(opened.storage);
275
- return {
276
- json: { folders },
277
- text: folders.length === 0
278
- ? "(no folders)"
279
- : folders.map((f) => `${f.id}\t${f.name}`).join("\n"),
280
- };
281
- }
282
- finally {
283
- await opened.close();
284
- }
285
- });
286
- });
287
- folder
288
- .command("join <folderId>")
289
- .description("Accept a pending folder invitation (join the room)")
290
- .action(async (folderId, _opts, command) => {
291
- await runAction(command, async () => {
292
- const opened = await openStorage();
293
- try {
294
- const result = await core.joinFolder(opened.storage, folderId);
295
- return { json: { ...result }, text: `Joined folder ${result.folderId}` };
296
- }
297
- finally {
298
- await opened.close();
299
- }
300
- });
301
- });
302
- folder
303
- .command("share <folderId> <userId>")
304
- .description("Invite a participant to a shared folder at a given role")
305
- .option("--role <role>", "viewer or editor", "viewer")
306
- .action(async (folderId, userId, opts, command) => {
307
- await runAction(command, async () => {
308
- // Validated here too (before openStorage/login is even attempted) so
309
- // a bad --role fails fast exactly as before; core.shareFolder repeats
310
- // the same check so it's still safe to call standalone (e.g. from a
311
- // future UI) without this CLI-side pre-check.
312
- if (opts.role !== "viewer" && opts.role !== "editor") {
313
- throw new CliError(`invalid --role "${opts.role}" (must be viewer or editor)`);
314
- }
315
- const opened = await openStorage();
316
- try {
317
- const result = await core.shareFolder(opened.storage, folderId, userId, opts.role);
318
- return {
319
- json: { ...result },
320
- text: `Invited ${result.userId} to ${result.folderId} as ${result.role}`,
321
- };
322
- }
323
- finally {
324
- await opened.close();
325
- }
326
- });
327
- });
328
- folder
329
- .command("members <folderId>")
330
- .description("List participants and their roles")
331
- .action(async (folderId, _opts, command) => {
332
- await runAction(command, async () => {
333
- const opened = await openStorage();
334
- try {
335
- const members = await core.listMembers(opened.storage, folderId);
336
- return {
337
- json: { members },
338
- text: members.map((m) => `${m.userId}\t${m.role}\t${m.membership}`).join("\n"),
339
- };
340
- }
341
- finally {
342
- await opened.close();
343
- }
344
- });
345
- });
346
- folder
347
- .command("unshare <folderId> <userId>")
348
- .description("Remove a participant from a shared folder")
349
- .action(async (folderId, userId, _opts, command) => {
350
- await runAction(command, async () => {
351
- const opened = await openStorage();
352
- try {
353
- const result = await core.unshareFolder(opened.storage, folderId, userId);
354
- return { json: { ...result }, text: `Removed ${result.userId} from ${result.folderId}` };
355
- }
356
- finally {
357
- await opened.close();
358
- }
359
- });
360
- });
361
- // ---------------------------------------------------------------------------
362
- // Files
363
- // ---------------------------------------------------------------------------
364
- const file = storage.command("file").description("File operations within a folder");
365
- file
366
- .command("upload <folderId> <path>")
367
- .description("Encrypt and upload a local file into a folder")
368
- .option("--name <name>", "Name to store the file as (default: basename of path)")
369
- .action(async (folderId, filePath, opts, command) => {
370
- await runAction(command, async () => {
371
- if (!fs.existsSync(filePath)) {
372
- throw new CliError(`file not found: ${filePath}`);
373
- }
374
- const opened = await openStorage();
375
- try {
376
- const name = opts.name ?? path.basename(filePath);
377
- const data = fs.readFileSync(filePath);
378
- const result = await core.uploadFile(opened.storage, folderId, name, data, guessMimetype(filePath));
379
- // If recovery/backup is already active for this account, give the
380
- // new session's key a chance to actually reach the server backup
381
- // before this short-lived process exits.
382
- await waitForBackupSettled(opened.storage);
383
- return {
384
- json: { fileId: result.id, name: result.name },
385
- text: `Uploaded "${result.name}" as ${result.id}`,
386
- };
387
- }
388
- finally {
389
- await opened.close();
390
- }
391
- });
392
- });
393
- file
394
- .command("list <folderId>")
395
- .description("List files in a folder")
396
- .action(async (folderId, _opts, command) => {
397
- await runAction(command, async () => {
398
- const opened = await openStorage();
399
- try {
400
- const files = await core.listFiles(opened.storage, folderId);
401
- return {
402
- json: { files },
403
- text: files.length === 0 ? "(no files)" : files.map((f) => `${f.id}\t${f.name}`).join("\n"),
404
- };
405
- }
406
- finally {
407
- await opened.close();
408
- }
409
- });
410
- });
411
- file
412
- .command("download <folderId> <fileId> <destPath>")
413
- .description("Download and decrypt a file to a local path")
414
- .action(async (folderId, fileId, destPath, _opts, command) => {
415
- await runAction(command, async () => {
416
- const opened = await openStorage();
417
- try {
418
- const result = await core.downloadFile(opened.storage, folderId, fileId);
419
- fs.writeFileSync(destPath, Buffer.from(result.bytes));
420
- return {
421
- json: { path: destPath, bytes: result.bytes.byteLength, mimetype: result.mimetype },
422
- text: `Downloaded ${result.bytes.byteLength} bytes to ${destPath}`,
423
- };
424
- }
425
- finally {
426
- await opened.close();
427
- }
428
- });
429
- });
430
- // ---------------------------------------------------------------------------
431
- program
432
- .parseAsync(process.argv)
433
- .catch((err) => {
434
- process.stderr.write(`Error: ${err.message}\n`);
435
- process.exitCode = 1;
436
- })
437
- .finally(() => {
438
- // Every action closes its own storage (stopClient()), but Node's global
439
- // fetch (undici) can keep a keep-alive socket open past that, leaving
440
- // the process hanging instead of exiting on its own. Exit explicitly
441
- // once the command has fully finished and printed its output.
442
- process.exit(process.exitCode ?? 0);
443
- });
@@ -1,19 +0,0 @@
1
- import type { Session } from "./profile.js";
2
- export interface DeviceCodeLoginHooks {
3
- /** Called once the device+user code is known, before polling begins —
4
- * the caller (CLI command) prints it and attempts to open the browser. */
5
- onVerification: (info: {
6
- verificationUri: string;
7
- verificationUriComplete?: string;
8
- userCode: string;
9
- }) => void;
10
- }
11
- /**
12
- * Runs the full device-code login flow against `homeserver`: discovery, DCR,
13
- * start device authorization, print verification info + try to open the
14
- * browser, poll until approved, confirm identity via `/whoami`. Returns a
15
- * `Session` ready to `writeSession()` — includes the OIDC token set fields
16
- * (`refreshToken`, `oidcIssuer`, `oidcClientId`, `oidcIdToken`) so later CLI
17
- * invocations can reuse + refresh it.
18
- */
19
- export declare function runDeviceCodeLogin(homeserver: string, hooks: DeviceCodeLoginHooks): Promise<Session>;
package/dist/cli/oidc.js DELETED
@@ -1,97 +0,0 @@
1
- /**
2
- * CLI-only OIDC/MAS adapter: device-code login (RFC 8628) against a
3
- * homeserver's delegated auth service (MAS). Node-only (child_process for
4
- * best-effort browser open). Platform-specific behavior stays out of `src/core/`; the actual
5
- * OIDC protocol calls all live in `src/core/oidc.ts` and are shared with the
6
- * UI's PKCE adapter.
7
- */
8
- import { randomBytes } from "node:crypto";
9
- import { spawn } from "node:child_process";
10
- import { discoverOidcIssuer, registerClient, startDeviceCodeLogin, waitForDeviceCodeLogin, isDeviceAccessTokenError, whoAmI, } from "../core/oidc.js";
11
- import { CliError } from "./errors.js";
12
- import { withOidcWindowShim } from "./oidcWindowPolyfill.js";
13
- /** Generates a device ID the same shape matrix-js-sdk itself would (short
14
- * uppercase alphanumeric) — this CLI process chooses it upfront (unlike the
15
- * UI's authorization-code flow, where the SDK picks a random one) so it's
16
- * available immediately for logging/display and is guaranteed to match the
17
- * resulting Matrix device_id (embedded in the requested scope, see
18
- * `core/oidc.ts`'s `startDeviceCodeLogin`). */
19
- function generateDeviceId() {
20
- return randomBytes(5).toString("hex").toUpperCase();
21
- }
22
- /** Best-effort: try to open the verification URL in the user's default
23
- * browser. Never throws — if it fails (headless box, no display, unknown
24
- * platform), the caller already printed the URL for the user to open by
25
- * hand. */
26
- function tryOpenBrowser(url) {
27
- const platform = process.platform;
28
- const cmd = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
29
- try {
30
- const child = spawn(cmd, platform === "win32" ? ["", url] : [url], {
31
- detached: true,
32
- stdio: "ignore",
33
- shell: platform === "win32",
34
- });
35
- child.unref();
36
- }
37
- catch {
38
- // best-effort only
39
- }
40
- }
41
- /**
42
- * Runs the full device-code login flow against `homeserver`: discovery, DCR,
43
- * start device authorization, print verification info + try to open the
44
- * browser, poll until approved, confirm identity via `/whoami`. Returns a
45
- * `Session` ready to `writeSession()` — includes the OIDC token set fields
46
- * (`refreshToken`, `oidcIssuer`, `oidcClientId`, `oidcIdToken`) so later CLI
47
- * invocations can reuse + refresh it.
48
- */
49
- export async function runDeviceCodeLogin(homeserver, hooks) {
50
- // See src/cli/oidcWindowPolyfill.ts: discovery is the one OIDC call that
51
- // needs a `window` stub under Node, and the only place in the CLI it's
52
- // safe to install one — nothing crypto/WASM-related exists yet in this
53
- // process.
54
- const authMetadata = await withOidcWindowShim(() => discoverOidcIssuer(homeserver));
55
- if (!authMetadata.device_authorization_endpoint) {
56
- throw new CliError(`${homeserver} does not advertise OIDC device-code support (no device_authorization_endpoint) — try password login instead.`);
57
- }
58
- const clientId = await registerClient(authMetadata, {
59
- clientName: "TeleCrypt.io CLI",
60
- clientUri: "https://telecrypt.io/",
61
- applicationType: "native",
62
- // Device-code flow never redirects a browser back to us, so this URI is
63
- // never actually dereferenced — it's a DCR-schema placeholder only.
64
- // Unverified against production MAS's DCR policy (only exercised here
65
- // against the local dev/test MAS, which allows insecure/mismatched URIs).
66
- redirectUris: ["http://localhost:0/"],
67
- contacts: undefined,
68
- tosUri: undefined,
69
- policyUri: undefined,
70
- });
71
- const deviceId = generateDeviceId();
72
- const session = await startDeviceCodeLogin(authMetadata, clientId, deviceId);
73
- hooks.onVerification({
74
- verificationUri: session.verification_uri,
75
- verificationUriComplete: session.verification_uri_complete,
76
- userCode: session.user_code,
77
- });
78
- tryOpenBrowser(session.verification_uri_complete ?? session.verification_uri);
79
- const result = await waitForDeviceCodeLogin(authMetadata, clientId, session);
80
- if (isDeviceAccessTokenError(result)) {
81
- throw new CliError(`device login was not approved: ${result.error_description ?? result.error}`);
82
- }
83
- const who = await whoAmI(homeserver, result.access_token);
84
- if (who.deviceId && who.deviceId !== deviceId) {
85
- throw new CliError(`device_id mismatch after OIDC login: requested ${deviceId}, server confirmed ${who.deviceId}`);
86
- }
87
- return {
88
- homeserver,
89
- userId: who.userId,
90
- deviceId,
91
- accessToken: result.access_token,
92
- refreshToken: result.refresh_token,
93
- oidcIssuer: authMetadata.issuer,
94
- oidcClientId: clientId,
95
- oidcTokenEndpoint: authMetadata.token_endpoint,
96
- };
97
- }
@@ -1 +0,0 @@
1
- export declare function withOidcWindowShim<T>(fn: () => Promise<T>): Promise<T>;
@@ -1,76 +0,0 @@
1
- /**
2
- * `src/core/oidc.ts`'s `discoverOidcIssuer()` calls matrix-js-sdk's
3
- * `MatrixClient.getAuthMetadata()`, which internally constructs
4
- * oidc-client-ts state requiring `window.sessionStorage`/`window.
5
- * localStorage` to exist — a real browser has both natively; plain Node does
6
- * not, so under Node it throws `ReferenceError: window is not defined`
7
- * before ever making the HTTP request. Confirmed by direct testing — this is
8
- * a genuine gap in matrix-js-sdk's OIDC helpers for non-browser consumers,
9
- * not a misconfiguration on our side. (The same root cause is why we
10
- * deliberately do NOT use matrix-js-sdk's `OidcTokenRefresher` for token
11
- * refresh — see `core/oidc.ts`'s `refreshOidcToken`/`buildTokenRefreshFunction`,
12
- * a plain hand-rolled `fetch` instead, which needs no `window` at all and so
13
- * has no such gap, no matter when a refresh happens to fire.)
14
- *
15
- * `withOidcWindowShim()` installs a minimal in-memory `Storage`-shaped
16
- * `window` stub, runs `fn`, then removes it again — DELIBERATELY SCOPED, not
17
- * a permanent global assignment:
18
- *
19
- * A permanent `globalThis.window` breaks something unrelated —
20
- * `@matrix-org/matrix-sdk-crypto-wasm`'s own environment detection
21
- * (`typeof window !== "undefined"` → assumes a real browser IndexedDB is
22
- * available) fails with "Unsupported environment" once `window` exists at
23
- * all, even as an empty-ish stub, since CLI crypto persistence depends on Node being detected as Node
24
- * (`fake-indexeddb`), not as a browser. Confirmed by direct testing:
25
- * `initRustCrypto()` broke the moment `globalThis.window` was defined
26
- * unconditionally at module load.
27
- *
28
- * This is safe to use ONLY around `discoverOidcIssuer()`, and ONLY at OIDC
29
- * login time (`src/cli/oidc.ts`'s `runDeviceCodeLogin`) — the one call site
30
- * where it runs before any `TeleCryptIOStorage`/`MatrixClient`/rust-crypto
31
- * WASM exists in the process, so there is no concurrent WASM environment
32
- * check for the temporary stub to corrupt. Never wrap anything that runs
33
- * concurrently with, or after, crypto initialisation.
34
- */
35
- class MemoryStorage {
36
- map = new Map();
37
- get length() {
38
- return this.map.size;
39
- }
40
- clear() {
41
- this.map.clear();
42
- }
43
- getItem(key) {
44
- return this.map.has(key) ? this.map.get(key) : null;
45
- }
46
- key(index) {
47
- return [...this.map.keys()][index] ?? null;
48
- }
49
- removeItem(key) {
50
- this.map.delete(key);
51
- }
52
- setItem(key, value) {
53
- this.map.set(key, value);
54
- }
55
- }
56
- export async function withOidcWindowShim(fn) {
57
- const hadWindow = "window" in globalThis;
58
- const previous = globalThis.window;
59
- if (!hadWindow) {
60
- globalThis.window = {
61
- sessionStorage: new MemoryStorage(),
62
- localStorage: new MemoryStorage(),
63
- };
64
- }
65
- try {
66
- return await fn();
67
- }
68
- finally {
69
- if (!hadWindow) {
70
- delete globalThis.window;
71
- }
72
- else {
73
- globalThis.window = previous;
74
- }
75
- }
76
- }
@@ -1,16 +0,0 @@
1
- import type { Command } from "commander";
2
- export interface CommandResult {
3
- /** Machine-readable payload for --json. */
4
- json: Record<string, unknown>;
5
- /** Human-readable text for the default (non --json) output. */
6
- text: string;
7
- }
8
- /**
9
- * Runs a command action: executes `fn`, prints its result (JSON or human
10
- * text) to stdout on success, or a clean `{ "error": "..." }` (JSON mode) /
11
- * `Error: ...` (text mode) to stderr on failure — never a raw stack trace.
12
- * Sets process.exitCode accordingly (0 on success, 1 on failure) rather than
13
- * calling process.exit() directly, so callers can do any final cleanup
14
- * first.
15
- */
16
- export declare function runAction(command: Command, fn: () => Promise<CommandResult>): Promise<void>;
@@ -1,24 +0,0 @@
1
- function isJsonMode(command) {
2
- return Boolean(command.optsWithGlobals().json);
3
- }
4
- /**
5
- * Runs a command action: executes `fn`, prints its result (JSON or human
6
- * text) to stdout on success, or a clean `{ "error": "..." }` (JSON mode) /
7
- * `Error: ...` (text mode) to stderr on failure — never a raw stack trace.
8
- * Sets process.exitCode accordingly (0 on success, 1 on failure) rather than
9
- * calling process.exit() directly, so callers can do any final cleanup
10
- * first.
11
- */
12
- export async function runAction(command, fn) {
13
- const json = isJsonMode(command);
14
- try {
15
- const result = await fn();
16
- process.stdout.write((json ? JSON.stringify(result.json) : result.text) + "\n");
17
- process.exitCode = 0;
18
- }
19
- catch (err) {
20
- const message = err instanceof Error ? err.message : String(err);
21
- process.stderr.write((json ? JSON.stringify({ error: message }) : `Error: ${message}`) + "\n");
22
- process.exitCode = 1;
23
- }
24
- }
@@ -1 +0,0 @@
1
- export { waitForCondition } from "../core/poll.js";
package/dist/cli/poll.js DELETED
@@ -1,3 +0,0 @@
1
- // Re-homed under src/core/poll.ts (platform-agnostic — no Node/CLI
2
- // dependencies), re-exported here so existing CLI imports keep working.
3
- export { waitForCondition } from "../core/poll.js";
@@ -1,33 +0,0 @@
1
- export interface Session {
2
- homeserver: string;
3
- userId: string;
4
- deviceId: string;
5
- accessToken: string;
6
- /**
7
- * Present only when logged in via `storage login --oidc` (device-code
8
- * grant against MAS; see src/cli/oidc.ts).
9
- * Absent for a plain password login. `oidcTokenEndpoint` is persisted
10
- * directly (rather than re-discovered from `oidcIssuer` on every command)
11
- * so reusing/refreshing the session never needs OIDC discovery again —
12
- * discovery is Node-hostile (see src/cli/oidcWindowPolyfill.ts) and this
13
- * avoids needing it more than once, at login time.
14
- */
15
- refreshToken?: string;
16
- oidcIssuer?: string;
17
- oidcClientId?: string;
18
- oidcTokenEndpoint?: string;
19
- }
20
- /**
21
- * Resolves the profile directory: everything this CLI persists (session,
22
- * crypto store) lives here. Overridable via TELECRYPT_IO_STORAGE_HOME so
23
- * tests can give each simulated user/device its own isolated profile.
24
- * Defaults to ~/.telecrypt-io/storage for normal interactive use.
25
- */
26
- export declare function profileDir(): string;
27
- export declare function sessionPath(dir?: string): string;
28
- export declare function cryptoSnapshotPath(dir?: string): string;
29
- export declare function ensureProfileDir(dir?: string): void;
30
- export declare function readSession(dir?: string): Session | null;
31
- export declare function writeSession(session: Session, dir?: string): void;
32
- /** Clears all local state for this profile (session + crypto store). */
33
- export declare function clearProfile(dir?: string): void;
@@ -1,46 +0,0 @@
1
- import * as fs from "node:fs";
2
- import * as path from "node:path";
3
- import * as os from "node:os";
4
- /**
5
- * Resolves the profile directory: everything this CLI persists (session,
6
- * crypto store) lives here. Overridable via TELECRYPT_IO_STORAGE_HOME so
7
- * tests can give each simulated user/device its own isolated profile.
8
- * Defaults to ~/.telecrypt-io/storage for normal interactive use.
9
- */
10
- export function profileDir() {
11
- const home = process.env.TELECRYPT_IO_STORAGE_HOME;
12
- if (home && home.trim() !== "")
13
- return home;
14
- return path.join(os.homedir(), ".telecrypt-io", "storage");
15
- }
16
- export function sessionPath(dir = profileDir()) {
17
- return path.join(dir, "session.json");
18
- }
19
- export function cryptoSnapshotPath(dir = profileDir()) {
20
- return path.join(dir, "crypto.snapshot");
21
- }
22
- export function ensureProfileDir(dir = profileDir()) {
23
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
24
- }
25
- export function readSession(dir = profileDir()) {
26
- const p = sessionPath(dir);
27
- if (!fs.existsSync(p))
28
- return null;
29
- const raw = fs.readFileSync(p, "utf8");
30
- return JSON.parse(raw);
31
- }
32
- export function writeSession(session, dir = profileDir()) {
33
- ensureProfileDir(dir);
34
- fs.writeFileSync(sessionPath(dir), JSON.stringify(session, null, 2), {
35
- mode: 0o600,
36
- });
37
- }
38
- /** Clears all local state for this profile (session + crypto store). */
39
- export function clearProfile(dir = profileDir()) {
40
- const sp = sessionPath(dir);
41
- const cp = cryptoSnapshotPath(dir);
42
- if (fs.existsSync(sp))
43
- fs.rmSync(sp);
44
- if (fs.existsSync(cp))
45
- fs.rmSync(cp);
46
- }
@@ -1,36 +0,0 @@
1
- import "fake-indexeddb/auto";
2
- import { TeleCryptIOStorage } from "../TeleCryptIOStorage.js";
3
- import { Session } from "./profile.js";
4
- export interface OpenedStorage {
5
- storage: TeleCryptIOStorage;
6
- session: Session;
7
- /** Persists the crypto store back to disk and stops the client. Call this
8
- * in a `finally` block around every command that opens storage, so the
9
- * next CLI invocation sees whatever this one learned (new megolm sessions,
10
- * device keys, etc.) — this is what makes decryption survive across
11
- * separate processes. */
12
- close: () => Promise<void>;
13
- }
14
- /**
15
- * Opens a TeleCryptIOStorage bound to the current profile's session, having first
16
- * restored the crypto store snapshot (if any) from a previous CLI
17
- * invocation. Throws CliError("not logged in") if there is no session.
18
- */
19
- export declare function openStorage(dir?: string): Promise<OpenedStorage>;
20
- /**
21
- * If server-side key backup is active for this account, waits (best-effort,
22
- * bounded) for the SDK's backup engine to report zero sessions remaining to
23
- * upload. This matters specifically because a CLI command is a *short-lived
24
- * process*: the backup engine deliberately fire-and-forgets its upload loop
25
- * with a randomised 0-10s startup jitter (to avoid a multi-device thundering
26
- * herd), so a command that creates a new megolm session (recovery setup,
27
- * or a file upload once recovery is already set up) and then exits
28
- * immediately can easily outrun that loop, leaving the new key silently
29
- * absent from the backup — recoverable-looking but not actually recoverable.
30
- * Never throws: this is a best-effort settle grace period, not a
31
- * correctness gate on the command's primary result.
32
- */
33
- export declare function waitForBackupSettled(storage: TeleCryptIOStorage, timeoutMs?: number): Promise<void>;
34
- /** Used by login/register: builds storage for a brand-new session and
35
- * establishes the initial (empty) crypto store snapshot on disk. */
36
- export declare function initStorageForNewSession(session: Session, dir?: string): Promise<OpenedStorage>;
@@ -1,126 +0,0 @@
1
- import "fake-indexeddb/auto";
2
- import { CryptoEvent } from "matrix-js-sdk/lib/crypto-api/index.js";
3
- import { TeleCryptIOStorage } from "../TeleCryptIOStorage.js";
4
- import { cryptoSnapshotPath, ensureProfileDir, profileDir, readSession, writeSession } from "./profile.js";
5
- import { persistCryptoStore, restoreCryptoStore } from "./cryptoSnapshot.js";
6
- import { CliError } from "./errors.js";
7
- import { buildTokenRefreshFunction } from "../core/oidc.js";
8
- /**
9
- * Builds a TeleCryptIOStorage for the given session — plain password/access-
10
- * token sessions go through `create()`; sessions with an OIDC token set
11
- * (persisted by `storage login --oidc`, see src/cli/oidc.ts) go through
12
- * `createFromOidc()` with a token refresh function wired to persist
13
- * refreshed tokens straight back to this profile's session.json, so a later
14
- * CLI invocation picks up the refreshed access token rather than the
15
- * (possibly now-expired) one this process started with. Needs no OIDC
16
- * discovery / `window` shim here — `oidcTokenEndpoint` was already resolved
17
- * and persisted at login time (see src/cli/oidc.ts).
18
- */
19
- async function buildStorageForSession(session, dir) {
20
- if (!session.refreshToken || !session.oidcClientId || !session.oidcTokenEndpoint) {
21
- return TeleCryptIOStorage.create({
22
- baseUrl: session.homeserver,
23
- userId: session.userId,
24
- accessToken: session.accessToken,
25
- deviceId: session.deviceId,
26
- });
27
- }
28
- const tokenRefreshFunction = buildTokenRefreshFunction(session.oidcTokenEndpoint, session.oidcClientId, async (tokens) => {
29
- writeSession({
30
- ...session,
31
- accessToken: tokens.accessToken,
32
- refreshToken: tokens.refreshToken ?? session.refreshToken,
33
- }, dir);
34
- });
35
- return TeleCryptIOStorage.createFromOidc({
36
- baseUrl: session.homeserver,
37
- userId: session.userId,
38
- accessToken: session.accessToken,
39
- deviceId: session.deviceId,
40
- refreshToken: session.refreshToken,
41
- tokenRefreshFunction,
42
- });
43
- }
44
- /**
45
- * Opens a TeleCryptIOStorage bound to the current profile's session, having first
46
- * restored the crypto store snapshot (if any) from a previous CLI
47
- * invocation. Throws CliError("not logged in") if there is no session.
48
- */
49
- export async function openStorage(dir = profileDir()) {
50
- const session = readSession(dir);
51
- if (!session) {
52
- throw new CliError("not logged in — run `telecrypt-io storage login` first");
53
- }
54
- const snapshotPath = cryptoSnapshotPath(dir);
55
- await restoreCryptoStore(snapshotPath);
56
- const storage = await buildStorageForSession(session, dir);
57
- let closed = false;
58
- const close = async () => {
59
- if (closed)
60
- return;
61
- closed = true;
62
- try {
63
- await persistCryptoStore(snapshotPath);
64
- }
65
- finally {
66
- storage.getClient().stopClient();
67
- }
68
- };
69
- return { storage, session, close };
70
- }
71
- /**
72
- * If server-side key backup is active for this account, waits (best-effort,
73
- * bounded) for the SDK's backup engine to report zero sessions remaining to
74
- * upload. This matters specifically because a CLI command is a *short-lived
75
- * process*: the backup engine deliberately fire-and-forgets its upload loop
76
- * with a randomised 0-10s startup jitter (to avoid a multi-device thundering
77
- * herd), so a command that creates a new megolm session (recovery setup,
78
- * or a file upload once recovery is already set up) and then exits
79
- * immediately can easily outrun that loop, leaving the new key silently
80
- * absent from the backup — recoverable-looking but not actually recoverable.
81
- * Never throws: this is a best-effort settle grace period, not a
82
- * correctness gate on the command's primary result.
83
- */
84
- export async function waitForBackupSettled(storage, timeoutMs = 20000) {
85
- const active = await storage.keys.isRecoverySetup();
86
- if (!active)
87
- return;
88
- const client = storage.getClient();
89
- await new Promise((resolve) => {
90
- let settled = false;
91
- const finish = () => {
92
- if (settled)
93
- return;
94
- settled = true;
95
- client.removeListener(CryptoEvent.KeyBackupSessionsRemaining, onRemaining);
96
- clearTimeout(timer);
97
- resolve();
98
- };
99
- const onRemaining = (remaining) => {
100
- if (remaining === 0)
101
- finish();
102
- };
103
- client.on(CryptoEvent.KeyBackupSessionsRemaining, onRemaining);
104
- const timer = setTimeout(finish, timeoutMs);
105
- });
106
- }
107
- /** Used by login/register: builds storage for a brand-new session and
108
- * establishes the initial (empty) crypto store snapshot on disk. */
109
- export async function initStorageForNewSession(session, dir = profileDir()) {
110
- ensureProfileDir(dir);
111
- const snapshotPath = cryptoSnapshotPath(dir);
112
- const storage = await buildStorageForSession(session, dir);
113
- let closed = false;
114
- const close = async () => {
115
- if (closed)
116
- return;
117
- closed = true;
118
- try {
119
- await persistCryptoStore(snapshotPath);
120
- }
121
- finally {
122
- storage.getClient().stopClient();
123
- }
124
- };
125
- return { storage, session, close };
126
- }