@telecrypt-io/storage 0.1.3 → 0.2.1
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/dist/TeleCryptIOStorage.d.ts +7 -0
- package/dist/TeleCryptIOStorage.js +14 -1
- package/dist/core/operations.js +9 -0
- package/package.json +7 -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/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";
|