@telecrypt-io/storage 0.5.31 → 0.6.1
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 +2 -7
- package/dist/core/operations.js +27 -98
- 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,14 @@ 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
|
-
|
|
31
|
-
readonly isActive
|
|
29
|
+
/** Matrix SDK keeps a redacted branch in state as an inactive object. */
|
|
30
|
+
readonly isActive?: boolean;
|
|
32
31
|
getName(): string;
|
|
33
32
|
setName(name: string): Promise<void>;
|
|
34
33
|
delete(): Promise<void>;
|
|
@@ -37,10 +36,6 @@ export interface FileBranch {
|
|
|
37
36
|
httpUrl: string;
|
|
38
37
|
}>;
|
|
39
38
|
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
39
|
}
|
|
45
40
|
export interface CreateTeleCryptIOStorageOptions {
|
|
46
41
|
/** Matrix homeserver base URL, e.g. "https://matrix.example.com". */
|
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
|
/**
|
|
@@ -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 });
|
|
@@ -336,26 +332,6 @@ function snapshotTreeSpaces(root, activeRelationClient) {
|
|
|
336
332
|
}
|
|
337
333
|
return spaces;
|
|
338
334
|
}
|
|
339
|
-
/**
|
|
340
|
-
* A deleted MSC3089 branch is represented by an authoritative empty state
|
|
341
|
-
* event. Inactive branches with metadata are historical versions, and a
|
|
342
|
-
* missing or malformed state cannot prove that the file was deleted.
|
|
343
|
-
*/
|
|
344
|
-
function isConfirmedDeletedFile(client, treeId, file) {
|
|
345
|
-
if (file.isActive)
|
|
346
|
-
return false;
|
|
347
|
-
const stateEvents = readRelationEvents(client, treeId, UNSTABLE_MSC3089_BRANCH.name, file.id);
|
|
348
|
-
if (!stateEvents || stateEvents.length !== 1)
|
|
349
|
-
return false;
|
|
350
|
-
const stateEvent = stateEvents[0];
|
|
351
|
-
if (relationStateKey(stateEvent) !== file.id)
|
|
352
|
-
return false;
|
|
353
|
-
const content = stateEvent.getContent?.();
|
|
354
|
-
return (typeof content === "object" &&
|
|
355
|
-
content !== null &&
|
|
356
|
-
!Array.isArray(content) &&
|
|
357
|
-
Object.keys(content).length === 0);
|
|
358
|
-
}
|
|
359
335
|
/**
|
|
360
336
|
* Deletion is intentionally narrower than MSC3089TreeSpace.delete(): a local
|
|
361
337
|
* child graph is not proof that the rooms are exclusively owned by this tree.
|
|
@@ -1194,61 +1170,6 @@ export async function renameFile(storage, treeId, fileId, name, options) {
|
|
|
1194
1170
|
return { id: fileId, name };
|
|
1195
1171
|
}, "mutation");
|
|
1196
1172
|
}
|
|
1197
|
-
/**
|
|
1198
|
-
* Resolve the complete encrypted version chain before mutating Matrix.
|
|
1199
|
-
*
|
|
1200
|
-
* matrix-js-sdk returns the active version first and older versions after it.
|
|
1201
|
-
* Treat a repeated event ID as a cycle/invalid relation graph, and bound both
|
|
1202
|
-
* the event walk and the media identifiers sent to Synapse. Resolving every
|
|
1203
|
-
* media URL first is important: a decryption or relation failure must not leave
|
|
1204
|
-
* a partially deleted file chain.
|
|
1205
|
-
*/
|
|
1206
|
-
async function resolveFileVersions(branch, signal) {
|
|
1207
|
-
let history;
|
|
1208
|
-
try {
|
|
1209
|
-
history = await branch.getVersionHistory();
|
|
1210
|
-
}
|
|
1211
|
-
catch (error) {
|
|
1212
|
-
throw new StorageError("could not resolve file version history safely", { cause: error });
|
|
1213
|
-
}
|
|
1214
|
-
if (!Array.isArray(history) || history.length === 0) {
|
|
1215
|
-
throw new StorageError("file version history is invalid");
|
|
1216
|
-
}
|
|
1217
|
-
const eventIds = new Set();
|
|
1218
|
-
const mediaIds = new Set();
|
|
1219
|
-
const versions = [];
|
|
1220
|
-
for (const version of history) {
|
|
1221
|
-
if (signal?.aborted)
|
|
1222
|
-
throw new StorageError("operation cancelled");
|
|
1223
|
-
if (!version || typeof version.id !== "string") {
|
|
1224
|
-
throw new StorageError("file version history is invalid");
|
|
1225
|
-
}
|
|
1226
|
-
try {
|
|
1227
|
-
validateMatrixEventId(version.id, "file version event ID");
|
|
1228
|
-
}
|
|
1229
|
-
catch (error) {
|
|
1230
|
-
throw new StorageError("file version history contains an invalid event ID", { cause: error });
|
|
1231
|
-
}
|
|
1232
|
-
if (eventIds.has(version.id)) {
|
|
1233
|
-
throw new StorageError("file version history contains a cycle");
|
|
1234
|
-
}
|
|
1235
|
-
eventIds.add(version.id);
|
|
1236
|
-
let fileInfo;
|
|
1237
|
-
try {
|
|
1238
|
-
fileInfo = await version.getFileInfo();
|
|
1239
|
-
}
|
|
1240
|
-
catch (error) {
|
|
1241
|
-
throw new StorageError("could not resolve encrypted file version safely", { cause: error });
|
|
1242
|
-
}
|
|
1243
|
-
const mediaId = fileInfo?.info?.url;
|
|
1244
|
-
if (typeof mediaId !== "string" || mediaId.length === 0) {
|
|
1245
|
-
throw new StorageError("encrypted file version has no media identifier");
|
|
1246
|
-
}
|
|
1247
|
-
mediaIds.add(mediaId);
|
|
1248
|
-
versions.push({ branch: version, mediaId });
|
|
1249
|
-
}
|
|
1250
|
-
return versions;
|
|
1251
|
-
}
|
|
1252
1173
|
function requireFileDeletionTransport(client) {
|
|
1253
1174
|
const http = client.http;
|
|
1254
1175
|
if (!http || typeof http.authedRequest !== "function") {
|
|
@@ -1274,19 +1195,26 @@ export async function deleteFile(storage, treeId, fileId, options) {
|
|
|
1274
1195
|
return withOperationDeadline(options, async (signal) => {
|
|
1275
1196
|
const tree = await resolveTree(storage, treeId, signal);
|
|
1276
1197
|
const branch = await resolveFile(storage, tree, fileId, signal);
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
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);
|
|
1280
1210
|
const completedIds = [];
|
|
1281
1211
|
try {
|
|
1282
1212
|
const client = typeof storage.getClient === "function" ? storage.getClient() : undefined;
|
|
1283
1213
|
if (!client)
|
|
1284
1214
|
throw new StorageError("Matrix client unavailable");
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
completedIds.push(version.id);
|
|
1289
|
-
}
|
|
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);
|
|
1290
1218
|
}
|
|
1291
1219
|
catch (error) {
|
|
1292
1220
|
if (error instanceof MutationOutcomeUnknownError)
|
|
@@ -1301,18 +1229,19 @@ export async function deleteFile(storage, treeId, fileId, options) {
|
|
|
1301
1229
|
await storage.refreshRoomState(tree.id, { signal });
|
|
1302
1230
|
ensureOperationActive(signal);
|
|
1303
1231
|
const current = tree.getFile(fileId);
|
|
1304
|
-
|
|
1232
|
+
// Matrix SDK keeps a redacted branch event in room state as an
|
|
1233
|
+
// inactive branch. Treat both an absent branch and that redacted
|
|
1234
|
+
// inactive branch as confirmed deletion.
|
|
1235
|
+
return !current || current.isActive === false ? true : null;
|
|
1305
1236
|
}, { timeoutMs: 15000, signal });
|
|
1306
1237
|
}
|
|
1307
1238
|
catch (error) {
|
|
1308
1239
|
if (error instanceof MutationOutcomeUnknownError)
|
|
1309
1240
|
throw error;
|
|
1310
|
-
const detail = "Matrix deletion completed but the
|
|
1241
|
+
const detail = "Matrix deletion completed but the file state could not be verified";
|
|
1311
1242
|
throw new MutationPartialError("delete file", completedIds, detail, { cause: error });
|
|
1312
1243
|
}
|
|
1313
|
-
|
|
1314
|
-
markFileDeleted(storage.getClient(), tree.id, version.id);
|
|
1315
|
-
}
|
|
1244
|
+
markFileDeleted(storage.getClient(), tree.id, branch.id);
|
|
1316
1245
|
ensureOperationActive(signal);
|
|
1317
1246
|
return { id: fileId, deleted: true };
|
|
1318
1247
|
}, "mutation");
|
|
@@ -1340,7 +1269,7 @@ export async function uploadFile(storage, treeId, name, bytes, mimetype, options
|
|
|
1340
1269
|
await storage.refreshRoomState(tree.id, { signal });
|
|
1341
1270
|
ensureOperationActive(signal);
|
|
1342
1271
|
const file = tree.getFile(fileId);
|
|
1343
|
-
return file
|
|
1272
|
+
return file ?? null;
|
|
1344
1273
|
}, { timeoutMs: 15000, signal });
|
|
1345
1274
|
}
|
|
1346
1275
|
catch (error) {
|