@telecrypt-io/storage 0.5.30 → 0.6.0
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 +4 -5
- package/dist/TeleCryptIOStorage.d.ts +0 -7
- package/dist/TeleCryptIOStorage.js +1 -1
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.js +1 -1
- package/dist/core/operations.d.ts +2 -2
- package/dist/core/operations.js +38 -119
- package/dist/core/validation.d.ts +0 -1
- package/dist/core/validation.js +0 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ is maintained in its own repository.
|
|
|
19
19
|
## Install
|
|
20
20
|
|
|
21
21
|
```bash
|
|
22
|
-
npm install @telecrypt-io/storage@0.
|
|
22
|
+
npm install @telecrypt-io/storage@0.6.0
|
|
23
23
|
```
|
|
24
24
|
|
|
25
25
|
This gives you the `TeleCryptIOStorage` library and its browser-safe `core` API.
|
|
@@ -91,20 +91,19 @@ a file tree using Matrix primitives:
|
|
|
91
91
|
| Vault | A Space (room marked as a file tree) |
|
|
92
92
|
| Folder | A child Space |
|
|
93
93
|
| File | An event pointing at encrypted uploaded content |
|
|
94
|
-
| Version | A newer event superseding the old |
|
|
95
94
|
| Sharing | Room invitation |
|
|
96
95
|
| Permissions | Power levels |
|
|
97
96
|
|
|
98
97
|
### Deletion
|
|
99
98
|
|
|
100
99
|
Vaults and folders are deleted only when they are empty. Delete each file first; deleting a file
|
|
101
|
-
also removes its
|
|
102
|
-
|
|
100
|
+
also removes its encrypted media object before redacting its Matrix event. Delete empty child
|
|
101
|
+
folders before deleting their parent. A nonempty delete
|
|
103
102
|
fails with `NonEmptyTreeError`, so a shared or nested tree is never removed implicitly.
|
|
104
103
|
|
|
105
104
|
Encryption uses the same scheme as Matrix attachments (AES-CTR with a per-file key, keys
|
|
106
105
|
distributed via the room's Megolm session). File deletion uses TeleCrypt's authenticated Synapse
|
|
107
|
-
storage extension to remove the local media
|
|
106
|
+
storage extension to remove the local media object before Matrix redaction; stock
|
|
108
107
|
Synapse without that extension cannot provide the SDK deletion contract.
|
|
109
108
|
|
|
110
109
|
## Development
|
|
@@ -20,15 +20,12 @@ export interface TreeSpace {
|
|
|
20
20
|
setPermissions(userId: string, role: string): Promise<void>;
|
|
21
21
|
getFile(fileEventId: string): FileBranch | null;
|
|
22
22
|
listFiles(): FileBranch[];
|
|
23
|
-
listAllFiles(): FileBranch[];
|
|
24
23
|
createFile(name: string, encryptedContents: ArrayBuffer | Uint8Array, info: Record<string, unknown>, additionalContent?: Record<string, unknown>): Promise<{
|
|
25
24
|
event_id: string;
|
|
26
25
|
}>;
|
|
27
26
|
}
|
|
28
27
|
export interface FileBranch {
|
|
29
28
|
readonly id: string;
|
|
30
|
-
readonly version: number;
|
|
31
|
-
readonly isActive: boolean;
|
|
32
29
|
getName(): string;
|
|
33
30
|
setName(name: string): Promise<void>;
|
|
34
31
|
delete(): Promise<void>;
|
|
@@ -37,10 +34,6 @@ export interface FileBranch {
|
|
|
37
34
|
httpUrl: string;
|
|
38
35
|
}>;
|
|
39
36
|
getFileEvent(): Promise<Pick<MatrixEvent, "getContent" | "getTs" | "isDecryptionFailure">>;
|
|
40
|
-
getVersionHistory(): Promise<FileBranch[]>;
|
|
41
|
-
createNewVersion(name: string, encryptedContents: ArrayBuffer | Uint8Array, info: Record<string, unknown>, additionalContent?: Record<string, unknown>): Promise<{
|
|
42
|
-
event_id: string;
|
|
43
|
-
}>;
|
|
44
37
|
}
|
|
45
38
|
export interface CreateTeleCryptIOStorageOptions {
|
|
46
39
|
/** Matrix homeserver base URL, e.g. "https://matrix.example.com". */
|
|
@@ -132,7 +132,7 @@ function matrixRequestTimeout(options) {
|
|
|
132
132
|
const value = options?.timeoutMs ?? options?.localTimeoutMs ?? MATRIX_HTTP_TIMEOUT_MS;
|
|
133
133
|
if (!Number.isFinite(value) || value <= 0)
|
|
134
134
|
throw new Error("invalid Matrix request timeout");
|
|
135
|
-
return
|
|
135
|
+
return value;
|
|
136
136
|
}
|
|
137
137
|
function matrixRequestSignal(options) {
|
|
138
138
|
return options?.signal ?? options?.abortSignal;
|
package/dist/core/index.d.ts
CHANGED
package/dist/core/index.js
CHANGED
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
import { TeleCryptIOStorage } from "../TeleCryptIOStorage.js";
|
|
14
14
|
import type { DownloadedFile, FolderDetails, FolderInfo, FileDetails, FileInfo, VaultDetails, VaultInfo, DeleteResult, JoinResult, Member, RecoveryRestore, RecoverySetup, RenameResult, ShareResult, UnshareResult } from "./types.js";
|
|
15
15
|
export interface OperationOptions {
|
|
16
|
-
/** Cancels
|
|
16
|
+
/** Cancels waits and rate-limit backoff before the next mutation. */
|
|
17
17
|
signal?: AbortSignal;
|
|
18
|
-
/**
|
|
18
|
+
/** Optional wall-clock budget supplied by the caller. */
|
|
19
19
|
timeoutMs?: number;
|
|
20
20
|
}
|
|
21
21
|
export declare function createVault(storage: TeleCryptIOStorage, name: string, options?: OperationOptions): Promise<VaultInfo>;
|
package/dist/core/operations.js
CHANGED
|
@@ -16,7 +16,6 @@ import { ClientPrefix } from "matrix-js-sdk/lib/http-api/prefix.js";
|
|
|
16
16
|
import { Method } from "matrix-js-sdk/lib/http-api/method.js";
|
|
17
17
|
import { FileTooLargeError, MutationPartialError, MutationOutcomeUnknownError, NonEmptyTreeError, RoomCreationAmbiguousError, RoomCleanupIncompleteError, StorageError, } from "./errors.js";
|
|
18
18
|
import { ConditionTimeoutError, waitForCondition } from "./poll.js";
|
|
19
|
-
import { validateMatrixEventId } from "./constants.js";
|
|
20
19
|
import { validateName } from "./validation.js";
|
|
21
20
|
import { getDeletedTreeIds, isFileDeleted, isTreeDeleted, markFileDeleted, markTreeDeleted, } from "../deletion-markers.js";
|
|
22
21
|
/**
|
|
@@ -31,13 +30,10 @@ const RATE_LIMIT_DEFAULT_DELAY_MS = 15_000;
|
|
|
31
30
|
// JavaScript timers cannot represent a longer delay reliably. The operation's
|
|
32
31
|
// own deadline remains the authority that stops retrying.
|
|
33
32
|
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
34
|
-
const MAX_DELETION_ROOMS = 4096;
|
|
35
|
-
const MAX_DELETION_DEPTH = 128;
|
|
36
|
-
const DEFAULT_MUTATION_TIMEOUT_MS = 5 * 60_000;
|
|
37
|
-
const MAX_MUTATION_TIMEOUT_MS = 15 * 60_000;
|
|
38
33
|
function createOperationDeadline(options) {
|
|
39
|
-
const timeoutMs = options?.timeoutMs
|
|
40
|
-
if (
|
|
34
|
+
const timeoutMs = options?.timeoutMs;
|
|
35
|
+
if (timeoutMs !== undefined &&
|
|
36
|
+
(!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_TIMER_DELAY_MS)) {
|
|
41
37
|
throw new StorageError("invalid operation timeout");
|
|
42
38
|
}
|
|
43
39
|
const controller = new AbortController();
|
|
@@ -45,11 +41,14 @@ function createOperationDeadline(options) {
|
|
|
45
41
|
options?.signal?.addEventListener("abort", onAbort, { once: true });
|
|
46
42
|
if (options?.signal?.aborted)
|
|
47
43
|
onAbort();
|
|
48
|
-
const timer =
|
|
44
|
+
const timer = timeoutMs === undefined
|
|
45
|
+
? undefined
|
|
46
|
+
: setTimeout(() => controller.abort(new Error("operation timed out")), timeoutMs);
|
|
49
47
|
return {
|
|
50
48
|
signal: controller.signal,
|
|
51
49
|
close: () => {
|
|
52
|
-
|
|
50
|
+
if (timer !== undefined)
|
|
51
|
+
clearTimeout(timer);
|
|
53
52
|
options?.signal?.removeEventListener("abort", onAbort);
|
|
54
53
|
},
|
|
55
54
|
};
|
|
@@ -206,20 +205,17 @@ async function resolveFile(storage, tree, fileId, signal) {
|
|
|
206
205
|
}
|
|
207
206
|
}
|
|
208
207
|
/**
|
|
209
|
-
* Deleting a tree is deliberately a one-room operation.
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
208
|
+
* Deleting a tree is deliberately a one-room operation. Callers must delete
|
|
209
|
+
* files explicitly and observe each result before removing their containing
|
|
210
|
+
* room. This also prevents a folder/vault delete from silently deleting a
|
|
211
|
+
* nested shared tree.
|
|
213
212
|
*/
|
|
214
213
|
function assertTreeEmptyForDeletion(storage, tree, spaces) {
|
|
215
214
|
if (spaces.length !== 1)
|
|
216
215
|
throw new NonEmptyTreeError(tree.id);
|
|
217
216
|
let files;
|
|
218
217
|
try {
|
|
219
|
-
files = tree
|
|
220
|
-
.listAllFiles()
|
|
221
|
-
.filter((file) => !isMarkedFileDeleted(storage, tree.id, file.id) &&
|
|
222
|
-
!isConfirmedDeletedFile(storage.getClient(), tree.id, file));
|
|
218
|
+
files = tree.listFiles().filter((file) => !isMarkedFileDeleted(storage, tree.id, file.id));
|
|
223
219
|
}
|
|
224
220
|
catch (error) {
|
|
225
221
|
throw new StorageError("could not enumerate storage files safely", { cause: error });
|
|
@@ -308,20 +304,16 @@ function activeTreeDirectories(client, tree) {
|
|
|
308
304
|
return child;
|
|
309
305
|
});
|
|
310
306
|
}
|
|
311
|
-
function snapshotTreeSpaces(root,
|
|
307
|
+
function snapshotTreeSpaces(root, activeRelationClient) {
|
|
312
308
|
const spaces = [];
|
|
313
309
|
const seen = new Set();
|
|
314
|
-
const pending = [
|
|
310
|
+
const pending = [root];
|
|
315
311
|
while (pending.length > 0) {
|
|
316
|
-
const
|
|
317
|
-
if (!
|
|
312
|
+
const tree = pending.pop();
|
|
313
|
+
if (!tree)
|
|
318
314
|
continue;
|
|
319
|
-
const { tree, depth } = next;
|
|
320
315
|
if (seen.has(tree.id))
|
|
321
316
|
continue;
|
|
322
|
-
if (depth > MAX_DELETION_DEPTH || spaces.length >= MAX_DELETION_ROOMS) {
|
|
323
|
-
throw new StorageError(tooLargeMessage);
|
|
324
|
-
}
|
|
325
317
|
seen.add(tree.id);
|
|
326
318
|
spaces.push(tree);
|
|
327
319
|
let children;
|
|
@@ -335,34 +327,11 @@ function snapshotTreeSpaces(root, tooLargeMessage = "storage tree is too large",
|
|
|
335
327
|
throw error;
|
|
336
328
|
throw new StorageError("could not enumerate storage space descendants safely", { cause: error });
|
|
337
329
|
}
|
|
338
|
-
if (children.length > MAX_DELETION_ROOMS) {
|
|
339
|
-
throw new StorageError(tooLargeMessage);
|
|
340
|
-
}
|
|
341
330
|
for (const child of children)
|
|
342
|
-
pending.push(
|
|
331
|
+
pending.push(child);
|
|
343
332
|
}
|
|
344
333
|
return spaces;
|
|
345
334
|
}
|
|
346
|
-
/**
|
|
347
|
-
* A deleted MSC3089 branch is represented by an authoritative empty state
|
|
348
|
-
* event. Inactive branches with metadata are historical versions, and a
|
|
349
|
-
* missing or malformed state cannot prove that the file was deleted.
|
|
350
|
-
*/
|
|
351
|
-
function isConfirmedDeletedFile(client, treeId, file) {
|
|
352
|
-
if (file.isActive)
|
|
353
|
-
return false;
|
|
354
|
-
const stateEvents = readRelationEvents(client, treeId, UNSTABLE_MSC3089_BRANCH.name, file.id);
|
|
355
|
-
if (!stateEvents || stateEvents.length !== 1)
|
|
356
|
-
return false;
|
|
357
|
-
const stateEvent = stateEvents[0];
|
|
358
|
-
if (relationStateKey(stateEvent) !== file.id)
|
|
359
|
-
return false;
|
|
360
|
-
const content = stateEvent.getContent?.();
|
|
361
|
-
return (typeof content === "object" &&
|
|
362
|
-
content !== null &&
|
|
363
|
-
!Array.isArray(content) &&
|
|
364
|
-
Object.keys(content).length === 0);
|
|
365
|
-
}
|
|
366
335
|
/**
|
|
367
336
|
* Deletion is intentionally narrower than MSC3089TreeSpace.delete(): a local
|
|
368
337
|
* child graph is not proof that the rooms are exclusively owned by this tree.
|
|
@@ -1112,7 +1081,7 @@ async function deleteTree(storage, treeId, options) {
|
|
|
1112
1081
|
// part of this operation and must not block deletion merely because
|
|
1113
1082
|
// their membership is not joined.
|
|
1114
1083
|
await refreshDeletionRooms(storage, tree.id, operation.signal);
|
|
1115
|
-
const spaces = snapshotTreeSpaces(tree,
|
|
1084
|
+
const spaces = snapshotTreeSpaces(tree, client);
|
|
1116
1085
|
const activeSpaces = spaces.filter((space) => !removedRooms.has(space.id));
|
|
1117
1086
|
const ids = new Set(activeSpaces.map((space) => space.id));
|
|
1118
1087
|
if (!ids.has(tree.id)) {
|
|
@@ -1201,61 +1170,6 @@ export async function renameFile(storage, treeId, fileId, name, options) {
|
|
|
1201
1170
|
return { id: fileId, name };
|
|
1202
1171
|
}, "mutation");
|
|
1203
1172
|
}
|
|
1204
|
-
/**
|
|
1205
|
-
* Resolve the complete encrypted version chain before mutating Matrix.
|
|
1206
|
-
*
|
|
1207
|
-
* matrix-js-sdk returns the active version first and older versions after it.
|
|
1208
|
-
* Treat a repeated event ID as a cycle/invalid relation graph, and bound both
|
|
1209
|
-
* the event walk and the media identifiers sent to Synapse. Resolving every
|
|
1210
|
-
* media URL first is important: a decryption or relation failure must not leave
|
|
1211
|
-
* a partially deleted file chain.
|
|
1212
|
-
*/
|
|
1213
|
-
async function resolveFileVersions(branch, signal) {
|
|
1214
|
-
let history;
|
|
1215
|
-
try {
|
|
1216
|
-
history = await branch.getVersionHistory();
|
|
1217
|
-
}
|
|
1218
|
-
catch (error) {
|
|
1219
|
-
throw new StorageError("could not resolve file version history safely", { cause: error });
|
|
1220
|
-
}
|
|
1221
|
-
if (!Array.isArray(history) || history.length === 0) {
|
|
1222
|
-
throw new StorageError("file version history is invalid");
|
|
1223
|
-
}
|
|
1224
|
-
const eventIds = new Set();
|
|
1225
|
-
const mediaIds = new Set();
|
|
1226
|
-
const versions = [];
|
|
1227
|
-
for (const version of history) {
|
|
1228
|
-
if (signal?.aborted)
|
|
1229
|
-
throw new StorageError("operation cancelled");
|
|
1230
|
-
if (!version || typeof version.id !== "string") {
|
|
1231
|
-
throw new StorageError("file version history is invalid");
|
|
1232
|
-
}
|
|
1233
|
-
try {
|
|
1234
|
-
validateMatrixEventId(version.id, "file version event ID");
|
|
1235
|
-
}
|
|
1236
|
-
catch (error) {
|
|
1237
|
-
throw new StorageError("file version history contains an invalid event ID", { cause: error });
|
|
1238
|
-
}
|
|
1239
|
-
if (eventIds.has(version.id)) {
|
|
1240
|
-
throw new StorageError("file version history contains a cycle");
|
|
1241
|
-
}
|
|
1242
|
-
eventIds.add(version.id);
|
|
1243
|
-
let fileInfo;
|
|
1244
|
-
try {
|
|
1245
|
-
fileInfo = await version.getFileInfo();
|
|
1246
|
-
}
|
|
1247
|
-
catch (error) {
|
|
1248
|
-
throw new StorageError("could not resolve encrypted file version safely", { cause: error });
|
|
1249
|
-
}
|
|
1250
|
-
const mediaId = fileInfo?.info?.url;
|
|
1251
|
-
if (typeof mediaId !== "string" || mediaId.length === 0) {
|
|
1252
|
-
throw new StorageError("encrypted file version has no media identifier");
|
|
1253
|
-
}
|
|
1254
|
-
mediaIds.add(mediaId);
|
|
1255
|
-
versions.push({ branch: version, mediaId });
|
|
1256
|
-
}
|
|
1257
|
-
return versions;
|
|
1258
|
-
}
|
|
1259
1173
|
function requireFileDeletionTransport(client) {
|
|
1260
1174
|
const http = client.http;
|
|
1261
1175
|
if (!http || typeof http.authedRequest !== "function") {
|
|
@@ -1281,19 +1195,26 @@ export async function deleteFile(storage, treeId, fileId, options) {
|
|
|
1281
1195
|
return withOperationDeadline(options, async (signal) => {
|
|
1282
1196
|
const tree = await resolveTree(storage, treeId, signal);
|
|
1283
1197
|
const branch = await resolveFile(storage, tree, fileId, signal);
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1198
|
+
let fileInfo;
|
|
1199
|
+
try {
|
|
1200
|
+
fileInfo = await branch.getFileInfo();
|
|
1201
|
+
}
|
|
1202
|
+
catch (error) {
|
|
1203
|
+
throw new StorageError("could not resolve encrypted file safely", { cause: error });
|
|
1204
|
+
}
|
|
1205
|
+
const mediaId = fileInfo?.info?.url;
|
|
1206
|
+
if (typeof mediaId !== "string" || mediaId.length === 0) {
|
|
1207
|
+
throw new StorageError("encrypted file has no media identifier");
|
|
1208
|
+
}
|
|
1209
|
+
await deleteFileMedia(storage, [mediaId], signal);
|
|
1287
1210
|
const completedIds = [];
|
|
1288
1211
|
try {
|
|
1289
1212
|
const client = typeof storage.getClient === "function" ? storage.getClient() : undefined;
|
|
1290
1213
|
if (!client)
|
|
1291
1214
|
throw new StorageError("Matrix client unavailable");
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
completedIds.push(version.id);
|
|
1296
|
-
}
|
|
1215
|
+
await withRateLimitRetry(() => withMatrixMutationAbort(client, () => client.sendStateEvent(tree.id, UNSTABLE_MSC3089_BRANCH.name, {}, branch.id), signal, "delete file state"), signal);
|
|
1216
|
+
await withRateLimitRetry(() => withMatrixMutationAbort(client, () => client.redactEvent(tree.id, branch.id), signal, "delete file event"), signal);
|
|
1217
|
+
completedIds.push(branch.id);
|
|
1297
1218
|
}
|
|
1298
1219
|
catch (error) {
|
|
1299
1220
|
if (error instanceof MutationOutcomeUnknownError)
|
|
@@ -1308,18 +1229,16 @@ export async function deleteFile(storage, treeId, fileId, options) {
|
|
|
1308
1229
|
await storage.refreshRoomState(tree.id, { signal });
|
|
1309
1230
|
ensureOperationActive(signal);
|
|
1310
1231
|
const current = tree.getFile(fileId);
|
|
1311
|
-
return !current
|
|
1232
|
+
return !current ? true : null;
|
|
1312
1233
|
}, { timeoutMs: 15000, signal });
|
|
1313
1234
|
}
|
|
1314
1235
|
catch (error) {
|
|
1315
1236
|
if (error instanceof MutationOutcomeUnknownError)
|
|
1316
1237
|
throw error;
|
|
1317
|
-
const detail = "Matrix deletion completed but the
|
|
1238
|
+
const detail = "Matrix deletion completed but the file state could not be verified";
|
|
1318
1239
|
throw new MutationPartialError("delete file", completedIds, detail, { cause: error });
|
|
1319
1240
|
}
|
|
1320
|
-
|
|
1321
|
-
markFileDeleted(storage.getClient(), tree.id, version.id);
|
|
1322
|
-
}
|
|
1241
|
+
markFileDeleted(storage.getClient(), tree.id, branch.id);
|
|
1323
1242
|
ensureOperationActive(signal);
|
|
1324
1243
|
return { id: fileId, deleted: true };
|
|
1325
1244
|
}, "mutation");
|
|
@@ -1347,7 +1266,7 @@ export async function uploadFile(storage, treeId, name, bytes, mimetype, options
|
|
|
1347
1266
|
await storage.refreshRoomState(tree.id, { signal });
|
|
1348
1267
|
ensureOperationActive(signal);
|
|
1349
1268
|
const file = tree.getFile(fileId);
|
|
1350
|
-
return file
|
|
1269
|
+
return file ?? null;
|
|
1351
1270
|
}, { timeoutMs: 15000, signal });
|
|
1352
1271
|
}
|
|
1353
1272
|
catch (error) {
|
|
@@ -1,4 +1,3 @@
|
|
|
1
1
|
/** Shared name contract used by SDK inputs and remote projections. */
|
|
2
|
-
export declare const MAX_NAME_LENGTH = 512;
|
|
3
2
|
export declare function isValidName(value: unknown): value is string;
|
|
4
3
|
export declare function validateName(value: unknown, label: string): asserts value is string;
|
package/dist/core/validation.js
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
/** Shared name contract used by SDK inputs and remote projections. */
|
|
2
|
-
export const MAX_NAME_LENGTH = 512;
|
|
3
2
|
export function isValidName(value) {
|
|
4
3
|
return (typeof value === "string" &&
|
|
5
4
|
value.length > 0 &&
|
|
6
|
-
value.length <= MAX_NAME_LENGTH &&
|
|
7
5
|
!/[\u0000-\u001f\u007f]/.test(value));
|
|
8
6
|
}
|
|
9
7
|
export function validateName(value, label) {
|