@telecrypt-io/storage 0.1.0 → 0.1.2
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 +43 -4
- package/dist/TeleCryptIOStorage.d.ts +54 -2
- package/dist/TeleCryptIOStorage.js +80 -30
- package/dist/cli/cryptoSnapshot.js +1 -1
- package/dist/cli/index.js +42 -14
- package/dist/cli/oidc.d.ts +19 -0
- package/dist/cli/oidc.js +97 -0
- package/dist/cli/oidcWindowPolyfill.d.ts +1 -0
- package/dist/cli/oidcWindowPolyfill.js +76 -0
- package/dist/cli/profile.d.ts +13 -0
- package/dist/cli/storage.js +40 -13
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.js +1 -0
- package/dist/core/oidc.d.ts +119 -0
- package/dist/core/oidc.js +170 -0
- package/dist/core/operations.d.ts +18 -1
- package/dist/core/operations.js +158 -0
- package/dist/core/types.d.ts +21 -0
- package/package.json +23 -10
package/README.md
CHANGED
|
@@ -1,11 +1,49 @@
|
|
|
1
1
|
# TeleCrypt.io Storage
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/@telecrypt-io/storage)
|
|
4
|
+
|
|
3
5
|
End-to-end encrypted file storage and sharing, built on Matrix.
|
|
4
6
|
|
|
5
7
|
Files are encrypted on the client before upload. The server stores only opaque ciphertext and
|
|
6
|
-
never holds the decryption keys.
|
|
8
|
+
never holds the decryption keys. Shared folders let multiple people add and read files, and a
|
|
9
|
+
Recovery Key restores your files on a new device — even if you lose the original.
|
|
10
|
+
|
|
11
|
+
**Status:** published and usable — library, CLI, and a React web UI.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install @telecrypt-io/storage
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
This gives you both the `TeleCryptIOStorage` library and the `telecrypt-io` CLI.
|
|
20
|
+
|
|
21
|
+
## Quick start
|
|
22
|
+
|
|
23
|
+
**Library:**
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import { TeleCryptIOStorage } from "@telecrypt-io/storage";
|
|
27
|
+
import * as core from "@telecrypt-io/storage/core";
|
|
28
|
+
|
|
29
|
+
const storage = await TeleCryptIOStorage.create({
|
|
30
|
+
baseUrl, userId, accessToken, deviceId,
|
|
31
|
+
});
|
|
32
|
+
const folder = await core.createFolder(storage, "Photos");
|
|
33
|
+
await core.uploadFile(storage, folder.id, "cat.jpg", bytes, "image/jpeg");
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**CLI:**
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
telecrypt-io storage login --homeserver https://your.server --user alice --password ...
|
|
40
|
+
telecrypt-io storage folder create Photos
|
|
41
|
+
telecrypt-io storage file upload <folderId> ./cat.jpg
|
|
42
|
+
telecrypt-io storage folder share <folderId> @bob:your.server --role editor
|
|
43
|
+
telecrypt-io storage recovery setup # prints your Recovery Key — save it
|
|
44
|
+
```
|
|
7
45
|
|
|
8
|
-
**
|
|
46
|
+
**Web UI:** a React app lives in [`ui/`](./ui) (`cd ui && npm install && npm run dev`).
|
|
9
47
|
|
|
10
48
|
## How it works
|
|
11
49
|
|
|
@@ -34,9 +72,10 @@ npm test
|
|
|
34
72
|
npm run synapse:down
|
|
35
73
|
```
|
|
36
74
|
|
|
37
|
-
Tests run against a real local Synapse in
|
|
75
|
+
Tests run against a real local Synapse in podman, never against a production server.
|
|
38
76
|
|
|
39
|
-
See [
|
|
77
|
+
See [CLI.md](./CLI.md) for command reference and [RELEASING.md](./RELEASING.md) for release
|
|
78
|
+
instructions.
|
|
40
79
|
|
|
41
80
|
## Licence
|
|
42
81
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { MatrixClient } from "matrix-js-sdk";
|
|
2
2
|
import type { CryptoCallbacks } from "matrix-js-sdk/lib/crypto-api/index.js";
|
|
3
|
+
import type { TokenRefreshFunction } from "matrix-js-sdk/lib/http-api/index.js";
|
|
3
4
|
export interface TreeSpace {
|
|
4
5
|
readonly id: string;
|
|
5
6
|
readonly room: {
|
|
@@ -68,6 +69,10 @@ export interface CreateTeleCryptIOStorageOptions {
|
|
|
68
69
|
initialSyncLimit?: number;
|
|
69
70
|
/** How long to wait for the first sync before giving up; default 15000ms. */
|
|
70
71
|
syncTimeoutMs?: number;
|
|
72
|
+
/** How long to wait for rust-crypto WASM + IndexedDB init; default 60000ms. */
|
|
73
|
+
initTimeoutMs?: number;
|
|
74
|
+
/** Optional progress reporter for UI/CLI status lines during bootstrap. */
|
|
75
|
+
onProgress?: (message: string) => void;
|
|
71
76
|
/**
|
|
72
77
|
* Optional platform-supplied crypto callbacks (e.g. to source the secret
|
|
73
78
|
* storage key from an OS keychain instead of prompting). `keys.setupRecovery`
|
|
@@ -77,6 +82,36 @@ export interface CreateTeleCryptIOStorageOptions {
|
|
|
77
82
|
*/
|
|
78
83
|
cryptoCallbacks?: CryptoCallbacks;
|
|
79
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Options for `TeleCryptIOStorage.createFromOidc` — mirrors
|
|
87
|
+
* `CreateTeleCryptIOStorageOptions` but sourced from an OIDC/MAS login
|
|
88
|
+
* (device-code or authorization-code+PKCE, see `src/core/oidc.ts`) instead
|
|
89
|
+
* of a password login. Same resulting shape either way: a ready
|
|
90
|
+
* `TeleCryptIOStorage` with a persistent crypto store.
|
|
91
|
+
*/
|
|
92
|
+
export interface CreateFromOidcOptions {
|
|
93
|
+
baseUrl: string;
|
|
94
|
+
userId: string;
|
|
95
|
+
accessToken: string;
|
|
96
|
+
deviceId: string;
|
|
97
|
+
/** If provided (together with `tokenRefreshFunction`), wires the
|
|
98
|
+
* underlying MatrixClient so an expired access token is transparently
|
|
99
|
+
* refreshed mid-request. */
|
|
100
|
+
refreshToken?: string;
|
|
101
|
+
/** See `src/core/oidc.ts`'s `buildTokenRefreshFunction` — a plain
|
|
102
|
+
* `(refreshToken) => Promise<AccessTokens>`, deliberately NOT
|
|
103
|
+
* matrix-js-sdk's `OidcTokenRefresher` (see that file's doc comment for
|
|
104
|
+
* why: it requires `window.sessionStorage`, which doesn't exist under
|
|
105
|
+
* Node). */
|
|
106
|
+
tokenRefreshFunction?: TokenRefreshFunction;
|
|
107
|
+
persistentCryptoStore?: boolean;
|
|
108
|
+
cryptoDatabasePrefix?: string;
|
|
109
|
+
initialSyncLimit?: number;
|
|
110
|
+
syncTimeoutMs?: number;
|
|
111
|
+
initTimeoutMs?: number;
|
|
112
|
+
onProgress?: (message: string) => void;
|
|
113
|
+
cryptoCallbacks?: CryptoCallbacks;
|
|
114
|
+
}
|
|
80
115
|
export declare class TeleCryptIOStorage {
|
|
81
116
|
private client;
|
|
82
117
|
constructor(client: MatrixClient);
|
|
@@ -95,6 +130,24 @@ export declare class TeleCryptIOStorage {
|
|
|
95
130
|
* must exist before `initRustCrypto` runs, not be added afterwards).
|
|
96
131
|
*/
|
|
97
132
|
static create(opts: CreateTeleCryptIOStorageOptions): Promise<TeleCryptIOStorage>;
|
|
133
|
+
/**
|
|
134
|
+
* As `create()`, but the resulting `MatrixClient` was authenticated via
|
|
135
|
+
* OIDC/MAS (device-code or authorization-code+PKCE — see
|
|
136
|
+
* `src/core/oidc.ts`) rather than `m.login.password`. If `refreshToken` +
|
|
137
|
+
* `tokenRefresher` are both given, the client's `tokenRefreshFunction` is
|
|
138
|
+
* wired so an expired access token is transparently refreshed mid-request
|
|
139
|
+
* — same mechanism matrix-js-sdk uses for any refresh-token-capable login.
|
|
140
|
+
*/
|
|
141
|
+
static createFromOidc(opts: CreateFromOidcOptions): Promise<TeleCryptIOStorage>;
|
|
142
|
+
/** Shared post-construction bootstrap for `create()`/`createFromOidc()`:
|
|
143
|
+
* persistent crypto store, first sync, wrap in a `TeleCryptIOStorage`. */
|
|
144
|
+
private static bootstrap;
|
|
145
|
+
private static withTimeout;
|
|
146
|
+
/** Wait until the client reaches Prepared/Syncing. Ignores transient
|
|
147
|
+
* pre-sync states instead of rejecting on the first Sync event (which can
|
|
148
|
+
* race `startClient`), and handles the case where sync already completed
|
|
149
|
+
* before the listener was attached. */
|
|
150
|
+
private static waitForFirstSync;
|
|
98
151
|
private requireCrypto;
|
|
99
152
|
/**
|
|
100
153
|
* Temporarily wires `getSecretStorageKey`/`cacheSecretStorageKey` on the
|
|
@@ -163,8 +216,7 @@ export declare class TeleCryptIOStorage {
|
|
|
163
216
|
* Deliberately reads directly from the server's authoritative REST state
|
|
164
217
|
* endpoints (`GET .../members`, `GET .../state/m.room.power_levels/`)
|
|
165
218
|
* rather than from the client's locally synced `tree.room`/`currentState`.
|
|
166
|
-
* On a freshly-started client (as every CLI command is
|
|
167
|
-
* docs/CLI_SPEC.md and src/cli/cryptoSnapshot.ts), room membership and
|
|
219
|
+
* On a freshly-started client (as every CLI command is), room membership and
|
|
168
220
|
* power-level state can take several more `/sync` round trips to fully
|
|
169
221
|
* converge locally after a change, even though the room itself is already
|
|
170
222
|
* visible — reading the same data straight from the server sidesteps that
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { ClientEvent, createClient, SyncState } from "matrix-js-sdk";
|
|
2
|
+
import { Method } from "matrix-js-sdk/lib/http-api/method.js";
|
|
3
|
+
import { ClientPrefix } from "matrix-js-sdk/lib/http-api/prefix.js";
|
|
2
4
|
import { encryptAttachment, decryptAttachment } from "matrix-encrypt-attachment";
|
|
3
5
|
import { decodeRecoveryKey } from "matrix-js-sdk/lib/crypto-api/recovery-key.js";
|
|
4
6
|
export class TeleCryptIOStorage {
|
|
@@ -30,26 +32,80 @@ export class TeleCryptIOStorage {
|
|
|
30
32
|
deviceId: opts.deviceId,
|
|
31
33
|
cryptoCallbacks: opts.cryptoCallbacks ?? {},
|
|
32
34
|
});
|
|
35
|
+
return TeleCryptIOStorage.bootstrap(client, opts);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* As `create()`, but the resulting `MatrixClient` was authenticated via
|
|
39
|
+
* OIDC/MAS (device-code or authorization-code+PKCE — see
|
|
40
|
+
* `src/core/oidc.ts`) rather than `m.login.password`. If `refreshToken` +
|
|
41
|
+
* `tokenRefresher` are both given, the client's `tokenRefreshFunction` is
|
|
42
|
+
* wired so an expired access token is transparently refreshed mid-request
|
|
43
|
+
* — same mechanism matrix-js-sdk uses for any refresh-token-capable login.
|
|
44
|
+
*/
|
|
45
|
+
static async createFromOidc(opts) {
|
|
46
|
+
const client = createClient({
|
|
47
|
+
baseUrl: opts.baseUrl,
|
|
48
|
+
userId: opts.userId,
|
|
49
|
+
accessToken: opts.accessToken,
|
|
50
|
+
deviceId: opts.deviceId,
|
|
51
|
+
refreshToken: opts.refreshToken,
|
|
52
|
+
tokenRefreshFunction: opts.refreshToken && opts.tokenRefreshFunction ? opts.tokenRefreshFunction : undefined,
|
|
53
|
+
cryptoCallbacks: opts.cryptoCallbacks ?? {},
|
|
54
|
+
});
|
|
55
|
+
return TeleCryptIOStorage.bootstrap(client, opts);
|
|
56
|
+
}
|
|
57
|
+
/** Shared post-construction bootstrap for `create()`/`createFromOidc()`:
|
|
58
|
+
* persistent crypto store, first sync, wrap in a `TeleCryptIOStorage`. */
|
|
59
|
+
static async bootstrap(client, opts) {
|
|
60
|
+
const progress = opts.onProgress ?? (() => { });
|
|
33
61
|
const persistent = opts.persistentCryptoStore ?? true;
|
|
34
|
-
|
|
62
|
+
progress("Loading encryption engine (WASM)…");
|
|
63
|
+
await TeleCryptIOStorage.withTimeout(client.initRustCrypto({
|
|
35
64
|
useIndexedDB: persistent,
|
|
36
65
|
cryptoDatabasePrefix: opts.cryptoDatabasePrefix ?? `telecrypt-io-storage::${opts.userId}::${opts.deviceId}`,
|
|
37
|
-
});
|
|
66
|
+
}), opts.initTimeoutMs ?? 60000, "crypto init");
|
|
67
|
+
progress("Encryption ready — opening secure store…");
|
|
68
|
+
progress("Starting Matrix client…");
|
|
38
69
|
await client.startClient({ initialSyncLimit: opts.initialSyncLimit ?? 10 });
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
70
|
+
progress("Waiting for first sync with homeserver…");
|
|
71
|
+
await TeleCryptIOStorage.waitForFirstSync(client, opts.syncTimeoutMs ?? 15000);
|
|
72
|
+
progress("Sync complete.");
|
|
73
|
+
return new TeleCryptIOStorage(client);
|
|
74
|
+
}
|
|
75
|
+
static withTimeout(promise, ms, label) {
|
|
76
|
+
return Promise.race([
|
|
77
|
+
promise,
|
|
78
|
+
new Promise((_, reject) => {
|
|
79
|
+
setTimeout(() => reject(new Error(`${label} timeout after ${ms}ms`)), ms);
|
|
80
|
+
}),
|
|
81
|
+
]);
|
|
82
|
+
}
|
|
83
|
+
/** Wait until the client reaches Prepared/Syncing. Ignores transient
|
|
84
|
+
* pre-sync states instead of rejecting on the first Sync event (which can
|
|
85
|
+
* race `startClient`), and handles the case where sync already completed
|
|
86
|
+
* before the listener was attached. */
|
|
87
|
+
static waitForFirstSync(client, timeoutMs) {
|
|
88
|
+
const ready = (state) => state === SyncState.Prepared || state === SyncState.Syncing;
|
|
89
|
+
if (ready(client.getSyncState())) {
|
|
90
|
+
return Promise.resolve();
|
|
91
|
+
}
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
const onSync = (state) => {
|
|
94
|
+
if (ready(state)) {
|
|
95
|
+
cleanup();
|
|
45
96
|
resolve();
|
|
46
97
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
98
|
+
};
|
|
99
|
+
const cleanup = () => {
|
|
100
|
+
clearTimeout(timeout);
|
|
101
|
+
client.removeListener(ClientEvent.Sync, onSync);
|
|
102
|
+
};
|
|
103
|
+
const timeout = setTimeout(() => {
|
|
104
|
+
cleanup();
|
|
105
|
+
reject(new Error("sync timeout"));
|
|
106
|
+
}, timeoutMs);
|
|
107
|
+
client.on(ClientEvent.Sync, onSync);
|
|
51
108
|
});
|
|
52
|
-
return new TeleCryptIOStorage(client);
|
|
53
109
|
}
|
|
54
110
|
requireCrypto() {
|
|
55
111
|
const crypto = this.client.getCrypto();
|
|
@@ -237,8 +293,7 @@ export class TeleCryptIOStorage {
|
|
|
237
293
|
* Deliberately reads directly from the server's authoritative REST state
|
|
238
294
|
* endpoints (`GET .../members`, `GET .../state/m.room.power_levels/`)
|
|
239
295
|
* rather than from the client's locally synced `tree.room`/`currentState`.
|
|
240
|
-
* On a freshly-started client (as every CLI command is
|
|
241
|
-
* docs/CLI_SPEC.md and src/cli/cryptoSnapshot.ts), room membership and
|
|
296
|
+
* On a freshly-started client (as every CLI command is), room membership and
|
|
242
297
|
* power-level state can take several more `/sync` round trips to fully
|
|
243
298
|
* converge locally after a change, even though the room itself is already
|
|
244
299
|
* visible — reading the same data straight from the server sidesteps that
|
|
@@ -247,22 +302,17 @@ export class TeleCryptIOStorage {
|
|
|
247
302
|
* owner, >= editLevel -> editor, else viewer).
|
|
248
303
|
*/
|
|
249
304
|
async listMembers(tree) {
|
|
250
|
-
const
|
|
251
|
-
const
|
|
252
|
-
const
|
|
253
|
-
const
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
305
|
+
const roomId = encodeURIComponent(tree.id);
|
|
306
|
+
const membersPath = `/rooms/${roomId}/members`;
|
|
307
|
+
const powerLevelsPath = `/rooms/${roomId}/state/m.room.power_levels/`;
|
|
308
|
+
const reqOpts = { prefix: ClientPrefix.V3 };
|
|
309
|
+
const [membersBody, plsRaw] = await Promise.all([
|
|
310
|
+
this.client.http.authedRequest(Method.Get, membersPath, undefined, undefined, reqOpts),
|
|
311
|
+
this.client.http
|
|
312
|
+
.authedRequest(Method.Get, powerLevelsPath, undefined, undefined, reqOpts)
|
|
313
|
+
.catch(() => ({})),
|
|
258
314
|
]);
|
|
259
|
-
|
|
260
|
-
throw new Error(`listMembers: failed to fetch room members (${membersRes.status})`);
|
|
261
|
-
}
|
|
262
|
-
const membersBody = (await membersRes.json());
|
|
263
|
-
const pls = powerLevelsRes.ok
|
|
264
|
-
? (await powerLevelsRes.json())
|
|
265
|
-
: {};
|
|
315
|
+
const pls = plsRaw;
|
|
266
316
|
const viewLevel = pls.users_default ?? 0;
|
|
267
317
|
const editLevel = pls.events_default ?? 50;
|
|
268
318
|
const adminLevel = pls.events?.["m.room.power_levels"] ?? 100;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Cross-process crypto persistence
|
|
2
|
+
* Cross-process crypto persistence for the process-per-command CLI.
|
|
3
3
|
*
|
|
4
4
|
* A CLI runs each command as a separate OS process. `TeleCryptIOStorage.create()`
|
|
5
5
|
* initialises rust-crypto with `useIndexedDB: true`, but in Node the only
|
package/dist/cli/index.js
CHANGED
|
@@ -9,6 +9,7 @@ import { initStorageForNewSession, openStorage, waitForBackupSettled } from "./s
|
|
|
9
9
|
import { CliError } from "./errors.js";
|
|
10
10
|
import { runAction } from "./output.js";
|
|
11
11
|
import * as core from "../core/operations.js";
|
|
12
|
+
import { runDeviceCodeLogin } from "./oidc.js";
|
|
12
13
|
// matrix-js-sdk (loglevel) and the rust-crypto WASM tracing layer write
|
|
13
14
|
// verbose logs straight to console.log/debug/info/trace (stdout by default)
|
|
14
15
|
// AND console.warn/error (stderr by default) — e.g. push-rule setup notices
|
|
@@ -67,24 +68,51 @@ storage
|
|
|
67
68
|
.command("login")
|
|
68
69
|
.description("Log in and persist the session + crypto store to the profile")
|
|
69
70
|
.requiredOption("--homeserver <url>", "Matrix homeserver base URL")
|
|
70
|
-
.
|
|
71
|
-
.
|
|
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")
|
|
72
74
|
.action(async (opts, command) => {
|
|
73
75
|
await runAction(command, async () => {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
+
});
|
|
78
96
|
}
|
|
79
|
-
|
|
80
|
-
|
|
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
|
+
};
|
|
81
115
|
}
|
|
82
|
-
const session = {
|
|
83
|
-
homeserver: opts.homeserver,
|
|
84
|
-
userId: res.user_id,
|
|
85
|
-
deviceId: res.device_id,
|
|
86
|
-
accessToken: res.access_token,
|
|
87
|
-
};
|
|
88
116
|
writeSession(session);
|
|
89
117
|
// Establishes this device's crypto identity and does a first sync
|
|
90
118
|
// (proves connectivity end-to-end), and writes the initial crypto
|
|
@@ -0,0 +1,19 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function withOidcWindowShim<T>(fn: () => Promise<T>): Promise<T>;
|
|
@@ -0,0 +1,76 @@
|
|
|
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/profile.d.ts
CHANGED
|
@@ -3,6 +3,19 @@ export interface Session {
|
|
|
3
3
|
userId: string;
|
|
4
4
|
deviceId: string;
|
|
5
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;
|
|
6
19
|
}
|
|
7
20
|
/**
|
|
8
21
|
* Resolves the profile directory: everything this CLI persists (session,
|
package/dist/cli/storage.js
CHANGED
|
@@ -1,9 +1,46 @@
|
|
|
1
1
|
import "fake-indexeddb/auto";
|
|
2
2
|
import { CryptoEvent } from "matrix-js-sdk/lib/crypto-api/index.js";
|
|
3
3
|
import { TeleCryptIOStorage } from "../TeleCryptIOStorage.js";
|
|
4
|
-
import { cryptoSnapshotPath, ensureProfileDir, profileDir, readSession } from "./profile.js";
|
|
4
|
+
import { cryptoSnapshotPath, ensureProfileDir, profileDir, readSession, writeSession } from "./profile.js";
|
|
5
5
|
import { persistCryptoStore, restoreCryptoStore } from "./cryptoSnapshot.js";
|
|
6
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
|
+
}
|
|
7
44
|
/**
|
|
8
45
|
* Opens a TeleCryptIOStorage bound to the current profile's session, having first
|
|
9
46
|
* restored the crypto store snapshot (if any) from a previous CLI
|
|
@@ -16,12 +53,7 @@ export async function openStorage(dir = profileDir()) {
|
|
|
16
53
|
}
|
|
17
54
|
const snapshotPath = cryptoSnapshotPath(dir);
|
|
18
55
|
await restoreCryptoStore(snapshotPath);
|
|
19
|
-
const storage = await
|
|
20
|
-
baseUrl: session.homeserver,
|
|
21
|
-
userId: session.userId,
|
|
22
|
-
accessToken: session.accessToken,
|
|
23
|
-
deviceId: session.deviceId,
|
|
24
|
-
});
|
|
56
|
+
const storage = await buildStorageForSession(session, dir);
|
|
25
57
|
let closed = false;
|
|
26
58
|
const close = async () => {
|
|
27
59
|
if (closed)
|
|
@@ -77,12 +109,7 @@ export async function waitForBackupSettled(storage, timeoutMs = 20000) {
|
|
|
77
109
|
export async function initStorageForNewSession(session, dir = profileDir()) {
|
|
78
110
|
ensureProfileDir(dir);
|
|
79
111
|
const snapshotPath = cryptoSnapshotPath(dir);
|
|
80
|
-
const storage = await
|
|
81
|
-
baseUrl: session.homeserver,
|
|
82
|
-
userId: session.userId,
|
|
83
|
-
accessToken: session.accessToken,
|
|
84
|
-
deviceId: session.deviceId,
|
|
85
|
-
});
|
|
112
|
+
const storage = await buildStorageForSession(session, dir);
|
|
86
113
|
let closed = false;
|
|
87
114
|
const close = async () => {
|
|
88
115
|
if (closed)
|
package/dist/core/index.d.ts
CHANGED
package/dist/core/index.js
CHANGED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { type OidcClientConfig, type OidcRegistrationClientMetadata, type DeviceAuthorizationResponse, type DeviceAccessTokenResponse, type DeviceAccessTokenError, type BearerTokenResponse } from "matrix-js-sdk/lib/oidc/index.js";
|
|
2
|
+
import type { AccessTokens, TokenRefreshFunction } from "matrix-js-sdk/lib/http-api/index.js";
|
|
3
|
+
import type { IdTokenClaims } from "oidc-client-ts";
|
|
4
|
+
export type { OidcClientConfig, DeviceAuthorizationResponse, DeviceAccessTokenResponse, DeviceAccessTokenError, BearerTokenResponse, AccessTokens, TokenRefreshFunction, };
|
|
5
|
+
/**
|
|
6
|
+
* The full result of a completed OIDC login (device-code or authorization
|
|
7
|
+
* code) — enough for a caller to build a ready `TeleCryptIOStorage` via
|
|
8
|
+
* `TeleCryptIOStorage.createFromOidc(...)` AND to persist for reuse/refresh
|
|
9
|
+
* across restarts (CLI: profile file; UI: localStorage).
|
|
10
|
+
*/
|
|
11
|
+
export interface OidcTokenSet {
|
|
12
|
+
homeserverUrl: string;
|
|
13
|
+
issuer: string;
|
|
14
|
+
clientId: string;
|
|
15
|
+
userId: string;
|
|
16
|
+
deviceId: string;
|
|
17
|
+
accessToken: string;
|
|
18
|
+
refreshToken?: string;
|
|
19
|
+
idToken: string;
|
|
20
|
+
/** epoch ms, if the token response included an expiry. */
|
|
21
|
+
expiresAt?: number;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Discovers the OIDC/MAS issuer + endpoints for a homeserver, via
|
|
25
|
+
* `MatrixClient.getAuthMetadata()` (MSC2965 `/auth_metadata`, falling back to
|
|
26
|
+
* the legacy `/auth_issuer` + issuer well-known) — the non-deprecated path
|
|
27
|
+
* recommended over `discoverAndValidateOIDCIssuerWellKnown`.
|
|
28
|
+
*
|
|
29
|
+
* NODE CALLERS: this internally constructs oidc-client-ts state that
|
|
30
|
+
* requires `window.sessionStorage`/`window.localStorage` to exist (a real
|
|
31
|
+
* browser has both natively). Under plain Node, wrap this ONE call in
|
|
32
|
+
* `src/cli/oidcWindowPolyfill.ts`'s `withOidcWindowShim()` — see that file's
|
|
33
|
+
* doc comment for why it must be scoped narrowly (a permanent global
|
|
34
|
+
* `window` breaks the rust-crypto WASM's own environment detection) and why
|
|
35
|
+
* it's safe here specifically (called once, before any crypto/WASM exists).
|
|
36
|
+
*/
|
|
37
|
+
export declare function discoverOidcIssuer(homeserverBaseUrl: string): Promise<OidcClientConfig>;
|
|
38
|
+
/**
|
|
39
|
+
* Dynamic client registration (DCR). `clientUri`/`redirectUris` must share a
|
|
40
|
+
* host unless the issuer's policy allows a mismatch (our local dev/test MAS
|
|
41
|
+
* does; a production issuer may not).
|
|
42
|
+
*/
|
|
43
|
+
export declare function registerClient(authMetadata: OidcClientConfig, metadata: OidcRegistrationClientMetadata): Promise<string>;
|
|
44
|
+
/**
|
|
45
|
+
* Starts a device-code authorization session. `deviceId` is caller-chosen
|
|
46
|
+
* (unlike the authorization-code flow, where the SDK picks a random one) so
|
|
47
|
+
* it can be reused as this Matrix device's `device_id` throughout —
|
|
48
|
+
* `generateScope(deviceId)` embeds it in the requested scope per MSC2967.
|
|
49
|
+
*/
|
|
50
|
+
export declare function startDeviceCodeLogin(authMetadata: OidcClientConfig, clientId: string, deviceId: string): Promise<DeviceAuthorizationResponse>;
|
|
51
|
+
/**
|
|
52
|
+
* Polls the token endpoint until the device-code session is approved (or
|
|
53
|
+
* denied/expired) — see matrix-js-sdk's `waitForDeviceAuthorization` for the
|
|
54
|
+
* RFC 8628 polling semantics (honours `interval`/`expires_in`,
|
|
55
|
+
* `authorization_pending`/`slow_down`).
|
|
56
|
+
*/
|
|
57
|
+
export declare function waitForDeviceCodeLogin(authMetadata: OidcClientConfig, clientId: string, session: DeviceAuthorizationResponse): Promise<DeviceAccessTokenResponse | DeviceAccessTokenError>;
|
|
58
|
+
export declare function isDeviceAccessTokenError(result: DeviceAccessTokenResponse | DeviceAccessTokenError): result is DeviceAccessTokenError;
|
|
59
|
+
/**
|
|
60
|
+
* Builds the authorization URL to redirect the browser to. PKCE
|
|
61
|
+
* verifier/state/nonce are generated and persisted to `window.sessionStorage`
|
|
62
|
+
* internally by matrix-js-sdk/oidc-client-ts (`mx_oidc_`-prefixed keys) — the
|
|
63
|
+
* caller does not need to manage them; `completeAuthorizationCodeFlow` reads
|
|
64
|
+
* them back after the redirect.
|
|
65
|
+
*/
|
|
66
|
+
export declare function beginAuthorizationCodeFlow(opts: {
|
|
67
|
+
authMetadata: OidcClientConfig;
|
|
68
|
+
clientId: string;
|
|
69
|
+
homeserverUrl: string;
|
|
70
|
+
redirectUri: string;
|
|
71
|
+
}): Promise<string>;
|
|
72
|
+
/** Completes the authorization-code exchange after the `?code&state` redirect. */
|
|
73
|
+
export declare function completeAuthorizationCodeFlow(code: string, state: string): Promise<{
|
|
74
|
+
tokenResponse: BearerTokenResponse;
|
|
75
|
+
oidcClientSettings: {
|
|
76
|
+
clientId: string;
|
|
77
|
+
issuer: string;
|
|
78
|
+
};
|
|
79
|
+
homeserverUrl: string;
|
|
80
|
+
idTokenClaims: IdTokenClaims;
|
|
81
|
+
}>;
|
|
82
|
+
/** The device_id granted is embedded in the token response's `scope`
|
|
83
|
+
* (`urn:matrix:org.matrix.msc2967.client:device:<id>`), per MSC2967 — the
|
|
84
|
+
* authorization-code flow doesn't return it any other way (the SDK
|
|
85
|
+
* auto-generates it when the caller doesn't request a specific one). */
|
|
86
|
+
export declare function extractDeviceIdFromScope(scope: string): string | null;
|
|
87
|
+
/** Confirms {userId, deviceId} for a freshly-obtained access token via
|
|
88
|
+
* `GET /_matrix/client/v3/account/whoami` — needed because neither the
|
|
89
|
+
* device-code nor authorization-code token response includes the Matrix
|
|
90
|
+
* user ID. */
|
|
91
|
+
export declare function whoAmI(homeserverUrl: string, accessToken: string): Promise<{
|
|
92
|
+
userId: string;
|
|
93
|
+
deviceId: string | null;
|
|
94
|
+
}>;
|
|
95
|
+
/**
|
|
96
|
+
* Refreshes an access token via a plain RFC 6749 `grant_type=refresh_token`
|
|
97
|
+
* POST to the token endpoint — deliberately NOT matrix-js-sdk's
|
|
98
|
+
* `OidcTokenRefresher`/oidc-client-ts, which unconditionally construct an
|
|
99
|
+
* internal `OidcClient` requiring `window.sessionStorage`/`window.
|
|
100
|
+
* localStorage` even though a plain refresh never actually reads or writes
|
|
101
|
+
* them (confirmed by direct testing: `ReferenceError: window is not
|
|
102
|
+
* defined` under plain Node; see test/functional/oidc.test.ts O.2). A public client (`token_endpoint_auth_method:
|
|
103
|
+
* "none"`, what `registerClient` above registers) authenticates a refresh
|
|
104
|
+
* with just `client_id` in the body, no secret — this is the whole request.
|
|
105
|
+
* Works identically in Node and the browser (plain `fetch`), so both
|
|
106
|
+
* adapters share it with zero platform-specific code.
|
|
107
|
+
*/
|
|
108
|
+
export declare function refreshOidcToken(tokenEndpoint: string, clientId: string, refreshToken: string): Promise<AccessTokens>;
|
|
109
|
+
/**
|
|
110
|
+
* Builds the `tokenRefreshFunction` `TeleCryptIOStorage.createFromOidc`
|
|
111
|
+
* expects: refreshes via `refreshOidcToken`, then calls `onPersist` with the
|
|
112
|
+
* new tokens before returning them — so a caller's adapter-specific
|
|
113
|
+
* persistence (CLI: profile file; UI: localStorage) always sees a refresh
|
|
114
|
+
* that just happened, not just ones it initiated itself.
|
|
115
|
+
*/
|
|
116
|
+
export declare function buildTokenRefreshFunction(tokenEndpoint: string, clientId: string, onPersist: (tokens: {
|
|
117
|
+
accessToken: string;
|
|
118
|
+
refreshToken?: string;
|
|
119
|
+
}) => Promise<void>): TokenRefreshFunction;
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared OIDC/MAS login building blocks — used by BOTH the CLI (device-code
|
|
3
|
+
* grant) and the web UI (authorization-code + PKCE). Browser-safe (no
|
|
4
|
+
* node:fs/path/v8/process/commander/fake-indexeddb): the functions here only
|
|
5
|
+
* ever call browser-only matrix-js-sdk OIDC helpers (window.crypto for PKCE)
|
|
6
|
+
* when actually invoked from the browser adapter — importing this module
|
|
7
|
+
* under Node is safe (nothing here touches `window` at import time), calling
|
|
8
|
+
* the PKCE-only functions from Node is not (by design: the CLI never calls
|
|
9
|
+
* them, only the device-code functions, which are plain `fetch` calls).
|
|
10
|
+
*/
|
|
11
|
+
import { createClient } from "matrix-js-sdk";
|
|
12
|
+
import { registerOidcClient, generateScope, generateOidcAuthorizationUrl, completeAuthorizationCodeGrant, startDeviceAuthorization, waitForDeviceAuthorization, } from "matrix-js-sdk/lib/oidc/index.js";
|
|
13
|
+
import { CliError } from "./errors.js";
|
|
14
|
+
/**
|
|
15
|
+
* Discovers the OIDC/MAS issuer + endpoints for a homeserver, via
|
|
16
|
+
* `MatrixClient.getAuthMetadata()` (MSC2965 `/auth_metadata`, falling back to
|
|
17
|
+
* the legacy `/auth_issuer` + issuer well-known) — the non-deprecated path
|
|
18
|
+
* recommended over `discoverAndValidateOIDCIssuerWellKnown`.
|
|
19
|
+
*
|
|
20
|
+
* NODE CALLERS: this internally constructs oidc-client-ts state that
|
|
21
|
+
* requires `window.sessionStorage`/`window.localStorage` to exist (a real
|
|
22
|
+
* browser has both natively). Under plain Node, wrap this ONE call in
|
|
23
|
+
* `src/cli/oidcWindowPolyfill.ts`'s `withOidcWindowShim()` — see that file's
|
|
24
|
+
* doc comment for why it must be scoped narrowly (a permanent global
|
|
25
|
+
* `window` breaks the rust-crypto WASM's own environment detection) and why
|
|
26
|
+
* it's safe here specifically (called once, before any crypto/WASM exists).
|
|
27
|
+
*/
|
|
28
|
+
export async function discoverOidcIssuer(homeserverBaseUrl) {
|
|
29
|
+
const client = createClient({ baseUrl: homeserverBaseUrl });
|
|
30
|
+
try {
|
|
31
|
+
return await client.getAuthMetadata();
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
throw new CliError(`OIDC discovery failed: ${err.message}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Dynamic client registration (DCR). `clientUri`/`redirectUris` must share a
|
|
39
|
+
* host unless the issuer's policy allows a mismatch (our local dev/test MAS
|
|
40
|
+
* does; a production issuer may not).
|
|
41
|
+
*/
|
|
42
|
+
export async function registerClient(authMetadata, metadata) {
|
|
43
|
+
try {
|
|
44
|
+
return await registerOidcClient(authMetadata, metadata);
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
throw new CliError(`OIDC dynamic client registration failed: ${err.message}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Device-code grant (RFC 8628) — the CLI's login flow. Plain `fetch` calls
|
|
52
|
+
// under the hood (matrix-js-sdk's `startDeviceAuthorization` /
|
|
53
|
+
// `waitForDeviceAuthorization`), nothing browser-only.
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
/**
|
|
56
|
+
* Starts a device-code authorization session. `deviceId` is caller-chosen
|
|
57
|
+
* (unlike the authorization-code flow, where the SDK picks a random one) so
|
|
58
|
+
* it can be reused as this Matrix device's `device_id` throughout —
|
|
59
|
+
* `generateScope(deviceId)` embeds it in the requested scope per MSC2967.
|
|
60
|
+
*/
|
|
61
|
+
export async function startDeviceCodeLogin(authMetadata, clientId, deviceId) {
|
|
62
|
+
const scope = generateScope(deviceId);
|
|
63
|
+
return startDeviceAuthorization({ clientId, scope, metadata: authMetadata });
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Polls the token endpoint until the device-code session is approved (or
|
|
67
|
+
* denied/expired) — see matrix-js-sdk's `waitForDeviceAuthorization` for the
|
|
68
|
+
* RFC 8628 polling semantics (honours `interval`/`expires_in`,
|
|
69
|
+
* `authorization_pending`/`slow_down`).
|
|
70
|
+
*/
|
|
71
|
+
export async function waitForDeviceCodeLogin(authMetadata, clientId, session) {
|
|
72
|
+
return waitForDeviceAuthorization({ session, metadata: authMetadata, clientId });
|
|
73
|
+
}
|
|
74
|
+
export function isDeviceAccessTokenError(result) {
|
|
75
|
+
return typeof result.error === "string";
|
|
76
|
+
}
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Authorization code + PKCE — the web UI's login flow. Browser-only when
|
|
79
|
+
// actually called (uses window.crypto/window.sessionStorage internally via
|
|
80
|
+
// oidc-client-ts) — never called from the CLI.
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
/**
|
|
83
|
+
* Builds the authorization URL to redirect the browser to. PKCE
|
|
84
|
+
* verifier/state/nonce are generated and persisted to `window.sessionStorage`
|
|
85
|
+
* internally by matrix-js-sdk/oidc-client-ts (`mx_oidc_`-prefixed keys) — the
|
|
86
|
+
* caller does not need to manage them; `completeAuthorizationCodeFlow` reads
|
|
87
|
+
* them back after the redirect.
|
|
88
|
+
*/
|
|
89
|
+
export async function beginAuthorizationCodeFlow(opts) {
|
|
90
|
+
const nonce = crypto.randomUUID();
|
|
91
|
+
return generateOidcAuthorizationUrl({
|
|
92
|
+
metadata: opts.authMetadata,
|
|
93
|
+
clientId: opts.clientId,
|
|
94
|
+
homeserverUrl: opts.homeserverUrl,
|
|
95
|
+
redirectUri: opts.redirectUri,
|
|
96
|
+
nonce,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
/** Completes the authorization-code exchange after the `?code&state` redirect. */
|
|
100
|
+
export async function completeAuthorizationCodeFlow(code, state) {
|
|
101
|
+
return completeAuthorizationCodeGrant(code, state);
|
|
102
|
+
}
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Shared helpers
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
/** The device_id granted is embedded in the token response's `scope`
|
|
107
|
+
* (`urn:matrix:org.matrix.msc2967.client:device:<id>`), per MSC2967 — the
|
|
108
|
+
* authorization-code flow doesn't return it any other way (the SDK
|
|
109
|
+
* auto-generates it when the caller doesn't request a specific one). */
|
|
110
|
+
export function extractDeviceIdFromScope(scope) {
|
|
111
|
+
const match = scope.match(/urn:matrix:org\.matrix\.msc2967\.client:device:(\S+)/);
|
|
112
|
+
return match ? match[1] : null;
|
|
113
|
+
}
|
|
114
|
+
/** Confirms {userId, deviceId} for a freshly-obtained access token via
|
|
115
|
+
* `GET /_matrix/client/v3/account/whoami` — needed because neither the
|
|
116
|
+
* device-code nor authorization-code token response includes the Matrix
|
|
117
|
+
* user ID. */
|
|
118
|
+
export async function whoAmI(homeserverUrl, accessToken) {
|
|
119
|
+
const client = createClient({ baseUrl: homeserverUrl, accessToken });
|
|
120
|
+
const res = await client.whoami();
|
|
121
|
+
return { userId: res.user_id, deviceId: res.device_id ?? null };
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Refreshes an access token via a plain RFC 6749 `grant_type=refresh_token`
|
|
125
|
+
* POST to the token endpoint — deliberately NOT matrix-js-sdk's
|
|
126
|
+
* `OidcTokenRefresher`/oidc-client-ts, which unconditionally construct an
|
|
127
|
+
* internal `OidcClient` requiring `window.sessionStorage`/`window.
|
|
128
|
+
* localStorage` even though a plain refresh never actually reads or writes
|
|
129
|
+
* them (confirmed by direct testing: `ReferenceError: window is not
|
|
130
|
+
* defined` under plain Node; see test/functional/oidc.test.ts O.2). A public client (`token_endpoint_auth_method:
|
|
131
|
+
* "none"`, what `registerClient` above registers) authenticates a refresh
|
|
132
|
+
* with just `client_id` in the body, no secret — this is the whole request.
|
|
133
|
+
* Works identically in Node and the browser (plain `fetch`), so both
|
|
134
|
+
* adapters share it with zero platform-specific code.
|
|
135
|
+
*/
|
|
136
|
+
export async function refreshOidcToken(tokenEndpoint, clientId, refreshToken) {
|
|
137
|
+
const res = await fetch(tokenEndpoint, {
|
|
138
|
+
method: "POST",
|
|
139
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
140
|
+
body: new URLSearchParams({
|
|
141
|
+
grant_type: "refresh_token",
|
|
142
|
+
refresh_token: refreshToken,
|
|
143
|
+
client_id: clientId,
|
|
144
|
+
}).toString(),
|
|
145
|
+
});
|
|
146
|
+
if (!res.ok) {
|
|
147
|
+
const body = await res.text();
|
|
148
|
+
throw new CliError(`OIDC token refresh failed (${res.status}): ${body}`);
|
|
149
|
+
}
|
|
150
|
+
const data = (await res.json());
|
|
151
|
+
return {
|
|
152
|
+
accessToken: data.access_token,
|
|
153
|
+
refreshToken: data.refresh_token,
|
|
154
|
+
expiry: data.expires_in ? new Date(Date.now() + data.expires_in * 1000) : undefined,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Builds the `tokenRefreshFunction` `TeleCryptIOStorage.createFromOidc`
|
|
159
|
+
* expects: refreshes via `refreshOidcToken`, then calls `onPersist` with the
|
|
160
|
+
* new tokens before returning them — so a caller's adapter-specific
|
|
161
|
+
* persistence (CLI: profile file; UI: localStorage) always sees a refresh
|
|
162
|
+
* that just happened, not just ones it initiated itself.
|
|
163
|
+
*/
|
|
164
|
+
export function buildTokenRefreshFunction(tokenEndpoint, clientId, onPersist) {
|
|
165
|
+
return async (refreshToken) => {
|
|
166
|
+
const tokens = await refreshOidcToken(tokenEndpoint, clientId, refreshToken);
|
|
167
|
+
await onPersist({ accessToken: tokens.accessToken, refreshToken: tokens.refreshToken });
|
|
168
|
+
return tokens;
|
|
169
|
+
};
|
|
170
|
+
}
|
|
@@ -12,11 +12,20 @@
|
|
|
12
12
|
* platform-specific and stays with the caller (see `src/cli/storage.ts`).
|
|
13
13
|
*/
|
|
14
14
|
import { TeleCryptIOStorage } from "../TeleCryptIOStorage.js";
|
|
15
|
-
import type { DownloadedFile, FileInfo, FolderInfo, JoinResult, Member, RecoveryRestore, RecoverySetup, ShareResult, UnshareResult } from "./types.js";
|
|
15
|
+
import type { DownloadedFile, FileDetails, FileInfo, FolderDetails, FolderInfo, DeleteResult, JoinResult, Member, RecoveryRestore, RecoverySetup, RenameResult, ShareResult, UnshareResult } from "./types.js";
|
|
16
16
|
export declare function createFolder(storage: TeleCryptIOStorage, name: string): Promise<FolderInfo>;
|
|
17
17
|
/** Top-level folders only — excludes subdirectories of an existing tree. */
|
|
18
18
|
export declare function listFolders(storage: TeleCryptIOStorage): Promise<FolderInfo[]>;
|
|
19
|
+
/** Current account's effective role for a folder, or null while it is unavailable. */
|
|
20
|
+
export declare function getMyFolderRole(storage: TeleCryptIOStorage, folderId: string): string | null;
|
|
19
21
|
export declare function joinFolder(storage: TeleCryptIOStorage, folderId: string): Promise<JoinResult>;
|
|
22
|
+
/** Rooms where this account is invited and the room looks like a file tree. */
|
|
23
|
+
export declare function listPendingInvites(storage: TeleCryptIOStorage): Promise<FolderInfo[]>;
|
|
24
|
+
/** Decline a folder invite (same as leaving before join). */
|
|
25
|
+
export declare function declineInvite(storage: TeleCryptIOStorage, folderId: string): Promise<{
|
|
26
|
+
folderId: string;
|
|
27
|
+
declined: boolean;
|
|
28
|
+
}>;
|
|
20
29
|
/**
|
|
21
30
|
* Invites `userId` to the folder at `role` and applies the role's
|
|
22
31
|
* permissions. Doubles as "change an existing participant's role" (call
|
|
@@ -29,7 +38,15 @@ export declare function shareFolder(storage: TeleCryptIOStorage, folderId: strin
|
|
|
29
38
|
export declare function unshareFolder(storage: TeleCryptIOStorage, folderId: string, userId: string): Promise<UnshareResult>;
|
|
30
39
|
export declare function listMembers(storage: TeleCryptIOStorage, folderId: string): Promise<Member[]>;
|
|
31
40
|
export declare function listFiles(storage: TeleCryptIOStorage, folderId: string): Promise<FileInfo[]>;
|
|
41
|
+
export declare function listSubfolders(storage: TeleCryptIOStorage, folderId: string): Promise<FolderInfo[]>;
|
|
42
|
+
export declare function createSubfolder(storage: TeleCryptIOStorage, folderId: string, name: string): Promise<FolderInfo>;
|
|
43
|
+
export declare function renameFolder(storage: TeleCryptIOStorage, folderId: string, name: string): Promise<RenameResult>;
|
|
44
|
+
export declare function deleteFolder(storage: TeleCryptIOStorage, folderId: string): Promise<DeleteResult>;
|
|
45
|
+
export declare function renameFile(storage: TeleCryptIOStorage, folderId: string, fileId: string, name: string): Promise<RenameResult>;
|
|
46
|
+
export declare function deleteFile(storage: TeleCryptIOStorage, folderId: string, fileId: string): Promise<DeleteResult>;
|
|
32
47
|
export declare function uploadFile(storage: TeleCryptIOStorage, folderId: string, name: string, bytes: Uint8Array, mimetype: string): Promise<FileInfo>;
|
|
33
48
|
export declare function downloadFile(storage: TeleCryptIOStorage, folderId: string, fileId: string): Promise<DownloadedFile>;
|
|
34
49
|
export declare function setupRecovery(storage: TeleCryptIOStorage): Promise<RecoverySetup>;
|
|
35
50
|
export declare function restoreRecovery(storage: TeleCryptIOStorage, recoveryKey: string): Promise<RecoveryRestore>;
|
|
51
|
+
export declare function getFileDetails(storage: TeleCryptIOStorage, folderId: string, fileId: string): Promise<FileDetails>;
|
|
52
|
+
export declare function getFolderDetails(storage: TeleCryptIOStorage, folderId: string): Promise<FolderDetails>;
|
package/dist/core/operations.js
CHANGED
|
@@ -40,6 +40,14 @@ export async function listFolders(storage) {
|
|
|
40
40
|
const trees = await storage.listTrees();
|
|
41
41
|
return trees.filter((t) => t.isTopLevel).map((t) => ({ id: t.id, name: t.room.name }));
|
|
42
42
|
}
|
|
43
|
+
/** Current account's effective role for a folder, or null while it is unavailable. */
|
|
44
|
+
export function getMyFolderRole(storage, folderId) {
|
|
45
|
+
const userId = storage.getClient().getUserId();
|
|
46
|
+
const tree = storage.getTree(folderId);
|
|
47
|
+
if (!userId || !tree)
|
|
48
|
+
return null;
|
|
49
|
+
return tree.getPermissions(userId);
|
|
50
|
+
}
|
|
43
51
|
export async function joinFolder(storage, folderId) {
|
|
44
52
|
try {
|
|
45
53
|
await storage.getClient().joinRoom(folderId);
|
|
@@ -49,6 +57,57 @@ export async function joinFolder(storage, folderId) {
|
|
|
49
57
|
}
|
|
50
58
|
return { folderId, joined: true };
|
|
51
59
|
}
|
|
60
|
+
function roomDisplayName(storage, roomId, fallbackName) {
|
|
61
|
+
const client = storage.getClient();
|
|
62
|
+
const room = client.getRoom(roomId);
|
|
63
|
+
if (fallbackName?.trim())
|
|
64
|
+
return fallbackName.trim();
|
|
65
|
+
if (room?.name)
|
|
66
|
+
return room.name;
|
|
67
|
+
const userId = client.getUserId();
|
|
68
|
+
if (room && userId)
|
|
69
|
+
return room.getDefaultRoomName(userId);
|
|
70
|
+
return roomId;
|
|
71
|
+
}
|
|
72
|
+
/** Rooms where this account is invited and the room looks like a file tree. */
|
|
73
|
+
export async function listPendingInvites(storage) {
|
|
74
|
+
const client = storage.getClient();
|
|
75
|
+
const invites = [];
|
|
76
|
+
for (const room of client.getRooms()) {
|
|
77
|
+
if (room.getMyMembership() !== "invite")
|
|
78
|
+
continue;
|
|
79
|
+
const tree = storage.getTree(room.roomId);
|
|
80
|
+
if (tree) {
|
|
81
|
+
invites.push({
|
|
82
|
+
id: tree.id,
|
|
83
|
+
name: roomDisplayName(storage, room.roomId, tree.room.name),
|
|
84
|
+
});
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
// Invite state may not have MSC3089 tree metadata yet — accept rooms whose
|
|
88
|
+
// create event marks them as a file tree space.
|
|
89
|
+
const createEvent = room.currentState?.getStateEvents("m.room.create", "");
|
|
90
|
+
const createContent = createEvent?.getContent();
|
|
91
|
+
const roomType = createContent?.["type"];
|
|
92
|
+
if (roomType === "org.matrix.msc3088.file" || roomType === "m.space") {
|
|
93
|
+
invites.push({
|
|
94
|
+
id: room.roomId,
|
|
95
|
+
name: roomDisplayName(storage, room.roomId),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return invites;
|
|
100
|
+
}
|
|
101
|
+
/** Decline a folder invite (same as leaving before join). */
|
|
102
|
+
export async function declineInvite(storage, folderId) {
|
|
103
|
+
try {
|
|
104
|
+
await storage.getClient().leave(folderId);
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
throw new CliError(`decline failed: ${err.message}`);
|
|
108
|
+
}
|
|
109
|
+
return { folderId, declined: true };
|
|
110
|
+
}
|
|
52
111
|
/**
|
|
53
112
|
* Invites `userId` to the folder at `role` and applies the role's
|
|
54
113
|
* permissions. Doubles as "change an existing participant's role" (call
|
|
@@ -91,6 +150,37 @@ export async function listFiles(storage, folderId) {
|
|
|
91
150
|
const tree = await resolveTree(storage, folderId);
|
|
92
151
|
return tree.listFiles().map((f) => ({ id: f.id, name: f.getName() }));
|
|
93
152
|
}
|
|
153
|
+
export async function listSubfolders(storage, folderId) {
|
|
154
|
+
const tree = await resolveTree(storage, folderId);
|
|
155
|
+
return tree.getDirectories().map((d) => ({ id: d.id, name: d.room.name }));
|
|
156
|
+
}
|
|
157
|
+
export async function createSubfolder(storage, folderId, name) {
|
|
158
|
+
const tree = await resolveTree(storage, folderId);
|
|
159
|
+
const sub = await tree.createDirectory(name);
|
|
160
|
+
return { id: sub.id, name };
|
|
161
|
+
}
|
|
162
|
+
export async function renameFolder(storage, folderId, name) {
|
|
163
|
+
const tree = await resolveTree(storage, folderId);
|
|
164
|
+
await tree.setName(name);
|
|
165
|
+
return { id: folderId, name };
|
|
166
|
+
}
|
|
167
|
+
export async function deleteFolder(storage, folderId) {
|
|
168
|
+
const tree = await resolveTree(storage, folderId);
|
|
169
|
+
await tree.delete();
|
|
170
|
+
return { id: folderId, deleted: true };
|
|
171
|
+
}
|
|
172
|
+
export async function renameFile(storage, folderId, fileId, name) {
|
|
173
|
+
const tree = await resolveTree(storage, folderId);
|
|
174
|
+
const branch = await resolveFile(tree, fileId);
|
|
175
|
+
await branch.setName(name);
|
|
176
|
+
return { id: fileId, name };
|
|
177
|
+
}
|
|
178
|
+
export async function deleteFile(storage, folderId, fileId) {
|
|
179
|
+
const tree = await resolveTree(storage, folderId);
|
|
180
|
+
const branch = await resolveFile(tree, fileId);
|
|
181
|
+
await branch.delete();
|
|
182
|
+
return { id: fileId, deleted: true };
|
|
183
|
+
}
|
|
94
184
|
export async function uploadFile(storage, folderId, name, bytes, mimetype) {
|
|
95
185
|
const tree = await resolveTree(storage, folderId);
|
|
96
186
|
const fileId = await storage.uploadFile(tree, name, toArrayBuffer(bytes), mimetype);
|
|
@@ -118,3 +208,71 @@ export async function setupRecovery(storage) {
|
|
|
118
208
|
export async function restoreRecovery(storage, recoveryKey) {
|
|
119
209
|
return storage.keys.restoreFromRecoveryKey(recoveryKey);
|
|
120
210
|
}
|
|
211
|
+
function tsToIso(ts) {
|
|
212
|
+
if (ts == null || !Number.isFinite(ts))
|
|
213
|
+
return null;
|
|
214
|
+
return new Date(ts).toISOString();
|
|
215
|
+
}
|
|
216
|
+
export async function getFileDetails(storage, folderId, fileId) {
|
|
217
|
+
const tree = await resolveTree(storage, folderId);
|
|
218
|
+
const branch = await resolveFile(tree, fileId);
|
|
219
|
+
const name = branch.getName();
|
|
220
|
+
let mimetype = null;
|
|
221
|
+
let size = null;
|
|
222
|
+
let createdAt = null;
|
|
223
|
+
let updatedAt = null;
|
|
224
|
+
try {
|
|
225
|
+
const { info } = await branch.getFileInfo();
|
|
226
|
+
if (info) {
|
|
227
|
+
if (typeof info["mimetype"] === "string")
|
|
228
|
+
mimetype = info["mimetype"];
|
|
229
|
+
if (typeof info["size"] === "number")
|
|
230
|
+
size = info["size"];
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
// Partial metadata is fine — UI shows "—" for unknown fields.
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
const event = await branch.getFileEvent();
|
|
238
|
+
const content = event.getContent();
|
|
239
|
+
const infoBlock = content["info"];
|
|
240
|
+
if (!mimetype && typeof infoBlock?.["mimetype"] === "string") {
|
|
241
|
+
mimetype = infoBlock["mimetype"];
|
|
242
|
+
}
|
|
243
|
+
if (size == null && typeof infoBlock?.["size"] === "number") {
|
|
244
|
+
size = infoBlock["size"];
|
|
245
|
+
}
|
|
246
|
+
const eventAny = event;
|
|
247
|
+
const ts = eventAny.getTs?.() ?? eventAny.origin_server_ts;
|
|
248
|
+
createdAt = tsToIso(ts);
|
|
249
|
+
updatedAt = createdAt;
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
// Same as above.
|
|
253
|
+
}
|
|
254
|
+
return { name, mimetype, size, createdAt, updatedAt };
|
|
255
|
+
}
|
|
256
|
+
export async function getFolderDetails(storage, folderId) {
|
|
257
|
+
const tree = await resolveTree(storage, folderId);
|
|
258
|
+
const client = storage.getClient();
|
|
259
|
+
const room = client.getRoom(folderId);
|
|
260
|
+
let createdAt = null;
|
|
261
|
+
let memberCount = null;
|
|
262
|
+
if (room) {
|
|
263
|
+
const createEvent = room.currentState?.getStateEvents("m.room.create", "");
|
|
264
|
+
createdAt = tsToIso(createEvent?.getTs());
|
|
265
|
+
try {
|
|
266
|
+
memberCount = room.getJoinedMemberCount();
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
memberCount = null;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return {
|
|
273
|
+
name: tree.room.name || roomDisplayName(storage, folderId),
|
|
274
|
+
id: folderId,
|
|
275
|
+
createdAt,
|
|
276
|
+
memberCount,
|
|
277
|
+
};
|
|
278
|
+
}
|
package/dist/core/types.d.ts
CHANGED
|
@@ -35,6 +35,14 @@ export interface JoinResult {
|
|
|
35
35
|
folderId: string;
|
|
36
36
|
joined: boolean;
|
|
37
37
|
}
|
|
38
|
+
export interface DeleteResult {
|
|
39
|
+
id: string;
|
|
40
|
+
deleted: boolean;
|
|
41
|
+
}
|
|
42
|
+
export interface RenameResult {
|
|
43
|
+
id: string;
|
|
44
|
+
name: string;
|
|
45
|
+
}
|
|
38
46
|
/** Bytes in/out are always `Uint8Array` — never a file path. */
|
|
39
47
|
export interface DownloadedFile {
|
|
40
48
|
bytes: Uint8Array;
|
|
@@ -48,3 +56,16 @@ export interface RecoveryRestore {
|
|
|
48
56
|
imported: number;
|
|
49
57
|
total: number;
|
|
50
58
|
}
|
|
59
|
+
export interface FileDetails {
|
|
60
|
+
name: string;
|
|
61
|
+
mimetype: string | null;
|
|
62
|
+
size: number | null;
|
|
63
|
+
createdAt: string | null;
|
|
64
|
+
updatedAt: string | null;
|
|
65
|
+
}
|
|
66
|
+
export interface FolderDetails {
|
|
67
|
+
name: string;
|
|
68
|
+
id: string;
|
|
69
|
+
createdAt: string | null;
|
|
70
|
+
memberCount: number | null;
|
|
71
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@telecrypt-io/storage",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"license": "BUSL-1.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
".": {
|
|
10
10
|
"types": "./dist/index.d.ts",
|
|
11
11
|
"import": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./core": {
|
|
14
|
+
"types": "./dist/core/index.d.ts",
|
|
15
|
+
"import": "./dist/core/index.js"
|
|
12
16
|
}
|
|
13
17
|
},
|
|
14
18
|
"bin": {
|
|
@@ -33,18 +37,27 @@
|
|
|
33
37
|
"synapse:down": "./throwaway_synapse/down.sh"
|
|
34
38
|
},
|
|
35
39
|
"dependencies": {
|
|
36
|
-
"commander": "
|
|
37
|
-
"fake-indexeddb": "
|
|
40
|
+
"commander": "15.0.0",
|
|
41
|
+
"fake-indexeddb": "6.2.5",
|
|
38
42
|
"matrix-encrypt-attachment": "1.0.3",
|
|
39
43
|
"matrix-js-sdk": "41.9.0"
|
|
40
44
|
},
|
|
41
45
|
"devDependencies": {
|
|
42
|
-
"@
|
|
43
|
-
"@
|
|
44
|
-
"@typescript-eslint/
|
|
45
|
-
"eslint": "
|
|
46
|
-
"
|
|
47
|
-
"
|
|
48
|
-
"
|
|
46
|
+
"@playwright/test": "1.61.1",
|
|
47
|
+
"@types/node": "22.20.1",
|
|
48
|
+
"@typescript-eslint/eslint-plugin": "8.64.0",
|
|
49
|
+
"@typescript-eslint/parser": "8.64.0",
|
|
50
|
+
"eslint": "9.39.5",
|
|
51
|
+
"tsx": "4.23.1",
|
|
52
|
+
"typescript": "5.9.3",
|
|
53
|
+
"vitest": "2.1.9"
|
|
54
|
+
},
|
|
55
|
+
"repository": {
|
|
56
|
+
"type": "git",
|
|
57
|
+
"url": "git+https://github.com/TeleCrypt-io/secure-storage.git"
|
|
58
|
+
},
|
|
59
|
+
"homepage": "https://github.com/TeleCrypt-io/secure-storage#readme",
|
|
60
|
+
"bugs": {
|
|
61
|
+
"url": "https://github.com/TeleCrypt-io/secure-storage/issues"
|
|
49
62
|
}
|
|
50
63
|
}
|