@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 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.5.28
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 complete Matrix version chain and reports a typed partial result if cleanup stops
102
- after some versions. Delete empty child folders before deleting their parent. A nonempty delete
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 and thumbnail objects before Matrix redaction; stock
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 Math.min(value, MATRIX_HTTP_TIMEOUT_MS);
135
+ return value;
136
136
  }
137
137
  function matrixRequestSignal(options) {
138
138
  return options?.signal ?? options?.abortSignal;
@@ -4,4 +4,4 @@ export * from "./operations.js";
4
4
  export * from "./errors.js";
5
5
  export * from "./oidc.js";
6
6
  export * from "./constants.js";
7
- export { isValidName, MAX_NAME_LENGTH, validateName } from "./validation.js";
7
+ export { isValidName, validateName } from "./validation.js";
@@ -4,4 +4,4 @@ export * from "./operations.js";
4
4
  export * from "./errors.js";
5
5
  export * from "./oidc.js";
6
6
  export * from "./constants.js";
7
- export { isValidName, MAX_NAME_LENGTH, validateName } from "./validation.js";
7
+ export { isValidName, validateName } from "./validation.js";
@@ -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 bounded waits and rate-limit backoff before the next mutation. */
16
+ /** Cancels waits and rate-limit backoff before the next mutation. */
17
17
  signal?: AbortSignal;
18
- /** Total wall-clock budget for one mutation, including all descendants. */
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>;
@@ -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 ?? DEFAULT_MUTATION_TIMEOUT_MS;
40
- if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_MUTATION_TIMEOUT_MS) {
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 = setTimeout(() => controller.abort(new Error("operation timed out")), timeoutMs);
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
- clearTimeout(timer);
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. File versions are
210
- * physical Matrix media events, so callers must delete files explicitly and
211
- * observe each result before removing their containing room. This also
212
- * prevents a folder/vault delete from silently deleting a nested shared tree.
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, tooLargeMessage = "storage tree is too large", activeRelationClient) {
307
+ function snapshotTreeSpaces(root, activeRelationClient) {
312
308
  const spaces = [];
313
309
  const seen = new Set();
314
- const pending = [{ tree: root, depth: 0 }];
310
+ const pending = [root];
315
311
  while (pending.length > 0) {
316
- const next = pending.pop();
317
- if (!next)
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({ tree: child, depth: depth + 1 });
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, "delete graph is too large", client);
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
- const versions = await resolveFileVersions(branch, signal);
1285
- const mediaIds = [...new Set(versions.map(({ mediaId }) => mediaId))];
1286
- await deleteFileMedia(storage, mediaIds, signal);
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
- for (const { branch: version } of versions) {
1293
- await withRateLimitRetry(() => withMatrixMutationAbort(client, () => client.sendStateEvent(tree.id, UNSTABLE_MSC3089_BRANCH.name, {}, version.id), signal, "delete file state"), signal);
1294
- await withRateLimitRetry(() => withMatrixMutationAbort(client, () => client.redactEvent(tree.id, version.id), signal, "delete file event"), signal);
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 || !current.isActive ? true : null;
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 inactive file state could not be verified";
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
- for (const { branch: version } of versions) {
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?.isActive ? file : null;
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;
@@ -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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telecrypt-io/storage",
3
- "version": "0.5.30",
3
+ "version": "0.6.0",
4
4
  "license": "BUSL-1.1",
5
5
  "type": "module",
6
6
  "packageManager": "npm@11.19.0",