@telecrypt-io/storage 0.5.27 → 0.5.29

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.
@@ -5,7 +5,8 @@ import { encryptAttachment, decryptAttachment } from "matrix-encrypt-attachment"
5
5
  import { decodeRecoveryKey } from "matrix-js-sdk/lib/crypto-api/recovery-key.js";
6
6
  import { FileTooLargeError, RecoveryAlreadyConfiguredError, RecoveryRestoreError, RecoverySetupAmbiguousError, RecoverySetupError, RecoveryRestoreAmbiguousError, MutationOutcomeUnknownError, RoomCreationAmbiguousError, RoomCleanupIncompleteError, StorageError, UndecryptableFileError, } from "./core/errors.js";
7
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";
8
+ import { cancelResponseBody, raceWithAbort, readMediaResponseBody, readResponseBody, ResponseBodyReadError, } from "./core/http.js";
9
+ import { sanitizeDiagnosticText } from "./core/oidc.js";
9
10
  import { validateName } from "./core/validation.js";
10
11
  import { isTreeDeleted } from "./deletion-markers.js";
11
12
  const recoveryMutationQueues = new WeakMap();
@@ -18,11 +19,6 @@ const RECOVERY_CRYPTO_TIMEOUT_MS = 60000;
18
19
  const MAX_MEDIA_REDIRECTS = 5;
19
20
  const MAX_MATRIX_TOKEN_LENGTH = 8192;
20
21
  const MAX_MIMETYPE_LENGTH = 255;
21
- const MAX_MATRIX_STATE_RESPONSE_BYTES = 4 * 1024 * 1024;
22
- const MAX_MATRIX_RESPONSE_BYTES = 16 * 1024 * 1024;
23
- const MAX_MATRIX_STATE_EVENTS = 10000;
24
- const MAX_MATRIX_MEMBERS = 10000;
25
- const MAX_MATRIX_JOINED_ROOMS = 4096;
26
22
  function cloneCryptoCallbacks(callbacks) {
27
23
  // MatrixClient retains this object for the lifetime of the client. Keep the
28
24
  // caller's object immutable: recovery temporarily installs key callbacks on
@@ -64,26 +60,21 @@ export function withMatrixMutationAbort(_client, operation, signal, operationNam
64
60
  pending = operation();
65
61
  }
66
62
  catch (error) {
67
- finish(() => reject(cancelled ? new MutationOutcomeUnknownError(operationName) : error));
63
+ finish(() => reject(cancelled ? new MutationOutcomeUnknownError(operationName, { cause: error }) : error));
68
64
  return;
69
65
  }
70
- pending.then((value) => finish(() => (cancelled ? reject(new MutationOutcomeUnknownError(operationName)) : resolve(value))), (error) => finish(() => reject(cancelled ? new MutationOutcomeUnknownError(operationName) : error)));
66
+ pending.then((value) => finish(() => (cancelled ? reject(new MutationOutcomeUnknownError(operationName)) : resolve(value))), (error) => finish(() => reject(cancelled ? new MutationOutcomeUnknownError(operationName, { cause: error }) : error)));
71
67
  });
72
68
  }
73
- async function readBoundedMatrixResponse(response, maxBytes, signal, abortError) {
74
- const body = await readBoundedResponseBody(response, maxBytes, signal, {
69
+ async function readMatrixResponse(response, signal, abortError) {
70
+ const body = await readResponseBody(response, signal, {
75
71
  abortError,
76
- deferReaderRelease: true,
77
72
  });
78
- if (body.truncated)
79
- throw new Error("Matrix response body is too large");
80
73
  if (!response.body)
81
74
  return response;
82
75
  // Fetch forbids a response body for 204, 205, and 304 responses. Some
83
76
  // browser implementations nevertheless expose an empty response stream for
84
- // a 204 from a proxy. Reconstructing that response with even an empty
85
- // Uint8Array then throws before matrix-js-sdk can accept the successful
86
- // response. Preserve the bodyless status after boundedly consuming the
77
+ // a 204 from a proxy. Preserve the bodyless status after consuming the
87
78
  // stream so callers such as FetchHttpApi can still read an empty Blob.
88
79
  const bodylessStatus = response.status === 204 || response.status === 205 || response.status === 304;
89
80
  if (bodylessStatus) {
@@ -102,14 +93,6 @@ async function readBoundedMatrixResponse(response, maxBytes, signal, abortError)
102
93
  headers: response.headers,
103
94
  });
104
95
  }
105
- function isMatrixStateOrMembershipUrl(input) {
106
- const url = typeof input === "string"
107
- ? new URL(input, "https://matrix.invalid")
108
- : input instanceof URL
109
- ? input
110
- : new URL(input.url, "https://matrix.invalid");
111
- return /\/rooms\/[^/]+\/(?:state|members)(?:\/|$)/.test(url.pathname);
112
- }
113
96
  export function boundedMatrixFetch(fetchFn) {
114
97
  const wrapped = ((input, init) => {
115
98
  const pending = (async () => {
@@ -126,25 +109,21 @@ export function boundedMatrixFetch(fetchFn) {
126
109
  else
127
110
  externalSignal?.addEventListener("abort", abortExternal, { once: true });
128
111
  try {
129
- 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
+ const response = await raceWithAbort(fetchFn(input, { ...init, redirect: "manual", signal: controller.signal }), controller.signal, () => (timedOut ? new Error("Matrix request timed out") : new DOMException("The operation was aborted", "AbortError")));
130
113
  if (response.redirected ||
131
114
  response.type === "opaqueredirect" ||
132
115
  (response.status >= 300 && response.status < 400)) {
133
- response.body?.cancel().catch(() => undefined);
134
- throw new Error("Matrix redirect rejected");
116
+ const failure = new Error("Matrix redirect rejected");
117
+ await cancelResponseBody(response, "Matrix redirect", failure);
118
+ throw failure;
135
119
  }
136
- return await readBoundedMatrixResponse(response, isMatrixStateOrMembershipUrl(input)
137
- ? MAX_MATRIX_STATE_RESPONSE_BYTES
138
- : MAX_MATRIX_RESPONSE_BYTES, controller.signal, () => (timedOut ? new Error("Matrix request timed out") : new DOMException("The operation was aborted", "AbortError")));
120
+ return await readMatrixResponse(response, controller.signal, () => (timedOut ? new Error("Matrix request timed out") : new DOMException("The operation was aborted", "AbortError")));
139
121
  }
140
122
  finally {
141
123
  clearTimeout(timeout);
142
124
  externalSignal?.removeEventListener("abort", abortExternal);
143
125
  }
144
126
  })();
145
- // Preserve fetch-style rejection for callers while observing an immediate
146
- // abort until the caller has had a chance to attach its own handler.
147
- void pending.catch(() => undefined);
148
127
  return pending;
149
128
  });
150
129
  return wrapped;
@@ -181,8 +160,8 @@ function validateHomeserverUrl(value) {
181
160
  try {
182
161
  url = new URL(value);
183
162
  }
184
- catch {
185
- throw new Error("invalid Matrix homeserver URL");
163
+ catch (error) {
164
+ throw new Error("invalid Matrix homeserver URL", { cause: error });
186
165
  }
187
166
  if ((url.protocol !== "http:" && url.protocol !== "https:") ||
188
167
  (url.protocol === "http:" && !isLoopbackHostname(url.hostname)) ||
@@ -224,9 +203,50 @@ function validateMimetype(value) {
224
203
  throw new Error("invalid MIME type");
225
204
  }
226
205
  }
206
+ /** Reads the event-owned metadata shared by details and download operations. */
207
+ export function readFileEventMetadata(content) {
208
+ if (!isRecord(content))
209
+ throw new StorageError("media metadata is invalid");
210
+ const rawInfo = content["info"];
211
+ if (rawInfo !== undefined && !isRecord(rawInfo)) {
212
+ throw new StorageError("media metadata is invalid");
213
+ }
214
+ const info = isRecord(rawInfo) ? rawInfo : undefined;
215
+ const rawSize = info?.["size"];
216
+ if (rawSize !== undefined) {
217
+ if (typeof rawSize !== "number" || !Number.isSafeInteger(rawSize) || rawSize < 0) {
218
+ throw new StorageError("media metadata is invalid");
219
+ }
220
+ if (rawSize > MAX_MEDIA_FILE_BYTES)
221
+ throw new FileTooLargeError();
222
+ }
223
+ const rawMimetype = info?.["mimetype"];
224
+ if (rawMimetype !== undefined && typeof rawMimetype !== "string") {
225
+ throw new StorageError("media metadata is invalid");
226
+ }
227
+ const mimetype = rawMimetype ?? null;
228
+ if (mimetype !== null) {
229
+ try {
230
+ validateMimetype(mimetype);
231
+ }
232
+ catch (error) {
233
+ throw new StorageError("media metadata is invalid", { cause: error });
234
+ }
235
+ }
236
+ return {
237
+ mimetype,
238
+ size: typeof rawSize === "number" ? rawSize : null,
239
+ };
240
+ }
227
241
  function isRecord(value) {
228
242
  return typeof value === "object" && value !== null && !Array.isArray(value);
229
243
  }
244
+ async function isUndecryptableFilePlaceholder(branch) {
245
+ // matrix-js-sdk replaces failed encrypted event content with m.bad.encrypted;
246
+ // its public failure flag is the authoritative state and avoids guessing
247
+ // from that display content (or misclassifying malformed plaintext events).
248
+ return (await branch.getFileEvent()).isDecryptionFailure();
249
+ }
230
250
  function validateSecretStorageStatus(value) {
231
251
  if (!isRecord(value))
232
252
  throw new RecoverySetupAmbiguousError();
@@ -295,7 +315,7 @@ async function withRecoveryCryptoDeadline(operation, signal, operationName) {
295
315
  signal?.addEventListener("abort", abortExternal, { once: true });
296
316
  const pending = Promise.resolve().then(operation);
297
317
  try {
298
- return await raceWithAbort(pending, controller.signal, () => undefined, () => timedOut
318
+ return await raceWithAbort(pending, controller.signal, () => timedOut
299
319
  ? new RecoveryCryptoTimeoutError(operationName)
300
320
  : new StorageError("operation cancelled"));
301
321
  }
@@ -308,9 +328,6 @@ function parseMatrixMembersResponse(value) {
308
328
  if (!isRecord(value) || !Array.isArray(value.chunk)) {
309
329
  throw new Error("invalid Matrix members response");
310
330
  }
311
- if (value.chunk.length > MAX_MATRIX_MEMBERS) {
312
- throw new Error("Matrix members response is too large");
313
- }
314
331
  return value.chunk.map((entry) => {
315
332
  if (!isRecord(entry) ||
316
333
  typeof entry.state_key !== "string" ||
@@ -329,9 +346,6 @@ function parseMatrixMembersResponse(value) {
329
346
  function parseMatrixStateResponse(value) {
330
347
  if (!Array.isArray(value))
331
348
  throw new Error("invalid Matrix room state response");
332
- if (value.length > MAX_MATRIX_STATE_EVENTS) {
333
- throw new Error("Matrix room state response is too large");
334
- }
335
349
  return value.map((event) => {
336
350
  if (!isRecord(event) ||
337
351
  typeof event.type !== "string" ||
@@ -362,9 +376,6 @@ function parseMatrixPowerLevels(value) {
362
376
  continue;
363
377
  if (!isRecord(field))
364
378
  throw new Error("invalid Matrix power-level response");
365
- if (Object.keys(field).length > MAX_MATRIX_MEMBERS) {
366
- throw new Error("Matrix power-level response is too large");
367
- }
368
379
  for (const key of Object.keys(field))
369
380
  validateMatrixIdentifier(key, `${name} key`);
370
381
  for (const level of Object.values(field)) {
@@ -375,27 +386,15 @@ function parseMatrixPowerLevels(value) {
375
386
  }
376
387
  return value;
377
388
  }
378
- function parseJoinedRoomsResponse(value) {
379
- if (!isRecord(value) ||
380
- !Array.isArray(value.joined_rooms) ||
381
- value.joined_rooms.length > MAX_MATRIX_JOINED_ROOMS) {
382
- throw new Error("invalid Matrix joined-room response");
383
- }
384
- return value.joined_rooms.map((roomId) => {
385
- try {
386
- return validateMatrixRoomId(roomId, "joined-room response");
387
- }
388
- catch {
389
- throw new Error("invalid Matrix joined-room response");
390
- }
391
- });
392
- }
393
389
  function isGoneRoomError(error) {
394
390
  return (error instanceof MatrixError &&
395
391
  (error.errcode === "M_NOT_FOUND" || error.errcode === "M_UNKNOWN"));
396
392
  }
397
- function throwWithCleanupDetail(error, roomId) {
398
- const cleanup = new RoomCleanupIncompleteError(roomId);
393
+ function throwWithCleanupDetail(error, roomId, cleanupError) {
394
+ const cleanupCause = cleanupError === undefined
395
+ ? error
396
+ : new AggregateError([error, cleanupError], "operation and room cleanup failed", { cause: error });
397
+ const cleanup = new RoomCleanupIncompleteError(roomId, cleanupCause);
399
398
  if (error instanceof Error) {
400
399
  try {
401
400
  Object.defineProperty(error, "cleanupIncomplete", {
@@ -408,9 +407,10 @@ function throwWithCleanupDetail(error, roomId) {
408
407
  });
409
408
  }
410
409
  catch {
411
- // Preserve the original mutation error even if an unusual Error object
412
- // is non-extensible; the cleanup detail remains in the cause chain only
413
- // when it could be attached safely.
410
+ // A non-extensible provider error cannot carry the cleanup marker. Throw
411
+ // the existing typed cleanup error and retain the provider error as cause
412
+ // so callers cannot mistake the mutation for a safe retry.
413
+ throw cleanup;
414
414
  }
415
415
  throw error;
416
416
  }
@@ -480,9 +480,6 @@ export class TeleCryptIOStorage {
480
480
  for (const [stateKey, event] of byStateKey) {
481
481
  if (present.has(`${eventType}\u0000${stateKey}`))
482
482
  continue;
483
- if (refreshed.length >= MAX_MATRIX_STATE_EVENTS) {
484
- throw new Error("Matrix room state response is too large");
485
- }
486
483
  const original = event.getEffectiveEvent();
487
484
  refreshed.push(new MatrixEvent({
488
485
  ...original,
@@ -582,8 +579,10 @@ export class TeleCryptIOStorage {
582
579
  try {
583
580
  client.stopClient();
584
581
  }
585
- catch {
586
- // Preserve the original bootstrap failure.
582
+ catch (cleanupError) {
583
+ throw new AggregateError([error, cleanupError], "storage bootstrap and client cleanup failed", {
584
+ cause: error,
585
+ });
587
586
  }
588
587
  throw error;
589
588
  }
@@ -784,7 +783,7 @@ export class TeleCryptIOStorage {
784
783
  if (signal?.aborted && error instanceof StorageError) {
785
784
  throw error;
786
785
  }
787
- throw new RecoverySetupAmbiguousError();
786
+ throw new RecoverySetupAmbiguousError(error);
788
787
  }
789
788
  if (status.defaultKeyId || status.ready || backupVersion !== null) {
790
789
  throw new RecoveryAlreadyConfiguredError();
@@ -804,11 +803,11 @@ export class TeleCryptIOStorage {
804
803
  authUploadDeviceSigningKeys: async () => undefined,
805
804
  }), signal, "cross-signing bootstrap");
806
805
  }
807
- catch {
806
+ catch (error) {
808
807
  // Cross-signing setup can commit server-side state before a transport
809
808
  // failure is observed. Do not turn that uncertainty into a retry that
810
809
  // could replace recovery state.
811
- throw new RecoverySetupAmbiguousError();
810
+ throw new RecoverySetupAmbiguousError(error);
812
811
  }
813
812
  // CryptoApi mutations do not accept an AbortSignal. Once one has settled,
814
813
  // do not cross the next one-way mutation boundary: the caller may already
@@ -819,8 +818,8 @@ export class TeleCryptIOStorage {
819
818
  try {
820
819
  generated = await withRecoveryCryptoDeadline(() => crypto.createRecoveryKeyFromPassphrase(), signal, "recovery-key generation");
821
820
  }
822
- catch {
823
- throw new RecoverySetupAmbiguousError();
821
+ catch (error) {
822
+ throw new RecoverySetupAmbiguousError(error);
824
823
  }
825
824
  if (!generated.encodedPrivateKey) {
826
825
  throw new RecoverySetupAmbiguousError();
@@ -873,15 +872,22 @@ export class TeleCryptIOStorage {
873
872
  throw new RecoverySetupAmbiguousError();
874
873
  }
875
874
  }
876
- catch (error) {
877
- if (error instanceof RecoverySetupAmbiguousError || error instanceof RecoverySetupError)
878
- throw error;
875
+ catch (statusError) {
876
+ if (statusError instanceof RecoverySetupAmbiguousError) {
877
+ throw new RecoverySetupAmbiguousError(new AggregateError([error, statusError], "recovery setup and status verification were both inconclusive", {
878
+ cause: error,
879
+ }));
880
+ }
881
+ if (statusError instanceof RecoverySetupError)
882
+ throw statusError;
879
883
  // A failed status probe cannot prove that the one-way operation did
880
884
  // not commit. Never turn that uncertainty into an apparently safe
881
885
  // retry that could replace account recovery state.
882
- throw new RecoverySetupAmbiguousError();
886
+ throw new RecoverySetupAmbiguousError(new AggregateError([error, statusError], "recovery setup and status verification failed", {
887
+ cause: error,
888
+ }));
883
889
  }
884
- throw new RecoverySetupError();
890
+ throw new RecoverySetupError(error);
885
891
  }
886
892
  try {
887
893
  const [rawAfterStatus, rawAfterBackup] = await Promise.all([
@@ -896,7 +902,7 @@ export class TeleCryptIOStorage {
896
902
  catch (error) {
897
903
  if (error instanceof RecoverySetupAmbiguousError)
898
904
  throw error;
899
- throw new RecoverySetupAmbiguousError();
905
+ throw new RecoverySetupAmbiguousError(error);
900
906
  }
901
907
  // This is the last boundary before exposing the only recovery credential
902
908
  // to the caller. Cancellation here must not return a key that the caller
@@ -926,8 +932,8 @@ export class TeleCryptIOStorage {
926
932
  const backupVersion = validateBackupVersion(rawBackupVersion);
927
933
  return storageStatus.ready && backupVersion !== null;
928
934
  }
929
- catch {
930
- throw new RecoverySetupAmbiguousError();
935
+ catch (error) {
936
+ throw new RecoverySetupAmbiguousError(error);
931
937
  }
932
938
  }
933
939
  /** Returns one consistent recovery topology snapshot without exposing raw crypto callbacks. */
@@ -967,8 +973,8 @@ export class TeleCryptIOStorage {
967
973
  backupVersion,
968
974
  };
969
975
  }
970
- catch {
971
- throw new RecoverySetupAmbiguousError();
976
+ catch (error) {
977
+ throw new RecoverySetupAmbiguousError(error);
972
978
  }
973
979
  }, signal);
974
980
  }
@@ -998,8 +1004,8 @@ export class TeleCryptIOStorage {
998
1004
  try {
999
1005
  privateKey = decodeRecoveryKey(recoveryKey);
1000
1006
  }
1001
- catch {
1002
- throw new RecoveryRestoreError();
1007
+ catch (error) {
1008
+ throw new RecoveryRestoreError(error);
1003
1009
  }
1004
1010
  if (signal?.aborted)
1005
1011
  throw new StorageError("operation cancelled");
@@ -1012,7 +1018,7 @@ export class TeleCryptIOStorage {
1012
1018
  catch (error) {
1013
1019
  if (error instanceof RecoveryRestoreAmbiguousError)
1014
1020
  throw error;
1015
- throw new RecoveryRestoreError();
1021
+ throw new RecoveryRestoreError(error);
1016
1022
  }
1017
1023
  try {
1018
1024
  const result = await withRecoveryCryptoDeadline(() => crypto.restoreKeyBackup(), signal, "key-backup restore");
@@ -1026,7 +1032,9 @@ export class TeleCryptIOStorage {
1026
1032
  catch (error) {
1027
1033
  if (error instanceof RecoveryRestoreAmbiguousError)
1028
1034
  throw error;
1029
- throw new RecoveryRestoreError();
1035
+ if (error instanceof RecoveryRestoreError)
1036
+ throw error;
1037
+ throw new RecoveryRestoreError(error);
1030
1038
  }
1031
1039
  }, signal);
1032
1040
  }
@@ -1090,6 +1098,7 @@ export class TeleCryptIOStorage {
1090
1098
  }
1091
1099
  async waitForTreeSpace(roomId, operation, signal) {
1092
1100
  const deadline = Date.now() + TREE_SYNC_TIMEOUT_MS;
1101
+ let lastError;
1093
1102
  for (;;) {
1094
1103
  if (signal?.aborted)
1095
1104
  throw new StorageError("operation cancelled");
@@ -1098,12 +1107,15 @@ export class TeleCryptIOStorage {
1098
1107
  if (tree)
1099
1108
  return this.decorateTreeSpace(tree);
1100
1109
  }
1101
- catch {
1110
+ catch (error) {
1111
+ lastError = error;
1102
1112
  // The room can be present before its complete state has been applied
1103
1113
  // to this client's local store. Keep polling the exact returned ID.
1104
1114
  }
1105
1115
  if (Date.now() >= deadline) {
1106
- throw new Error(`${operation}: created room did not become a file tree space in local sync state`);
1116
+ throw new Error(`${operation}: created room did not become a file tree space in local sync state`, {
1117
+ cause: lastError,
1118
+ });
1107
1119
  }
1108
1120
  await new Promise((resolve, reject) => {
1109
1121
  let timer;
@@ -1144,12 +1156,14 @@ export class TeleCryptIOStorage {
1144
1156
  const cleanupSignal = new AbortController().signal;
1145
1157
  let incomplete = false;
1146
1158
  let safeToForget = true;
1159
+ const failures = [];
1147
1160
  try {
1148
1161
  await TeleCryptIOStorage.withTimeout(withMatrixMutationAbort(this.client, () => this.client.leave(roomId), cleanupSignal), CLEANUP_TIMEOUT_MS, "room leave cleanup", cleanupSignal);
1149
1162
  }
1150
1163
  catch (error) {
1151
1164
  if (!isGoneRoomError(error)) {
1152
1165
  incomplete = true;
1166
+ failures.push(error);
1153
1167
  // A timed-out or failed leave may still be in flight. Do not race a
1154
1168
  // forget request against it; the caller must retry cleanup later.
1155
1169
  safeToForget = false;
@@ -1160,12 +1174,17 @@ export class TeleCryptIOStorage {
1160
1174
  await TeleCryptIOStorage.withTimeout(withMatrixMutationAbort(this.client, () => this.client.forget(roomId), cleanupSignal), CLEANUP_TIMEOUT_MS, "room forget cleanup", cleanupSignal);
1161
1175
  }
1162
1176
  catch (error) {
1163
- if (!isGoneRoomError(error))
1177
+ if (!isGoneRoomError(error)) {
1164
1178
  incomplete = true;
1179
+ failures.push(error);
1180
+ }
1165
1181
  }
1166
1182
  }
1167
1183
  if (incomplete) {
1168
- throw new RoomCleanupIncompleteError(roomId);
1184
+ const cause = failures.length === 1
1185
+ ? failures[0]
1186
+ : new AggregateError(failures, "room cleanup failed");
1187
+ throw new RoomCleanupIncompleteError(roomId, cause);
1169
1188
  }
1170
1189
  }
1171
1190
  async removeRelationLink(roomId, eventType, stateKey, signal) {
@@ -1203,14 +1222,14 @@ export class TeleCryptIOStorage {
1203
1222
  // retryable generic error that could orphan a second room.
1204
1223
  if (error instanceof RoomCreationAmbiguousError)
1205
1224
  throw error;
1206
- throw new RoomCreationAmbiguousError(operation);
1225
+ throw new RoomCreationAmbiguousError(operation, error);
1207
1226
  });
1208
1227
  let roomId;
1209
1228
  try {
1210
1229
  roomId = validateMatrixRoomId(response?.room_id, "room creation response");
1211
1230
  }
1212
- catch {
1213
- throw new RoomCreationAmbiguousError(operation);
1231
+ catch (error) {
1232
+ throw new RoomCreationAmbiguousError(operation, error);
1214
1233
  }
1215
1234
  try {
1216
1235
  return await this.waitForTreeSpace(roomId, operation, signal);
@@ -1219,13 +1238,13 @@ export class TeleCryptIOStorage {
1219
1238
  try {
1220
1239
  await this.cleanupCreatedRoom(roomId, signal);
1221
1240
  }
1222
- catch {
1223
- throwWithCleanupDetail(error, roomId);
1241
+ catch (cleanupError) {
1242
+ throwWithCleanupDetail(error, roomId, cleanupError);
1224
1243
  }
1225
1244
  // The room existed even if cleanup happened to succeed. Keep the
1226
1245
  // caller on the reconciliation path: a retry based on a local timeout
1227
1246
  // or malformed response must never assume that no room was created.
1228
- throw new RoomCreationAmbiguousError(operation);
1247
+ throw new RoomCreationAmbiguousError(operation, error);
1229
1248
  }
1230
1249
  }
1231
1250
  /**
@@ -1239,8 +1258,6 @@ export class TeleCryptIOStorage {
1239
1258
  if (signal?.aborted)
1240
1259
  throw new StorageError("operation cancelled");
1241
1260
  const rooms = this.client.getRooms();
1242
- if (rooms.length > MAX_MATRIX_JOINED_ROOMS)
1243
- throw new StorageError("room list is too large");
1244
1261
  const trees = [];
1245
1262
  for (const room of rooms) {
1246
1263
  if (signal?.aborted)
@@ -1295,6 +1312,7 @@ export class TeleCryptIOStorage {
1295
1312
  // signal, then report incomplete cleanup if they cannot be verified.
1296
1313
  const compensationSignal = new AbortController().signal;
1297
1314
  let rollbackIncomplete = false;
1315
+ const cleanupFailures = [];
1298
1316
  // A sendStateEvent rejection is ambiguous: the homeserver may have
1299
1317
  // committed the event before the client observed a transport error.
1300
1318
  // Refresh the parent before probing even when no event ID was
@@ -1318,6 +1336,7 @@ export class TeleCryptIOStorage {
1318
1336
  Object.keys(content).length > 0);
1319
1337
  })) {
1320
1338
  rollbackIncomplete = true;
1339
+ cleanupFailures.push(new Error("parent link rollback could not be verified"));
1321
1340
  }
1322
1341
  }
1323
1342
  else {
@@ -1325,12 +1344,15 @@ export class TeleCryptIOStorage {
1325
1344
  const removed = await this.removeChildLink(currentParent.id, tree.id, compensationSignal);
1326
1345
  // Without an event ID, a missing link after one refresh does not
1327
1346
  // prove that a request which timed out will never commit.
1328
- if (!removed)
1347
+ if (!removed) {
1329
1348
  rollbackIncomplete = true;
1349
+ cleanupFailures.push(new Error("parent link rollback could not be verified"));
1350
+ }
1330
1351
  }
1331
1352
  }
1332
- catch {
1353
+ catch (cleanupError) {
1333
1354
  rollbackIncomplete = true;
1355
+ cleanupFailures.push(cleanupError);
1334
1356
  }
1335
1357
  }
1336
1358
  if (childLinkAttempted) {
@@ -1350,27 +1372,35 @@ export class TeleCryptIOStorage {
1350
1372
  Object.keys(content).length > 0);
1351
1373
  })) {
1352
1374
  rollbackIncomplete = true;
1375
+ cleanupFailures.push(new Error("child link rollback could not be verified"));
1353
1376
  }
1354
1377
  }
1355
1378
  else {
1356
1379
  await this.refreshRoomState(tree.id, { signal: compensationSignal });
1357
1380
  const removed = await this.removeParentLink(tree.id, currentParent.id, compensationSignal);
1358
- if (!removed)
1381
+ if (!removed) {
1359
1382
  rollbackIncomplete = true;
1383
+ cleanupFailures.push(new Error("child link rollback could not be verified"));
1384
+ }
1360
1385
  }
1361
1386
  }
1362
- catch {
1387
+ catch (cleanupError) {
1363
1388
  rollbackIncomplete = true;
1389
+ cleanupFailures.push(cleanupError);
1364
1390
  }
1365
1391
  }
1366
1392
  try {
1367
1393
  await this.cleanupCreatedRoom(tree.id, effectiveSignal);
1368
1394
  }
1369
- catch {
1370
- throwWithCleanupDetail(error, tree.id);
1395
+ catch (cleanupError) {
1396
+ cleanupFailures.push(cleanupError);
1397
+ }
1398
+ if (rollbackIncomplete || cleanupFailures.length > 0) {
1399
+ const cleanupCause = cleanupFailures.length === 1
1400
+ ? cleanupFailures[0]
1401
+ : new AggregateError(cleanupFailures, "tree creation cleanup failed");
1402
+ throwWithCleanupDetail(error, tree.id, cleanupCause);
1371
1403
  }
1372
- if (rollbackIncomplete)
1373
- throwWithCleanupDetail(error, tree.id);
1374
1404
  throw error;
1375
1405
  }
1376
1406
  }, signal);
@@ -1435,19 +1465,6 @@ export class TeleCryptIOStorage {
1435
1465
  role: roleFor(e.state_key),
1436
1466
  }));
1437
1467
  }
1438
- /**
1439
- * Returns the homeserver's complete joined-room inventory. Local sync room
1440
- * lists can be truncated by initialSyncLimit and are not an authoritative
1441
- * basis for deletion safety.
1442
- */
1443
- async listJoinedRoomIds(options) {
1444
- const body = await requireMatrixHttpTransport(this.client).authedRequest(Method.Get, "/joined_rooms", undefined, undefined, {
1445
- prefix: ClientPrefix.V3,
1446
- localTimeoutMs: matrixRequestTimeout(options),
1447
- abortSignal: matrixRequestSignal(options),
1448
- });
1449
- return parseJoinedRoomsResponse(body);
1450
- }
1451
1468
  /** Reads one room membership from the homeserver, not the lagging local sync store. */
1452
1469
  async getRoomMembership(roomId, userId = this.client.getUserId(), options) {
1453
1470
  if (!userId)
@@ -1481,17 +1498,17 @@ export class TeleCryptIOStorage {
1481
1498
  // mutation outcome is safe to propagate unchanged.
1482
1499
  if (error instanceof MutationOutcomeUnknownError)
1483
1500
  throw error;
1484
- throw new MutationOutcomeUnknownError("file upload");
1501
+ throw new MutationOutcomeUnknownError("file upload", { cause: error });
1485
1502
  }
1486
1503
  if (signal?.aborted)
1487
1504
  throw new StorageError("operation cancelled");
1488
1505
  try {
1489
1506
  return validateMatrixEventId(response?.event_id, "file upload response event ID");
1490
1507
  }
1491
- catch {
1508
+ catch (error) {
1492
1509
  // An invalid response can still follow a committed event. Treat it as
1493
1510
  // unknown rather than presenting a retryable generic upload failure.
1494
- throw new MutationOutcomeUnknownError("file upload");
1511
+ throw new MutationOutcomeUnknownError("file upload", { cause: error });
1495
1512
  }
1496
1513
  }
1497
1514
  async downloadFile(branch, signal) {
@@ -1501,26 +1518,38 @@ export class TeleCryptIOStorage {
1501
1518
  try {
1502
1519
  ({ info } = await branch.getFileInfo());
1503
1520
  }
1504
- catch {
1505
- // matrix-js-sdk's MSC3089Branch.getFileInfo() reads `file["url"]` off
1506
- // the raw event content; when the event is undecryptable on this
1507
- // device it hands back a placeholder with no `file` block, so that
1508
- // read throws an opaque "Cannot read properties of undefined" instead
1509
- // of a useful error. Surface the real cause.
1510
- throw new UndecryptableFileError();
1511
- }
1512
- // Also reject an incomplete placeholder if matrix-js-sdk returns one.
1521
+ catch (error) {
1522
+ if (signal?.aborted)
1523
+ throw new StorageError("operation cancelled");
1524
+ if (error instanceof StorageError || error instanceof MatrixError)
1525
+ throw error;
1526
+ // matrix-js-sdk's file-info accessor reads an attachment absent from
1527
+ // failed-decryption events. Use its explicit decryption status, not a
1528
+ // guessed content shape; preserve other SDK/transport failures.
1529
+ let isPlaceholder = false;
1530
+ try {
1531
+ isPlaceholder = await isUndecryptableFilePlaceholder(branch);
1532
+ }
1533
+ catch (placeholderError) {
1534
+ throw new AggregateError([error, placeholderError], "file metadata lookup and placeholder inspection both failed", { cause: error });
1535
+ }
1536
+ if (isPlaceholder) {
1537
+ throw new UndecryptableFileError();
1538
+ }
1539
+ throw error;
1540
+ }
1541
+ // Missing metadata alone does not establish a decryption failure.
1513
1542
  if (!info || typeof info.url !== "string") {
1514
- throw new UndecryptableFileError();
1515
- }
1516
- const declaredSize = info.size;
1517
- if (declaredSize !== undefined &&
1518
- (typeof declaredSize !== "number" ||
1519
- !Number.isSafeInteger(declaredSize) ||
1520
- declaredSize < 0 ||
1521
- declaredSize > MAX_MEDIA_FILE_BYTES)) {
1522
- throw new FileTooLargeError();
1543
+ throw new StorageError("file metadata is missing a media URL");
1523
1544
  }
1545
+ // `info` is the encrypted attachment descriptor. Its shape is owned by
1546
+ // matrix-encrypt-attachment and does not carry the plaintext size. The
1547
+ // event's `info` block is the sole source of user-facing file metadata.
1548
+ const eventMetadata = readFileEventMetadata((await branch.getFileEvent()).getContent());
1549
+ if (signal?.aborted)
1550
+ throw new StorageError("operation cancelled");
1551
+ const declaredSize = eventMetadata.size;
1552
+ const mimetype = eventMetadata.mimetype ?? "application/octet-stream";
1524
1553
  const mxcUrl = info.url;
1525
1554
  const clientAny = this.client;
1526
1555
  const accessToken = clientAny.getAccessToken();
@@ -1529,8 +1558,8 @@ export class TeleCryptIOStorage {
1529
1558
  try {
1530
1559
  trustedOrigin = validateHomeserverUrl(clientAny.getHomeserverUrl()).origin;
1531
1560
  }
1532
- catch {
1533
- throw new Error("failed to build media URL");
1561
+ catch (error) {
1562
+ throw new Error("failed to build media URL", { cause: error });
1534
1563
  }
1535
1564
  const downloadUrl = clientAny.mxcUrlToHttp(mxcUrl, undefined, undefined, undefined, false, true, true);
1536
1565
  if (!downloadUrl)
@@ -1539,8 +1568,8 @@ export class TeleCryptIOStorage {
1539
1568
  try {
1540
1569
  currentUrl = new URL(downloadUrl);
1541
1570
  }
1542
- catch {
1543
- throw new Error("failed to build media URL");
1571
+ catch (error) {
1572
+ throw new Error("failed to build media URL", { cause: error });
1544
1573
  }
1545
1574
  if ((currentUrl.protocol !== "http:" && currentUrl.protocol !== "https:") ||
1546
1575
  currentUrl.username !== "" ||
@@ -1563,67 +1592,92 @@ export class TeleCryptIOStorage {
1563
1592
  redirect: "manual",
1564
1593
  signal: controller.signal,
1565
1594
  headers: { Authorization: `Bearer ${accessToken}` },
1566
- }), controller.signal, () => undefined, () => signal?.aborted
1595
+ }), controller.signal, () => signal?.aborted
1567
1596
  ? new StorageError("operation cancelled")
1568
1597
  : new Error("media download timed out"));
1569
1598
  if ([301, 302, 303, 307, 308].includes(res.status)) {
1570
- try {
1571
- void res.body?.cancel().catch(() => undefined);
1572
- }
1573
- catch {
1574
- // The redirect response is already being rejected or discarded.
1575
- }
1576
- if (redirect >= MAX_MEDIA_REDIRECTS)
1577
- throw new Error("media download redirect limit exceeded");
1578
- const location = res.headers.get("location");
1579
- if (!location)
1580
- throw new Error("media download redirect missing location");
1581
- try {
1582
- currentUrl = new URL(location, currentUrl);
1599
+ let redirectError;
1600
+ let nextUrl;
1601
+ if (redirect >= MAX_MEDIA_REDIRECTS) {
1602
+ redirectError = new Error("media download redirect limit exceeded");
1583
1603
  }
1584
- catch {
1585
- throw new Error("media download redirect is invalid");
1586
- }
1587
- if ((currentUrl.protocol !== "http:" && currentUrl.protocol !== "https:") ||
1588
- currentUrl.username !== "" ||
1589
- currentUrl.password !== "") {
1590
- throw new Error("media download redirect is invalid");
1591
- }
1592
- if (currentUrl.origin !== trustedOrigin) {
1593
- throw new Error("media download redirect crossed origin");
1604
+ else {
1605
+ const location = res.headers.get("location");
1606
+ if (!location) {
1607
+ redirectError = new Error("media download redirect missing location");
1608
+ }
1609
+ else {
1610
+ try {
1611
+ nextUrl = new URL(location, currentUrl);
1612
+ }
1613
+ catch (error) {
1614
+ redirectError = new Error("media download redirect is invalid", { cause: error });
1615
+ }
1616
+ if (nextUrl &&
1617
+ ((nextUrl.protocol !== "http:" && nextUrl.protocol !== "https:") ||
1618
+ nextUrl.username !== "" ||
1619
+ nextUrl.password !== "")) {
1620
+ redirectError = new Error("media download redirect is invalid");
1621
+ }
1622
+ if (nextUrl && nextUrl.origin !== trustedOrigin) {
1623
+ redirectError = new Error("media download redirect crossed origin");
1624
+ }
1625
+ }
1594
1626
  }
1627
+ await cancelResponseBody(res, "media redirect", redirectError);
1628
+ if (redirectError)
1629
+ throw redirectError;
1630
+ currentUrl = nextUrl;
1595
1631
  continue;
1596
1632
  }
1597
1633
  if (!res.ok) {
1634
+ const statusFailure = new Error(`media download failed: ${res.status}`);
1635
+ let detail = "";
1598
1636
  try {
1599
- void res.body?.cancel().catch(() => undefined);
1637
+ // Error responses are diagnostics, not product media. Consume the
1638
+ // complete body so no provider detail is discarded, then apply
1639
+ // the SDK's shared secret/control-character sanitizer before it
1640
+ // crosses the SDK boundary.
1641
+ const body = await readResponseBody(res, controller.signal);
1642
+ detail = sanitizeDiagnosticText(new TextDecoder().decode(body.bytes));
1600
1643
  }
1601
- catch {
1602
- // The error response is already being rejected.
1644
+ catch (bodyError) {
1645
+ const partial = bodyError instanceof ResponseBodyReadError
1646
+ ? sanitizeDiagnosticText(new TextDecoder().decode(bodyError.bytes))
1647
+ : "";
1648
+ const readFailure = bodyError instanceof ResponseBodyReadError
1649
+ ? new Error(`media error response body could not be read${partial === "" ? "" : `: ${partial}`}`, { cause: bodyError })
1650
+ : bodyError;
1651
+ throw new AggregateError([statusFailure, readFailure], "media download failed while reading the response body", { cause: statusFailure });
1603
1652
  }
1604
- throw new Error(`media download failed: ${res.status}`);
1653
+ throw new Error(`${statusFailure.message}${detail === "" ? "" : `: ${detail}`}`);
1605
1654
  }
1606
1655
  if (!res.body) {
1607
- // A bodyless Response has no bounded reader. Even a plausible
1608
- // Content-Length is only advisory and must not authorize an
1609
- // unbounded arrayBuffer() allocation.
1610
- throw new Error("media download has no bounded response body");
1656
+ throw new Error("media download has no response body");
1611
1657
  }
1612
- const body = await readBoundedResponseBody(res, MAX_MEDIA_FILE_BYTES, controller.signal);
1613
- if (body.truncated)
1614
- throw new FileTooLargeError();
1658
+ const body = await readMediaResponseBody(res, MAX_MEDIA_FILE_BYTES, controller.signal);
1615
1659
  ciphertext = body.bytes.buffer;
1616
1660
  break;
1617
1661
  }
1618
1662
  }
1619
1663
  catch (error) {
1664
+ if (error instanceof AggregateError) {
1665
+ if (signal?.aborted)
1666
+ throw new StorageError("operation cancelled", { cause: error });
1667
+ if (controller.signal.aborted)
1668
+ throw new Error("media download timed out", { cause: error });
1669
+ throw error;
1670
+ }
1671
+ if (error instanceof ResponseBodyReadError) {
1672
+ throw new Error("media download failed", { cause: error.cause ?? new Error(error.message) });
1673
+ }
1620
1674
  if (signal?.aborted)
1621
- throw new StorageError("operation cancelled");
1675
+ throw new StorageError("operation cancelled", { cause: error });
1622
1676
  if (controller.signal.aborted)
1623
- throw new Error("media download timed out");
1677
+ throw new Error("media download timed out", { cause: error });
1624
1678
  if (error instanceof FileTooLargeError)
1625
1679
  throw error;
1626
- throw new Error("media download failed");
1680
+ throw new Error("media download failed", { cause: error });
1627
1681
  }
1628
1682
  finally {
1629
1683
  clearTimeout(timeout);
@@ -1631,41 +1685,20 @@ export class TeleCryptIOStorage {
1631
1685
  }
1632
1686
  let data;
1633
1687
  try {
1634
- // matrix-encrypt-attachment's supported AES-CTR v1/v2 formats are
1635
- // size-preserving: decryption allocates exactly the ciphertext length.
1636
- // The ciphertext reader above therefore bounds the allocation before
1637
- // entering the non-streaming third-party decryptor; the authenticated
1638
- // event size is also rejected before decryption when present.
1639
- if (ciphertext.byteLength > MAX_MEDIA_FILE_BYTES)
1640
- throw new FileTooLargeError();
1641
1688
  data = await decryptAttachment(ciphertext, info);
1642
1689
  }
1643
1690
  catch (error) {
1644
1691
  if (error instanceof FileTooLargeError)
1645
1692
  throw error;
1646
- throw new Error("media decryption failed");
1693
+ throw new Error("media decryption failed", { cause: error });
1647
1694
  }
1695
+ if (data.byteLength > MAX_MEDIA_FILE_BYTES)
1696
+ throw new FileTooLargeError();
1648
1697
  if (signal?.aborted)
1649
1698
  throw new StorageError("operation cancelled");
1650
- const eventContent = (await branch.getFileEvent()).getContent();
1651
- if (signal?.aborted)
1652
- throw new StorageError("operation cancelled");
1653
- if (!isRecord(eventContent))
1654
- throw new Error("media metadata is invalid");
1655
- const infoBlock = isRecord(eventContent["info"])
1656
- ? eventContent["info"]
1657
- : undefined;
1658
- const mimetype = typeof infoBlock?.["mimetype"] === "string"
1659
- ? infoBlock["mimetype"]
1660
- : "application/octet-stream";
1661
- try {
1662
- validateMimetype(mimetype);
1699
+ if (declaredSize !== null && data.byteLength !== declaredSize) {
1700
+ throw new StorageError("media metadata size does not match decrypted file");
1663
1701
  }
1664
- catch {
1665
- throw new Error("media metadata is invalid");
1666
- }
1667
- if (data.byteLength > MAX_MEDIA_FILE_BYTES)
1668
- throw new FileTooLargeError();
1669
1702
  if (signal?.aborted)
1670
1703
  throw new StorageError("operation cancelled");
1671
1704
  return { data, mimetype };