@telecrypt-io/storage 0.3.0 → 0.5.7

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,5 +1,23 @@
1
- import { StorageError } from "./errors.js";
1
+ /**
2
+ * Platform-agnostic operations: one function per user-facing action, each
3
+ * taking an already-created `TeleCryptIOStorage` plus plain inputs and returning
4
+ * one of the typed results in `./types.ts`. No I/O beyond the Matrix client
5
+ * itself, no stdout, no `process`, no file paths — bytes in/out are always
6
+ * `Uint8Array`. All callers run the same tested logic and share the same result
7
+ * shapes.
8
+ *
9
+ * `core/` never creates the `TeleCryptIOStorage`/`MatrixClient` itself — store
10
+ * config (persistent crypto store, session credentials, etc.) is
11
+ * platform-specific and stays with the caller.
12
+ */
13
+ import { withMatrixMutationAbort, withTreeMutation, } from "../TeleCryptIOStorage.js";
14
+ import { EventType, MatrixError, RoomCreateTypeField, RoomType, UNSTABLE_MSC3089_BRANCH, UNSTABLE_MSC3088_ENABLED, UNSTABLE_MSC3088_PURPOSE, UNSTABLE_MSC3089_TREE_SUBTYPE, } from "matrix-js-sdk";
15
+ import { ClientPrefix } from "matrix-js-sdk/lib/http-api/prefix.js";
16
+ import { Method } from "matrix-js-sdk/lib/http-api/method.js";
17
+ import { FileTooLargeError, MutationPartialError, MutationOutcomeUnknownError, NonEmptyTreeError, RoomCreationAmbiguousError, RoomCleanupIncompleteError, StorageError, UndecryptableFileError, } from "./errors.js";
2
18
  import { waitForCondition } from "./poll.js";
19
+ import { validateMatrixEventId } from "./constants.js";
20
+ import { validateName } from "./validation.js";
3
21
  /**
4
22
  * Production Synapse enforces the built-in rc_messages budget (per-account
5
23
  * burst 10, refill 1/5s) on every room/state mutation the tree operations
@@ -9,6 +27,77 @@ import { waitForCondition } from "./poll.js";
9
27
  * `withRateLimitRetry`; must not mask non-rate-limit failures.
10
28
  */
11
29
  const RATE_LIMIT_RETRIES = 6;
30
+ const RATE_LIMIT_DEFAULT_DELAY_MS = 15_000;
31
+ const RATE_LIMIT_MAX_DELAY_MS = 30_000;
32
+ const RATE_LIMIT_MAX_TOTAL_DELAY_MS = 90_000;
33
+ const MAX_DELETION_ROOMS = 4096;
34
+ const MAX_DELETION_DEPTH = 128;
35
+ const MAX_FILE_VERSION_CHAIN = 128;
36
+ const MAX_LIST_ITEMS = 10000;
37
+ const DEFAULT_MUTATION_TIMEOUT_MS = 5 * 60_000;
38
+ const MAX_MUTATION_TIMEOUT_MS = 15 * 60_000;
39
+ function createOperationDeadline(options) {
40
+ const timeoutMs = options?.timeoutMs ?? DEFAULT_MUTATION_TIMEOUT_MS;
41
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_MUTATION_TIMEOUT_MS) {
42
+ throw new StorageError("invalid operation timeout");
43
+ }
44
+ const controller = new AbortController();
45
+ const onAbort = () => controller.abort(options?.signal?.reason);
46
+ options?.signal?.addEventListener("abort", onAbort, { once: true });
47
+ if (options?.signal?.aborted)
48
+ onAbort();
49
+ const timer = setTimeout(() => controller.abort(new Error("operation timed out")), timeoutMs);
50
+ return {
51
+ signal: controller.signal,
52
+ close: () => {
53
+ clearTimeout(timer);
54
+ options?.signal?.removeEventListener("abort", onAbort);
55
+ },
56
+ };
57
+ }
58
+ function ensureOperationActive(signal) {
59
+ if (signal.aborted)
60
+ throw new StorageError("operation cancelled");
61
+ }
62
+ async function raceOperationDeadline(deadline, pending, kind) {
63
+ let onAbort;
64
+ try {
65
+ const abort = new Promise((_, reject) => {
66
+ const abortHandler = () => {
67
+ reject(kind === "mutation"
68
+ ? new MutationOutcomeUnknownError("operation")
69
+ : new StorageError("operation cancelled"));
70
+ };
71
+ onAbort = abortHandler;
72
+ deadline.signal.addEventListener("abort", abortHandler, { once: true });
73
+ if (deadline.signal.aborted)
74
+ abortHandler();
75
+ });
76
+ return await Promise.race([pending, abort]);
77
+ }
78
+ finally {
79
+ if (onAbort)
80
+ deadline.signal.removeEventListener("abort", onAbort);
81
+ void pending.catch(() => undefined);
82
+ }
83
+ }
84
+ async function withOperationDeadline(options, operation, kind = "read") {
85
+ const deadline = createOperationDeadline(options);
86
+ let pending;
87
+ try {
88
+ ensureOperationActive(deadline.signal);
89
+ pending = Promise.resolve().then(() => operation(deadline.signal));
90
+ return await raceOperationDeadline(deadline, pending, kind);
91
+ }
92
+ finally {
93
+ deadline.close();
94
+ // A caller-facing deadline must not wait for a broken provider, but the
95
+ // late result remains observed so an ignored AbortSignal cannot create an
96
+ // unhandled rejection.
97
+ if (pending)
98
+ void pending.catch(() => undefined);
99
+ }
100
+ }
12
101
  function isRateLimited(error) {
13
102
  if (!(error instanceof Error))
14
103
  return false;
@@ -17,8 +106,11 @@ function isRateLimited(error) {
17
106
  const isRateLimitError = error.isRateLimitError;
18
107
  return typeof isRateLimitError === "function" && isRateLimitError.call(error) === true;
19
108
  }
20
- async function withRateLimitRetry(operation) {
109
+ async function withRateLimitRetry(operation, signal) {
110
+ let totalDelay = 0;
21
111
  for (let attempt = 0;; attempt++) {
112
+ if (signal?.aborted)
113
+ throw new StorageError("operation cancelled");
22
114
  try {
23
115
  return await operation();
24
116
  }
@@ -28,7 +120,7 @@ async function withRateLimitRetry(operation) {
28
120
  // The server's retry_after_ms only covers one token; tree operations
29
121
  // need several (kick + leave + forget). Wait at least 15s so the
30
122
  // burst refills.
31
- let retryAfter = 15_000;
123
+ let retryAfter = RATE_LIMIT_DEFAULT_DELAY_MS;
32
124
  if (error instanceof Error && "getRetryAfterMs" in error) {
33
125
  const getRetryAfterMs = error.getRetryAfterMs;
34
126
  if (typeof getRetryAfterMs === "function") {
@@ -38,66 +130,511 @@ async function withRateLimitRetry(operation) {
38
130
  }
39
131
  }
40
132
  }
41
- await new Promise((resolve) => setTimeout(resolve, retryAfter));
133
+ retryAfter = Math.min(retryAfter, RATE_LIMIT_MAX_DELAY_MS);
134
+ const remaining = RATE_LIMIT_MAX_TOTAL_DELAY_MS - totalDelay;
135
+ if (remaining <= 0)
136
+ throw error;
137
+ retryAfter = Math.min(retryAfter, remaining);
138
+ totalDelay += retryAfter;
139
+ await new Promise((resolve, reject) => {
140
+ let timer;
141
+ const onAbort = () => {
142
+ clearTimeout(timer);
143
+ signal?.removeEventListener("abort", onAbort);
144
+ reject(new StorageError("operation cancelled"));
145
+ };
146
+ timer = setTimeout(() => {
147
+ signal?.removeEventListener("abort", onAbort);
148
+ resolve();
149
+ }, retryAfter);
150
+ signal?.addEventListener("abort", onAbort, { once: true });
151
+ if (signal?.aborted)
152
+ onAbort();
153
+ });
42
154
  }
43
155
  }
44
156
  }
45
157
  /**
46
- * Resolves a folder by ID, polling briefly: a room this same account just
158
+ * Resolves a vault or folder by ID, polling briefly: a room this same account just
47
159
  * created (or was just invited to, by another process/session) can be
48
160
  * momentarily absent from a from-scratch `/sync` before showing up moments
49
161
  * later — real async settling, not an instant "not found". Throws a clean
50
- * error if the folder still isn't visible once the poll times out.
162
+ * error if the storage tree still isn't visible once the poll times out.
51
163
  */
52
- async function resolveTree(storage, folderId) {
164
+ async function resolveTree(storage, treeId, signal) {
53
165
  try {
54
- return await waitForCondition(() => storage.getTree(folderId), {
166
+ return await waitForCondition(() => storage.getTree(treeId), {
55
167
  timeoutMs: 15000,
168
+ signal,
56
169
  });
57
170
  }
58
171
  catch {
59
- throw new StorageError(`folder not found: ${folderId}`);
172
+ if (signal?.aborted)
173
+ throw new StorageError("operation cancelled");
174
+ throw new StorageError("storage space not found");
60
175
  }
61
176
  }
62
177
  /** As `resolveTree`, but for a specific file within an already-resolved
63
- * folder — covers the same settling window for a file another
178
+ * vault or folder — covers the same settling window for a file another
64
179
  * process/session just uploaded. */
65
- async function resolveFile(tree, fileId) {
180
+ async function resolveFile(tree, fileId, signal) {
181
+ try {
182
+ return await waitForCondition(() => tree.getFile(fileId), { timeoutMs: 15000, signal });
183
+ }
184
+ catch {
185
+ if (signal?.aborted)
186
+ throw new StorageError("operation cancelled");
187
+ throw new StorageError("file not found");
188
+ }
189
+ }
190
+ function snapshotTreeSpaces(root, tooLargeMessage = "storage tree is too large") {
191
+ const spaces = [];
192
+ const seen = new Set();
193
+ const pending = [{ tree: root, depth: 0 }];
194
+ while (pending.length > 0) {
195
+ const next = pending.pop();
196
+ if (!next)
197
+ continue;
198
+ const { tree, depth } = next;
199
+ if (seen.has(tree.id))
200
+ continue;
201
+ if (depth > MAX_DELETION_DEPTH || spaces.length >= MAX_DELETION_ROOMS) {
202
+ throw new StorageError(tooLargeMessage);
203
+ }
204
+ seen.add(tree.id);
205
+ spaces.push(tree);
206
+ let children;
207
+ try {
208
+ children = tree.getDirectories();
209
+ }
210
+ catch {
211
+ throw new StorageError("could not enumerate storage space descendants safely");
212
+ }
213
+ if (children.length > MAX_DELETION_ROOMS) {
214
+ throw new StorageError(tooLargeMessage);
215
+ }
216
+ for (const child of children)
217
+ pending.push({ tree: child, depth: depth + 1 });
218
+ }
219
+ return spaces;
220
+ }
221
+ /**
222
+ * Deleting a tree is deliberately a one-room operation. File versions are
223
+ * physical Matrix media events, so callers must delete files explicitly and
224
+ * observe each result before removing their containing room. This also
225
+ * prevents a folder/vault delete from silently deleting a nested shared tree.
226
+ */
227
+ function assertTreeEmptyForDeletion(tree, spaces) {
228
+ if (spaces.length !== 1)
229
+ throw new NonEmptyTreeError(tree.id);
230
+ let files;
66
231
  try {
67
- return await waitForCondition(() => tree.getFile(fileId), { timeoutMs: 15000 });
232
+ files = tree.listAllFiles();
68
233
  }
69
234
  catch {
70
- throw new StorageError(`file not found: ${fileId}`);
235
+ throw new StorageError("could not enumerate storage files safely");
71
236
  }
237
+ if (files.length > 0)
238
+ throw new NonEmptyTreeError(tree.id);
239
+ }
240
+ async function refreshDeletionRooms(storage, signal) {
241
+ const client = storage.getClient();
242
+ const http = client.http;
243
+ // A real MatrixClient always exposes its authenticated HTTP transport. A
244
+ // client without one cannot prove that its local relation graph is current.
245
+ if (typeof http?.authedRequest !== "function") {
246
+ throw new StorageError("delete graph refresh is unavailable");
247
+ }
248
+ const roomIds = await storage.listJoinedRoomIds({ signal });
249
+ if (roomIds.length > MAX_DELETION_ROOMS)
250
+ throw new StorageError("delete graph is too large");
251
+ const joined = new Set(roomIds);
252
+ // Matrix has no bounded endpoint that enumerates every invited/non-joined
253
+ // room carrying a space relation. Refresh every locally visible room and
254
+ // fail closed for an invite/knock/unknown membership; a joined external
255
+ // parent remains inspectable and is validated by validateDeletionGraph.
256
+ for (const room of client.getRooms()) {
257
+ if (!joined.has(room.roomId)) {
258
+ const membership = room.getMyMembership?.();
259
+ if (membership !== "join")
260
+ throw new StorageError("delete graph inventory is incomplete");
261
+ await storage.refreshRoomState(room.roomId, { signal });
262
+ }
263
+ }
264
+ for (const roomId of roomIds) {
265
+ if (signal?.aborted)
266
+ throw new StorageError("operation cancelled");
267
+ await storage.refreshRoomState(roomId, { signal });
268
+ }
269
+ }
270
+ async function refreshTreeSpaces(storage, spaces, signal) {
271
+ for (const space of spaces) {
272
+ ensureOperationActive(signal);
273
+ await storage.refreshRoomState(space.id, { signal });
274
+ }
275
+ }
276
+ function readRelationEvents(client, roomId, eventType, stateKey) {
277
+ const room = client.getRoom?.(roomId);
278
+ const currentState = room?.currentState;
279
+ if (!currentState?.getStateEvents)
280
+ return null;
281
+ const result = currentState.getStateEvents(eventType, stateKey);
282
+ if (result === null || result === undefined)
283
+ return [];
284
+ return Array.isArray(result) ? result : [result];
285
+ }
286
+ function relationStateKey(event, fallback) {
287
+ return event.getStateKey?.() ?? fallback;
288
+ }
289
+ function isActiveRelationEvent(event) {
290
+ const content = event.getContent?.();
291
+ return (typeof content === "object" &&
292
+ content !== null &&
293
+ !Array.isArray(content) &&
294
+ Object.keys(content).length > 0);
295
+ }
296
+ /**
297
+ * Deletion is intentionally narrower than MSC3089TreeSpace.delete(): a local
298
+ * child graph is not proof that the rooms are exclusively owned by this tree.
299
+ * Validate every observed edge in both directions and reject any parent or
300
+ * child edge outside the exact deletion set before the first mutation.
301
+ */
302
+ function validateDeletionGraph(client, root, ids, deletedRooms) {
303
+ const edges = new Set();
304
+ const externalParents = [];
305
+ const addEdge = (parentId, childId) => {
306
+ if (deletedRooms.has(childId))
307
+ return;
308
+ if (!ids.has(childId))
309
+ throw new StorageError("delete graph is unsafe");
310
+ edges.add(`${parentId}\u0000${childId}`);
311
+ };
312
+ for (const id of ids) {
313
+ const tree = client.unstableGetFileTreeSpace(id);
314
+ if (!tree)
315
+ throw new StorageError("delete graph is unsafe");
316
+ let directories;
317
+ try {
318
+ directories = tree.getDirectories();
319
+ }
320
+ catch {
321
+ throw new StorageError("delete graph is unsafe");
322
+ }
323
+ for (const child of directories)
324
+ addEdge(id, child.id);
325
+ const childEvents = readRelationEvents(client, id, EventType.SpaceChild);
326
+ if (childEvents === null)
327
+ throw new StorageError("delete graph is unsafe");
328
+ for (const event of childEvents) {
329
+ if (!isActiveRelationEvent(event))
330
+ continue;
331
+ const childId = relationStateKey(event);
332
+ if (!childId)
333
+ throw new StorageError("delete graph is unsafe");
334
+ addEdge(id, childId);
335
+ }
336
+ }
337
+ for (const edge of edges) {
338
+ const separator = edge.indexOf("\u0000");
339
+ const parentId = edge.slice(0, separator);
340
+ const childId = edge.slice(separator + 1);
341
+ const childLink = readRelationEvents(client, parentId, EventType.SpaceChild, childId);
342
+ const parentLink = readRelationEvents(client, childId, EventType.SpaceParent, parentId);
343
+ if (!childLink?.some((event) => isActiveRelationEvent(event) && relationStateKey(event, childId) === childId) ||
344
+ !parentLink?.some((event) => isActiveRelationEvent(event) && relationStateKey(event, parentId) === parentId)) {
345
+ throw new StorageError("delete graph is unsafe");
346
+ }
347
+ }
348
+ for (const id of ids) {
349
+ const parentEvents = readRelationEvents(client, id, EventType.SpaceParent);
350
+ if (parentEvents === null)
351
+ throw new StorageError("delete graph is unsafe");
352
+ for (const event of parentEvents) {
353
+ if (!isActiveRelationEvent(event))
354
+ continue;
355
+ const parentId = relationStateKey(event);
356
+ if (!parentId)
357
+ throw new StorageError("delete graph is unsafe");
358
+ if (!ids.has(parentId)) {
359
+ if (id !== root.id || externalParents.includes(parentId)) {
360
+ throw new StorageError("delete graph is unsafe");
361
+ }
362
+ externalParents.push(parentId);
363
+ }
364
+ }
365
+ }
366
+ const rooms = client.getRooms();
367
+ if (rooms.length > MAX_DELETION_ROOMS)
368
+ throw new StorageError("delete graph is too large");
369
+ for (const room of rooms) {
370
+ const parentId = room.roomId;
371
+ const childEvents = readRelationEvents(client, parentId, EventType.SpaceChild);
372
+ if (childEvents === null)
373
+ throw new StorageError("delete graph is unsafe");
374
+ for (const event of childEvents) {
375
+ if (!isActiveRelationEvent(event))
376
+ continue;
377
+ const childId = relationStateKey(event);
378
+ if (!childId)
379
+ throw new StorageError("delete graph is unsafe");
380
+ if (ids.has(childId) && !ids.has(parentId)) {
381
+ if (childId === root.id) {
382
+ if (!externalParents.includes(parentId))
383
+ externalParents.push(parentId);
384
+ continue;
385
+ }
386
+ throw new StorageError("delete graph is unsafe");
387
+ }
388
+ }
389
+ }
390
+ if (!ids.has(root.id))
391
+ throw new StorageError("delete graph is unsafe");
392
+ if (externalParents.length > 1)
393
+ throw new StorageError("delete graph is unsafe");
394
+ return { externalParents };
395
+ }
396
+ async function unlinkExternalParents(storage, rootId, externalParents, signal) {
397
+ const client = storage.getClient();
398
+ let mutationAttempted = false;
399
+ try {
400
+ for (const parentId of externalParents) {
401
+ if (signal?.aborted)
402
+ throw new StorageError("operation cancelled");
403
+ // Empty state-event content is the MSC3089-supported unlink form. Redaction
404
+ // is not used here because it can be delayed or rejected independently of
405
+ // the relation update.
406
+ mutationAttempted = true;
407
+ await withRateLimitRetry(() => withMatrixMutationAbort(client, () => client.sendStateEvent(parentId, EventType.SpaceChild, {}, rootId), signal), signal);
408
+ if (signal?.aborted)
409
+ throw new StorageError("operation cancelled");
410
+ await withRateLimitRetry(() => withMatrixMutationAbort(client, () => client.sendStateEvent(rootId, EventType.SpaceParent, {}, parentId), signal), signal);
411
+ await storage.refreshRoomState(parentId, { signal });
412
+ await storage.refreshRoomState(rootId, { signal });
413
+ const childLink = readRelationEvents(client, parentId, EventType.SpaceChild, rootId);
414
+ const parentLink = readRelationEvents(client, rootId, EventType.SpaceParent, parentId);
415
+ if (!childLink ||
416
+ !parentLink ||
417
+ childLink.some((event) => isActiveRelationEvent(event)) ||
418
+ parentLink.some((event) => isActiveRelationEvent(event))) {
419
+ throw new StorageError("delete graph unlink could not be verified");
420
+ }
421
+ }
422
+ }
423
+ catch (error) {
424
+ if (!mutationAttempted && error instanceof StorageError && error.message === "operation cancelled") {
425
+ throw error;
426
+ }
427
+ // Unlinking is a two-room transaction. A failed second state update must
428
+ // not leave a still-live vault detached from its external parent. Best
429
+ // effort is insufficient here: surface a distinct failure if the repair
430
+ // itself cannot be completed so callers never mistake a partial mutation
431
+ // for a successful delete.
432
+ try {
433
+ await relinkExternalParents(storage, rootId, externalParents, new AbortController().signal);
434
+ }
435
+ catch {
436
+ throw new StorageError("delete graph unlink cleanup is incomplete");
437
+ }
438
+ throw error;
439
+ }
440
+ }
441
+ async function relinkExternalParents(storage, rootId, externalParents, signal) {
442
+ const client = storage.getClient();
443
+ const via = client.getDomain();
444
+ if (!via)
445
+ throw new StorageError("delete graph relink is unavailable");
446
+ for (const parentId of externalParents) {
447
+ if (signal?.aborted)
448
+ throw new StorageError("operation cancelled");
449
+ await withRateLimitRetry(() => withMatrixMutationAbort(client, () => client.sendStateEvent(parentId, EventType.SpaceChild, { via: [via] }, rootId), signal), signal);
450
+ await withRateLimitRetry(() => withMatrixMutationAbort(client, () => client.sendStateEvent(rootId, EventType.SpaceParent, { via: [via] }, parentId), signal), signal);
451
+ await storage.refreshRoomState(parentId, { signal });
452
+ await storage.refreshRoomState(rootId, { signal });
453
+ const childLink = readRelationEvents(client, parentId, EventType.SpaceChild, rootId);
454
+ const parentLink = readRelationEvents(client, rootId, EventType.SpaceParent, parentId);
455
+ if (!childLink?.some((event) => isActiveRelationEvent(event)) ||
456
+ !parentLink?.some((event) => isActiveRelationEvent(event))) {
457
+ throw new StorageError("delete graph relink could not be verified");
458
+ }
459
+ }
460
+ }
461
+ const deletedTreeIds = new WeakMap();
462
+ const deletedRoomIds = new WeakMap();
463
+ function isGoneError(error) {
464
+ return (error instanceof MatrixError &&
465
+ (error.errcode === "M_NOT_FOUND" || error.errcode === "M_UNKNOWN"));
466
+ }
467
+ async function deleteRoomDeterministically(storage, roomId, signal) {
468
+ const client = storage.getClient();
469
+ const tree = storage.getTree(roomId);
470
+ if (!tree)
471
+ throw new StorageError("delete graph is unsafe");
472
+ const self = client.getUserId();
473
+ if (!self)
474
+ throw new StorageError("delete graph is unsafe");
475
+ const members = await storage.listMembers(tree, { signal });
476
+ if (members.some((member) => member.userId !== self && member.role === "owner" &&
477
+ (member.membership === "join" || member.membership === "invite" || member.membership === "knock"))) {
478
+ throw new StorageError("delete will not kick another room owner");
479
+ }
480
+ for (const member of members) {
481
+ const membership = member.membership;
482
+ if (member.userId === self ||
483
+ (membership !== "join" && membership !== "invite" && membership !== "knock")) {
484
+ continue;
485
+ }
486
+ try {
487
+ await withRateLimitRetry(() => withMatrixMutationAbort(client, () => client.kick(roomId, member.userId, "Room deleted"), signal), signal);
488
+ }
489
+ catch (error) {
490
+ if (isGoneError(error))
491
+ continue;
492
+ if (error instanceof MatrixError && error.errcode === "M_FORBIDDEN") {
493
+ const currentMembership = await storage.getRoomMembership(roomId, member.userId, { signal });
494
+ if (currentMembership === "leave" ||
495
+ currentMembership === "ban" ||
496
+ !currentMembership) {
497
+ continue;
498
+ }
499
+ }
500
+ throw error;
501
+ }
502
+ }
503
+ const ownMembership = await storage.getRoomMembership(roomId, undefined, { signal });
504
+ if (ownMembership === "join" || ownMembership === "invite" || ownMembership === "knock") {
505
+ try {
506
+ await withRateLimitRetry(() => withMatrixMutationAbort(client, () => client.leave(roomId), signal), signal);
507
+ }
508
+ catch (error) {
509
+ if (!isGoneError(error))
510
+ throw error;
511
+ }
512
+ }
513
+ try {
514
+ await withRateLimitRetry(() => withMatrixMutationAbort(client, () => client.forget(roomId), signal), signal);
515
+ }
516
+ catch (error) {
517
+ if (!isGoneError(error))
518
+ throw error;
519
+ }
520
+ removeRoomFromLocalStore(client, roomId);
521
+ let deleted = deletedRoomIds.get(client);
522
+ if (!deleted) {
523
+ deleted = new Set();
524
+ deletedRoomIds.set(client, deleted);
525
+ }
526
+ deleted.add(roomId);
527
+ }
528
+ /**
529
+ * The SDK normally evicts a room as part of forget(). Keep the local-store
530
+ * postcondition explicit because a custom store or a raced membership update
531
+ * may otherwise leave a stale invite visible until the next sync.
532
+ */
533
+ function removeRoomFromLocalStore(client, roomId) {
534
+ try {
535
+ client.store?.removeRoom(roomId);
536
+ }
537
+ catch {
538
+ // Server-side leave/forget already completed; a store implementation that
539
+ // cannot evict synchronously must not turn authoritative cleanup into a
540
+ // false failure.
541
+ }
542
+ }
543
+ function isActiveMembership(membership) {
544
+ return membership === "join" || membership === "invite";
545
+ }
546
+ function isRevocableMembership(membership) {
547
+ return membership === "join" || membership === "invite" || membership === "knock";
548
+ }
549
+ function safePartialDetail(error) {
550
+ // Provider/Matrix errors can contain response bodies, URLs, or credentials.
551
+ // StorageError messages are authored by this package and are already
552
+ // bounded; retain those local policy details so callers still get useful
553
+ // owner/self-target diagnostics.
554
+ if (!(error instanceof StorageError) || error instanceof MutationOutcomeUnknownError)
555
+ return undefined;
556
+ return error.message.length <= 256 ? error.message : undefined;
72
557
  }
73
558
  function toArrayBuffer(bytes) {
74
559
  return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
75
560
  }
76
- export async function createFolder(storage, name) {
77
- const tree = await withRateLimitRetry(() => storage.createTree(name));
78
- return { id: tree.id, name };
561
+ export async function createVault(storage, name, options) {
562
+ return withOperationDeadline(options, async (signal) => {
563
+ let tree;
564
+ try {
565
+ tree = await withRateLimitRetry(() => storage.createTree(name, signal), signal);
566
+ }
567
+ catch (error) {
568
+ if (error instanceof RoomCleanupIncompleteError ||
569
+ error instanceof RoomCreationAmbiguousError ||
570
+ error instanceof MutationOutcomeUnknownError ||
571
+ (error instanceof Error &&
572
+ error.cleanupIncomplete === true)) {
573
+ throw error;
574
+ }
575
+ if (signal.aborted)
576
+ throw new StorageError("operation cancelled");
577
+ throw new StorageError("create vault failed");
578
+ }
579
+ ensureOperationActive(signal);
580
+ return { id: tree.id, name };
581
+ }, "mutation");
79
582
  }
80
- /** Top-level folders only — excludes subdirectories of an existing tree. */
81
- export async function listFolders(storage) {
82
- const trees = await storage.listTrees();
83
- return trees.filter((t) => t.isTopLevel).map((t) => ({ id: t.id, name: t.room.name }));
583
+ /** Top-level vaults only — excludes subdirectories of an existing tree. */
584
+ export async function listVaults(storage, options) {
585
+ return withOperationDeadline(options, async (signal) => {
586
+ const trees = await storage.listTrees(signal);
587
+ return trees.filter((t) => t.isTopLevel).map((t) => ({ id: t.id, name: t.room.name }));
588
+ });
84
589
  }
85
- /** Current account's effective role for a folder, or null while it is unavailable. */
86
- export function getMyFolderRole(storage, folderId) {
590
+ /** Current account's effective role for a vault, or null while it is unavailable. */
591
+ export function getMyVaultRole(storage, vaultId) {
87
592
  const userId = storage.getClient().getUserId();
88
- const tree = storage.getTree(folderId);
593
+ const tree = storage.getTree(vaultId);
89
594
  if (!userId || !tree)
90
595
  return null;
91
596
  return tree.getPermissions(userId);
92
597
  }
93
- export async function joinFolder(storage, folderId) {
94
- try {
95
- await withRateLimitRetry(() => storage.getClient().joinRoom(folderId));
96
- }
97
- catch (err) {
98
- throw new StorageError(`join failed: ${err.message}`);
99
- }
100
- return { folderId, joined: true };
598
+ export async function joinVault(storage, vaultId, options) {
599
+ return withOperationDeadline(options, async (signal) => {
600
+ let membership;
601
+ try {
602
+ membership = await storage.getRoomMembership(vaultId, undefined, { signal });
603
+ }
604
+ catch (err) {
605
+ if (signal.aborted)
606
+ throw new StorageError("operation cancelled");
607
+ // Synapse refuses GET /rooms/:room/members for an invited user with
608
+ // M_FORBIDDEN. That response means the membership preflight cannot
609
+ // inspect the room yet; it is not evidence that joining is forbidden.
610
+ // Attempt the idempotent join and let its own response decide access.
611
+ if (err instanceof MatrixError && err.errcode === "M_FORBIDDEN") {
612
+ membership = null;
613
+ }
614
+ else {
615
+ throw new StorageError("join failed");
616
+ }
617
+ }
618
+ if (membership === "join")
619
+ return { vaultId, joined: true };
620
+ try {
621
+ await withRateLimitRetry(() => withMatrixMutationAbort(storage.getClient(), () => storage.getClient().joinRoom(vaultId), signal), signal);
622
+ }
623
+ catch (err) {
624
+ if (err instanceof MutationOutcomeUnknownError)
625
+ throw err;
626
+ if (err instanceof MatrixError && err.errcode === "M_FORBIDDEN") {
627
+ const afterForbidden = await storage.getRoomMembership(vaultId, undefined, { signal });
628
+ if (afterForbidden === "join")
629
+ return { vaultId, joined: true };
630
+ }
631
+ if (signal.aborted)
632
+ throw new StorageError("operation cancelled");
633
+ throw new StorageError("join failed");
634
+ }
635
+ ensureOperationActive(signal);
636
+ return { vaultId, joined: true };
637
+ }, "mutation");
101
638
  }
102
639
  function roomDisplayName(storage, roomId, fallbackName) {
103
640
  const client = storage.getClient();
@@ -111,227 +648,758 @@ function roomDisplayName(storage, roomId, fallbackName) {
111
648
  return room.getDefaultRoomName(userId);
112
649
  return roomId;
113
650
  }
651
+ function hasActiveSpaceParent(room) {
652
+ const result = room.currentState?.getStateEvents?.(EventType.SpaceParent);
653
+ const events = result == null ? [] : Array.isArray(result) ? result : [result];
654
+ return events.some((event) => isActiveRelationEvent(event));
655
+ }
656
+ function isReviewedStorageTreeRoom(room) {
657
+ const createEvent = room?.currentState?.getStateEvents?.(EventType.RoomCreate, "");
658
+ const createContent = createEvent?.getContent?.();
659
+ const purposeEvent = room?.currentState?.getStateEvents?.(UNSTABLE_MSC3088_PURPOSE.name, UNSTABLE_MSC3089_TREE_SUBTYPE.name);
660
+ const purposeContent = purposeEvent?.getContent?.();
661
+ return (createContent?.[RoomCreateTypeField] === RoomType.Space &&
662
+ purposeContent?.[UNSTABLE_MSC3088_ENABLED.name] === true);
663
+ }
114
664
  /** Rooms where this account is invited and the room looks like a file tree. */
115
- export async function listPendingInvites(storage) {
116
- const client = storage.getClient();
117
- const invites = [];
118
- for (const room of client.getRooms()) {
119
- if (room.getMyMembership() !== "invite")
120
- continue;
121
- const tree = storage.getTree(room.roomId);
122
- if (tree) {
123
- invites.push({
124
- id: tree.id,
125
- name: roomDisplayName(storage, room.roomId, tree.room.name),
126
- });
127
- continue;
665
+ export async function listPendingInvites(storage, options) {
666
+ return withOperationDeadline(options, async (signal) => {
667
+ const client = storage.getClient();
668
+ const rooms = client.getRooms();
669
+ if (rooms.length > MAX_LIST_ITEMS)
670
+ throw new StorageError("invite list is too large");
671
+ const invites = [];
672
+ for (const room of rooms) {
673
+ ensureOperationActive(signal);
674
+ if (room.getMyMembership() !== "invite")
675
+ continue;
676
+ if (hasActiveSpaceParent(room))
677
+ continue;
678
+ const tree = storage.getTree(room.roomId);
679
+ if (tree) {
680
+ if (tree.isTopLevel) {
681
+ invites.push({
682
+ id: tree.id,
683
+ name: roomDisplayName(storage, room.roomId, tree.room.name),
684
+ });
685
+ }
686
+ continue;
687
+ }
688
+ // Invite state may not have MSC3089 tree metadata yet — accept rooms whose
689
+ // create event marks them as a file tree space.
690
+ if (isReviewedStorageTreeRoom(room)) {
691
+ invites.push({
692
+ id: room.roomId,
693
+ name: roomDisplayName(storage, room.roomId),
694
+ });
695
+ }
128
696
  }
129
- // Invite state may not have MSC3089 tree metadata yet — accept rooms whose
130
- // create event marks them as a file tree space.
131
- const createEvent = room.currentState?.getStateEvents("m.room.create", "");
132
- const createContent = createEvent?.getContent();
133
- const roomType = createContent?.["type"];
134
- if (roomType === "org.matrix.msc3088.file" || roomType === "m.space") {
135
- invites.push({
136
- id: room.roomId,
137
- name: roomDisplayName(storage, room.roomId),
138
- });
697
+ return invites;
698
+ });
699
+ }
700
+ /** Decline a vault invite (same as leaving before join). */
701
+ export async function declineInvite(storage, vaultId, options) {
702
+ return withOperationDeadline(options, async (signal) => {
703
+ const client = storage.getClient();
704
+ try {
705
+ const room = typeof client.getRoom === "function" ? client.getRoom(vaultId) : undefined;
706
+ if (!room)
707
+ throw new StorageError("decline failed");
708
+ const localMembership = typeof room.getMyMembership === "function"
709
+ ? room.getMyMembership()
710
+ : null;
711
+ // Invites are destructive: refresh the exact room before validating its
712
+ // space relation and local tree metadata. A stale sync snapshot could
713
+ // otherwise make a now-nested or unrelated room look safe to forget.
714
+ // Synapse deliberately rejects full-state reads for pre-join rooms, so
715
+ // an invite/knock must use the reviewed stripped state delivered with
716
+ // the invite; joined rooms still get the authoritative refresh.
717
+ if (localMembership !== "invite" && localMembership !== "knock") {
718
+ await storage.refreshRoomState(vaultId, { signal });
719
+ }
720
+ const currentRoom = typeof client.getRoom === "function" ? client.getRoom(vaultId) : undefined;
721
+ // Matrix 42's unstableGetFileTreeSpace() intentionally returns null for
722
+ // invited rooms: it only constructs a tree after local membership is
723
+ // `join`. A pending invite is nevertheless a reviewed top-level tree
724
+ // room, so validate the authoritative room state directly instead of
725
+ // requiring a joined TreeSpace object.
726
+ if (!currentRoom ||
727
+ hasActiveSpaceParent(currentRoom) ||
728
+ !isReviewedStorageTreeRoom(currentRoom)) {
729
+ throw new StorageError("decline failed");
730
+ }
731
+ let membership;
732
+ try {
733
+ membership = await storage.getRoomMembership(vaultId, undefined, { signal });
734
+ }
735
+ catch (error) {
736
+ // The membership endpoint is also forbidden for an invited user. The
737
+ // local pre-join membership is the server-delivered invite state that
738
+ // authorizes this narrowly scoped leave/forget operation.
739
+ if (error instanceof MatrixError &&
740
+ error.errcode === "M_FORBIDDEN" &&
741
+ (localMembership === "invite" || localMembership === "knock")) {
742
+ membership = localMembership;
743
+ }
744
+ else {
745
+ throw error;
746
+ }
747
+ }
748
+ // Decline is intentionally narrower than delete/leave: it must never
749
+ // remove an already-joined vault or forget an unrelated room ID.
750
+ if (membership !== "invite" && membership !== "knock") {
751
+ return { vaultId, declined: false };
752
+ }
753
+ try {
754
+ await withRateLimitRetry(() => withMatrixMutationAbort(client, () => client.leave(vaultId), signal), signal);
755
+ }
756
+ catch (error) {
757
+ let suppress = isGoneError(error);
758
+ if (!suppress && error instanceof MatrixError && error.errcode === "M_FORBIDDEN") {
759
+ const after = await storage.getRoomMembership(vaultId, undefined, { signal });
760
+ suppress = after !== "join" && after !== "invite" && after !== "knock";
761
+ }
762
+ if (!suppress)
763
+ throw error;
764
+ }
765
+ try {
766
+ await withRateLimitRetry(() => withMatrixMutationAbort(client, () => client.forget(vaultId), signal), signal);
767
+ }
768
+ catch (error) {
769
+ let suppress = isGoneError(error);
770
+ if (!suppress && error instanceof MatrixError && error.errcode === "M_FORBIDDEN") {
771
+ const after = await storage.getRoomMembership(vaultId, undefined, { signal });
772
+ suppress = after !== "join" && after !== "invite" && after !== "knock";
773
+ }
774
+ if (!suppress)
775
+ throw error;
776
+ }
777
+ removeRoomFromLocalStore(client, vaultId);
778
+ }
779
+ catch (error) {
780
+ // A cancellation or transport failure after leave/forget may have
781
+ // changed membership. Keep the outcome explicit instead of claiming a
782
+ // clean decline.
783
+ if (error instanceof MutationOutcomeUnknownError)
784
+ throw error;
785
+ if (signal.aborted)
786
+ throw new StorageError("operation cancelled");
787
+ throw new StorageError("decline failed");
139
788
  }
789
+ ensureOperationActive(signal);
790
+ return { vaultId, declined: true };
791
+ }, "mutation");
792
+ }
793
+ /** Invites `userId` to the vault and applies the requested role. */
794
+ export async function shareVault(storage, vaultId, userId, role, options) {
795
+ if (role !== "viewer" && role !== "editor") {
796
+ throw new StorageError("invalid --role (must be viewer or editor)");
797
+ }
798
+ const operation = createOperationDeadline(options);
799
+ const completedRoomIds = new Set();
800
+ try {
801
+ ensureOperationActive(operation.signal);
802
+ const tree = await resolveTree(storage, vaultId, operation.signal);
803
+ const pending = withTreeMutation(storage.getClient(), async () => {
804
+ const client = storage.getClient();
805
+ try {
806
+ ensureOperationActive(operation.signal);
807
+ await storage.refreshRoomState(tree.id, { signal: operation.signal });
808
+ const spaces = snapshotTreeSpaces(tree);
809
+ await refreshTreeSpaces(storage, spaces, operation.signal);
810
+ const currentUser = storage.getClient().getUserId();
811
+ if (currentUser && userId === currentUser) {
812
+ throw new StorageError("share target is the current user");
813
+ }
814
+ const currentMembers = await withRateLimitRetry(() => storage.listMembers(tree, { signal: operation.signal }), operation.signal);
815
+ if (currentMembers.some((member) => member.userId === userId && member.role === "owner")) {
816
+ throw new StorageError("share will not demote an existing owner");
817
+ }
818
+ for (const [index, space] of spaces.entries()) {
819
+ ensureOperationActive(operation.signal);
820
+ const currentMembership = index === 0
821
+ ? currentMembers.find((member) => member.userId === userId)?.membership
822
+ : await storage.getRoomMembership(space.id, userId, {
823
+ signal: operation.signal,
824
+ });
825
+ if (isActiveMembership(currentMembership ?? ""))
826
+ continue;
827
+ try {
828
+ await withRateLimitRetry(() => withMatrixMutationAbort(client, () => space.invite(userId), operation.signal), operation.signal);
829
+ completedRoomIds.add(space.id);
830
+ }
831
+ catch (error) {
832
+ if (!(error instanceof MatrixError) || error.errcode !== "M_FORBIDDEN")
833
+ throw error;
834
+ // Synapse may race another invite/join and answer M_FORBIDDEN. Only
835
+ // suppress that typed condition after re-reading authoritative
836
+ // membership; never infer it from provider-controlled error text.
837
+ const membership = await storage.getRoomMembership(space.id, userId, {
838
+ signal: operation.signal,
839
+ });
840
+ if (!isActiveMembership(membership ?? ""))
841
+ throw new StorageError("share failed");
842
+ }
843
+ }
844
+ for (const space of spaces) {
845
+ ensureOperationActive(operation.signal);
846
+ const members = space.id === tree.id
847
+ ? currentMembers
848
+ : await withRateLimitRetry(() => storage.listMembers(space, { signal: operation.signal }), operation.signal);
849
+ if (members.some((member) => member.userId === userId && member.role === "owner")) {
850
+ throw new StorageError("share will not demote an existing owner");
851
+ }
852
+ await withRateLimitRetry(() => withMatrixMutationAbort(client, () => space.setPermissions(userId, role), operation.signal), operation.signal);
853
+ completedRoomIds.add(space.id);
854
+ }
855
+ }
856
+ catch (error) {
857
+ if (error instanceof MutationOutcomeUnknownError)
858
+ throw error;
859
+ if (completedRoomIds.size > 0) {
860
+ throw new MutationPartialError("share", [...completedRoomIds], safePartialDetail(error));
861
+ }
862
+ if (error instanceof StorageError &&
863
+ (error.message === "share failed" ||
864
+ error.message.includes("owner") ||
865
+ error.message.includes("current user") ||
866
+ error.message === "operation cancelled")) {
867
+ throw error;
868
+ }
869
+ throw new StorageError("share failed");
870
+ }
871
+ return { vaultId, userId, role };
872
+ }, operation.signal);
873
+ return await raceOperationDeadline(operation, pending, "mutation");
874
+ }
875
+ finally {
876
+ operation.close();
877
+ }
878
+ }
879
+ export async function unshareVault(storage, vaultId, userId, options) {
880
+ const operation = createOperationDeadline(options);
881
+ const completedRoomIds = new Set();
882
+ try {
883
+ ensureOperationActive(operation.signal);
884
+ const tree = await resolveTree(storage, vaultId, operation.signal);
885
+ const pending = withTreeMutation(storage.getClient(), async () => {
886
+ try {
887
+ const currentUser = storage.getClient().getUserId();
888
+ if (currentUser && userId === currentUser) {
889
+ throw new StorageError("unshare target is the current user");
890
+ }
891
+ await storage.refreshRoomState(tree.id, { signal: operation.signal });
892
+ const spaces = snapshotTreeSpaces(tree);
893
+ await refreshTreeSpaces(storage, spaces, operation.signal);
894
+ for (const space of spaces) {
895
+ ensureOperationActive(operation.signal);
896
+ const members = await withRateLimitRetry(() => storage.listMembers(space, { signal: operation.signal }), operation.signal);
897
+ if (members.some((member) => member.userId === userId && member.role === "owner")) {
898
+ throw new StorageError("unshare will not remove an existing owner");
899
+ }
900
+ let membership;
901
+ try {
902
+ membership = await storage.getRoomMembership(space.id, userId, {
903
+ signal: operation.signal,
904
+ });
905
+ }
906
+ catch (error) {
907
+ if (isGoneError(error))
908
+ continue;
909
+ throw error;
910
+ }
911
+ if (!isRevocableMembership(membership))
912
+ continue;
913
+ try {
914
+ await withRateLimitRetry(() => withMatrixMutationAbort(storage.getClient(), () => storage.getClient().kick(space.id, userId, "unshared"), operation.signal), operation.signal);
915
+ completedRoomIds.add(space.id);
916
+ }
917
+ catch (error) {
918
+ if (isGoneError(error))
919
+ continue;
920
+ if (error instanceof MatrixError && error.errcode === "M_FORBIDDEN") {
921
+ const afterForbidden = await storage.getRoomMembership(space.id, userId, {
922
+ signal: operation.signal,
923
+ });
924
+ if (!isRevocableMembership(afterForbidden))
925
+ continue;
926
+ }
927
+ throw error;
928
+ }
929
+ }
930
+ }
931
+ catch (error) {
932
+ if (error instanceof MutationOutcomeUnknownError)
933
+ throw error;
934
+ if (completedRoomIds.size > 0) {
935
+ throw new MutationPartialError("unshare", [...completedRoomIds], safePartialDetail(error));
936
+ }
937
+ if (error instanceof StorageError &&
938
+ (error.message.includes("owner") ||
939
+ error.message.includes("current user") ||
940
+ error.message === "operation cancelled")) {
941
+ throw error;
942
+ }
943
+ throw new StorageError("unshare failed");
944
+ }
945
+ return { vaultId, userId, removed: true };
946
+ }, operation.signal);
947
+ return await raceOperationDeadline(operation, pending, "mutation");
948
+ }
949
+ finally {
950
+ operation.close();
140
951
  }
141
- return invites;
142
952
  }
143
- /** Decline a folder invite (same as leaving before join). */
144
- export async function declineInvite(storage, folderId) {
953
+ export async function listMembers(storage, vaultId, options) {
954
+ return withOperationDeadline(options, async (signal) => {
955
+ const tree = await resolveTree(storage, vaultId, signal);
956
+ try {
957
+ return await withRateLimitRetry(() => storage.listMembers(tree, { signal }), signal);
958
+ }
959
+ catch {
960
+ if (signal.aborted)
961
+ throw new StorageError("operation cancelled");
962
+ throw new StorageError("list members failed");
963
+ }
964
+ });
965
+ }
966
+ export async function listFiles(storage, treeId, options) {
967
+ return withOperationDeadline(options, async (signal) => {
968
+ const tree = await resolveTree(storage, treeId, signal);
969
+ try {
970
+ await storage.refreshRoomState(treeId, { signal });
971
+ ensureOperationActive(signal);
972
+ const files = tree.listFiles();
973
+ if (files.length > MAX_LIST_ITEMS)
974
+ throw new StorageError("file list is too large");
975
+ return files.map((f) => ({ id: f.id, name: f.getName() }));
976
+ }
977
+ catch (error) {
978
+ if (signal.aborted)
979
+ throw new StorageError("operation cancelled");
980
+ if (error instanceof StorageError && error.message === "file list is too large")
981
+ throw error;
982
+ throw new StorageError("list files failed");
983
+ }
984
+ });
985
+ }
986
+ /** Lists the direct child folders of a vault or folder. */
987
+ export async function listSubfolders(storage, parentId, options) {
988
+ return withOperationDeadline(options, async (signal) => {
989
+ const tree = await resolveTree(storage, parentId, signal);
990
+ ensureOperationActive(signal);
991
+ const directories = tree.getDirectories();
992
+ if (directories.length > MAX_LIST_ITEMS)
993
+ throw new StorageError("folder list is too large");
994
+ return directories.map((d) => ({ id: d.id, name: d.room.name }));
995
+ });
996
+ }
997
+ export async function createSubfolder(storage, parentId, name, options) {
998
+ validateName(name, "name");
999
+ return withOperationDeadline(options, async (signal) => {
1000
+ const tree = await resolveTree(storage, parentId, signal);
1001
+ let sub;
1002
+ try {
1003
+ sub = await withRateLimitRetry(() => storage.createSubtree(tree, name, signal), signal);
1004
+ }
1005
+ catch (error) {
1006
+ if (error instanceof RoomCleanupIncompleteError ||
1007
+ error instanceof RoomCreationAmbiguousError ||
1008
+ error instanceof MutationOutcomeUnknownError ||
1009
+ (error instanceof Error &&
1010
+ error.cleanupIncomplete === true)) {
1011
+ throw error;
1012
+ }
1013
+ if (signal.aborted)
1014
+ throw new StorageError("operation cancelled");
1015
+ throw new StorageError("create folder failed");
1016
+ }
1017
+ ensureOperationActive(signal);
1018
+ return { id: sub.id, name };
1019
+ }, "mutation");
1020
+ }
1021
+ async function renameTree(storage, treeId, name, options) {
1022
+ validateName(name, "name");
1023
+ return withOperationDeadline(options, async (signal) => {
1024
+ const tree = await resolveTree(storage, treeId, signal);
1025
+ try {
1026
+ const client = typeof storage.getClient === "function" ? storage.getClient() : undefined;
1027
+ await withRateLimitRetry(() => client ? withMatrixMutationAbort(client, () => tree.setName(name), signal) : tree.setName(name), signal);
1028
+ await waitForCondition(async () => {
1029
+ ensureOperationActive(signal);
1030
+ const current = storage.getTree(treeId);
1031
+ if (current?.room.name === name)
1032
+ return current;
1033
+ try {
1034
+ await storage.refreshRoomState(treeId, { signal });
1035
+ }
1036
+ catch {
1037
+ return null;
1038
+ }
1039
+ const refreshed = storage.getTree(treeId);
1040
+ return refreshed?.room.name === name ? refreshed : null;
1041
+ }, { timeoutMs: 15000, signal });
1042
+ }
1043
+ catch (error) {
1044
+ if (error instanceof MutationOutcomeUnknownError)
1045
+ throw error;
1046
+ if (signal.aborted)
1047
+ throw new StorageError("operation cancelled");
1048
+ throw new StorageError("rename failed");
1049
+ }
1050
+ return { id: treeId, name };
1051
+ }, "mutation");
1052
+ }
1053
+ /** Renames a top-level Vault. */
1054
+ export async function renameVault(storage, vaultId, name, options) {
1055
+ return renameTree(storage, vaultId, name, options);
1056
+ }
1057
+ /** Renames a nested folder. */
1058
+ export async function renameFolder(storage, folderId, name, options) {
1059
+ return renameTree(storage, folderId, name, options);
1060
+ }
1061
+ async function deleteTree(storage, treeId, options) {
1062
+ const operation = createOperationDeadline(options);
145
1063
  const client = storage.getClient();
1064
+ let deleted = deletedTreeIds.get(client);
1065
+ if (!deleted) {
1066
+ deleted = new Set();
1067
+ deletedTreeIds.set(client, deleted);
1068
+ }
146
1069
  try {
147
- await withRateLimitRetry(async () => {
148
- await client.leave(folderId);
149
- // The server only allows forgetting a room once this account's
150
- // membership is `leave`; forgetting removes the room from the local
151
- // store immediately, so a declined invite stops showing up in
152
- // listFolders/listPendingInvites without waiting for the leave event
153
- // to round-trip through the background sync loop.
154
- await client.forget(folderId);
155
- });
1070
+ ensureOperationActive(operation.signal);
1071
+ const pending = withTreeMutation(client, async () => {
1072
+ if (deleted.has(treeId))
1073
+ return { id: treeId, deleted: true };
1074
+ const tree = await resolveTree(storage, treeId, operation.signal);
1075
+ // Read reconciliation state after this operation reaches the single
1076
+ // client queue; a preceding delete may have populated the set while we
1077
+ // were waiting for its turn.
1078
+ const removedRooms = deletedRoomIds.get(client) ?? new Set();
1079
+ let graph;
1080
+ try {
1081
+ // Re-read every locally visible room before taking the immutable graph
1082
+ // snapshot. Local MSC3089 relations and memberships can lag another
1083
+ // session; deleting from that stale view can orphan shared descendants.
1084
+ await refreshDeletionRooms(storage, operation.signal);
1085
+ const spaces = snapshotTreeSpaces(tree, "delete graph is too large");
1086
+ const activeSpaces = spaces.filter((space) => !removedRooms.has(space.id));
1087
+ const ids = new Set(activeSpaces.map((space) => space.id));
1088
+ if (!ids.has(tree.id)) {
1089
+ if (removedRooms.has(tree.id)) {
1090
+ deleted.add(treeId);
1091
+ return { id: treeId, deleted: true };
1092
+ }
1093
+ throw new StorageError("delete graph is unsafe");
1094
+ }
1095
+ assertTreeEmptyForDeletion(tree, activeSpaces);
1096
+ graph = validateDeletionGraph(client, tree, ids, removedRooms);
1097
+ }
1098
+ catch (error) {
1099
+ if (error instanceof MutationOutcomeUnknownError ||
1100
+ error instanceof MutationPartialError ||
1101
+ error instanceof NonEmptyTreeError)
1102
+ throw error;
1103
+ if (error instanceof StorageError && error.message === "operation cancelled")
1104
+ throw error;
1105
+ throw new StorageError("delete failed");
1106
+ }
1107
+ // Keep external parent links intact until the room deletion succeeds.
1108
+ // Unlinking first would silently detach a live vault on failure.
1109
+ try {
1110
+ await unlinkExternalParents(storage, tree.id, graph.externalParents, operation.signal);
1111
+ }
1112
+ catch (error) {
1113
+ if (error instanceof MutationOutcomeUnknownError)
1114
+ throw error;
1115
+ if (error instanceof MutationPartialError)
1116
+ throw error;
1117
+ if (error instanceof StorageError && error.message === "delete graph unlink cleanup is incomplete") {
1118
+ throw error;
1119
+ }
1120
+ if (error instanceof StorageError && error.message === "operation cancelled")
1121
+ throw error;
1122
+ throw new StorageError("delete failed");
1123
+ }
1124
+ try {
1125
+ await deleteRoomDeterministically(storage, tree.id, operation.signal);
1126
+ }
1127
+ catch (error) {
1128
+ // The root is still live when its relation is removed. If deletion then
1129
+ // fails, restore both sides before reporting failure; otherwise a live
1130
+ // vault would be silently detached from its external parent.
1131
+ try {
1132
+ await relinkExternalParents(storage, tree.id, graph.externalParents, new AbortController().signal);
1133
+ }
1134
+ catch {
1135
+ throw new StorageError("delete cleanup is incomplete");
1136
+ }
1137
+ if (error instanceof MutationOutcomeUnknownError)
1138
+ throw error;
1139
+ if (error instanceof MutationPartialError)
1140
+ throw error;
1141
+ if (error instanceof StorageError && error.message === "operation cancelled")
1142
+ throw error;
1143
+ throw new StorageError("delete failed");
1144
+ }
1145
+ deleted.add(treeId);
1146
+ return { id: treeId, deleted: true };
1147
+ }, operation.signal);
1148
+ return await raceOperationDeadline(operation, pending, "mutation");
156
1149
  }
157
- catch (err) {
158
- throw new StorageError(`decline failed: ${err.message}`);
1150
+ finally {
1151
+ operation.close();
159
1152
  }
160
- return { folderId, declined: true };
1153
+ }
1154
+ /** Deletes a top-level Vault. */
1155
+ export async function deleteVault(storage, vaultId, options) {
1156
+ return deleteTree(storage, vaultId, options);
1157
+ }
1158
+ /** Deletes a nested folder. */
1159
+ export async function deleteFolder(storage, folderId, options) {
1160
+ return deleteTree(storage, folderId, options);
1161
+ }
1162
+ export async function renameFile(storage, treeId, fileId, name, options) {
1163
+ validateName(name, "file name");
1164
+ return withOperationDeadline(options, async (signal) => {
1165
+ const tree = await resolveTree(storage, treeId, signal);
1166
+ const branch = await resolveFile(tree, fileId, signal);
1167
+ try {
1168
+ const client = typeof storage.getClient === "function" ? storage.getClient() : undefined;
1169
+ await withRateLimitRetry(() => client ? withMatrixMutationAbort(client, () => branch.setName(name), signal) : branch.setName(name), signal);
1170
+ // `setName` resolves when the homeserver accepts the state event, but a
1171
+ // fresh CLI/UI process can still read the previous local room state for a
1172
+ // short time. Do not report success until this client has observed the new
1173
+ // name through its normal sync loop.
1174
+ await waitForCondition(() => {
1175
+ const current = tree.getFile(fileId);
1176
+ return current?.getName() === name ? current : null;
1177
+ }, { timeoutMs: 15000, signal });
1178
+ }
1179
+ catch (error) {
1180
+ if (error instanceof MutationOutcomeUnknownError)
1181
+ throw error;
1182
+ if (signal.aborted)
1183
+ throw new StorageError("operation cancelled");
1184
+ throw new StorageError("rename file failed");
1185
+ }
1186
+ return { id: fileId, name };
1187
+ }, "mutation");
161
1188
  }
162
1189
  /**
163
- * Invites `userId` to the folder at `role` and applies the role's
164
- * permissions. Doubles as "change an existing participant's role" (call
165
- * again with a different role): inviting someone who's already a member is
166
- * a 403 from the server, not a real failure — that specific error is
167
- * swallowed and the role change still applies. Any other invite failure
168
- * (e.g. unknown user) propagates.
1190
+ * Resolve the complete encrypted version chain before mutating Matrix.
1191
+ *
1192
+ * matrix-js-sdk returns the active version first and older versions after it.
1193
+ * Treat a repeated event ID as a cycle/invalid relation graph, and bound both
1194
+ * the event walk and the media identifiers sent to Synapse. Resolving every
1195
+ * media URL first is important: a decryption or relation failure must not leave
1196
+ * a partially deleted file chain.
169
1197
  */
170
- export async function shareFolder(storage, folderId, userId, role) {
171
- if (role !== "viewer" && role !== "editor") {
172
- throw new StorageError(`invalid --role "${role}" (must be viewer or editor)`);
173
- }
174
- const tree = await resolveTree(storage, folderId);
1198
+ async function resolveFileVersions(branch, signal) {
1199
+ let history;
175
1200
  try {
176
- await withRateLimitRetry(() => tree.invite(userId));
1201
+ history = await branch.getVersionHistory();
1202
+ }
1203
+ catch {
1204
+ throw new StorageError("could not resolve file version history safely");
177
1205
  }
178
- catch (err) {
179
- if (!/already in the room/i.test(err.message)) {
180
- throw err;
1206
+ if (!Array.isArray(history) || history.length === 0 || history.length > MAX_FILE_VERSION_CHAIN) {
1207
+ throw new StorageError("file version history is invalid or too large");
1208
+ }
1209
+ const eventIds = new Set();
1210
+ const mediaIds = new Set();
1211
+ const versions = [];
1212
+ for (const version of history) {
1213
+ if (signal?.aborted)
1214
+ throw new StorageError("operation cancelled");
1215
+ if (!version || typeof version.id !== "string") {
1216
+ throw new StorageError("file version history is invalid");
1217
+ }
1218
+ try {
1219
+ validateMatrixEventId(version.id, "file version event ID");
1220
+ }
1221
+ catch {
1222
+ throw new StorageError("file version history contains an invalid event ID");
1223
+ }
1224
+ if (eventIds.has(version.id)) {
1225
+ throw new StorageError("file version history contains a cycle");
1226
+ }
1227
+ eventIds.add(version.id);
1228
+ let fileInfo;
1229
+ try {
1230
+ fileInfo = await version.getFileInfo();
1231
+ }
1232
+ catch {
1233
+ throw new StorageError("could not resolve encrypted file version safely");
181
1234
  }
1235
+ const mediaId = fileInfo?.info?.url;
1236
+ if (typeof mediaId !== "string" || mediaId.length === 0) {
1237
+ throw new StorageError("encrypted file version has no media identifier");
1238
+ }
1239
+ mediaIds.add(mediaId);
1240
+ if (mediaIds.size > MAX_FILE_VERSION_CHAIN) {
1241
+ throw new StorageError("file media version history is too large");
1242
+ }
1243
+ versions.push({ branch: version, mediaId });
182
1244
  }
183
- await withRateLimitRetry(() => tree.setPermissions(userId, role));
184
- return { folderId, userId, role };
1245
+ return versions;
185
1246
  }
186
- export async function unshareFolder(storage, folderId, userId) {
187
- await resolveTree(storage, folderId);
188
- try {
189
- await withRateLimitRetry(() => storage.getClient().kick(folderId, userId, "unshared"));
190
- }
191
- catch (err) {
192
- throw new StorageError(`unshare failed: ${err.message}`);
193
- }
194
- return { folderId, userId, removed: true };
195
- }
196
- export async function listMembers(storage, folderId) {
197
- const tree = await resolveTree(storage, folderId);
198
- return withRateLimitRetry(() => storage.listMembers(tree));
199
- }
200
- export async function listFiles(storage, folderId) {
201
- const tree = await resolveTree(storage, folderId);
202
- await storage.refreshRoomState(folderId);
203
- return tree.listFiles().map((f) => ({ id: f.id, name: f.getName() }));
204
- }
205
- export async function listSubfolders(storage, folderId) {
206
- const tree = await resolveTree(storage, folderId);
207
- return tree.getDirectories().map((d) => ({ id: d.id, name: d.room.name }));
208
- }
209
- export async function createSubfolder(storage, folderId, name) {
210
- const tree = await resolveTree(storage, folderId);
211
- const sub = await withRateLimitRetry(() => storage.createSubtree(tree, name));
212
- return { id: sub.id, name };
213
- }
214
- export async function renameFolder(storage, folderId, name) {
215
- const tree = await resolveTree(storage, folderId);
216
- await withRateLimitRetry(() => tree.setName(name));
217
- return { id: folderId, name };
218
- }
219
- export async function deleteFolder(storage, folderId) {
220
- const tree = await resolveTree(storage, folderId);
221
- // `tree.delete()` kicks members and leaves this room AND every
222
- // subdirectory room, but never forgets any of them, so they all linger in
223
- // the local store and listFolders keeps returning the deleted folder until
224
- // the leave events round-trip through the background sync loop (30-60s
225
- // under full-suite load). Forgetting removes each room from the local
226
- // store immediately; the server only accepts a forget once membership is
227
- // `leave`, which `tree.delete()` has just completed for the whole tree.
228
- const subdirectoryIds = tree.getDirectories().map((d) => d.id);
1247
+ function requireFileDeletionTransport(client) {
1248
+ const http = client.http;
1249
+ if (!http || typeof http.authedRequest !== "function") {
1250
+ throw new StorageError("Matrix HTTP transport unavailable");
1251
+ }
1252
+ return http;
1253
+ }
1254
+ async function deleteFileMedia(storage, mediaIds, signal) {
229
1255
  const client = storage.getClient();
230
- await withRateLimitRetry(async () => {
231
- await tree.delete();
232
- await client.forget(folderId);
233
- for (const subId of subdirectoryIds) {
234
- await client.forget(subId);
235
- }
236
- });
237
- return { id: folderId, deleted: true };
238
- }
239
- export async function renameFile(storage, folderId, fileId, name) {
240
- const tree = await resolveTree(storage, folderId);
241
- const branch = await resolveFile(tree, fileId);
242
- await withRateLimitRetry(() => branch.setName(name));
243
- // `setName` resolves when the homeserver accepts the state event, but a
244
- // fresh CLI/UI process can still read the previous local room state for a
245
- // short time. Do not report success until this client has observed the new
246
- // name through its normal sync loop.
247
- await waitForCondition(() => {
248
- const current = tree.getFile(fileId);
249
- return current?.getName() === name ? current : null;
250
- }, { timeoutMs: 15000 });
251
- return { id: fileId, name };
252
- }
253
- export async function deleteFile(storage, folderId, fileId) {
254
- const tree = await resolveTree(storage, folderId);
255
- const branch = await resolveFile(tree, fileId);
256
- await withRateLimitRetry(() => branch.delete());
257
- return { id: fileId, deleted: true };
258
- }
259
- export async function uploadFile(storage, folderId, name, bytes, mimetype) {
260
- const tree = await resolveTree(storage, folderId);
261
- const fileId = await withRateLimitRetry(() => storage.uploadFile(tree, name, toArrayBuffer(bytes), mimetype));
262
- return { id: fileId, name, mimetype };
263
- }
264
- export async function downloadFile(storage, folderId, fileId) {
265
- const tree = await resolveTree(storage, folderId);
266
- const branch = await resolveFile(tree, fileId);
267
- let result;
1256
+ const http = requireFileDeletionTransport(client);
268
1257
  try {
269
- result = await storage.downloadFile(branch);
1258
+ await withRateLimitRetry(() => withMatrixMutationAbort(client, () => http.authedRequest(Method.Post, "/io.telecrypt.storage/delete_media", undefined, { media_ids: [...mediaIds] }, { prefix: ClientPrefix.Unstable, abortSignal: signal }), signal, "delete file media"), signal);
270
1259
  }
271
- catch (err) {
272
- throw new StorageError(`download failed: ${err.message}`);
1260
+ catch (error) {
1261
+ if (error instanceof MutationOutcomeUnknownError)
1262
+ throw error;
1263
+ if (signal?.aborted)
1264
+ throw new StorageError("operation cancelled");
1265
+ throw new StorageError("delete file media failed");
273
1266
  }
274
- return {
275
- bytes: new Uint8Array(result.data),
276
- mimetype: result.mimetype,
277
- name: branch.getName(),
278
- };
279
1267
  }
280
- export async function setupRecovery(storage) {
281
- return storage.keys.setupRecovery();
1268
+ export async function deleteFile(storage, treeId, fileId, options) {
1269
+ return withOperationDeadline(options, async (signal) => {
1270
+ const tree = await resolveTree(storage, treeId, signal);
1271
+ const branch = await resolveFile(tree, fileId, signal);
1272
+ const versions = await resolveFileVersions(branch, signal);
1273
+ const mediaIds = [...new Set(versions.map(({ mediaId }) => mediaId))];
1274
+ await deleteFileMedia(storage, mediaIds, signal);
1275
+ const completedIds = [];
1276
+ try {
1277
+ const client = typeof storage.getClient === "function" ? storage.getClient() : undefined;
1278
+ if (!client)
1279
+ throw new StorageError("Matrix client unavailable");
1280
+ for (const { branch: version } of versions) {
1281
+ await withRateLimitRetry(() => withMatrixMutationAbort(client, () => client.sendStateEvent(tree.id, UNSTABLE_MSC3089_BRANCH.name, {}, version.id), signal, "delete file state"), signal);
1282
+ await withRateLimitRetry(() => withMatrixMutationAbort(client, () => client.redactEvent(tree.id, version.id), signal, "delete file event"), signal);
1283
+ completedIds.push(version.id);
1284
+ }
1285
+ }
1286
+ catch (error) {
1287
+ if (error instanceof MutationOutcomeUnknownError)
1288
+ throw error;
1289
+ if (signal.aborted) {
1290
+ throw new MutationPartialError("delete file", completedIds, "media was deleted but Matrix event cleanup was cancelled");
1291
+ }
1292
+ throw new MutationPartialError("delete file", completedIds, "media was deleted but Matrix event cleanup stopped");
1293
+ }
1294
+ ensureOperationActive(signal);
1295
+ return { id: fileId, deleted: true };
1296
+ }, "mutation");
1297
+ }
1298
+ export async function uploadFile(storage, treeId, name, bytes, mimetype, options) {
1299
+ return withOperationDeadline(options, async (signal) => {
1300
+ const tree = await resolveTree(storage, treeId, signal);
1301
+ const fileId = await withRateLimitRetry(() => storage.uploadFile(tree, name, toArrayBuffer(bytes), mimetype, signal), signal).catch((error) => {
1302
+ if (error instanceof FileTooLargeError)
1303
+ throw error;
1304
+ if (error instanceof MutationOutcomeUnknownError)
1305
+ throw error;
1306
+ if (signal.aborted)
1307
+ throw new StorageError("operation cancelled");
1308
+ throw new StorageError("upload failed");
1309
+ });
1310
+ ensureOperationActive(signal);
1311
+ return { id: fileId, name, mimetype };
1312
+ }, "mutation");
1313
+ }
1314
+ export async function downloadFile(storage, treeId, fileId, options) {
1315
+ return withOperationDeadline(options, async (signal) => {
1316
+ const tree = await resolveTree(storage, treeId, signal);
1317
+ const branch = await resolveFile(tree, fileId, signal);
1318
+ let result;
1319
+ try {
1320
+ result = await storage.downloadFile(branch, signal);
1321
+ }
1322
+ catch (error) {
1323
+ if (error instanceof UndecryptableFileError)
1324
+ throw error;
1325
+ if (signal.aborted)
1326
+ throw new StorageError("operation cancelled");
1327
+ throw new StorageError("download failed");
1328
+ }
1329
+ ensureOperationActive(signal);
1330
+ return {
1331
+ bytes: new Uint8Array(result.data),
1332
+ mimetype: result.mimetype,
1333
+ name: branch.getName(),
1334
+ };
1335
+ });
282
1336
  }
283
- export async function restoreRecovery(storage, recoveryKey) {
284
- return storage.keys.restoreFromRecoveryKey(recoveryKey);
1337
+ export async function setupRecovery(storage, options) {
1338
+ return withOperationDeadline(options, (signal) => storage.keys.setupRecovery(signal), "mutation");
1339
+ }
1340
+ export async function restoreRecovery(storage, recoveryKey, options) {
1341
+ return withOperationDeadline(options, (signal) => storage.keys.restoreFromRecoveryKey(recoveryKey, signal), "mutation");
285
1342
  }
286
1343
  function tsToIso(ts) {
287
1344
  if (ts == null || !Number.isFinite(ts))
288
1345
  return null;
289
1346
  return new Date(ts).toISOString();
290
1347
  }
291
- export async function getFileDetails(storage, folderId, fileId) {
292
- const tree = await resolveTree(storage, folderId);
293
- const branch = await resolveFile(tree, fileId);
294
- const name = branch.getName();
295
- let mimetype = null;
296
- let size = null;
297
- let createdAt = null;
298
- let updatedAt = null;
299
- try {
300
- const { info } = await branch.getFileInfo();
301
- if (info) {
302
- if (typeof info["mimetype"] === "string")
303
- mimetype = info["mimetype"];
304
- if (typeof info["size"] === "number")
305
- size = info["size"];
1348
+ export async function getFileDetails(storage, treeId, fileId, options) {
1349
+ return withOperationDeadline(options, async (signal) => {
1350
+ const tree = await resolveTree(storage, treeId, signal);
1351
+ const branch = await resolveFile(tree, fileId, signal);
1352
+ const name = branch.getName();
1353
+ let mimetype = null;
1354
+ let size = null;
1355
+ let createdAt = null;
1356
+ let updatedAt = null;
1357
+ try {
1358
+ ensureOperationActive(signal);
1359
+ const { info } = await branch.getFileInfo();
1360
+ if (info) {
1361
+ if (typeof info["mimetype"] === "string")
1362
+ mimetype = info["mimetype"];
1363
+ if (typeof info["size"] === "number")
1364
+ size = info["size"];
1365
+ }
306
1366
  }
307
- }
308
- catch {
309
- // Partial metadata is fine — UI shows "—" for unknown fields.
310
- }
311
- try {
312
- const event = await branch.getFileEvent();
313
- const content = event.getContent();
314
- const infoBlock = content["info"];
315
- if (!mimetype && typeof infoBlock?.["mimetype"] === "string") {
316
- mimetype = infoBlock["mimetype"];
1367
+ catch {
1368
+ if (signal.aborted)
1369
+ throw new StorageError("operation cancelled");
1370
+ // Partial metadata is fine — UI shows "—" for unknown fields.
317
1371
  }
318
- if (size == null && typeof infoBlock?.["size"] === "number") {
319
- size = infoBlock["size"];
1372
+ try {
1373
+ ensureOperationActive(signal);
1374
+ const event = await branch.getFileEvent();
1375
+ const content = event.getContent();
1376
+ const infoBlock = content["info"];
1377
+ if (!mimetype && typeof infoBlock?.["mimetype"] === "string") {
1378
+ mimetype = infoBlock["mimetype"];
1379
+ }
1380
+ if (size == null && typeof infoBlock?.["size"] === "number") {
1381
+ size = infoBlock["size"];
1382
+ }
1383
+ const eventAny = event;
1384
+ const ts = eventAny.getTs?.() ?? eventAny.origin_server_ts;
1385
+ createdAt = tsToIso(ts);
1386
+ updatedAt = createdAt;
320
1387
  }
321
- const eventAny = event;
322
- const ts = eventAny.getTs?.() ?? eventAny.origin_server_ts;
323
- createdAt = tsToIso(ts);
324
- updatedAt = createdAt;
325
- }
326
- catch {
327
- // Same as above.
328
- }
329
- return { name, mimetype, size, createdAt, updatedAt };
1388
+ catch {
1389
+ if (signal.aborted)
1390
+ throw new StorageError("operation cancelled");
1391
+ // Same as above.
1392
+ }
1393
+ ensureOperationActive(signal);
1394
+ return { name, mimetype, size, createdAt, updatedAt };
1395
+ });
330
1396
  }
331
- export async function getFolderDetails(storage, folderId) {
332
- const tree = await resolveTree(storage, folderId);
1397
+ async function getTreeDetails(storage, treeId, signal) {
1398
+ const tree = await resolveTree(storage, treeId, signal);
1399
+ if (signal?.aborted)
1400
+ throw new StorageError("operation cancelled");
333
1401
  const client = storage.getClient();
334
- const room = client.getRoom(folderId);
1402
+ const room = client.getRoom(treeId);
335
1403
  let createdAt = null;
336
1404
  let memberCount = null;
337
1405
  if (room) {
@@ -345,9 +1413,17 @@ export async function getFolderDetails(storage, folderId) {
345
1413
  }
346
1414
  }
347
1415
  return {
348
- name: tree.room.name || roomDisplayName(storage, folderId),
349
- id: folderId,
1416
+ name: tree.room.name || roomDisplayName(storage, treeId),
1417
+ id: treeId,
350
1418
  createdAt,
351
1419
  memberCount,
352
1420
  };
353
1421
  }
1422
+ /** Returns details for a top-level Vault. */
1423
+ export async function getVaultDetails(storage, vaultId, options) {
1424
+ return withOperationDeadline(options, (signal) => getTreeDetails(storage, vaultId, signal));
1425
+ }
1426
+ /** Returns details for a nested folder. */
1427
+ export async function getFolderDetails(storage, folderId, options) {
1428
+ return withOperationDeadline(options, (signal) => getTreeDetails(storage, folderId, signal));
1429
+ }