@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 +9 -15
- package/package.json +6 -10
- package/dist/cli/cryptoSnapshot.d.ts +0 -37
- package/dist/cli/cryptoSnapshot.js +0 -187
- package/dist/cli/errors.d.ts +0 -1
- package/dist/cli/errors.js +0 -3
- package/dist/cli/index.d.ts +0 -2
- package/dist/cli/index.js +0 -443
- package/dist/cli/oidc.d.ts +0 -19
- package/dist/cli/oidc.js +0 -97
- package/dist/cli/oidcWindowPolyfill.d.ts +0 -1
- package/dist/cli/oidcWindowPolyfill.js +0 -76
- package/dist/cli/output.d.ts +0 -16
- package/dist/cli/output.js +0 -24
- package/dist/cli/poll.d.ts +0 -1
- package/dist/cli/poll.js +0 -3
- package/dist/cli/profile.d.ts +0 -33
- package/dist/cli/profile.js +0 -46
- package/dist/cli/storage.d.ts +0 -36
- package/dist/cli/storage.js +0 -126
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:**
|
|
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
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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 [
|
|
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.
|
|
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/
|
|
53
|
+
"url": "git+https://github.com/TeleCrypt-io/storage-sdk.git"
|
|
58
54
|
},
|
|
59
|
-
"homepage": "https://github.com/TeleCrypt-io/
|
|
55
|
+
"homepage": "https://github.com/TeleCrypt-io/storage-sdk#readme",
|
|
60
56
|
"bugs": {
|
|
61
|
-
"url": "https://github.com/TeleCrypt-io/
|
|
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
|
-
}
|
package/dist/cli/errors.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export { CliError } from "../core/errors.js";
|
package/dist/cli/errors.js
DELETED
package/dist/cli/index.d.ts
DELETED
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
|
-
});
|
package/dist/cli/oidc.d.ts
DELETED
|
@@ -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
|
-
}
|
package/dist/cli/output.d.ts
DELETED
|
@@ -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>;
|
package/dist/cli/output.js
DELETED
|
@@ -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
|
-
}
|
package/dist/cli/poll.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export { waitForCondition } from "../core/poll.js";
|
package/dist/cli/poll.js
DELETED
package/dist/cli/profile.d.ts
DELETED
|
@@ -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;
|
package/dist/cli/profile.js
DELETED
|
@@ -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
|
-
}
|
package/dist/cli/storage.d.ts
DELETED
|
@@ -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>;
|
package/dist/cli/storage.js
DELETED
|
@@ -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
|
-
}
|