@telecrypt-io/storage 0.4.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.
- package/README.md +54 -9
- package/dist/TeleCryptIOStorage.d.ts +86 -45
- package/dist/TeleCryptIOStorage.js +1418 -230
- package/dist/core/constants.d.ts +16 -0
- package/dist/core/constants.js +54 -0
- package/dist/core/errors.d.ts +66 -0
- package/dist/core/errors.js +108 -0
- package/dist/core/http.d.ts +24 -0
- package/dist/core/http.js +199 -0
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.js +1 -0
- package/dist/core/oidc.d.ts +47 -69
- package/dist/core/oidc.js +1196 -137
- package/dist/core/operations.d.ts +31 -32
- package/dist/core/operations.js +1270 -219
- package/dist/core/poll.d.ts +2 -1
- package/dist/core/poll.js +59 -3
- package/dist/core/types.d.ts +14 -0
- package/dist/core/validation.d.ts +3 -0
- package/dist/core/validation.js +10 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/package.json +11 -9
package/dist/core/operations.js
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
|
-
|
|
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 =
|
|
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,7 +130,27 @@ async function withRateLimitRetry(operation) {
|
|
|
38
130
|
}
|
|
39
131
|
}
|
|
40
132
|
}
|
|
41
|
-
|
|
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
|
}
|
|
@@ -49,38 +161,431 @@ async function withRateLimitRetry(operation) {
|
|
|
49
161
|
* later — real async settling, not an instant "not found". Throws a clean
|
|
50
162
|
* error if the storage tree still isn't visible once the poll times out.
|
|
51
163
|
*/
|
|
52
|
-
async function resolveTree(storage, treeId) {
|
|
164
|
+
async function resolveTree(storage, treeId, signal) {
|
|
53
165
|
try {
|
|
54
166
|
return await waitForCondition(() => storage.getTree(treeId), {
|
|
55
167
|
timeoutMs: 15000,
|
|
168
|
+
signal,
|
|
56
169
|
});
|
|
57
170
|
}
|
|
58
171
|
catch {
|
|
59
|
-
|
|
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
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;
|
|
231
|
+
try {
|
|
232
|
+
files = tree.listAllFiles();
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
throw new StorageError("could not enumerate storage files safely");
|
|
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
|
+
}
|
|
66
513
|
try {
|
|
67
|
-
|
|
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);
|
|
68
536
|
}
|
|
69
537
|
catch {
|
|
70
|
-
|
|
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.
|
|
71
541
|
}
|
|
72
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;
|
|
557
|
+
}
|
|
73
558
|
function toArrayBuffer(bytes) {
|
|
74
559
|
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
75
560
|
}
|
|
76
|
-
export async function createVault(storage, name) {
|
|
77
|
-
|
|
78
|
-
|
|
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
583
|
/** Top-level vaults only — excludes subdirectories of an existing tree. */
|
|
81
|
-
export async function listVaults(storage) {
|
|
82
|
-
|
|
83
|
-
|
|
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
590
|
/** Current account's effective role for a vault, or null while it is unavailable. */
|
|
86
591
|
export function getMyVaultRole(storage, vaultId) {
|
|
@@ -90,14 +595,46 @@ export function getMyVaultRole(storage, vaultId) {
|
|
|
90
595
|
return null;
|
|
91
596
|
return tree.getPermissions(userId);
|
|
92
597
|
}
|
|
93
|
-
export async function joinVault(storage, vaultId) {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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,242 +648,756 @@ 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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
if (
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
+
}
|
|
139
696
|
}
|
|
140
|
-
|
|
141
|
-
|
|
697
|
+
return invites;
|
|
698
|
+
});
|
|
142
699
|
}
|
|
143
700
|
/** Decline a vault invite (same as leaving before join). */
|
|
144
|
-
export async function declineInvite(storage, vaultId) {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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");
|
|
788
|
+
}
|
|
789
|
+
ensureOperationActive(signal);
|
|
790
|
+
return { vaultId, declined: true };
|
|
791
|
+
}, "mutation");
|
|
161
792
|
}
|
|
162
|
-
/**
|
|
163
|
-
|
|
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.
|
|
169
|
-
*/
|
|
170
|
-
export async function shareVault(storage, vaultId, userId, role) {
|
|
793
|
+
/** Invites `userId` to the vault and applies the requested role. */
|
|
794
|
+
export async function shareVault(storage, vaultId, userId, role, options) {
|
|
171
795
|
if (role !== "viewer" && role !== "editor") {
|
|
172
|
-
throw new StorageError(
|
|
796
|
+
throw new StorageError("invalid --role (must be viewer or editor)");
|
|
173
797
|
}
|
|
174
|
-
const
|
|
798
|
+
const operation = createOperationDeadline(options);
|
|
799
|
+
const completedRoomIds = new Set();
|
|
175
800
|
try {
|
|
176
|
-
|
|
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");
|
|
177
874
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
throw err;
|
|
181
|
-
}
|
|
875
|
+
finally {
|
|
876
|
+
operation.close();
|
|
182
877
|
}
|
|
183
|
-
await withRateLimitRetry(() => tree.setPermissions(userId, role));
|
|
184
|
-
return { vaultId, userId, role };
|
|
185
878
|
}
|
|
186
|
-
export async function unshareVault(storage, vaultId, userId) {
|
|
187
|
-
|
|
879
|
+
export async function unshareVault(storage, vaultId, userId, options) {
|
|
880
|
+
const operation = createOperationDeadline(options);
|
|
881
|
+
const completedRoomIds = new Set();
|
|
188
882
|
try {
|
|
189
|
-
|
|
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");
|
|
190
948
|
}
|
|
191
|
-
|
|
192
|
-
|
|
949
|
+
finally {
|
|
950
|
+
operation.close();
|
|
193
951
|
}
|
|
194
|
-
return { vaultId, userId, removed: true };
|
|
195
952
|
}
|
|
196
|
-
export async function listMembers(storage, vaultId) {
|
|
197
|
-
|
|
198
|
-
|
|
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
|
+
});
|
|
199
965
|
}
|
|
200
|
-
export async function listFiles(storage, treeId) {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
+
});
|
|
204
985
|
}
|
|
205
986
|
/** Lists the direct child folders of a vault or folder. */
|
|
206
|
-
export async function listSubfolders(storage, parentId) {
|
|
207
|
-
|
|
208
|
-
|
|
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
|
+
});
|
|
209
996
|
}
|
|
210
|
-
export async function createSubfolder(storage, parentId, name) {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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");
|
|
214
1020
|
}
|
|
215
|
-
async function renameTree(storage, treeId, name) {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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");
|
|
219
1052
|
}
|
|
220
1053
|
/** Renames a top-level Vault. */
|
|
221
|
-
export async function renameVault(storage, vaultId, name) {
|
|
222
|
-
return renameTree(storage, vaultId, name);
|
|
1054
|
+
export async function renameVault(storage, vaultId, name, options) {
|
|
1055
|
+
return renameTree(storage, vaultId, name, options);
|
|
223
1056
|
}
|
|
224
1057
|
/** Renames a nested folder. */
|
|
225
|
-
export async function renameFolder(storage, folderId, name) {
|
|
226
|
-
return renameTree(storage, folderId, name);
|
|
227
|
-
}
|
|
228
|
-
async function deleteTree(storage, treeId) {
|
|
229
|
-
const
|
|
230
|
-
// `tree.delete()` kicks members and leaves this room AND every
|
|
231
|
-
// subdirectory room, but never forgets any of them, so they all linger in
|
|
232
|
-
// the local store and tree listings keep returning the deleted tree until
|
|
233
|
-
// the leave events round-trip through the background sync loop (30-60s
|
|
234
|
-
// under full-suite load). Forgetting removes each room from the local
|
|
235
|
-
// store immediately; the server only accepts a forget once membership is
|
|
236
|
-
// `leave`, which `tree.delete()` has just completed for the whole tree.
|
|
237
|
-
const subdirectoryIds = tree.getDirectories().map((d) => d.id);
|
|
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);
|
|
238
1063
|
const client = storage.getClient();
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
1064
|
+
let deleted = deletedTreeIds.get(client);
|
|
1065
|
+
if (!deleted) {
|
|
1066
|
+
deleted = new Set();
|
|
1067
|
+
deletedTreeIds.set(client, deleted);
|
|
1068
|
+
}
|
|
1069
|
+
try {
|
|
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");
|
|
1149
|
+
}
|
|
1150
|
+
finally {
|
|
1151
|
+
operation.close();
|
|
1152
|
+
}
|
|
247
1153
|
}
|
|
248
1154
|
/** Deletes a top-level Vault. */
|
|
249
|
-
export async function deleteVault(storage, vaultId) {
|
|
250
|
-
return deleteTree(storage, vaultId);
|
|
1155
|
+
export async function deleteVault(storage, vaultId, options) {
|
|
1156
|
+
return deleteTree(storage, vaultId, options);
|
|
251
1157
|
}
|
|
252
1158
|
/** Deletes a nested folder. */
|
|
253
|
-
export async function deleteFolder(storage, folderId) {
|
|
254
|
-
return deleteTree(storage, folderId);
|
|
255
|
-
}
|
|
256
|
-
export async function renameFile(storage, treeId, fileId, name) {
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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");
|
|
1188
|
+
}
|
|
1189
|
+
/**
|
|
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.
|
|
1197
|
+
*/
|
|
1198
|
+
async function resolveFileVersions(branch, signal) {
|
|
1199
|
+
let history;
|
|
285
1200
|
try {
|
|
286
|
-
|
|
1201
|
+
history = await branch.getVersionHistory();
|
|
287
1202
|
}
|
|
288
|
-
catch
|
|
289
|
-
throw new StorageError(
|
|
1203
|
+
catch {
|
|
1204
|
+
throw new StorageError("could not resolve file version history safely");
|
|
290
1205
|
}
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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");
|
|
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 });
|
|
1244
|
+
}
|
|
1245
|
+
return versions;
|
|
1246
|
+
}
|
|
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) {
|
|
1255
|
+
const client = storage.getClient();
|
|
1256
|
+
const http = requireFileDeletionTransport(client);
|
|
1257
|
+
try {
|
|
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);
|
|
1259
|
+
}
|
|
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");
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
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");
|
|
296
1297
|
}
|
|
297
|
-
export async function
|
|
298
|
-
return
|
|
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
|
+
});
|
|
299
1336
|
}
|
|
300
|
-
export async function
|
|
301
|
-
return storage.keys.
|
|
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");
|
|
302
1342
|
}
|
|
303
1343
|
function tsToIso(ts) {
|
|
304
1344
|
if (ts == null || !Number.isFinite(ts))
|
|
305
1345
|
return null;
|
|
306
1346
|
return new Date(ts).toISOString();
|
|
307
1347
|
}
|
|
308
|
-
export async function getFileDetails(storage, treeId, fileId) {
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
+
}
|
|
323
1366
|
}
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
try {
|
|
329
|
-
const event = await branch.getFileEvent();
|
|
330
|
-
const content = event.getContent();
|
|
331
|
-
const infoBlock = content["info"];
|
|
332
|
-
if (!mimetype && typeof infoBlock?.["mimetype"] === "string") {
|
|
333
|
-
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.
|
|
334
1371
|
}
|
|
335
|
-
|
|
336
|
-
|
|
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;
|
|
337
1387
|
}
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
}
|
|
346
|
-
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
|
+
});
|
|
347
1396
|
}
|
|
348
|
-
async function getTreeDetails(storage, treeId) {
|
|
349
|
-
const tree = await resolveTree(storage, treeId);
|
|
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");
|
|
350
1401
|
const client = storage.getClient();
|
|
351
1402
|
const room = client.getRoom(treeId);
|
|
352
1403
|
let createdAt = null;
|
|
@@ -369,10 +1420,10 @@ async function getTreeDetails(storage, treeId) {
|
|
|
369
1420
|
};
|
|
370
1421
|
}
|
|
371
1422
|
/** Returns details for a top-level Vault. */
|
|
372
|
-
export async function getVaultDetails(storage, vaultId) {
|
|
373
|
-
return getTreeDetails(storage, vaultId);
|
|
1423
|
+
export async function getVaultDetails(storage, vaultId, options) {
|
|
1424
|
+
return withOperationDeadline(options, (signal) => getTreeDetails(storage, vaultId, signal));
|
|
374
1425
|
}
|
|
375
1426
|
/** Returns details for a nested folder. */
|
|
376
|
-
export async function getFolderDetails(storage, folderId) {
|
|
377
|
-
return getTreeDetails(storage, folderId);
|
|
1427
|
+
export async function getFolderDetails(storage, folderId, options) {
|
|
1428
|
+
return withOperationDeadline(options, (signal) => getTreeDetails(storage, folderId, signal));
|
|
378
1429
|
}
|