@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 CHANGED
@@ -1,11 +1,49 @@
1
1
  # TeleCrypt.io Storage
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/@telecrypt-io/storage)](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
- **Status:** early development. Not yet usable.
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 Docker, never against a production server.
75
+ Tests run against a real local Synapse in podman, never against a production server.
38
76
 
39
- See [IMPLEMENTATION_PLAN.md](./IMPLEMENTATION_PLAN.md) for the phased build plan.
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 — see
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
- await client.initRustCrypto({
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
- await new Promise((resolve, reject) => {
40
- const timeoutMs = opts.syncTimeoutMs ?? 15000;
41
- const timeout = setTimeout(() => reject(new Error("sync timeout")), timeoutMs);
42
- client.once(ClientEvent.Sync, (state) => {
43
- clearTimeout(timeout);
44
- if (state === SyncState.Prepared || state === SyncState.Syncing) {
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
- else {
48
- reject(new Error(`unexpected sync state: ${state}`));
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 — see
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 clientAny = this.client;
251
- const baseUrl = clientAny.getHomeserverUrl();
252
- const token = clientAny.getAccessToken();
253
- const authHeaders = { Authorization: `Bearer ${token}` };
254
- const roomPath = `${baseUrl}/_matrix/client/v3/rooms/${encodeURIComponent(tree.id)}`;
255
- const [membersRes, powerLevelsRes] = await Promise.all([
256
- fetch(`${roomPath}/members`, { headers: authHeaders }),
257
- fetch(`${roomPath}/state/m.room.power_levels/`, { headers: authHeaders }),
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
- if (!membersRes.ok) {
260
- throw new Error(`listMembers: failed to fetch room members (${membersRes.status})`);
261
- }
262
- const membersBody = (await membersRes.json());
263
- const pls = powerLevelsRes.ok
264
- ? (await powerLevelsRes.json())
265
- : {};
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 (THE central challenge — see docs/CLI_SPEC.md).
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
- .requiredOption("--user <localpart>", "Username (localpart or full MXID)")
71
- .requiredOption("--password <pw>", "Password")
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
- const client = createClient({ baseUrl: opts.homeserver });
75
- let res;
76
- try {
77
- res = await client.loginWithPassword(opts.user, opts.password);
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
- catch (err) {
80
- throw new CliError(`login failed: ${err.message}`);
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>;
@@ -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
+ }
@@ -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,
@@ -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 TeleCryptIOStorage.create({
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 TeleCryptIOStorage.create({
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)
@@ -3,3 +3,4 @@
3
3
  export * from "./types.js";
4
4
  export * from "./operations.js";
5
5
  export * from "./errors.js";
6
+ export * from "./oidc.js";
@@ -3,3 +3,4 @@
3
3
  export * from "./types.js";
4
4
  export * from "./operations.js";
5
5
  export * from "./errors.js";
6
+ export * from "./oidc.js";
@@ -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>;
@@ -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
+ }
@@ -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.0",
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": "^15.0.0",
37
- "fake-indexeddb": "^6.2.5",
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
- "@types/node": "^22.0.0",
43
- "@typescript-eslint/eslint-plugin": "^8.0.0",
44
- "@typescript-eslint/parser": "^8.0.0",
45
- "eslint": "^9.0.0",
46
- "tsx": "^4.23.1",
47
- "typescript": "^5.6.0",
48
- "vitest": "^2.0.0"
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
  }