@telecrypt-io/storage 0.4.0 → 0.5.8

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.
@@ -1,13 +1,430 @@
1
- import { ClientEvent, createClient, EventType, MatrixEvent, SyncState } from "matrix-js-sdk";
1
+ import { ClientEvent, createClient, EventType, MatrixEvent, MatrixError, Preset, RoomCreateTypeField, RoomType, SyncState, UNSTABLE_MSC3088_ENABLED, UNSTABLE_MSC3088_PURPOSE, UNSTABLE_MSC3089_TREE_SUBTYPE, } from "matrix-js-sdk";
2
2
  import { Method } from "matrix-js-sdk/lib/http-api/method.js";
3
3
  import { ClientPrefix } from "matrix-js-sdk/lib/http-api/prefix.js";
4
4
  import { encryptAttachment, decryptAttachment } from "matrix-encrypt-attachment";
5
5
  import { decodeRecoveryKey } from "matrix-js-sdk/lib/crypto-api/recovery-key.js";
6
- const recoveryCallbackQueues = new WeakMap();
6
+ import { FileTooLargeError, RecoveryAlreadyConfiguredError, RecoveryRestoreError, RecoverySetupAmbiguousError, RecoverySetupError, RecoveryRestoreAmbiguousError, MutationOutcomeUnknownError, RoomCreationAmbiguousError, RoomCleanupIncompleteError, StorageError, UndecryptableFileError, } from "./core/errors.js";
7
+ import { MAX_MEDIA_FILE_BYTES, MAX_MATRIX_IDENTIFIER_LENGTH, validateCanonicalMatrixUserId, validateMatrixDeviceId, validateMatrixEventId, validateMatrixRoomId, } from "./core/constants.js";
8
+ import { raceWithAbort, readBoundedResponseBody } from "./core/http.js";
9
+ import { validateName } from "./core/validation.js";
10
+ const recoveryMutationQueues = new WeakMap();
11
+ const treeMutationQueues = new WeakMap();
12
+ const TREE_SYNC_TIMEOUT_MS = 15000;
13
+ const MEDIA_TIMEOUT_MS = 30000;
14
+ const MATRIX_HTTP_TIMEOUT_MS = 30000;
15
+ const CLEANUP_TIMEOUT_MS = 30000;
16
+ const RECOVERY_CRYPTO_TIMEOUT_MS = 60000;
17
+ const MAX_MEDIA_REDIRECTS = 5;
18
+ const MAX_MATRIX_TOKEN_LENGTH = 8192;
19
+ const MAX_MIMETYPE_LENGTH = 255;
20
+ const MAX_MATRIX_STATE_RESPONSE_BYTES = 4 * 1024 * 1024;
21
+ const MAX_MATRIX_RESPONSE_BYTES = 16 * 1024 * 1024;
22
+ const MAX_MATRIX_STATE_EVENTS = 10000;
23
+ const MAX_MATRIX_MEMBERS = 10000;
24
+ const MAX_MATRIX_JOINED_ROOMS = 4096;
25
+ function cloneCryptoCallbacks(callbacks) {
26
+ // MatrixClient retains this object for the lifetime of the client. Keep the
27
+ // caller's object immutable: recovery temporarily installs key callbacks on
28
+ // the client-owned copy, never on a shared options object that another
29
+ // client could observe concurrently.
30
+ return callbacks ? { ...callbacks } : {};
31
+ }
32
+ /** Waits for one Matrix mutation to settle without aborting the shared client.
33
+ * If cancellation arrives after the mutation starts, the outcome is explicitly
34
+ * unknown: the caller must reconcile and may safely retry idempotent work. */
35
+ export function withMatrixMutationAbort(_client, operation, signal, operationName = "Matrix mutation") {
36
+ if (!signal)
37
+ return operation();
38
+ if (signal.aborted)
39
+ return Promise.reject(new StorageError("operation cancelled"));
40
+ return new Promise((resolve, reject) => {
41
+ let settled = false;
42
+ let started = false;
43
+ let cancelled = false;
44
+ const cleanup = () => signal.removeEventListener("abort", onAbort);
45
+ const finish = (fn) => {
46
+ if (settled)
47
+ return;
48
+ settled = true;
49
+ cleanup();
50
+ fn();
51
+ };
52
+ const onAbort = () => {
53
+ // MatrixClient owns one HTTP abort controller for all of its requests;
54
+ // aborting it here would cancel unrelated work. Wait for this request's
55
+ // own promise, then report the outcome as unknown if it was in flight.
56
+ if (started)
57
+ cancelled = true;
58
+ };
59
+ signal.addEventListener("abort", onAbort, { once: true });
60
+ let pending;
61
+ started = true;
62
+ try {
63
+ pending = operation();
64
+ }
65
+ catch (error) {
66
+ finish(() => reject(cancelled ? new MutationOutcomeUnknownError(operationName) : error));
67
+ return;
68
+ }
69
+ pending.then((value) => finish(() => (cancelled ? reject(new MutationOutcomeUnknownError(operationName)) : resolve(value))), (error) => finish(() => reject(cancelled ? new MutationOutcomeUnknownError(operationName) : error)));
70
+ });
71
+ }
72
+ async function readBoundedMatrixResponse(response, maxBytes, signal, abortError) {
73
+ const body = await readBoundedResponseBody(response, maxBytes, signal, {
74
+ abortError,
75
+ deferReaderRelease: true,
76
+ });
77
+ if (body.truncated)
78
+ throw new Error("Matrix response body is too large");
79
+ if (!response.body)
80
+ return response;
81
+ return new Response(body.bytes, {
82
+ status: response.status,
83
+ statusText: response.statusText,
84
+ headers: response.headers,
85
+ });
86
+ }
87
+ function isMatrixStateOrMembershipUrl(input) {
88
+ const url = typeof input === "string"
89
+ ? new URL(input, "https://matrix.invalid")
90
+ : input instanceof URL
91
+ ? input
92
+ : new URL(input.url, "https://matrix.invalid");
93
+ return /\/rooms\/[^/]+\/(?:state|members)(?:\/|$)/.test(url.pathname);
94
+ }
95
+ function boundedMatrixFetch(fetchFn) {
96
+ const wrapped = ((input, init) => {
97
+ const pending = (async () => {
98
+ const controller = new AbortController();
99
+ const externalSignal = init?.signal;
100
+ const abortExternal = () => controller.abort(externalSignal?.reason);
101
+ let timedOut = false;
102
+ const timeout = setTimeout(() => {
103
+ timedOut = true;
104
+ controller.abort(new Error("Matrix request timed out"));
105
+ }, MATRIX_HTTP_TIMEOUT_MS);
106
+ if (externalSignal?.aborted)
107
+ abortExternal();
108
+ else
109
+ externalSignal?.addEventListener("abort", abortExternal, { once: true });
110
+ try {
111
+ const response = await raceWithAbort(fetchFn(input, { ...init, redirect: "manual", signal: controller.signal }), controller.signal, () => undefined, () => (timedOut ? new Error("Matrix request timed out") : new DOMException("The operation was aborted", "AbortError")));
112
+ if (response.redirected ||
113
+ response.type === "opaqueredirect" ||
114
+ (response.status >= 300 && response.status < 400)) {
115
+ response.body?.cancel().catch(() => undefined);
116
+ throw new Error("Matrix redirect rejected");
117
+ }
118
+ return await readBoundedMatrixResponse(response, isMatrixStateOrMembershipUrl(input)
119
+ ? MAX_MATRIX_STATE_RESPONSE_BYTES
120
+ : MAX_MATRIX_RESPONSE_BYTES, controller.signal, () => (timedOut ? new Error("Matrix request timed out") : new DOMException("The operation was aborted", "AbortError")));
121
+ }
122
+ finally {
123
+ clearTimeout(timeout);
124
+ externalSignal?.removeEventListener("abort", abortExternal);
125
+ }
126
+ })();
127
+ // Preserve fetch-style rejection for callers while observing an immediate
128
+ // abort until the caller has had a chance to attach its own handler.
129
+ void pending.catch(() => undefined);
130
+ return pending;
131
+ });
132
+ return wrapped;
133
+ }
134
+ function matrixRequestTimeout(options) {
135
+ const value = options?.timeoutMs ?? options?.localTimeoutMs ?? MATRIX_HTTP_TIMEOUT_MS;
136
+ if (!Number.isFinite(value) || value <= 0)
137
+ throw new Error("invalid Matrix request timeout");
138
+ return Math.min(value, MATRIX_HTTP_TIMEOUT_MS);
139
+ }
140
+ function matrixRequestSignal(options) {
141
+ return options?.signal ?? options?.abortSignal;
142
+ }
143
+ function requireMatrixHttpTransport(client) {
144
+ const http = client.http;
145
+ if (!http || typeof http.authedRequest !== "function") {
146
+ throw new StorageError("Matrix HTTP transport unavailable");
147
+ }
148
+ return http;
149
+ }
150
+ function isLoopbackHostname(hostname) {
151
+ const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
152
+ return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1";
153
+ }
154
+ function validateHomeserverUrl(value) {
155
+ if (typeof value !== "string" ||
156
+ value.length === 0 ||
157
+ value.length > 2048 ||
158
+ value.trim() !== value ||
159
+ /[\s\u0000-\u001f\u007f]/.test(value)) {
160
+ throw new Error("invalid Matrix homeserver URL");
161
+ }
162
+ let url;
163
+ try {
164
+ url = new URL(value);
165
+ }
166
+ catch {
167
+ throw new Error("invalid Matrix homeserver URL");
168
+ }
169
+ if ((url.protocol !== "http:" && url.protocol !== "https:") ||
170
+ (url.protocol === "http:" && !isLoopbackHostname(url.hostname)) ||
171
+ !url.hostname ||
172
+ url.username ||
173
+ url.password ||
174
+ url.search ||
175
+ url.hash) {
176
+ throw new Error("invalid Matrix homeserver URL");
177
+ }
178
+ return url;
179
+ }
180
+ function validateMatrixToken(value) {
181
+ if (typeof value !== "string" ||
182
+ value.length === 0 ||
183
+ value.length > MAX_MATRIX_TOKEN_LENGTH ||
184
+ value.trim() !== value ||
185
+ /[\s\u0000-\u001f\u007f]/.test(value)) {
186
+ throw new Error("invalid Matrix token");
187
+ }
188
+ }
189
+ function validateMatrixIdentifier(value, name) {
190
+ if (typeof value !== "string" ||
191
+ value.length === 0 ||
192
+ value.length > MAX_MATRIX_IDENTIFIER_LENGTH ||
193
+ /[\s\u0000-\u001f\u007f]/.test(value)) {
194
+ throw new Error(`invalid Matrix ${name}`);
195
+ }
196
+ }
197
+ function validateFileName(value) {
198
+ validateName(value, "file name");
199
+ }
200
+ function validateMimetype(value) {
201
+ if (typeof value !== "string" ||
202
+ value.length === 0 ||
203
+ value.length > MAX_MIMETYPE_LENGTH ||
204
+ /[\u0000-\u001f\u007f]/.test(value) ||
205
+ !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+\/[!#$%&'*+.^_`|~0-9A-Za-z-]+(?:\s*;\s*[!#$%&'*+.^_`|~0-9A-Za-z-]+=[^;\r\n]+)*$/.test(value)) {
206
+ throw new Error("invalid MIME type");
207
+ }
208
+ }
209
+ function isRecord(value) {
210
+ return typeof value === "object" && value !== null && !Array.isArray(value);
211
+ }
212
+ function validateSecretStorageStatus(value) {
213
+ if (!isRecord(value))
214
+ throw new RecoverySetupAmbiguousError();
215
+ const defaultKeyId = value.defaultKeyId;
216
+ if (defaultKeyId !== null && (typeof defaultKeyId !== "string" || defaultKeyId.length === 0)) {
217
+ throw new RecoverySetupAmbiguousError();
218
+ }
219
+ if (typeof value.ready !== "boolean")
220
+ throw new RecoverySetupAmbiguousError();
221
+ return { defaultKeyId, ready: value.ready };
222
+ }
223
+ function validateBackupVersion(value) {
224
+ if (value !== null && (typeof value !== "string" || value.length === 0)) {
225
+ throw new RecoverySetupAmbiguousError();
226
+ }
227
+ return value;
228
+ }
229
+ function validateCrossSigningStatus(value) {
230
+ if (!isRecord(value) || typeof value.publicKeysOnDevice !== "boolean" ||
231
+ typeof value.privateKeysInSecretStorage !== "boolean" ||
232
+ !isRecord(value.privateKeysCachedLocally)) {
233
+ throw new RecoverySetupAmbiguousError();
234
+ }
235
+ const cached = value.privateKeysCachedLocally;
236
+ if (typeof cached.masterKey !== "boolean" ||
237
+ typeof cached.selfSigningKey !== "boolean" ||
238
+ typeof cached.userSigningKey !== "boolean") {
239
+ throw new RecoverySetupAmbiguousError();
240
+ }
241
+ return {
242
+ publicKeysOnDevice: value.publicKeysOnDevice,
243
+ privateKeysCachedLocally: {
244
+ masterKey: cached.masterKey,
245
+ selfSigningKey: cached.selfSigningKey,
246
+ userSigningKey: cached.userSigningKey,
247
+ },
248
+ privateKeysInSecretStorage: value.privateKeysInSecretStorage,
249
+ };
250
+ }
251
+ class RecoveryCryptoTimeoutError extends Error {
252
+ constructor(operation) {
253
+ super(`recovery ${operation} timed out`);
254
+ this.name = "RecoveryCryptoTimeoutError";
255
+ }
256
+ }
257
+ /**
258
+ * matrix-js-sdk's CryptoApi methods do not currently accept an AbortSignal.
259
+ * Keep that implementation detail behind one bounded adapter so a hung WASM
260
+ * or transport call cannot outlive the public recovery operation indefinitely.
261
+ * The underlying promise is still observed after cancellation; some crypto
262
+ * calls are one-way and must be treated as ambiguous by their caller.
263
+ */
264
+ async function withRecoveryCryptoDeadline(operation, signal, operationName) {
265
+ if (signal?.aborted)
266
+ return Promise.reject(new StorageError("operation cancelled"));
267
+ const controller = new AbortController();
268
+ let timedOut = false;
269
+ const timeout = setTimeout(() => {
270
+ timedOut = true;
271
+ controller.abort(new Error("recovery crypto deadline exceeded"));
272
+ }, RECOVERY_CRYPTO_TIMEOUT_MS);
273
+ const abortExternal = () => controller.abort(signal?.reason);
274
+ if (signal?.aborted)
275
+ abortExternal();
276
+ else
277
+ signal?.addEventListener("abort", abortExternal, { once: true });
278
+ const pending = Promise.resolve().then(operation);
279
+ try {
280
+ return await raceWithAbort(pending, controller.signal, () => undefined, () => timedOut
281
+ ? new RecoveryCryptoTimeoutError(operationName)
282
+ : new StorageError("operation cancelled"));
283
+ }
284
+ finally {
285
+ clearTimeout(timeout);
286
+ signal?.removeEventListener("abort", abortExternal);
287
+ }
288
+ }
289
+ function parseMatrixMembersResponse(value) {
290
+ if (!isRecord(value) || !Array.isArray(value.chunk)) {
291
+ throw new Error("invalid Matrix members response");
292
+ }
293
+ if (value.chunk.length > MAX_MATRIX_MEMBERS) {
294
+ throw new Error("Matrix members response is too large");
295
+ }
296
+ return value.chunk.map((entry) => {
297
+ if (!isRecord(entry) ||
298
+ typeof entry.state_key !== "string" ||
299
+ !isRecord(entry.content) ||
300
+ typeof entry.content.membership !== "string") {
301
+ throw new Error("invalid Matrix members response");
302
+ }
303
+ validateMatrixIdentifier(entry.state_key, "member ID");
304
+ validateMatrixIdentifier(entry.content.membership, "membership");
305
+ return {
306
+ state_key: entry.state_key,
307
+ content: { membership: entry.content.membership },
308
+ };
309
+ });
310
+ }
311
+ function parseMatrixStateResponse(value) {
312
+ if (!Array.isArray(value))
313
+ throw new Error("invalid Matrix room state response");
314
+ if (value.length > MAX_MATRIX_STATE_EVENTS) {
315
+ throw new Error("Matrix room state response is too large");
316
+ }
317
+ return value.map((event) => {
318
+ if (!isRecord(event) ||
319
+ typeof event.type !== "string" ||
320
+ typeof event.state_key !== "string" ||
321
+ !isRecord(event.content)) {
322
+ throw new Error("invalid Matrix room state response");
323
+ }
324
+ validateMatrixIdentifier(event.type, "state event type");
325
+ if (event.state_key.length > MAX_MATRIX_IDENTIFIER_LENGTH ||
326
+ /[\s\u0000-\u001f\u007f]/.test(event.state_key)) {
327
+ throw new Error("invalid Matrix state event key");
328
+ }
329
+ return event;
330
+ });
331
+ }
332
+ function parseMatrixPowerLevels(value) {
333
+ if (!isRecord(value))
334
+ throw new Error("invalid Matrix power-level response");
335
+ for (const name of ["users_default", "events_default"]) {
336
+ const field = value[name];
337
+ if (field !== undefined && (typeof field !== "number" || !Number.isFinite(field))) {
338
+ throw new Error("invalid Matrix power-level response");
339
+ }
340
+ }
341
+ for (const name of ["events", "users"]) {
342
+ const field = value[name];
343
+ if (field === undefined)
344
+ continue;
345
+ if (!isRecord(field))
346
+ throw new Error("invalid Matrix power-level response");
347
+ if (Object.keys(field).length > MAX_MATRIX_MEMBERS) {
348
+ throw new Error("Matrix power-level response is too large");
349
+ }
350
+ for (const key of Object.keys(field))
351
+ validateMatrixIdentifier(key, `${name} key`);
352
+ for (const level of Object.values(field)) {
353
+ if (typeof level !== "number" || !Number.isFinite(level)) {
354
+ throw new Error("invalid Matrix power-level response");
355
+ }
356
+ }
357
+ }
358
+ return value;
359
+ }
360
+ function parseJoinedRoomsResponse(value) {
361
+ if (!isRecord(value) ||
362
+ !Array.isArray(value.joined_rooms) ||
363
+ value.joined_rooms.length > MAX_MATRIX_JOINED_ROOMS) {
364
+ throw new Error("invalid Matrix joined-room response");
365
+ }
366
+ return value.joined_rooms.map((roomId) => {
367
+ try {
368
+ return validateMatrixRoomId(roomId, "joined-room response");
369
+ }
370
+ catch {
371
+ throw new Error("invalid Matrix joined-room response");
372
+ }
373
+ });
374
+ }
375
+ function isGoneRoomError(error) {
376
+ return (error instanceof MatrixError &&
377
+ (error.errcode === "M_NOT_FOUND" || error.errcode === "M_UNKNOWN"));
378
+ }
379
+ function throwWithCleanupDetail(error, roomId) {
380
+ const cleanup = new RoomCleanupIncompleteError(roomId);
381
+ if (error instanceof Error) {
382
+ try {
383
+ Object.defineProperty(error, "cleanupIncomplete", {
384
+ value: true,
385
+ enumerable: false,
386
+ });
387
+ Object.defineProperty(error, "cleanupError", {
388
+ value: cleanup,
389
+ enumerable: false,
390
+ });
391
+ }
392
+ catch {
393
+ // Preserve the original mutation error even if an unusual Error object
394
+ // is non-extensible; the cleanup detail remains in the cause chain only
395
+ // when it could be attached safely.
396
+ }
397
+ throw error;
398
+ }
399
+ throw cleanup;
400
+ }
401
+ /**
402
+ * Serializes all tree mutations for one MatrixClient. A single queue is
403
+ * intentional: separate per-room locks can deadlock when a parent mutation
404
+ * needs to inspect or update a child while another operation does the inverse.
405
+ * Each invocation still creates its own room; Matrix has no server-side
406
+ * room-creation idempotency key for MSC3089.
407
+ */
408
+ export async function withTreeMutation(client, operation, signal) {
409
+ const previous = treeMutationQueues.get(client) ?? Promise.resolve();
410
+ const run = previous.catch(() => undefined).then(() => {
411
+ if (signal?.aborted)
412
+ throw new StorageError("operation cancelled");
413
+ return operation(signal);
414
+ });
415
+ const settled = run.then(() => undefined, () => undefined);
416
+ treeMutationQueues.set(client, settled);
417
+ return run;
418
+ }
7
419
  export class TeleCryptIOStorage {
8
420
  client;
421
+ decoratedTreeSpaces = new WeakSet();
9
422
  constructor(client) {
10
423
  this.client = client;
424
+ // Advanced callers may construct a MatrixClient themselves. The SDK does
425
+ // not mutate matrix-js-sdk internals: configure that client with the
426
+ // supported createClient({ fetchFn, localTimeoutMs }) options, or use
427
+ // TeleCryptIOStorage.create()/createFromOidc().
11
428
  }
12
429
  /** The underlying matrix-js-sdk client (e.g. to stop it, or for advanced/interop use). */
13
430
  getClient() {
@@ -19,12 +436,46 @@ export class TeleCryptIOStorage {
19
436
  * otherwise list a stale state snapshot immediately after another process
20
437
  * changed a file name or branch.
21
438
  */
22
- async refreshRoomState(roomId) {
439
+ async refreshRoomState(roomId, options) {
23
440
  const room = this.client.getRoom(roomId);
24
441
  if (!room)
25
- throw new Error(`Unknown room: ${roomId}`);
26
- const stateEvents = await this.client.roomState(roomId);
27
- room.currentState.setStateEvents(stateEvents.map((event) => new MatrixEvent(event)));
442
+ throw new Error("unknown room");
443
+ const http = requireMatrixHttpTransport(this.client);
444
+ const stateEvents = await http.authedRequest(Method.Get, `/rooms/${encodeURIComponent(roomId)}/state`, undefined, undefined, {
445
+ prefix: ClientPrefix.V3,
446
+ localTimeoutMs: matrixRequestTimeout(options),
447
+ abortSignal: matrixRequestSignal(options),
448
+ });
449
+ const parsedStateEvents = parseMatrixStateResponse(stateEvents);
450
+ if (typeof room.currentState.setStateEvents === "function") {
451
+ const refreshed = parsedStateEvents.map((event) => new MatrixEvent(event));
452
+ // RoomState.setStateEvents overwrites matching tuples, but deliberately
453
+ // retains tuples absent from the input. Replace those stale local
454
+ // events with empty-content state events so a refresh is an exact server
455
+ // snapshot instead of a union with old state. This is important for
456
+ // relation checks: a stale child/parent link must not authorize a
457
+ // destructive operation.
458
+ const currentEvents = room.currentState.events;
459
+ if (currentEvents instanceof Map) {
460
+ const present = new Set(refreshed.map((event) => `${event.getType()}\u0000${event.getStateKey() ?? ""}`));
461
+ for (const [eventType, byStateKey] of currentEvents) {
462
+ for (const [stateKey, event] of byStateKey) {
463
+ if (present.has(`${eventType}\u0000${stateKey}`))
464
+ continue;
465
+ if (refreshed.length >= MAX_MATRIX_STATE_EVENTS) {
466
+ throw new Error("Matrix room state response is too large");
467
+ }
468
+ const original = event.getEffectiveEvent();
469
+ refreshed.push(new MatrixEvent({
470
+ ...original,
471
+ content: {},
472
+ unsigned: { ...original.unsigned },
473
+ }));
474
+ }
475
+ }
476
+ }
477
+ room.currentState.setStateEvents(refreshed);
478
+ }
28
479
  }
29
480
  /**
30
481
  * Recommended entry point: builds the MatrixClient with a persistent crypto
@@ -37,14 +488,21 @@ export class TeleCryptIOStorage {
37
488
  * construction time (see `cryptoCallbacks` above for why: matrix-js-sdk
38
489
  * captures a single callbacks object at MatrixClient construction, so it
39
490
  * must exist before `initRustCrypto` runs, not be added afterwards).
40
- */
491
+ */
41
492
  static async create(opts) {
493
+ const baseUrl = validateHomeserverUrl(opts.baseUrl).toString();
494
+ validateCanonicalMatrixUserId(opts.userId, opts.serverName);
495
+ validateMatrixDeviceId(opts.deviceId);
496
+ validateMatrixToken(opts.accessToken);
497
+ TeleCryptIOStorage.throwIfAborted(opts.signal);
42
498
  const client = createClient({
43
- baseUrl: opts.baseUrl,
499
+ baseUrl,
44
500
  userId: opts.userId,
45
501
  accessToken: opts.accessToken,
46
502
  deviceId: opts.deviceId,
47
- cryptoCallbacks: opts.cryptoCallbacks ?? {},
503
+ fetchFn: boundedMatrixFetch(globalThis.fetch.bind(globalThis)),
504
+ localTimeoutMs: MATRIX_HTTP_TIMEOUT_MS,
505
+ cryptoCallbacks: cloneCryptoCallbacks(opts.cryptoCallbacks),
48
506
  });
49
507
  return TeleCryptIOStorage.bootstrap(client, opts);
50
508
  }
@@ -52,19 +510,28 @@ export class TeleCryptIOStorage {
52
510
  * As `create()`, but the resulting `MatrixClient` was authenticated via
53
511
  * OIDC/MAS (device-code or authorization-code+PKCE — see
54
512
  * `src/core/oidc.ts`) rather than `m.login.password`. If `refreshToken` +
55
- * `tokenRefresher` are both given, the client's `tokenRefreshFunction` is
513
+ * `tokenRefreshFunction` are both given, the client's `tokenRefreshFunction` is
56
514
  * wired so an expired access token is transparently refreshed mid-request
57
515
  * — same mechanism matrix-js-sdk uses for any refresh-token-capable login.
58
- */
516
+ */
59
517
  static async createFromOidc(opts) {
518
+ const baseUrl = validateHomeserverUrl(opts.baseUrl).toString();
519
+ validateCanonicalMatrixUserId(opts.userId, opts.serverName);
520
+ validateMatrixDeviceId(opts.deviceId);
521
+ validateMatrixToken(opts.accessToken);
522
+ if (opts.refreshToken !== undefined)
523
+ validateMatrixToken(opts.refreshToken);
524
+ TeleCryptIOStorage.throwIfAborted(opts.signal);
60
525
  const client = createClient({
61
- baseUrl: opts.baseUrl,
526
+ baseUrl,
62
527
  userId: opts.userId,
63
528
  accessToken: opts.accessToken,
64
529
  deviceId: opts.deviceId,
65
530
  refreshToken: opts.refreshToken,
66
531
  tokenRefreshFunction: opts.refreshToken && opts.tokenRefreshFunction ? opts.tokenRefreshFunction : undefined,
67
- cryptoCallbacks: opts.cryptoCallbacks ?? {},
532
+ fetchFn: boundedMatrixFetch(globalThis.fetch.bind(globalThis)),
533
+ localTimeoutMs: MATRIX_HTTP_TIMEOUT_MS,
534
+ cryptoCallbacks: cloneCryptoCallbacks(opts.cryptoCallbacks),
68
535
  });
69
536
  return TeleCryptIOStorage.bootstrap(client, opts);
70
537
  }
@@ -73,52 +540,128 @@ export class TeleCryptIOStorage {
73
540
  static async bootstrap(client, opts) {
74
541
  const progress = opts.onProgress ?? (() => { });
75
542
  const persistent = opts.persistentCryptoStore ?? true;
76
- progress("Loading encryption engine (WASM)…");
77
- await TeleCryptIOStorage.withTimeout(client.initRustCrypto({
78
- useIndexedDB: persistent,
79
- cryptoDatabasePrefix: opts.cryptoDatabasePrefix ?? `telecrypt-io-storage::${opts.userId}::${opts.deviceId}`,
80
- }), opts.initTimeoutMs ?? 60000, "crypto init");
81
- progress("Encryption ready — opening secure store…");
82
- progress("Starting Matrix client…");
83
- await client.startClient({ initialSyncLimit: opts.initialSyncLimit ?? 10 });
84
- progress("Waiting for first sync with homeserver…");
85
- await TeleCryptIOStorage.waitForFirstSync(client, opts.syncTimeoutMs ?? 15000);
86
- progress("Sync complete.");
87
- return new TeleCryptIOStorage(client);
88
- }
89
- static withTimeout(promise, ms, label) {
90
- return Promise.race([
91
- promise,
92
- new Promise((_, reject) => {
93
- setTimeout(() => reject(new Error(`${label} timeout after ${ms}ms`)), ms);
94
- }),
95
- ]);
543
+ try {
544
+ progress("Loading encryption engine (WASM)…");
545
+ await TeleCryptIOStorage.withTimeout(client.initRustCrypto({
546
+ useIndexedDB: persistent,
547
+ cryptoDatabasePrefix: opts.cryptoDatabasePrefix ?? `telecrypt-io-storage::${opts.userId}::${opts.deviceId}`,
548
+ }), opts.initTimeoutMs ?? 60000, "crypto init", opts.signal);
549
+ progress("Encryption ready — opening secure store…");
550
+ progress("Starting Matrix client…");
551
+ // Mark this before awaiting: startClient can start its sync loop and then
552
+ // reject while reporting an error from the initial request.
553
+ await TeleCryptIOStorage.withTimeout(client.startClient({ initialSyncLimit: opts.initialSyncLimit ?? 10 }), opts.syncTimeoutMs ?? 15000, "client start", opts.signal);
554
+ progress("Waiting for first sync with homeserver…");
555
+ await TeleCryptIOStorage.waitForFirstSync(client, opts.syncTimeoutMs ?? 15000, opts.signal);
556
+ TeleCryptIOStorage.throwIfAborted(opts.signal);
557
+ progress("Sync complete.");
558
+ return new TeleCryptIOStorage(client);
559
+ }
560
+ catch (error) {
561
+ // stopClient is idempotent in matrix-js-sdk and is also required after
562
+ // init/timeout failures: crypto startup can have installed listeners or
563
+ // a sync task before the awaited operation rejects.
564
+ try {
565
+ client.stopClient();
566
+ }
567
+ catch {
568
+ // Preserve the original bootstrap failure.
569
+ }
570
+ throw error;
571
+ }
572
+ }
573
+ static throwIfAborted(signal) {
574
+ if (signal?.aborted)
575
+ throw new Error("storage bootstrap cancelled");
576
+ }
577
+ static withTimeout(promise, ms, label, signal) {
578
+ return new Promise((resolve, reject) => {
579
+ let settled = false;
580
+ const cleanup = () => {
581
+ clearTimeout(timer);
582
+ signal?.removeEventListener("abort", onAbort);
583
+ };
584
+ const settle = (fn) => {
585
+ if (settled)
586
+ return;
587
+ settled = true;
588
+ cleanup();
589
+ fn();
590
+ };
591
+ const onAbort = () => settle(() => reject(new Error("storage bootstrap cancelled")));
592
+ const timer = setTimeout(() => {
593
+ settle(() => reject(new Error(`${label} timeout after ${ms}ms`)));
594
+ }, ms);
595
+ signal?.addEventListener("abort", onAbort, { once: true });
596
+ if (signal?.aborted) {
597
+ onAbort();
598
+ return;
599
+ }
600
+ promise.then((value) => settle(() => resolve(value)), (error) => settle(() => reject(error)));
601
+ });
602
+ }
603
+ /** Bounds a Matrix mutation without cancelling a shared client's other
604
+ * requests. A timeout means the server may have committed the mutation. */
605
+ static withMutationTimeout(promise, ms, operation) {
606
+ return new Promise((resolve, reject) => {
607
+ let settled = false;
608
+ const timer = setTimeout(() => {
609
+ if (settled)
610
+ return;
611
+ settled = true;
612
+ reject(new MutationOutcomeUnknownError(operation));
613
+ }, ms);
614
+ promise.then((value) => {
615
+ if (settled)
616
+ return;
617
+ settled = true;
618
+ clearTimeout(timer);
619
+ resolve(value);
620
+ }, (error) => {
621
+ if (settled)
622
+ return;
623
+ settled = true;
624
+ clearTimeout(timer);
625
+ reject(error);
626
+ });
627
+ });
96
628
  }
97
629
  /** Wait until the client reaches Prepared/Syncing. Ignores transient
98
630
  * pre-sync states instead of rejecting on the first Sync event (which can
99
631
  * race `startClient`), and handles the case where sync already completed
100
632
  * before the listener was attached. */
101
- static waitForFirstSync(client, timeoutMs) {
633
+ static waitForFirstSync(client, timeoutMs, signal) {
634
+ TeleCryptIOStorage.throwIfAborted(signal);
102
635
  const ready = (state) => state === SyncState.Prepared || state === SyncState.Syncing;
103
636
  if (ready(client.getSyncState())) {
104
637
  return Promise.resolve();
105
638
  }
106
639
  return new Promise((resolve, reject) => {
107
- const onSync = (state) => {
108
- if (ready(state)) {
109
- cleanup();
110
- resolve();
111
- }
112
- };
640
+ let settled = false;
113
641
  const cleanup = () => {
114
642
  clearTimeout(timeout);
115
643
  client.removeListener(ClientEvent.Sync, onSync);
644
+ signal?.removeEventListener("abort", onAbort);
116
645
  };
117
- const timeout = setTimeout(() => {
646
+ const settle = (fn) => {
647
+ if (settled)
648
+ return;
649
+ settled = true;
118
650
  cleanup();
119
- reject(new Error("sync timeout"));
651
+ fn();
652
+ };
653
+ const onSync = (state) => {
654
+ if (ready(state))
655
+ settle(() => resolve());
656
+ };
657
+ const onAbort = () => settle(() => reject(new Error("storage bootstrap cancelled")));
658
+ const timeout = setTimeout(() => {
659
+ settle(() => reject(new Error("sync timeout")));
120
660
  }, timeoutMs);
121
661
  client.on(ClientEvent.Sync, onSync);
662
+ signal?.addEventListener("abort", onAbort, { once: true });
663
+ if (signal?.aborted)
664
+ onAbort();
122
665
  });
123
666
  }
124
667
  requireCrypto() {
@@ -135,26 +678,49 @@ export class TeleCryptIOStorage {
135
678
  * failure. Requires that the client was constructed with a cryptoCallbacks
136
679
  * object in the first place (see `create()`'s doc comment).
137
680
  */
138
- async withSecretStorageKey(privateKey, fn) {
139
- const previous = recoveryCallbackQueues.get(this.client) ?? Promise.resolve();
140
- const run = previous.then(async () => {
141
- const callbacks = this.client.cryptoCallbacks;
142
- const prevGetKey = callbacks.getSecretStorageKey;
143
- const prevCache = callbacks.cacheSecretStorageKey;
144
- callbacks.getSecretStorageKey = async ({ keys }) => {
145
- const keyId = Object.keys(keys)[0];
146
- return [keyId, privateKey];
147
- };
148
- callbacks.cacheSecretStorageKey = () => { };
149
- try {
150
- return await fn();
151
- }
152
- finally {
153
- callbacks.getSecretStorageKey = prevGetKey;
154
- callbacks.cacheSecretStorageKey = prevCache;
681
+ async withSecretStorageKey(privateKey, fn, signal) {
682
+ const crypto = this.requireCrypto();
683
+ const rawStatus = await withRecoveryCryptoDeadline(() => crypto.getSecretStorageStatus(), signal, "secret-storage status");
684
+ const status = validateSecretStorageStatus(rawStatus);
685
+ // A restore must use the account's current default key. During setup the
686
+ // default does not exist yet, so the SDK permits exactly one requested key
687
+ // and pins that ID for the remainder of the operation. Any ambiguous or
688
+ // unrelated request fails closed instead of handing the same private key
689
+ // to an arbitrary secret-storage slot.
690
+ let selectedKeyId = status.defaultKeyId ?? undefined;
691
+ const callbacks = this.client.cryptoCallbacks;
692
+ const prevGetKey = callbacks.getSecretStorageKey;
693
+ const prevCache = callbacks.cacheSecretStorageKey;
694
+ callbacks.getSecretStorageKey = async ({ keys }) => {
695
+ const keyIds = Object.keys(keys);
696
+ if (keyIds.length === 0)
697
+ return null;
698
+ if (selectedKeyId === undefined) {
699
+ if (keyIds.length !== 1)
700
+ return null;
701
+ selectedKeyId = keyIds[0];
155
702
  }
703
+ if (!Object.prototype.hasOwnProperty.call(keys, selectedKeyId))
704
+ return null;
705
+ return [selectedKeyId, privateKey];
706
+ };
707
+ callbacks.cacheSecretStorageKey = () => { };
708
+ try {
709
+ return await fn();
710
+ }
711
+ finally {
712
+ callbacks.getSecretStorageKey = prevGetKey;
713
+ callbacks.cacheSecretStorageKey = prevCache;
714
+ }
715
+ }
716
+ async withRecoveryMutation(operation, signal) {
717
+ const previous = recoveryMutationQueues.get(this.client) ?? Promise.resolve();
718
+ const run = previous.catch(() => undefined).then(() => {
719
+ if (signal?.aborted)
720
+ throw new StorageError("operation cancelled");
721
+ return operation(signal);
156
722
  });
157
- recoveryCallbackQueues.set(this.client, run.then(() => undefined, () => undefined));
723
+ recoveryMutationQueues.set(this.client, run.then(() => undefined, () => undefined));
158
724
  return run;
159
725
  }
160
726
  /**
@@ -162,50 +728,231 @@ export class TeleCryptIOStorage {
162
728
  * key backup, and returns the Recovery Key for the caller to show the user.
163
729
  * This is what makes `restoreFromRecoveryKey` on another device possible.
164
730
  */
165
- async keysSetupRecovery() {
731
+ async keysSetupRecovery(signal) {
732
+ return this.withRecoveryMutation((queuedSignal) => this.performKeysSetupRecovery(queuedSignal), signal);
733
+ }
734
+ async performKeysSetupRecovery(signal) {
735
+ if (signal?.aborted)
736
+ throw new StorageError("operation cancelled");
166
737
  const crypto = this.requireCrypto();
167
- // If this account already has secret storage configured (e.g. from a
168
- // previous setup on another device), we cannot unlock it here: the SDK
169
- // has no `getSecretStorageKey` callback wired yet and the old recovery
170
- // key is not available, so `bootstrapCrossSigning` would try to read the
171
- // existing 4S and fail with "No getSecretStorageKey callback supplied".
172
- // Reset the account's 4S + key backup so we can bootstrap a fresh,
173
- // self-consistent recovery state (new cross-signing keys, new 4S key,
174
- // new backup) and hand the user a working recovery key.
175
- const status = await crypto.getSecretStorageStatus();
176
- if (status.defaultKeyId) {
177
- await crypto.disableKeyStorage();
178
- }
179
- await crypto.bootstrapCrossSigning({
180
- // No existing verified device to interactively re-authenticate against
181
- // for this account, so there is nothing to feed into `makeRequest`;
182
- // matches the working pattern already proven in keys.test.ts.
183
- authUploadDeviceSigningKeys: async () => undefined,
184
- });
185
- const generated = await crypto.createRecoveryKeyFromPassphrase();
738
+ // Recovery setup is deliberately one-way for an account. Replacing an
739
+ // existing secret-storage key or backup can strand files on other devices;
740
+ // never disable or reset either resource just to mint a new key.
741
+ let status;
742
+ let backupVersion;
743
+ let crossSigningStatus;
744
+ try {
745
+ const crossSigningPromise = typeof crypto.getCrossSigningStatus === "function"
746
+ ? withRecoveryCryptoDeadline(() => crypto.getCrossSigningStatus(), signal, "cross-signing status")
747
+ : Promise.resolve(undefined);
748
+ const [rawStatus, rawBackupVersion, rawCrossSigningStatus] = await Promise.all([
749
+ withRecoveryCryptoDeadline(() => crypto.getSecretStorageStatus(), signal, "secret-storage status"),
750
+ withRecoveryCryptoDeadline(() => crypto.getActiveSessionBackupVersion(), signal, "backup status"),
751
+ crossSigningPromise,
752
+ ]);
753
+ const validatedStatus = validateSecretStorageStatus(rawStatus);
754
+ const validatedBackupVersion = validateBackupVersion(rawBackupVersion);
755
+ const validatedCrossSigningStatus = rawCrossSigningStatus === undefined
756
+ ? undefined
757
+ : validateCrossSigningStatus(rawCrossSigningStatus);
758
+ status = validatedStatus;
759
+ backupVersion = validatedBackupVersion;
760
+ crossSigningStatus = validatedCrossSigningStatus;
761
+ }
762
+ catch (error) {
763
+ // A failed preflight cannot prove that recovery is absent. This is a
764
+ // permanent fail-closed boundary: callers must obtain an independent
765
+ // status result before attempting another one-way setup.
766
+ if (signal?.aborted && error instanceof StorageError) {
767
+ throw error;
768
+ }
769
+ throw new RecoverySetupAmbiguousError();
770
+ }
771
+ if (status.defaultKeyId || status.ready || backupVersion !== null) {
772
+ throw new RecoveryAlreadyConfiguredError();
773
+ }
774
+ if (crossSigningStatus?.publicKeysOnDevice &&
775
+ !crossSigningStatus.privateKeysInSecretStorage &&
776
+ !Object.values(crossSigningStatus.privateKeysCachedLocally).every(Boolean)) {
777
+ throw new RecoverySetupAmbiguousError();
778
+ }
779
+ if (signal?.aborted)
780
+ throw new StorageError("operation cancelled");
781
+ try {
782
+ await withRecoveryCryptoDeadline(() => crypto.bootstrapCrossSigning({
783
+ // No existing verified device to interactively re-authenticate against
784
+ // for this account, so there is nothing to feed into `makeRequest`;
785
+ // matches the working pattern already proven in keys.test.ts.
786
+ authUploadDeviceSigningKeys: async () => undefined,
787
+ }), signal, "cross-signing bootstrap");
788
+ }
789
+ catch {
790
+ // Cross-signing setup can commit server-side state before a transport
791
+ // failure is observed. Do not turn that uncertainty into a retry that
792
+ // could replace recovery state.
793
+ throw new RecoverySetupAmbiguousError();
794
+ }
795
+ // CryptoApi mutations do not accept an AbortSignal. Once one has settled,
796
+ // do not cross the next one-way mutation boundary: the caller may already
797
+ // have timed out and would otherwise lose the only key it could display.
798
+ if (signal?.aborted)
799
+ throw new RecoverySetupAmbiguousError();
800
+ let generated;
801
+ try {
802
+ generated = await withRecoveryCryptoDeadline(() => crypto.createRecoveryKeyFromPassphrase(), signal, "recovery-key generation");
803
+ }
804
+ catch {
805
+ throw new RecoverySetupAmbiguousError();
806
+ }
186
807
  if (!generated.encodedPrivateKey) {
187
- throw new Error("setupRecovery: matrix-js-sdk did not return an encoded recovery key");
808
+ throw new RecoverySetupAmbiguousError();
188
809
  }
189
- await this.withSecretStorageKey(generated.privateKey, async () => {
190
- await crypto.bootstrapSecretStorage({
191
- setupNewSecretStorage: true,
192
- setupNewKeyBackup: true,
193
- createSecretStorageKey: async () => generated,
194
- });
195
- await crypto.checkKeyBackupAndEnable();
196
- });
810
+ if (signal?.aborted)
811
+ throw new RecoverySetupAmbiguousError();
812
+ let generatedWasUsed = false;
813
+ try {
814
+ await this.withSecretStorageKey(generated.privateKey, async () => {
815
+ if (signal?.aborted)
816
+ throw new RecoverySetupAmbiguousError();
817
+ await withRecoveryCryptoDeadline(() => crypto.bootstrapSecretStorage({
818
+ createSecretStorageKey: async () => {
819
+ if (signal?.aborted)
820
+ throw new RecoverySetupAmbiguousError();
821
+ generatedWasUsed = true;
822
+ return generated;
823
+ },
824
+ // The preflight above proves that this account has no active
825
+ // backup. Matrix 42 does not create a backup when this option is
826
+ // omitted; it only stores a key for an already-existing backup.
827
+ // Keep the preflight/postflight ambiguity handling around this
828
+ // one-way operation: an uncertain result must never be retried.
829
+ // The per-client queue closes the local race; callers must inspect
830
+ // the resulting status before attempting setup from another client.
831
+ setupNewKeyBackup: true,
832
+ }), signal, "secret-storage bootstrap");
833
+ if (signal?.aborted)
834
+ throw new RecoverySetupAmbiguousError();
835
+ await withRecoveryCryptoDeadline(() => crypto.checkKeyBackupAndEnable(), signal, "key-backup enable");
836
+ }, signal);
837
+ if (signal?.aborted)
838
+ throw new RecoverySetupAmbiguousError();
839
+ if (!generatedWasUsed)
840
+ throw new RecoverySetupAmbiguousError();
841
+ }
842
+ catch (error) {
843
+ if (error instanceof RecoverySetupAmbiguousError)
844
+ throw error;
845
+ // Both calls can commit server-side state before a transport error. A
846
+ // status probe distinguishes a clean failure from an unsafe retry.
847
+ try {
848
+ const [rawAfterStatus, rawAfterBackup] = await Promise.all([
849
+ withRecoveryCryptoDeadline(() => crypto.getSecretStorageStatus(), signal, "secret-storage status"),
850
+ withRecoveryCryptoDeadline(() => crypto.getActiveSessionBackupVersion(), signal, "backup status"),
851
+ ]);
852
+ const afterStatus = validateSecretStorageStatus(rawAfterStatus);
853
+ const afterBackup = validateBackupVersion(rawAfterBackup);
854
+ if (afterStatus.defaultKeyId || afterStatus.ready || afterBackup !== null) {
855
+ throw new RecoverySetupAmbiguousError();
856
+ }
857
+ }
858
+ catch (error) {
859
+ if (error instanceof RecoverySetupAmbiguousError || error instanceof RecoverySetupError)
860
+ throw error;
861
+ // A failed status probe cannot prove that the one-way operation did
862
+ // not commit. Never turn that uncertainty into an apparently safe
863
+ // retry that could replace account recovery state.
864
+ throw new RecoverySetupAmbiguousError();
865
+ }
866
+ throw new RecoverySetupError();
867
+ }
868
+ try {
869
+ const [rawAfterStatus, rawAfterBackup] = await Promise.all([
870
+ withRecoveryCryptoDeadline(() => crypto.getSecretStorageStatus(), signal, "secret-storage status"),
871
+ withRecoveryCryptoDeadline(() => crypto.getActiveSessionBackupVersion(), signal, "backup status"),
872
+ ]);
873
+ const afterStatus = validateSecretStorageStatus(rawAfterStatus);
874
+ const afterBackup = validateBackupVersion(rawAfterBackup);
875
+ if (!afterStatus.ready || afterBackup === null)
876
+ throw new RecoverySetupAmbiguousError();
877
+ }
878
+ catch (error) {
879
+ if (error instanceof RecoverySetupAmbiguousError)
880
+ throw error;
881
+ throw new RecoverySetupAmbiguousError();
882
+ }
883
+ // This is the last boundary before exposing the only recovery credential
884
+ // to the caller. Cancellation here must not return a key that the caller
885
+ // may already have abandoned.
886
+ if (signal?.aborted)
887
+ throw new RecoverySetupAmbiguousError();
197
888
  return { recoveryKey: generated.encodedPrivateKey };
198
889
  }
199
890
  /** Is there an active key backup and a ready secret storage right now? */
200
- async keysIsRecoverySetup() {
891
+ async keysIsRecoverySetup(signal) {
892
+ return this.withRecoveryMutation((queuedSignal) => {
893
+ if (queuedSignal?.aborted)
894
+ throw new StorageError("operation cancelled");
895
+ return this.performKeysIsRecoverySetup(queuedSignal);
896
+ }, signal);
897
+ }
898
+ async performKeysIsRecoverySetup(signal) {
201
899
  const crypto = this.client.getCrypto();
202
900
  if (!crypto)
203
901
  return false;
204
- const [storageStatus, backupVersion] = await Promise.all([
205
- crypto.getSecretStorageStatus(),
206
- crypto.getActiveSessionBackupVersion(),
207
- ]);
208
- return storageStatus.ready && backupVersion !== null;
902
+ try {
903
+ const [rawStorageStatus, rawBackupVersion] = await Promise.all([
904
+ withRecoveryCryptoDeadline(() => crypto.getSecretStorageStatus(), signal, "secret-storage status"),
905
+ withRecoveryCryptoDeadline(() => crypto.getActiveSessionBackupVersion(), signal, "backup status"),
906
+ ]);
907
+ const storageStatus = validateSecretStorageStatus(rawStorageStatus);
908
+ const backupVersion = validateBackupVersion(rawBackupVersion);
909
+ return storageStatus.ready && backupVersion !== null;
910
+ }
911
+ catch {
912
+ throw new RecoverySetupAmbiguousError();
913
+ }
914
+ }
915
+ /** Returns one consistent recovery topology snapshot without exposing raw crypto callbacks. */
916
+ async keysGetRecoveryStatus(signal) {
917
+ return this.withRecoveryMutation(async (queuedSignal) => {
918
+ if (queuedSignal?.aborted)
919
+ throw new StorageError("operation cancelled");
920
+ const crypto = this.requireCrypto();
921
+ try {
922
+ const [rawSecretStorage, rawBackupVersion, rawCrossSigning] = await Promise.all([
923
+ withRecoveryCryptoDeadline(() => crypto.getSecretStorageStatus(), queuedSignal, "secret-storage status"),
924
+ withRecoveryCryptoDeadline(() => crypto.getActiveSessionBackupVersion(), queuedSignal, "backup status"),
925
+ withRecoveryCryptoDeadline(() => crypto.getCrossSigningStatus(), queuedSignal, "cross-signing status"),
926
+ ]);
927
+ const secretStorage = validateSecretStorageStatus(rawSecretStorage);
928
+ const backupVersion = validateBackupVersion(rawBackupVersion);
929
+ const crossSigning = validateCrossSigningStatus(rawCrossSigning);
930
+ const privateKeysCachedLocally = Object.values(crossSigning.privateKeysCachedLocally).every(Boolean);
931
+ const ready = crossSigning.publicKeysOnDevice &&
932
+ (privateKeysCachedLocally || crossSigning.privateKeysInSecretStorage) &&
933
+ secretStorage.ready &&
934
+ backupVersion !== null;
935
+ const unconfigured = !crossSigning.publicKeysOnDevice &&
936
+ !secretStorage.defaultKeyId &&
937
+ backupVersion === null;
938
+ return {
939
+ state: ready ? "ready" : unconfigured ? "unconfigured" : "partial",
940
+ crossSigning: {
941
+ publicKeysOnDevice: crossSigning.publicKeysOnDevice,
942
+ privateKeysCachedLocally,
943
+ privateKeysInSecretStorage: crossSigning.privateKeysInSecretStorage,
944
+ },
945
+ secretStorage: {
946
+ ready: secretStorage.ready,
947
+ defaultKeyId: secretStorage.defaultKeyId,
948
+ },
949
+ backupVersion,
950
+ };
951
+ }
952
+ catch {
953
+ throw new RecoverySetupAmbiguousError();
954
+ }
955
+ }, signal);
209
956
  }
210
957
  /**
211
958
  * On a new device: unlocks secret storage with the Recovery Key, loads the
@@ -216,157 +963,401 @@ export class TeleCryptIOStorage {
216
963
  * recovery key is malformed or does not unlock this account's secret
217
964
  * storage / key backup.
218
965
  */
219
- async keysRestoreFromRecoveryKey(recoveryKey) {
966
+ async keysRestoreFromRecoveryKey(recoveryKey, signal) {
967
+ return this.withRecoveryMutation((queuedSignal) => this.performKeysRestoreFromRecoveryKey(recoveryKey, queuedSignal), signal);
968
+ }
969
+ async performKeysRestoreFromRecoveryKey(recoveryKey, signal) {
970
+ if (signal?.aborted)
971
+ throw new StorageError("operation cancelled");
220
972
  const crypto = this.requireCrypto();
973
+ if (typeof recoveryKey !== "string" ||
974
+ recoveryKey.length === 0 ||
975
+ recoveryKey.length > 256 ||
976
+ /[^A-Za-z0-9 ]/.test(recoveryKey)) {
977
+ throw new RecoveryRestoreError();
978
+ }
221
979
  let privateKey;
222
980
  try {
223
981
  privateKey = decodeRecoveryKey(recoveryKey);
224
982
  }
225
- catch (err) {
226
- throw new Error(`restoreFromRecoveryKey: not a valid recovery key (${err.message})`);
983
+ catch {
984
+ throw new RecoveryRestoreError();
227
985
  }
986
+ if (signal?.aborted)
987
+ throw new StorageError("operation cancelled");
228
988
  return this.withSecretStorageKey(privateKey, async () => {
229
989
  try {
230
- await crypto.loadSessionBackupPrivateKeyFromSecretStorage();
990
+ await withRecoveryCryptoDeadline(() => crypto.loadSessionBackupPrivateKeyFromSecretStorage(), signal, "backup-key load");
991
+ if (signal?.aborted)
992
+ throw new RecoveryRestoreAmbiguousError();
231
993
  }
232
- catch (err) {
233
- throw new Error(`restoreFromRecoveryKey: recovery key did not unlock secret storage / key backup (${err.message})`);
994
+ catch (error) {
995
+ if (error instanceof RecoveryRestoreAmbiguousError)
996
+ throw error;
997
+ throw new RecoveryRestoreError();
234
998
  }
235
999
  try {
236
- const result = await crypto.restoreKeyBackup();
1000
+ const result = await withRecoveryCryptoDeadline(() => crypto.restoreKeyBackup(), signal, "key-backup restore");
1001
+ if (signal?.aborted)
1002
+ throw new RecoveryRestoreAmbiguousError();
1003
+ if (!Number.isSafeInteger(result.imported) || !Number.isSafeInteger(result.total) || result.imported < 0 || result.total < result.imported) {
1004
+ throw new RecoveryRestoreError();
1005
+ }
237
1006
  return { imported: result.imported, total: result.total };
238
1007
  }
239
- catch (err) {
240
- throw new Error(`restoreFromRecoveryKey: failed to restore key backup (${err.message})`);
1008
+ catch (error) {
1009
+ if (error instanceof RecoveryRestoreAmbiguousError)
1010
+ throw error;
1011
+ throw new RecoveryRestoreError();
241
1012
  }
242
- });
1013
+ }, signal);
243
1014
  }
244
1015
  /** Key-management API: setup, status, and restore for server-side Secure Backup. */
245
1016
  get keys() {
246
1017
  return {
247
- setupRecovery: () => this.keysSetupRecovery(),
248
- isRecoverySetup: () => this.keysIsRecoverySetup(),
249
- restoreFromRecoveryKey: (recoveryKey) => this.keysRestoreFromRecoveryKey(recoveryKey),
1018
+ setupRecovery: (signal) => this.keysSetupRecovery(signal),
1019
+ isRecoverySetup: (signal) => this.keysIsRecoverySetup(signal),
1020
+ getStatus: (signal) => this.keysGetRecoveryStatus(signal),
1021
+ restoreFromRecoveryKey: (recoveryKey, signal) => this.keysRestoreFromRecoveryKey(recoveryKey, signal),
250
1022
  };
251
1023
  }
252
1024
  /**
253
1025
  * Creates a new top-level shared vault (tree space).
254
1026
  *
255
- * Works around a genuine race in matrix-js-sdk's own
256
- * `unstableCreateFileTree()`: it creates the room via a plain
257
- * `createRoom()` HTTP call, then immediately wraps the result by looking
258
- * the room up in the *local* client store (`new MSC3089TreeSpace(this,
259
- * roomId)` calls `client.getRoom(roomId)` and throws `Error("Unknown
260
- * room")` if it's not there yet). The local store is only populated by
261
- * the client's background sync processing, which is asynchronous and can
262
- * easily not have caught up in the few milliseconds between the HTTP
263
- * response and that lookup — especially right after a client has just
264
- * started. The room really was created server-side by that point (that's
265
- * how we get "Unknown room" at all); this method waits for the client's
266
- * own ongoing sync loop to surface it, then wraps it directly via
267
- * `unstableGetFileTreeSpace`, instead of surfacing a spurious failure for
268
- * a vault that in fact exists.
1027
+ * Uses Matrix's public `createRoom()` response as the authoritative room
1028
+ * identity, then waits for that exact room to appear in local sync state.
269
1029
  */
270
- async createTree(name) {
271
- const knownRoomIdsBefore = new Set(this.client.getRooms().map((r) => r.roomId));
272
- try {
273
- // The `await` here is load-bearing: without it, the promise's
274
- // eventual rejection would happen after this try/catch has already
275
- // returned, bypassing the recovery logic below entirely.
276
- return (await this.client.unstableCreateFileTree(name));
277
- }
278
- catch (err) {
279
- if (!(err instanceof Error) || err.message !== "Unknown room")
280
- throw err;
281
- const newRoomId = await new Promise((resolve, reject) => {
282
- const deadline = Date.now() + 15000;
283
- const poll = () => {
284
- const found = this.client.getRooms().find((r) => !knownRoomIdsBefore.has(r.roomId));
285
- if (found) {
286
- resolve(found.roomId);
287
- return;
288
- }
289
- if (Date.now() >= deadline) {
290
- reject(new Error("createTree: room was created server-side but never appeared in this client's local sync state"));
291
- return;
292
- }
293
- setTimeout(poll, 200);
1030
+ treeRoomOptions(name) {
1031
+ validateName(name, "name");
1032
+ const userId = this.client.getUserId();
1033
+ if (!userId)
1034
+ throw new Error("createTree: Matrix client has no user ID");
1035
+ return {
1036
+ name,
1037
+ preset: Preset.PrivateChat,
1038
+ power_level_content_override: {
1039
+ invite: 100,
1040
+ kick: 100,
1041
+ ban: 100,
1042
+ redact: 50,
1043
+ state_default: 50,
1044
+ events_default: 50,
1045
+ users_default: 0,
1046
+ events: {
1047
+ [EventType.RoomPowerLevels]: 100,
1048
+ [EventType.RoomHistoryVisibility]: 100,
1049
+ [EventType.RoomTombstone]: 100,
1050
+ [EventType.RoomEncryption]: 100,
1051
+ [EventType.RoomName]: 50,
1052
+ [EventType.RoomMessage]: 50,
1053
+ [EventType.RoomMessageEncrypted]: 50,
1054
+ [EventType.Sticker]: 50,
1055
+ },
1056
+ users: { [userId]: 100 },
1057
+ },
1058
+ creation_content: { [RoomCreateTypeField]: RoomType.Space },
1059
+ initial_state: [
1060
+ {
1061
+ type: UNSTABLE_MSC3088_PURPOSE.name,
1062
+ state_key: UNSTABLE_MSC3089_TREE_SUBTYPE.name,
1063
+ content: { [UNSTABLE_MSC3088_ENABLED.name]: true },
1064
+ },
1065
+ {
1066
+ type: EventType.RoomEncryption,
1067
+ state_key: "",
1068
+ content: { algorithm: "m.megolm.v1.aes-sha2" },
1069
+ },
1070
+ ],
1071
+ };
1072
+ }
1073
+ async waitForTreeSpace(roomId, operation, signal) {
1074
+ const deadline = Date.now() + TREE_SYNC_TIMEOUT_MS;
1075
+ for (;;) {
1076
+ if (signal?.aborted)
1077
+ throw new StorageError("operation cancelled");
1078
+ try {
1079
+ const tree = this.client.unstableGetFileTreeSpace(roomId);
1080
+ if (tree)
1081
+ return this.decorateTreeSpace(tree);
1082
+ }
1083
+ catch {
1084
+ // The room can be present before its complete state has been applied
1085
+ // to this client's local store. Keep polling the exact returned ID.
1086
+ }
1087
+ if (Date.now() >= deadline) {
1088
+ throw new Error(`${operation}: created room did not become a file tree space in local sync state`);
1089
+ }
1090
+ await new Promise((resolve, reject) => {
1091
+ let timer;
1092
+ const onAbort = () => {
1093
+ clearTimeout(timer);
1094
+ signal?.removeEventListener("abort", onAbort);
1095
+ reject(new StorageError("operation cancelled"));
294
1096
  };
295
- poll();
1097
+ timer = setTimeout(() => {
1098
+ signal?.removeEventListener("abort", onAbort);
1099
+ resolve();
1100
+ }, 100);
1101
+ signal?.addEventListener("abort", onAbort, { once: true });
1102
+ if (signal?.aborted)
1103
+ onAbort();
296
1104
  });
297
- const tree = this.client.unstableGetFileTreeSpace(newRoomId);
298
- if (!tree) {
299
- throw new Error(`createTree: room ${newRoomId} appeared but is not a valid file tree space`);
300
- }
1105
+ }
1106
+ }
1107
+ /**
1108
+ * MSC3089's built-in createDirectory only sends the two relationship events;
1109
+ * it does not update either room's local state after the homeserver accepts
1110
+ * them. Wrap returned spaces so child creation goes through createSubtree,
1111
+ * which refreshes the exact parent/child state before reporting success.
1112
+ */
1113
+ decorateTreeSpace(tree) {
1114
+ if (this.decoratedTreeSpaces.has(tree))
301
1115
  return tree;
1116
+ this.decoratedTreeSpaces.add(tree);
1117
+ const originalGetDirectories = tree.getDirectories.bind(tree);
1118
+ tree.getDirectories = () => originalGetDirectories().map((child) => this.decorateTreeSpace(child));
1119
+ tree.createDirectory = (name) => this.createSubtree(tree, name);
1120
+ return tree;
1121
+ }
1122
+ /** Cleans up only a room created by this operation and reports incomplete cleanup. */
1123
+ async cleanupCreatedRoom(roomId, _signal) {
1124
+ // Cleanup is compensating work. It must not inherit the caller's already
1125
+ // aborted deadline, or a timed-out create could never even attempt leave.
1126
+ const cleanupSignal = new AbortController().signal;
1127
+ let incomplete = false;
1128
+ let safeToForget = true;
1129
+ try {
1130
+ await TeleCryptIOStorage.withTimeout(withMatrixMutationAbort(this.client, () => this.client.leave(roomId), cleanupSignal), CLEANUP_TIMEOUT_MS, "room leave cleanup", cleanupSignal);
1131
+ }
1132
+ catch (error) {
1133
+ if (!isGoneRoomError(error)) {
1134
+ incomplete = true;
1135
+ // A timed-out or failed leave may still be in flight. Do not race a
1136
+ // forget request against it; the caller must retry cleanup later.
1137
+ safeToForget = false;
1138
+ }
1139
+ }
1140
+ if (safeToForget) {
1141
+ try {
1142
+ await TeleCryptIOStorage.withTimeout(withMatrixMutationAbort(this.client, () => this.client.forget(roomId), cleanupSignal), CLEANUP_TIMEOUT_MS, "room forget cleanup", cleanupSignal);
1143
+ }
1144
+ catch (error) {
1145
+ if (!isGoneRoomError(error))
1146
+ incomplete = true;
1147
+ }
1148
+ }
1149
+ if (incomplete) {
1150
+ throw new RoomCleanupIncompleteError(roomId);
1151
+ }
1152
+ }
1153
+ async removeRelationLink(roomId, eventType, stateKey, signal) {
1154
+ const room = this.client.getRoom(roomId);
1155
+ const raw = room?.currentState.getStateEvents(eventType, stateKey);
1156
+ const event = Array.isArray(raw) ? raw[0] : raw;
1157
+ if (!event)
1158
+ return false;
1159
+ // This path is used only after an ambiguous send without an event ID. A
1160
+ // state-key match alone is unsafe: another actor may have replaced the
1161
+ // relation while the failed send was in flight. Redact only an event whose
1162
+ // sender is provably this client; otherwise leave it in place and report
1163
+ // incomplete cleanup to the caller.
1164
+ const currentUserId = this.client.getUserId();
1165
+ const getSender = event.getSender;
1166
+ if (!currentUserId || typeof getSender !== "function" || getSender() !== currentUserId)
1167
+ return false;
1168
+ const eventId = validateMatrixEventId(event.getId(), "state event ID");
1169
+ await withMatrixMutationAbort(this.client, () => this.client.redactEvent(roomId, eventId), signal);
1170
+ return true;
1171
+ }
1172
+ async removeChildLink(parentId, childId, signal) {
1173
+ return this.removeRelationLink(parentId, EventType.SpaceChild, childId, signal);
1174
+ }
1175
+ async removeParentLink(childId, parentId, signal) {
1176
+ return this.removeRelationLink(childId, EventType.SpaceParent, parentId, signal);
1177
+ }
1178
+ async createTreeSpace(name, operation, signal) {
1179
+ if (signal?.aborted)
1180
+ throw new StorageError("operation cancelled");
1181
+ const response = await TeleCryptIOStorage.withMutationTimeout(withMatrixMutationAbort(this.client, () => this.client.createRoom(this.treeRoomOptions(name)), signal, `${operation} room creation`), MATRIX_HTTP_TIMEOUT_MS, `${operation} room creation`).catch((error) => {
1182
+ // createRoom is a server-side mutation. A rejected HTTP promise does
1183
+ // not prove that the request was rejected before commit, so every
1184
+ // non-validation failure follows the reconciliation path instead of a
1185
+ // retryable generic error that could orphan a second room.
1186
+ if (error instanceof RoomCreationAmbiguousError)
1187
+ throw error;
1188
+ throw new RoomCreationAmbiguousError(operation);
1189
+ });
1190
+ let roomId;
1191
+ try {
1192
+ roomId = validateMatrixRoomId(response?.room_id, "room creation response");
1193
+ }
1194
+ catch {
1195
+ throw new RoomCreationAmbiguousError(operation);
1196
+ }
1197
+ try {
1198
+ return await this.waitForTreeSpace(roomId, operation, signal);
302
1199
  }
1200
+ catch (error) {
1201
+ try {
1202
+ await this.cleanupCreatedRoom(roomId, signal);
1203
+ }
1204
+ catch {
1205
+ throwWithCleanupDetail(error, roomId);
1206
+ }
1207
+ // The room existed even if cleanup happened to succeed. Keep the
1208
+ // caller on the reconciliation path: a retry based on a local timeout
1209
+ // or malformed response must never assume that no room was created.
1210
+ throw new RoomCreationAmbiguousError(operation);
1211
+ }
1212
+ }
1213
+ /**
1214
+ * Creates one top-level shared vault per invocation. Same-name calls are
1215
+ * serialized for this MatrixClient, but display names are not identities.
1216
+ */
1217
+ async createTree(name, signal) {
1218
+ return withTreeMutation(this.client, (queuedSignal) => this.createTreeSpace(name, "createTree", queuedSignal), signal);
303
1219
  }
304
- async listTrees() {
1220
+ async listTrees(signal) {
1221
+ if (signal?.aborted)
1222
+ throw new StorageError("operation cancelled");
305
1223
  const rooms = this.client.getRooms();
1224
+ if (rooms.length > MAX_MATRIX_JOINED_ROOMS)
1225
+ throw new StorageError("room list is too large");
306
1226
  const trees = [];
307
1227
  for (const room of rooms) {
1228
+ if (signal?.aborted)
1229
+ throw new StorageError("operation cancelled");
308
1230
  const tree = this.client.unstableGetFileTreeSpace(room.roomId);
309
- if (tree) {
310
- trees.push(tree);
311
- }
1231
+ if (tree)
1232
+ trees.push(this.decorateTreeSpace(tree));
312
1233
  }
313
1234
  return trees;
314
1235
  }
315
- /**
316
- * Creates a subdirectory (nested tree space) under `parent` and links it
317
- * with the parent's space.child / the child's space.parent state events.
318
- *
319
- * Mirrors `createTree`'s workaround for the same matrix-js-sdk race:
320
- * `MSC3089TreeSpace.createDirectory()` calls `unstableCreateFileTree()`,
321
- * which creates the room via a plain `createRoom()` HTTP call and then
322
- * immediately wraps the result by looking the room up in the *local*
323
- * client store — throwing `Error("Unknown room")` when the background
324
- * sync loop hasn't surfaced the new room yet. Without the workaround the
325
- * subfolder room is created server-side but never linked (no space.child
326
- * event), leaving an orphaned room and a spurious failure for a folder
327
- * that in fact exists.
328
- */
329
- async createSubtree(parent, name) {
330
- const knownRoomIdsBefore = new Set(this.client.getRooms().map((r) => r.roomId));
331
- try {
332
- // The `await` here is load-bearing: without it, the promise's
333
- // eventual rejection would happen after this try/catch has already
334
- // returned, bypassing the recovery logic below entirely.
335
- return (await parent.createDirectory(name));
336
- }
337
- catch (err) {
338
- if (!(err instanceof Error) || err.message !== "Unknown room")
339
- throw err;
340
- const newRoomId = await new Promise((resolve, reject) => {
341
- const deadline = Date.now() + 15000;
342
- const poll = () => {
343
- const found = this.client.getRooms().find((r) => !knownRoomIdsBefore.has(r.roomId));
344
- if (found) {
345
- resolve(found.roomId);
346
- return;
1236
+ /** Creates and links a child tree space using the exact Matrix room ID. */
1237
+ async createSubtree(parent, name, signal) {
1238
+ return withTreeMutation(this.client, async (queuedSignal) => {
1239
+ const effectiveSignal = queuedSignal ?? signal;
1240
+ await this.refreshRoomState(parent.id, { signal: effectiveSignal });
1241
+ const currentParent = this.getTree(parent.id);
1242
+ if (!currentParent)
1243
+ throw new Error("createSubtree: parent is unavailable");
1244
+ const tree = await this.createTreeSpace(name, "createSubtree", effectiveSignal);
1245
+ let parentLinkAttempted = false;
1246
+ let parentLinkEventId;
1247
+ let childLinkAttempted = false;
1248
+ let childLinkEventId;
1249
+ try {
1250
+ const via = this.client.getDomain();
1251
+ if (!via)
1252
+ throw new Error("createSubtree: Matrix client has no server domain");
1253
+ parentLinkAttempted = true;
1254
+ const parentLink = await TeleCryptIOStorage.withMutationTimeout(withMatrixMutationAbort(this.client, () => this.client.sendStateEvent(currentParent.id, EventType.SpaceChild, { via: [via] }, tree.id), effectiveSignal), MATRIX_HTTP_TIMEOUT_MS, "parent link");
1255
+ if (parentLink?.event_id !== undefined) {
1256
+ parentLinkEventId = validateMatrixEventId(parentLink.event_id, "parent link response event ID");
1257
+ }
1258
+ childLinkAttempted = true;
1259
+ const childLink = await TeleCryptIOStorage.withMutationTimeout(withMatrixMutationAbort(this.client, () => this.client.sendStateEvent(tree.id, EventType.SpaceParent, { via: [via] }, currentParent.id), effectiveSignal), MATRIX_HTTP_TIMEOUT_MS, "child link");
1260
+ if (childLink?.event_id !== undefined) {
1261
+ childLinkEventId = validateMatrixEventId(childLink.event_id, "child link response event ID");
1262
+ }
1263
+ // sendStateEvent resolves when the server accepts the event, not when
1264
+ // this client's sync loop has applied it. Refresh both exact rooms so
1265
+ // isTopLevel/getDirectories and recursive invites observe the link.
1266
+ await this.refreshRoomState(currentParent.id, { signal: effectiveSignal });
1267
+ if (this.client.getRoom(tree.id)) {
1268
+ await this.refreshRoomState(tree.id, { signal: effectiveSignal });
1269
+ }
1270
+ return tree;
1271
+ }
1272
+ catch (error) {
1273
+ // Compensation is independent of the caller's cancelled deadline.
1274
+ // Reconcile and retry these idempotent relation updates with a fresh
1275
+ // signal, then report incomplete cleanup if they cannot be verified.
1276
+ const compensationSignal = new AbortController().signal;
1277
+ let rollbackIncomplete = false;
1278
+ // A sendStateEvent rejection is ambiguous: the homeserver may have
1279
+ // committed the event before the client observed a transport error.
1280
+ // Refresh the parent before probing even when no event ID was
1281
+ // returned. A sendStateEvent rejection can race the sync loop, so the
1282
+ // pre-refresh local state is not authoritative. A missing link after
1283
+ // the refresh is the expected outcome for a rejection before commit.
1284
+ if (parentLinkAttempted) {
1285
+ try {
1286
+ if (parentLinkEventId) {
1287
+ await TeleCryptIOStorage.withTimeout(withMatrixMutationAbort(this.client, () => this.client.redactEvent(currentParent.id, parentLinkEventId), compensationSignal), MATRIX_HTTP_TIMEOUT_MS, "parent link rollback", compensationSignal);
1288
+ await this.refreshRoomState(currentParent.id, { signal: compensationSignal });
1289
+ const remaining = this.client
1290
+ .getRoom(currentParent.id)
1291
+ ?.currentState.getStateEvents(EventType.SpaceChild, tree.id);
1292
+ if (remaining &&
1293
+ (Array.isArray(remaining) ? remaining : [remaining]).some((event) => {
1294
+ const content = event.getContent?.();
1295
+ return (typeof content === "object" &&
1296
+ content !== null &&
1297
+ !Array.isArray(content) &&
1298
+ Object.keys(content).length > 0);
1299
+ })) {
1300
+ rollbackIncomplete = true;
1301
+ }
1302
+ }
1303
+ else {
1304
+ await this.refreshRoomState(currentParent.id, { signal: compensationSignal });
1305
+ const removed = await this.removeChildLink(currentParent.id, tree.id, compensationSignal);
1306
+ // Without an event ID, a missing link after one refresh does not
1307
+ // prove that a request which timed out will never commit.
1308
+ if (!removed)
1309
+ rollbackIncomplete = true;
1310
+ }
347
1311
  }
348
- if (Date.now() >= deadline) {
349
- reject(new Error("createSubtree: room was created server-side but never appeared in this client's local sync state"));
350
- return;
1312
+ catch {
1313
+ rollbackIncomplete = true;
351
1314
  }
352
- setTimeout(poll, 200);
353
- };
354
- poll();
355
- });
356
- const tree = this.client.unstableGetFileTreeSpace(newRoomId);
357
- if (!tree) {
358
- throw new Error(`createSubtree: room ${newRoomId} appeared but is not a valid file tree space`);
1315
+ }
1316
+ if (childLinkAttempted) {
1317
+ try {
1318
+ if (childLinkEventId) {
1319
+ await TeleCryptIOStorage.withTimeout(withMatrixMutationAbort(this.client, () => this.client.redactEvent(tree.id, childLinkEventId), compensationSignal), MATRIX_HTTP_TIMEOUT_MS, "child link rollback", compensationSignal);
1320
+ await this.refreshRoomState(tree.id, { signal: compensationSignal });
1321
+ const remaining = this.client
1322
+ .getRoom(tree.id)
1323
+ ?.currentState.getStateEvents(EventType.SpaceParent, currentParent.id);
1324
+ if (remaining &&
1325
+ (Array.isArray(remaining) ? remaining : [remaining]).some((event) => {
1326
+ const content = event.getContent?.();
1327
+ return (typeof content === "object" &&
1328
+ content !== null &&
1329
+ !Array.isArray(content) &&
1330
+ Object.keys(content).length > 0);
1331
+ })) {
1332
+ rollbackIncomplete = true;
1333
+ }
1334
+ }
1335
+ else {
1336
+ await this.refreshRoomState(tree.id, { signal: compensationSignal });
1337
+ const removed = await this.removeParentLink(tree.id, currentParent.id, compensationSignal);
1338
+ if (!removed)
1339
+ rollbackIncomplete = true;
1340
+ }
1341
+ }
1342
+ catch {
1343
+ rollbackIncomplete = true;
1344
+ }
1345
+ }
1346
+ try {
1347
+ await this.cleanupCreatedRoom(tree.id, effectiveSignal);
1348
+ }
1349
+ catch {
1350
+ throwWithCleanupDetail(error, tree.id);
1351
+ }
1352
+ if (rollbackIncomplete)
1353
+ throwWithCleanupDetail(error, tree.id);
1354
+ throw error;
359
1355
  }
360
- // The room appeared in sync, but `createDirectory` threw before it
361
- // could send the space.child / space.parent link events. Send them
362
- // now so the subfolder is actually reachable from its parent.
363
- await this.client.sendStateEvent(parent.id, EventType.SpaceChild, { via: [this.client.getDomain()] }, newRoomId);
364
- await this.client.sendStateEvent(newRoomId, EventType.SpaceParent, { via: [this.client.getDomain()] }, parent.id);
365
- return tree;
366
- }
1356
+ }, signal);
367
1357
  }
368
1358
  getTree(roomId) {
369
- return this.client.unstableGetFileTreeSpace(roomId);
1359
+ const tree = this.client.unstableGetFileTreeSpace(roomId);
1360
+ return tree ? this.decorateTreeSpace(tree) : null;
370
1361
  }
371
1362
  /**
372
1363
  * Lists the participants of a shared vault (tree space) and their role,
@@ -385,18 +1376,22 @@ export class TeleCryptIOStorage {
385
1376
  * `MSC3089TreeSpace.getPermissions()` exactly (userLevel >= adminLevel ->
386
1377
  * owner, >= editLevel -> editor, else viewer).
387
1378
  */
388
- async listMembers(tree) {
1379
+ async listMembers(tree, options) {
389
1380
  const roomId = encodeURIComponent(tree.id);
390
1381
  const membersPath = `/rooms/${roomId}/members`;
391
1382
  const powerLevelsPath = `/rooms/${roomId}/state/m.room.power_levels/`;
392
- const reqOpts = { prefix: ClientPrefix.V3 };
1383
+ const reqOpts = {
1384
+ prefix: ClientPrefix.V3,
1385
+ localTimeoutMs: matrixRequestTimeout(options),
1386
+ abortSignal: matrixRequestSignal(options),
1387
+ };
1388
+ const http = requireMatrixHttpTransport(this.client);
393
1389
  const [membersBody, plsRaw] = await Promise.all([
394
- this.client.http.authedRequest(Method.Get, membersPath, undefined, undefined, reqOpts),
395
- this.client.http
396
- .authedRequest(Method.Get, powerLevelsPath, undefined, undefined, reqOpts)
397
- .catch(() => ({})),
1390
+ http.authedRequest(Method.Get, membersPath, undefined, undefined, reqOpts),
1391
+ http.authedRequest(Method.Get, powerLevelsPath, undefined, undefined, reqOpts),
398
1392
  ]);
399
- const pls = plsRaw;
1393
+ const members = parseMatrixMembersResponse(membersBody);
1394
+ const pls = parseMatrixPowerLevels(plsRaw);
400
1395
  const viewLevel = pls.users_default ?? 0;
401
1396
  const editLevel = pls.events_default ?? 50;
402
1397
  const adminLevel = pls.events?.["m.room.power_levels"] ?? 100;
@@ -408,20 +1403,78 @@ export class TeleCryptIOStorage {
408
1403
  return "editor";
409
1404
  return "viewer";
410
1405
  };
411
- return membersBody.chunk
412
- .filter((e) => e.content.membership === "join" || e.content.membership === "invite")
1406
+ return members
1407
+ .filter((e) => e.content.membership === "join" ||
1408
+ e.content.membership === "invite" ||
1409
+ e.content.membership === "knock")
413
1410
  .map((e) => ({
414
1411
  userId: e.state_key,
415
1412
  membership: e.content.membership,
416
1413
  role: roleFor(e.state_key),
417
1414
  }));
418
1415
  }
419
- async uploadFile(tree, name, data, mimetype) {
1416
+ /**
1417
+ * Returns the homeserver's complete joined-room inventory. Local sync room
1418
+ * lists can be truncated by initialSyncLimit and are not an authoritative
1419
+ * basis for deletion safety.
1420
+ */
1421
+ async listJoinedRoomIds(options) {
1422
+ const body = await requireMatrixHttpTransport(this.client).authedRequest(Method.Get, "/joined_rooms", undefined, undefined, {
1423
+ prefix: ClientPrefix.V3,
1424
+ localTimeoutMs: matrixRequestTimeout(options),
1425
+ abortSignal: matrixRequestSignal(options),
1426
+ });
1427
+ return parseJoinedRoomsResponse(body);
1428
+ }
1429
+ /** Reads one room membership from the homeserver, not the lagging local sync store. */
1430
+ async getRoomMembership(roomId, userId = this.client.getUserId(), options) {
1431
+ if (!userId)
1432
+ return null;
1433
+ const body = await requireMatrixHttpTransport(this.client).authedRequest(Method.Get, `/rooms/${encodeURIComponent(roomId)}/members`, undefined, undefined, {
1434
+ prefix: ClientPrefix.V3,
1435
+ localTimeoutMs: matrixRequestTimeout(options),
1436
+ abortSignal: matrixRequestSignal(options),
1437
+ });
1438
+ const member = parseMatrixMembersResponse(body).find((entry) => entry.state_key === userId);
1439
+ return member?.content.membership ?? null;
1440
+ }
1441
+ async uploadFile(tree, name, data, mimetype, signal) {
1442
+ if (signal?.aborted)
1443
+ throw new StorageError("operation cancelled");
1444
+ validateFileName(name);
1445
+ validateMimetype(mimetype);
1446
+ if (data.byteLength > MAX_MEDIA_FILE_BYTES)
1447
+ throw new FileTooLargeError();
420
1448
  const encrypted = await encryptAttachment(data);
421
- const { event_id } = await tree.createFile(name, Buffer.from(encrypted.data), encrypted.info, { info: { mimetype, size: data.byteLength } });
422
- return event_id;
1449
+ if (signal?.aborted)
1450
+ throw new StorageError("operation cancelled");
1451
+ let response;
1452
+ try {
1453
+ response = await withMatrixMutationAbort(this.client, () => tree.createFile(name, new Uint8Array(encrypted.data), encrypted.info, { info: { mimetype, size: data.byteLength } }), signal, "file upload");
1454
+ }
1455
+ catch (error) {
1456
+ // A transport failure after the encrypted event was submitted cannot be
1457
+ // distinguished from a failure before commit. Do not let a caller retry
1458
+ // this operation and create a duplicate file; only the explicit typed
1459
+ // mutation outcome is safe to propagate unchanged.
1460
+ if (error instanceof MutationOutcomeUnknownError)
1461
+ throw error;
1462
+ throw new MutationOutcomeUnknownError("file upload");
1463
+ }
1464
+ if (signal?.aborted)
1465
+ throw new StorageError("operation cancelled");
1466
+ try {
1467
+ return validateMatrixEventId(response?.event_id, "file upload response event ID");
1468
+ }
1469
+ catch {
1470
+ // An invalid response can still follow a committed event. Treat it as
1471
+ // unknown rather than presenting a retryable generic upload failure.
1472
+ throw new MutationOutcomeUnknownError("file upload");
1473
+ }
423
1474
  }
424
- async downloadFile(branch) {
1475
+ async downloadFile(branch, signal) {
1476
+ if (signal?.aborted)
1477
+ throw new StorageError("operation cancelled");
425
1478
  let info;
426
1479
  try {
427
1480
  ({ info } = await branch.getFileInfo());
@@ -432,32 +1485,167 @@ export class TeleCryptIOStorage {
432
1485
  // device it hands back a placeholder with no `file` block, so that
433
1486
  // read throws an opaque "Cannot read properties of undefined" instead
434
1487
  // of a useful error. Surface the real cause.
435
- throw new Error(`downloadFile: could not read file info from the event — it is likely undecryptable on this device (missing megolm session; try restoring from a Recovery Key)`);
1488
+ throw new UndecryptableFileError();
436
1489
  }
437
1490
  // Also reject an incomplete placeholder if matrix-js-sdk returns one.
438
1491
  if (!info || typeof info.url !== "string") {
439
- throw new Error("downloadFile: could not read file info from the event — it is likely undecryptable on this device (missing megolm session; try restoring from a Recovery Key)");
1492
+ throw new UndecryptableFileError();
1493
+ }
1494
+ const declaredSize = info.size;
1495
+ if (declaredSize !== undefined &&
1496
+ (typeof declaredSize !== "number" ||
1497
+ !Number.isSafeInteger(declaredSize) ||
1498
+ declaredSize < 0 ||
1499
+ declaredSize > MAX_MEDIA_FILE_BYTES)) {
1500
+ throw new FileTooLargeError();
440
1501
  }
441
1502
  const mxcUrl = info.url;
442
1503
  const clientAny = this.client;
1504
+ const accessToken = clientAny.getAccessToken();
1505
+ validateMatrixToken(accessToken);
1506
+ let trustedOrigin;
1507
+ try {
1508
+ trustedOrigin = validateHomeserverUrl(clientAny.getHomeserverUrl()).origin;
1509
+ }
1510
+ catch {
1511
+ throw new Error("failed to build media URL");
1512
+ }
443
1513
  const downloadUrl = clientAny.mxcUrlToHttp(mxcUrl, undefined, undefined, undefined, false, true, true);
444
1514
  if (!downloadUrl)
445
1515
  throw new Error("failed to build media URL");
446
- const res = await fetch(downloadUrl, {
447
- headers: {
448
- Authorization: `Bearer ${clientAny.getAccessToken()}`,
449
- },
450
- });
451
- if (!res.ok) {
452
- throw new Error(`media download failed: ${res.status}`);
1516
+ let currentUrl;
1517
+ try {
1518
+ currentUrl = new URL(downloadUrl);
1519
+ }
1520
+ catch {
1521
+ throw new Error("failed to build media URL");
1522
+ }
1523
+ if ((currentUrl.protocol !== "http:" && currentUrl.protocol !== "https:") ||
1524
+ currentUrl.username !== "" ||
1525
+ currentUrl.password !== "") {
1526
+ throw new Error("failed to build media URL");
1527
+ }
1528
+ if (currentUrl.origin !== trustedOrigin)
1529
+ throw new Error("media download origin is untrusted");
1530
+ const controller = new AbortController();
1531
+ const timeout = setTimeout(() => controller.abort(), MEDIA_TIMEOUT_MS);
1532
+ const abortExternal = () => controller.abort(signal?.reason);
1533
+ if (signal?.aborted)
1534
+ abortExternal();
1535
+ else
1536
+ signal?.addEventListener("abort", abortExternal, { once: true });
1537
+ let ciphertext;
1538
+ try {
1539
+ for (let redirect = 0;; redirect += 1) {
1540
+ const res = await raceWithAbort(fetch(currentUrl, {
1541
+ redirect: "manual",
1542
+ signal: controller.signal,
1543
+ headers: { Authorization: `Bearer ${accessToken}` },
1544
+ }), controller.signal, () => undefined, () => signal?.aborted
1545
+ ? new StorageError("operation cancelled")
1546
+ : new Error("media download timed out"));
1547
+ if ([301, 302, 303, 307, 308].includes(res.status)) {
1548
+ try {
1549
+ void res.body?.cancel().catch(() => undefined);
1550
+ }
1551
+ catch {
1552
+ // The redirect response is already being rejected or discarded.
1553
+ }
1554
+ if (redirect >= MAX_MEDIA_REDIRECTS)
1555
+ throw new Error("media download redirect limit exceeded");
1556
+ const location = res.headers.get("location");
1557
+ if (!location)
1558
+ throw new Error("media download redirect missing location");
1559
+ try {
1560
+ currentUrl = new URL(location, currentUrl);
1561
+ }
1562
+ catch {
1563
+ throw new Error("media download redirect is invalid");
1564
+ }
1565
+ if ((currentUrl.protocol !== "http:" && currentUrl.protocol !== "https:") ||
1566
+ currentUrl.username !== "" ||
1567
+ currentUrl.password !== "") {
1568
+ throw new Error("media download redirect is invalid");
1569
+ }
1570
+ if (currentUrl.origin !== trustedOrigin) {
1571
+ throw new Error("media download redirect crossed origin");
1572
+ }
1573
+ continue;
1574
+ }
1575
+ if (!res.ok) {
1576
+ try {
1577
+ void res.body?.cancel().catch(() => undefined);
1578
+ }
1579
+ catch {
1580
+ // The error response is already being rejected.
1581
+ }
1582
+ throw new Error(`media download failed: ${res.status}`);
1583
+ }
1584
+ if (!res.body) {
1585
+ // A bodyless Response has no bounded reader. Even a plausible
1586
+ // Content-Length is only advisory and must not authorize an
1587
+ // unbounded arrayBuffer() allocation.
1588
+ throw new Error("media download has no bounded response body");
1589
+ }
1590
+ const body = await readBoundedResponseBody(res, MAX_MEDIA_FILE_BYTES, controller.signal);
1591
+ if (body.truncated)
1592
+ throw new FileTooLargeError();
1593
+ ciphertext = body.bytes.buffer;
1594
+ break;
1595
+ }
453
1596
  }
454
- const ciphertext = await res.arrayBuffer();
455
- const data = await decryptAttachment(ciphertext, info);
1597
+ catch (error) {
1598
+ if (signal?.aborted)
1599
+ throw new StorageError("operation cancelled");
1600
+ if (controller.signal.aborted)
1601
+ throw new Error("media download timed out");
1602
+ if (error instanceof FileTooLargeError)
1603
+ throw error;
1604
+ throw new Error("media download failed");
1605
+ }
1606
+ finally {
1607
+ clearTimeout(timeout);
1608
+ signal?.removeEventListener("abort", abortExternal);
1609
+ }
1610
+ let data;
1611
+ try {
1612
+ // matrix-encrypt-attachment's supported AES-CTR v1/v2 formats are
1613
+ // size-preserving: decryption allocates exactly the ciphertext length.
1614
+ // The ciphertext reader above therefore bounds the allocation before
1615
+ // entering the non-streaming third-party decryptor; the authenticated
1616
+ // event size is also rejected before decryption when present.
1617
+ if (ciphertext.byteLength > MAX_MEDIA_FILE_BYTES)
1618
+ throw new FileTooLargeError();
1619
+ data = await decryptAttachment(ciphertext, info);
1620
+ }
1621
+ catch (error) {
1622
+ if (error instanceof FileTooLargeError)
1623
+ throw error;
1624
+ throw new Error("media decryption failed");
1625
+ }
1626
+ if (signal?.aborted)
1627
+ throw new StorageError("operation cancelled");
456
1628
  const eventContent = (await branch.getFileEvent()).getContent();
457
- const infoBlock = eventContent["info"];
1629
+ if (signal?.aborted)
1630
+ throw new StorageError("operation cancelled");
1631
+ if (!isRecord(eventContent))
1632
+ throw new Error("media metadata is invalid");
1633
+ const infoBlock = isRecord(eventContent["info"])
1634
+ ? eventContent["info"]
1635
+ : undefined;
458
1636
  const mimetype = typeof infoBlock?.["mimetype"] === "string"
459
1637
  ? infoBlock["mimetype"]
460
1638
  : "application/octet-stream";
1639
+ try {
1640
+ validateMimetype(mimetype);
1641
+ }
1642
+ catch {
1643
+ throw new Error("media metadata is invalid");
1644
+ }
1645
+ if (data.byteLength > MAX_MEDIA_FILE_BYTES)
1646
+ throw new FileTooLargeError();
1647
+ if (signal?.aborted)
1648
+ throw new StorageError("operation cancelled");
461
1649
  return { data, mimetype };
462
1650
  }
463
1651
  }