@telecrypt-io/storage 0.7.0 → 0.7.2

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
@@ -2,19 +2,9 @@
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/@telecrypt-io/storage)](https://www.npmjs.com/package/@telecrypt-io/storage)
4
4
 
5
- End-to-end encrypted file storage and sharing, built on Matrix.
6
-
7
- The current public TeleCrypt architecture, responsibilities, and product limits are authoritative at
8
- [telecrypt.io/llms.txt](https://telecrypt.io/llms.txt). This README documents this SDK's package API
9
- and development workflow.
10
-
11
- Files are encrypted on the client before upload. The server stores only opaque ciphertext and
12
- never holds the decryption keys. Shared vaults let multiple people add and read files, and a
13
- Recovery Key restores your files on a new device — even if you lose the original.
14
-
15
- This repository publishes the library package from annotated version tags using npm Trusted
16
- Publishing. GitHub Releases identify the corresponding source version. The command-line client is
17
- maintained in its own repository.
5
+ A JavaScript/TypeScript SDK for encrypted Matrix file trees, based on
6
+ [MSC3089](https://github.com/matrix-org/matrix-spec-proposals/pull/3089), an open and currently
7
+ unmerged Matrix proposal.
18
8
 
19
9
  ## Install
20
10
 
@@ -22,119 +12,79 @@ maintained in its own repository.
22
12
  npm install @telecrypt-io/storage
23
13
  ```
24
14
 
25
- This gives you the `TeleCryptIOStorage` library and its browser-safe `core` API.
26
-
27
- ## Quick start
15
+ ## Browser quick start
28
16
 
29
- **Library:**
17
+ The example below starts with an authenticated OIDC session. Obtain `userId`, `accessToken`, and
18
+ `deviceId` from that completed session. `baseUrl` and `serverName` must come from trusted
19
+ application configuration for the same Matrix deployment; do not accept them from an untrusted
20
+ request.
30
21
 
31
22
  ```ts
32
23
  import { TeleCryptIOStorage } from "@telecrypt-io/storage";
33
24
  import * as core from "@telecrypt-io/storage/core";
34
25
 
26
+ // baseUrl/serverName come from trusted application configuration.
27
+ // userId/accessToken/deviceId come from completed OIDC authentication.
35
28
  const storage = await TeleCryptIOStorage.create({
36
- baseUrl, userId, accessToken, deviceId,
29
+ baseUrl, serverName, userId, accessToken, deviceId,
37
30
  });
38
- const vault = await core.createVault(storage, "Photos");
39
- await core.uploadFile(storage, vault.id, "cat.jpg", bytes, "image/jpeg");
31
+ try {
32
+ const vault = await core.createVault(storage, "Example");
33
+ const file = await core.uploadFile(
34
+ storage, vault.id, "hello.txt",
35
+ new TextEncoder().encode("Hello"), "text/plain",
36
+ );
37
+ const downloaded = await core.downloadFile(storage, vault.id, file.id);
38
+ console.log(new TextDecoder().decode(downloaded.bytes));
39
+ } finally {
40
+ storage.getClient().stopClient();
41
+ }
40
42
  ```
41
43
 
42
- The recommended constructors configure the Matrix client with the SDK's manual-redirect transport
43
- and finite request deadline. The public `new TeleCryptIOStorage(client)` constructor is
44
- an advanced escape hatch: it does not rewrite matrix-js-sdk internals. Callers using it must build
45
- the client through matrix-js-sdk's supported `createClient` options (`fetchFn` and
46
- `localTimeoutMs`) and remain responsible for the safety of other Matrix SDK requests. SDK methods
47
- that need the authenticated transport fail closed when it is unavailable; they do not silently
48
- install a private transport configuration.
49
-
50
- Same-name vault/folder creation is serialized within one `TeleCryptIOStorage` instance, but
51
- each call creates a distinct room. Display names are labels, not identities, and Matrix
52
- provides no server-side room-creation idempotency key.
53
-
54
- ## OIDC API
55
-
56
- The SDK owns the browser authorization-code PKCE context in `sessionStorage` and provides device-code,
57
- discovery, dynamic registration, identity confirmation, and refresh helpers. OAuth metadata, token
58
- scopes, Matrix user/device identities, redirects, and complete response bodies are validated before
59
- they are returned. Network-bound helpers accept `AbortSignal` where cancellation is meaningful; token
60
- refresh uses a public client and persists the resulting token pair through the caller's callback.
61
-
62
- The command-line client is sourced by
63
- [`TeleCrypt-io/storage-cli`](https://github.com/TeleCrypt-io/storage-cli). The static web
64
- application is sourced by
65
- [`TeleCrypt-io/storage.telecrypt.io`](https://github.com/TeleCrypt-io/storage.telecrypt.io).
66
-
67
- ### 0.5 OIDC migration
68
-
69
- Version 0.5 targets Matrix JS SDK 42 and the stable Matrix OAuth metadata endpoint. Existing
70
- integrations must use the current `createFromOidc` options and pass the confirmed Matrix user ID,
71
- device ID, and access token returned by the login flow. Replace old Matrix SDK OIDC imports and
72
- private HTTP discovery with `discoverOidcIssuer`, `registerClient`, the device-code or PKCE flow
73
- helpers, and `whoAmI` from `core/oidc`; the discovery URL is the stable
74
- `/_matrix/client/v1/auth_metadata` endpoint. Refresh integration uses
75
- `buildTokenRefreshFunction` as the current `tokenRefreshFunction` callback and must persist the
76
- returned token pair before reporting success. The SDK no longer carries pre-0.5 compatibility
77
- paths, so no adapter-side shim is required or supported. Discovery accepts providers that omit
78
- `revocation_endpoint`; revocation is only an opportunistic cleanup path after refresh persistence
79
- fails. Device-code sessions retain their requested device ID in-process; callers that reconstruct a
80
- session must pass its expected device ID to `waitForDeviceCodeLogin`. `buildTokenRefreshFunction`
81
- always requires the expected device ID because a reconstructed refresh callback has no safe implicit
82
- binding. Returned OAuth scopes are rejected unless they match that intended Matrix device exactly.
83
-
84
- ## How it works
85
-
86
- Built on [MSC3089](https://github.com/matrix-org/matrix-spec-proposals/pull/3089), which models
87
- a file tree using Matrix primitives:
88
-
89
- | Storage concept | Matrix concept |
90
- |---|---|
91
- | Vault | A Space (room marked as a file tree) |
92
- | Folder | A child Space |
93
- | File | An event pointing at encrypted uploaded content |
94
- | Sharing | Room invitation |
95
- | Permissions | Power levels |
96
-
97
- ### Deletion
98
-
99
- Vaults and folders are deleted only when they are empty. Delete each file first; deleting a file
100
- also removes its encrypted media object before redacting its Matrix event. Delete empty child
101
- folders before deleting their parent. A nonempty delete
102
- fails with `NonEmptyTreeError`, so a shared or nested tree is never removed implicitly.
103
-
104
- Encryption uses the same scheme as Matrix attachments (AES-CTR with a per-file key, keys
105
- distributed via the room's Megolm session). File deletion uses TeleCrypt's authenticated Synapse
106
- storage extension to remove the local media object before Matrix redaction; stock
107
- Synapse without that extension cannot provide the SDK deletion contract.
108
-
109
- ## Development
44
+ `TeleCryptIOStorage.create` initializes the Matrix client and its persistent browser crypto store
45
+ by default. Browser persistence uses IndexedDB and the Matrix Rust crypto WASM runtime, so this
46
+ example assumes a browser environment. OIDC discovery, login, refresh, and the corresponding
47
+ `createFromOidc` inputs are exported from `@telecrypt-io/storage/core`.
110
48
 
111
- ```bash
112
- npm ci --ignore-scripts --no-fund --no-audit
113
- npm run test:unit # unit-only checks; does not start Podman
114
- npm run synapse:up # disposable local Synapse/MAS fixture
115
- npm test # full SDK suite, including fixture-backed functional tests
116
- ```
49
+ The example creates retained Matrix rooms and encrypted media. Delete its file and then its empty
50
+ vault when finished; cleanup is part of the operation, not an automatic consequence of stopping
51
+ the client.
52
+
53
+ ## Storage model and behavior
54
+
55
+ The SDK maps the file tree to Matrix primitives defined by MSC3089:
117
56
 
118
- The full suite uses the real local Synapse/MAS/Postgres fixture in Podman at `localhost:8008`, never
119
- a production server. This is the shared fixture for the SDK, CLI, and Web functional/e2e suites;
120
- start it from this checkout and reuse it for the sibling repositories. Unit tests may use mocks for
121
- isolated boundaries, but they do not replace the real-stack checks.
57
+ - A vault is a Matrix Space room marked as a file tree.
58
+ - A folder is a child Space in that tree.
59
+ - A file is a Matrix event referring to encrypted uploaded content.
60
+ - Sharing uses Matrix room invitations and permissions use Matrix power levels.
122
61
 
123
- If setup or tests fail, inspect the relevant logs, then run `npm run synapse:down` when finished.
62
+ Vault, folder, and file IDs are opaque Matrix room and event IDs. Names are display labels: two
63
+ objects with the same name are still distinct, and a name is not an idempotency key.
124
64
 
125
- After a successful run, use `npm run synapse:down` for scoped teardown. Use
126
- `./throwaway_synapse/down.sh --wipe` only when a complete fixture reset is explicitly required.
65
+ File bytes are encrypted in the client before upload with Matrix attachment encryption (AES-CTR
66
+ and a per-file key distributed through the room's Megolm session). The server stores encrypted
67
+ media and Matrix events, but encryption does not hide all room, event, or other metadata from the
68
+ server or users who can read the room.
127
69
 
128
- See [RELEASING.md](./RELEASING.md) for the npm release procedure.
70
+ Recovery is explicit. Use `core.setupRecovery(storage)` to create a recovery key and keep that key
71
+ securely; use `core.restoreRecovery(storage, recoveryKey)` on a new device. Losing the recovery key
72
+ can prevent encrypted keys from being restored.
129
73
 
130
- ## Licence
74
+ Deletion has strict ordering. Delete files before their folders or vaults because folders and
75
+ vaults must be empty. File deletion removes the encrypted media object before redacting its Matrix
76
+ event and requires TeleCrypt's Synapse storage extension; stock Synapse cannot provide that
77
+ deletion contract.
131
78
 
132
- [Business Source License 1.1](./LICENSE). Non-commercial use is permitted; converts to
133
- Apache License 2.0 on 2030-07-20.
79
+ ## Source repositories
134
80
 
135
- For commercial licensing, contact TeleCrypt.io.
81
+ - Web application: [`TeleCrypt-io/storage`](https://github.com/TeleCrypt-io/storage)
82
+ - Command-line client: [`cli/`](https://github.com/TeleCrypt-io/storage/tree/main/cli)
136
83
 
137
- ## Third-party code
84
+ ## License
138
85
 
139
- - [`matrix-js-sdk`](https://github.com/matrix-org/matrix-js-sdk) — Apache-2.0 — dependency
140
- - [`matrix-encrypt-attachment`](https://github.com/matrix-org/matrix-encrypt-attachment) — Apache-2.0 — dependency
86
+ See [`LICENSE`](./LICENSE) for the complete terms. The current license is Business Source License
87
+ 1.1 with an additional grant for non-commercial use; it is not an open-source license. The stated
88
+ Change Date is 2030-07-20, and the Change License is Apache License 2.0. The license also changes
89
+ on the fourth anniversary of the first public distribution of a specific version if that occurs
90
+ before its stated Change Date. The full license controls in case of any discrepancy.
@@ -26,6 +26,8 @@ export interface TreeSpace {
26
26
  }
27
27
  export interface FileBranch {
28
28
  readonly id: string;
29
+ /** Current MSC3089 branch/listing event. Its event ID is distinct from `id` (the state key). */
30
+ readonly indexEvent?: Pick<MatrixEvent, "getId">;
29
31
  /** Matrix SDK keeps a redacted branch in state as an inactive object. */
30
32
  readonly isActive?: boolean;
31
33
  getName(): string;
@@ -1,4 +1,4 @@
1
- import { ClientEvent, createClient, EventType, MatrixEvent, MatrixError, Preset, RoomCreateTypeField, RoomType, SyncState, UNSTABLE_MSC3088_ENABLED, UNSTABLE_MSC3088_PURPOSE, UNSTABLE_MSC3089_TREE_SUBTYPE, } from "matrix-js-sdk";
1
+ import { ClientEvent, createClient, EventType, MatrixEvent, MatrixError, Preset, RoomCreateTypeField, RoomType, SyncState, UNSTABLE_MSC3088_ENABLED, UNSTABLE_MSC3088_PURPOSE, UNSTABLE_MSC3089_BRANCH, UNSTABLE_MSC3089_TREE_SUBTYPE, } from "matrix-js-sdk";
2
2
  import { Method } from "matrix-js-sdk/lib/http-api/method.js";
3
3
  import { ClientPrefix } from "matrix-js-sdk/lib/http-api/prefix.js";
4
4
  import { encryptAttachment, decryptAttachment } from "matrix-encrypt-attachment";
@@ -1036,19 +1036,22 @@ export class TeleCryptIOStorage {
1036
1036
  invite: 100,
1037
1037
  kick: 100,
1038
1038
  ban: 100,
1039
- redact: 50,
1040
- state_default: 50,
1041
- events_default: 50,
1039
+ redact: 100,
1040
+ state_default: 0,
1041
+ events_default: 0,
1042
1042
  users_default: 0,
1043
1043
  events: {
1044
1044
  [EventType.RoomPowerLevels]: 100,
1045
1045
  [EventType.RoomHistoryVisibility]: 100,
1046
1046
  [EventType.RoomTombstone]: 100,
1047
1047
  [EventType.RoomEncryption]: 100,
1048
- [EventType.RoomName]: 50,
1049
- [EventType.RoomMessage]: 50,
1050
- [EventType.RoomMessageEncrypted]: 50,
1051
- [EventType.Sticker]: 50,
1048
+ [EventType.RoomName]: 100,
1049
+ [EventType.RoomMessage]: 0,
1050
+ [EventType.RoomMessageEncrypted]: 0,
1051
+ [EventType.Sticker]: 0,
1052
+ [EventType.SpaceChild]: 100,
1053
+ [EventType.SpaceParent]: 100,
1054
+ [UNSTABLE_MSC3089_BRANCH.name]: 100,
1052
1055
  },
1053
1056
  users: { [userId]: 100 },
1054
1057
  },
@@ -9,6 +9,8 @@ export declare const MAX_MATRIX_IDENTIFIER_BYTES = 255;
9
9
  * backend hostname that differs from the user-visible Matrix server name.
10
10
  */
11
11
  export declare function validateCanonicalMatrixUserId(value: unknown, serverName: unknown): string;
12
+ /** Validates a Matrix user ID without binding it to a particular homeserver. */
13
+ export declare function validateMatrixUserId(value: unknown): string;
12
14
  export declare function validateMatrixDeviceId(value: unknown): string;
13
15
  export declare function validateMatrixRoomId(value: unknown, name?: string): string;
14
16
  export declare function validateMatrixEventId(value: unknown, name?: string): string;
@@ -15,17 +15,25 @@ const MATRIX_EVENT_ID_PATTERN = /^\$[A-Za-z0-9._~:/+\-]+$/;
15
15
  * backend hostname that differs from the user-visible Matrix server name.
16
16
  */
17
17
  export function validateCanonicalMatrixUserId(value, serverName) {
18
- if (typeof value !== "string" ||
19
- new TextEncoder().encode(value).byteLength > MAX_MATRIX_IDENTIFIER_BYTES) {
20
- throw new Error("invalid Matrix user ID");
21
- }
18
+ const userId = validateMatrixUserId(value);
22
19
  if (typeof serverName !== "string" || !MATRIX_SERVER_NAME_REGEXP.test(serverName)) {
23
20
  throw new Error("invalid Matrix server name");
24
21
  }
25
- const match = value.match(MATRIX_USER_ID_PATTERN);
22
+ const match = userId.match(MATRIX_USER_ID_PATTERN);
26
23
  if (!match || match[2].toLowerCase() !== serverName.toLowerCase()) {
27
24
  throw new Error("invalid Matrix user ID for this homeserver");
28
25
  }
26
+ return userId;
27
+ }
28
+ /** Validates a Matrix user ID without binding it to a particular homeserver. */
29
+ export function validateMatrixUserId(value) {
30
+ if (typeof value !== "string" ||
31
+ new TextEncoder().encode(value).byteLength > MAX_MATRIX_IDENTIFIER_BYTES) {
32
+ throw new Error("invalid Matrix user ID");
33
+ }
34
+ if (!MATRIX_USER_ID_PATTERN.test(value)) {
35
+ throw new Error("invalid Matrix user ID");
36
+ }
29
37
  return value;
30
38
  }
31
39
  export function validateMatrixDeviceId(value) {
@@ -4,4 +4,6 @@ export * from "./operations.js";
4
4
  export * from "./errors.js";
5
5
  export * from "./oidc.js";
6
6
  export * from "./constants.js";
7
+ export { cancelResponseBody, readResponseBody, ResponseBodyReadError } from "./http.js";
8
+ export type { ResponseBody, ResponseBodyOptions } from "./http.js";
7
9
  export { isValidName, validateName } from "./validation.js";
@@ -4,4 +4,5 @@ export * from "./operations.js";
4
4
  export * from "./errors.js";
5
5
  export * from "./oidc.js";
6
6
  export * from "./constants.js";
7
+ export { cancelResponseBody, readResponseBody, ResponseBodyReadError } from "./http.js";
7
8
  export { isValidName, validateName } from "./validation.js";
@@ -34,6 +34,8 @@ export type OidcTokenEndpointMetadata = Pick<OidcClientConfig, "issuer" | "token
34
34
  * identifiers, or control characters from an untrusted response to cross the
35
35
  * SDK boundary. */
36
36
  export declare function sanitizeDiagnosticText(value: string): string;
37
+ /** Validates an endpoint against the configured homeserver and optional issuer path. */
38
+ export declare function assertOidcEndpoint(value: unknown, trustedHomeserver: string, name: string, issuer?: URL, allowQuery?: boolean): string;
37
39
  /**
38
40
  * Discovers the OIDC/MAS issuer and endpoints for a homeserver via the
39
41
  * Matrix client's stable `/auth_metadata` endpoint. Matrix 42 performs this
package/dist/core/oidc.js CHANGED
@@ -382,7 +382,7 @@ function abortableDelay(milliseconds, signal) {
382
382
  schedule();
383
383
  });
384
384
  }
385
- function parseHttpUrl(value, name) {
385
+ function parseHttpUrl(value, name, allowQuery = false) {
386
386
  const text = requireOpaqueString(value, name);
387
387
  let parsed;
388
388
  try {
@@ -396,7 +396,7 @@ function parseHttpUrl(value, name) {
396
396
  (parsed.protocol === "http:" && !isLoopbackHostname(parsed.hostname)) ||
397
397
  parsed.username !== "" ||
398
398
  parsed.password !== "" ||
399
- parsed.search !== "" ||
399
+ (!allowQuery && parsed.search !== "") ||
400
400
  parsed.hash !== "" ||
401
401
  (parsed.toString() !== text && !isRootWithoutSlash)) {
402
402
  throw new StorageError(`OIDC authorization context has an invalid ${name}`);
@@ -409,6 +409,15 @@ function isWithinIssuerPath(issuer, endpoint) {
409
409
  const prefix = issuer.pathname.endsWith("/") ? issuer.pathname : `${issuer.pathname}/`;
410
410
  return endpoint.pathname === issuer.pathname || endpoint.pathname.startsWith(prefix);
411
411
  }
412
+ /** Validates an endpoint against the configured homeserver and optional issuer path. */
413
+ export function assertOidcEndpoint(value, trustedHomeserver, name, issuer, allowQuery = false) {
414
+ const homeserver = parseHttpUrl(trustedHomeserver, "homeserver URL");
415
+ const endpoint = parseHttpUrl(value, name, allowQuery);
416
+ if (endpoint.origin !== homeserver.origin || (issuer && !isWithinIssuerPath(issuer, endpoint))) {
417
+ throw new StorageError(`${name} must remain on the configured OIDC origin and issuer path`);
418
+ }
419
+ return endpoint.toString();
420
+ }
412
421
  function matrixAuthMetadataEndpoint(homeserver) {
413
422
  const basePath = homeserver.pathname.replace(/\/+$/u, "");
414
423
  return new URL(`${basePath}/_matrix/client/v1/auth_metadata`, homeserver.origin).toString();
@@ -640,7 +649,12 @@ export async function discoverOidcIssuer(homeserverBaseUrl, signal) {
640
649
  throw failure;
641
650
  }
642
651
  ensureOidcNotCancelled(signal, "discovery");
643
- return validateTrustedAuthMetadata(body);
652
+ try {
653
+ return validateTrustedAuthMetadata(body);
654
+ }
655
+ catch (error) {
656
+ throw responseValidationFailure("discovery", response, responseText ?? "", error);
657
+ }
644
658
  }
645
659
  catch (err) {
646
660
  if (err instanceof AggregateError)
@@ -1100,14 +1114,20 @@ export async function whoAmI(homeserverUrl, accessToken, serverName, signal) {
1100
1114
  if (!isRecord(body)) {
1101
1115
  throw responseValidationFailure("identity confirmation", response, responseText ?? "", new StorageError("OIDC identity confirmation returned an invalid response"));
1102
1116
  }
1103
- const userId = validateMatrixUserId(body.user_id, serverName);
1117
+ let userId;
1118
+ try {
1119
+ userId = validateMatrixUserId(body.user_id, serverName);
1120
+ }
1121
+ catch (error) {
1122
+ throw responseValidationFailure("identity confirmation", response, responseText ?? "", error);
1123
+ }
1104
1124
  let deviceId = null;
1105
1125
  if (body.device_id !== undefined && body.device_id !== null) {
1106
1126
  try {
1107
1127
  deviceId = validateMatrixDeviceId(body.device_id);
1108
1128
  }
1109
1129
  catch (error) {
1110
- throw new StorageError("OIDC identity confirmation returned an invalid device ID", { cause: error });
1130
+ throw responseValidationFailure("identity confirmation", response, responseText ?? "", new StorageError("OIDC identity confirmation returned an invalid device ID", { cause: error }));
1111
1131
  }
1112
1132
  }
1113
1133
  if (signal?.aborted)
@@ -31,7 +31,7 @@ export declare function declineInvite(storage: TeleCryptIOStorage, vaultId: stri
31
31
  vaultId: string;
32
32
  declined: boolean;
33
33
  }>;
34
- /** Invites `userId` to the vault and applies the requested role. */
34
+ /** Invites `userId` to the vault as a reader. */
35
35
  export declare function shareVault(storage: TeleCryptIOStorage, vaultId: string, userId: string, role: string, options?: OperationOptions): Promise<ShareResult>;
36
36
  export declare function unshareVault(storage: TeleCryptIOStorage, vaultId: string, userId: string, options?: OperationOptions): Promise<UnshareResult>;
37
37
  export declare function listMembers(storage: TeleCryptIOStorage, vaultId: string, options?: OperationOptions): Promise<Member[]>;
@@ -17,7 +17,7 @@ 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
19
  import { validateName } from "./validation.js";
20
- import { getDeletedTreeIds, isFileDeleted, isTreeDeleted, markFileDeleted, markTreeDeleted, } from "../deletion-markers.js";
20
+ import { isFileDeleted, isTreeDeleted, markFileDeleted, markTreeDeleted, } from "../deletion-markers.js";
21
21
  /**
22
22
  * Production Synapse enforces the built-in rc_messages budget (per-account
23
23
  * burst 10, refill 1/5s) on every room/state mutation the tree operations
@@ -204,9 +204,7 @@ async function resolveFile(storage, tree, fileId, signal) {
204
204
  * room. This also prevents a folder/vault delete from silently deleting a
205
205
  * nested shared tree.
206
206
  */
207
- function assertTreeEmptyForDeletion(storage, tree, spaces) {
208
- if (spaces.length !== 1)
209
- throw new NonEmptyTreeError(tree.id);
207
+ function assertTreeEmptyForDeletion(storage, tree) {
210
208
  let files;
211
209
  try {
212
210
  files = tree.listFiles().filter((file) => !isMarkedFileDeleted(storage, tree.id, file.id));
@@ -216,40 +214,74 @@ function assertTreeEmptyForDeletion(storage, tree, spaces) {
216
214
  }
217
215
  if (files.length > 0)
218
216
  throw new NonEmptyTreeError(tree.id);
217
+ const childEvents = readRelationEvents(storage.getClient(), tree.id, EventType.SpaceChild);
218
+ if (childEvents === null)
219
+ throw new StorageError("could not enumerate storage folders safely");
220
+ for (const event of childEvents) {
221
+ if (!isActiveRelationEvent(event))
222
+ continue;
223
+ const childId = relationStateKey(event);
224
+ if (!childId)
225
+ throw new StorageError("could not enumerate storage folders safely");
226
+ if (!isTreeDeleted(storage.getClient(), childId))
227
+ throw new NonEmptyTreeError(tree.id);
228
+ }
219
229
  }
220
230
  async function refreshDeletionRooms(storage, rootId, signal) {
221
231
  const client = storage.getClient();
222
- const http = client.http;
223
- // A real MatrixClient always exposes its authenticated HTTP transport. A
224
- // client without one cannot prove that its local relation graph is current.
225
- if (typeof http?.authedRequest !== "function") {
226
- throw new StorageError("delete graph refresh is unavailable");
227
- }
228
232
  await storage.refreshRoomState(rootId, { signal });
229
233
  if (signal?.aborted)
230
234
  throw new StorageError("operation cancelled");
231
- // The refreshed root owns its parent relation. Verify its one supported
232
- // external parent directly; unrelated local rooms are outside this deletion.
233
235
  const parentEvents = readRelationEvents(client, rootId, EventType.SpaceParent);
234
236
  if (parentEvents === null)
235
- throw new StorageError("delete graph is unsafe");
237
+ throw new StorageError("could not enumerate storage parent safely");
236
238
  const parentIds = new Set();
237
239
  for (const event of parentEvents) {
238
240
  if (!isActiveRelationEvent(event))
239
241
  continue;
240
242
  const parentId = relationStateKey(event);
241
243
  if (!parentId)
242
- throw new StorageError("delete graph is unsafe");
244
+ throw new StorageError("could not enumerate storage parent safely");
243
245
  if (parentId !== rootId)
244
246
  parentIds.add(parentId);
245
247
  }
246
248
  if (parentIds.size > 1)
247
- throw new StorageError("delete graph is unsafe");
248
- const parentId = parentIds.values().next().value;
249
- if (parentId)
250
- await storage.refreshRoomState(parentId, { signal });
249
+ throw new StorageError("storage room has multiple parents");
250
+ const parents = [...parentIds];
251
+ if (parents[0]) {
252
+ await storage.refreshRoomState(parents[0], { signal });
253
+ const childEvents = readRelationEvents(client, parents[0], EventType.SpaceChild, rootId);
254
+ if (childEvents === null ||
255
+ !childEvents.some((event) => isActiveRelationEvent(event) && relationStateKey(event, rootId) === rootId)) {
256
+ throw new StorageError("storage parent relation is inconsistent");
257
+ }
258
+ }
251
259
  if (signal?.aborted)
252
260
  throw new StorageError("operation cancelled");
261
+ return parents;
262
+ }
263
+ function snapshotTreeSpaces(root) {
264
+ const spaces = [];
265
+ const seen = new Set();
266
+ const pending = [root];
267
+ while (pending.length > 0) {
268
+ const tree = pending.pop();
269
+ if (!tree || seen.has(tree.id))
270
+ continue;
271
+ seen.add(tree.id);
272
+ spaces.push(tree);
273
+ let children;
274
+ try {
275
+ children = tree.getDirectories();
276
+ }
277
+ catch (error) {
278
+ if (error instanceof StorageError)
279
+ throw error;
280
+ throw new StorageError("could not enumerate storage folders", { cause: error });
281
+ }
282
+ pending.push(...children);
283
+ }
284
+ return spaces;
253
285
  }
254
286
  async function refreshTreeSpaces(storage, spaces, signal) {
255
287
  for (const space of spaces) {
@@ -280,126 +312,24 @@ function isActiveRelationEvent(event) {
280
312
  function activeTreeDirectories(client, tree) {
281
313
  const childEvents = readRelationEvents(client, tree.id, EventType.SpaceChild);
282
314
  if (childEvents === null)
283
- throw new StorageError("delete graph is unsafe");
315
+ throw new StorageError("storage folder state is unavailable");
284
316
  const activeChildIds = new Set();
285
317
  for (const event of childEvents) {
286
318
  if (!isActiveRelationEvent(event))
287
319
  continue;
288
320
  const childId = relationStateKey(event);
289
321
  if (!childId)
290
- throw new StorageError("delete graph is unsafe");
322
+ throw new StorageError("storage folder state is inconsistent");
291
323
  activeChildIds.add(childId);
292
324
  }
293
325
  const knownChildren = new Map(tree.getDirectories().map((child) => [child.id, child]));
294
326
  return [...activeChildIds].map((childId) => {
295
327
  const child = knownChildren.get(childId);
296
328
  if (!child)
297
- throw new StorageError("delete graph is unsafe");
329
+ throw new StorageError("storage folder state is inconsistent");
298
330
  return child;
299
331
  });
300
332
  }
301
- function snapshotTreeSpaces(root, activeRelationClient) {
302
- const spaces = [];
303
- const seen = new Set();
304
- const pending = [root];
305
- while (pending.length > 0) {
306
- const tree = pending.pop();
307
- if (!tree)
308
- continue;
309
- if (seen.has(tree.id))
310
- continue;
311
- seen.add(tree.id);
312
- spaces.push(tree);
313
- let children;
314
- try {
315
- children = activeRelationClient
316
- ? activeTreeDirectories(activeRelationClient, tree)
317
- : tree.getDirectories();
318
- }
319
- catch (error) {
320
- if (error instanceof StorageError)
321
- throw error;
322
- throw new StorageError("could not enumerate storage space descendants safely", { cause: error });
323
- }
324
- for (const child of children)
325
- pending.push(child);
326
- }
327
- return spaces;
328
- }
329
- /**
330
- * Deletion is intentionally narrower than MSC3089TreeSpace.delete(): a local
331
- * child graph is not proof that the rooms are exclusively owned by this tree.
332
- * Validate every observed edge in both directions and reject any parent or
333
- * child edge outside the exact deletion set before the first mutation.
334
- */
335
- function validateDeletionGraph(client, root, ids, deletedRooms) {
336
- const edges = new Set();
337
- const externalParents = [];
338
- const addEdge = (parentId, childId) => {
339
- if (deletedRooms.has(childId))
340
- return;
341
- if (!ids.has(childId))
342
- throw new StorageError("delete graph is unsafe");
343
- edges.add(`${parentId}\u0000${childId}`);
344
- };
345
- for (const id of ids) {
346
- const tree = client.unstableGetFileTreeSpace(id);
347
- if (!tree)
348
- throw new StorageError("delete graph is unsafe");
349
- const directories = activeTreeDirectories(client, tree);
350
- for (const child of directories)
351
- addEdge(id, child.id);
352
- const childEvents = readRelationEvents(client, id, EventType.SpaceChild);
353
- if (childEvents === null)
354
- throw new StorageError("delete graph is unsafe");
355
- for (const event of childEvents) {
356
- if (!isActiveRelationEvent(event))
357
- continue;
358
- const childId = relationStateKey(event);
359
- if (!childId)
360
- throw new StorageError("delete graph is unsafe");
361
- addEdge(id, childId);
362
- }
363
- }
364
- for (const edge of edges) {
365
- const separator = edge.indexOf("\u0000");
366
- const parentId = edge.slice(0, separator);
367
- const childId = edge.slice(separator + 1);
368
- const childLink = readRelationEvents(client, parentId, EventType.SpaceChild, childId);
369
- const parentLink = readRelationEvents(client, childId, EventType.SpaceParent, parentId);
370
- if (!childLink?.some((event) => isActiveRelationEvent(event) && relationStateKey(event, childId) === childId) ||
371
- !parentLink?.some((event) => isActiveRelationEvent(event) && relationStateKey(event, parentId) === parentId)) {
372
- throw new StorageError("delete graph is unsafe");
373
- }
374
- }
375
- for (const id of ids) {
376
- const parentEvents = readRelationEvents(client, id, EventType.SpaceParent);
377
- if (parentEvents === null)
378
- throw new StorageError("delete graph is unsafe");
379
- for (const event of parentEvents) {
380
- if (!isActiveRelationEvent(event))
381
- continue;
382
- const parentId = relationStateKey(event);
383
- if (!parentId)
384
- throw new StorageError("delete graph is unsafe");
385
- if (!ids.has(parentId)) {
386
- if (id !== root.id || externalParents.includes(parentId)) {
387
- throw new StorageError("delete graph is unsafe");
388
- }
389
- const childLink = readRelationEvents(client, parentId, EventType.SpaceChild, id);
390
- if (!childLink?.some((candidate) => isActiveRelationEvent(candidate) && relationStateKey(candidate, id) === id)) {
391
- throw new StorageError("delete graph is unsafe");
392
- }
393
- externalParents.push(parentId);
394
- }
395
- }
396
- }
397
- if (!ids.has(root.id))
398
- throw new StorageError("delete graph is unsafe");
399
- if (externalParents.length > 1)
400
- throw new StorageError("delete graph is unsafe");
401
- return { externalParents };
402
- }
403
333
  async function unlinkExternalParents(storage, rootId, externalParents, signal) {
404
334
  const client = storage.getClient();
405
335
  let mutationAttempted = false;
@@ -481,10 +411,10 @@ async function deleteRoomDeterministically(storage, roomId, signal) {
481
411
  const client = storage.getClient();
482
412
  const tree = storage.getTree(roomId);
483
413
  if (!tree)
484
- throw new StorageError("delete graph is unsafe");
414
+ throw new StorageError("storage room is unavailable for deletion");
485
415
  const self = client.getUserId();
486
416
  if (!self)
487
- throw new StorageError("delete graph is unsafe");
417
+ throw new StorageError("Matrix user identity is unavailable");
488
418
  const members = await storage.listMembers(tree, { signal });
489
419
  if (members.some((member) => member.userId !== self && member.role === "owner" &&
490
420
  (member.membership === "join" || member.membership === "invite" || member.membership === "knock"))) {
@@ -798,10 +728,10 @@ export async function declineInvite(storage, vaultId, options) {
798
728
  return { vaultId, declined: true };
799
729
  }, "mutation");
800
730
  }
801
- /** Invites `userId` to the vault and applies the requested role. */
731
+ /** Invites `userId` to the vault as a reader. */
802
732
  export async function shareVault(storage, vaultId, userId, role, options) {
803
- if (role !== "viewer" && role !== "editor") {
804
- throw new StorageError("invalid --role (must be viewer or editor)");
733
+ if (role !== "viewer") {
734
+ throw new StorageError("storage sharing supports viewers only");
805
735
  }
806
736
  const operation = createOperationDeadline(options);
807
737
  const completedRoomIds = new Set();
@@ -824,6 +754,12 @@ export async function shareVault(storage, vaultId, userId, role, options) {
824
754
  }
825
755
  for (const [index, space] of spaces.entries()) {
826
756
  ensureOperationActive(operation.signal);
757
+ const members = index === 0
758
+ ? currentMembers
759
+ : await withRateLimitRetry(() => storage.listMembers(space, { signal: operation.signal }), operation.signal);
760
+ if (members.some((member) => member.userId === userId && member.role === "owner")) {
761
+ throw new StorageError("share will not demote an existing owner");
762
+ }
827
763
  const currentMembership = index === 0
828
764
  ? currentMembers.find((member) => member.userId === userId)?.membership
829
765
  : await storage.getRoomMembership(space.id, userId, {
@@ -848,17 +784,10 @@ export async function shareVault(storage, vaultId, userId, role, options) {
848
784
  throw new StorageError("share failed");
849
785
  }
850
786
  }
851
- for (const space of spaces) {
852
- ensureOperationActive(operation.signal);
853
- const members = space.id === tree.id
854
- ? currentMembers
855
- : await withRateLimitRetry(() => storage.listMembers(space, { signal: operation.signal }), operation.signal);
856
- if (members.some((member) => member.userId === userId && member.role === "owner")) {
857
- throw new StorageError("share will not demote an existing owner");
858
- }
859
- await withRateLimitRetry(() => withMatrixMutationAbort(() => space.setPermissions(userId, role), operation.signal), operation.signal);
860
- completedRoomIds.add(space.id);
861
- }
787
+ // Storage rooms have one immutable owner power level and readers use
788
+ // the room's default user level. Inviting is the complete sharing
789
+ // operation; changing per-user power levels would create editors and
790
+ // let metadata authorship drift away from the owner.
862
791
  }
863
792
  catch (error) {
864
793
  if (error instanceof MutationOutcomeUnknownError)
@@ -875,7 +804,7 @@ export async function shareVault(storage, vaultId, userId, role, options) {
875
804
  }
876
805
  throw new StorageError("share failed", { cause: error });
877
806
  }
878
- return { vaultId, userId, role };
807
+ return { vaultId, userId, role: "viewer" };
879
808
  }, operation.signal);
880
809
  return await raceOperationDeadline(operation, pending, "mutation");
881
810
  }
@@ -1062,38 +991,20 @@ async function deleteTree(storage, treeId, options) {
1062
991
  if (isTreeDeleted(client, treeId))
1063
992
  return { id: treeId, deleted: true };
1064
993
  const tree = await resolveTree(storage, treeId, operation.signal);
1065
- // Read reconciliation state after this operation reaches the single
1066
- // client queue; a preceding delete may have populated the set while we
1067
- // were waiting for its turn.
1068
- const removedRooms = getDeletedTreeIds(client);
1069
- let graph;
994
+ let externalParents;
1070
995
  try {
1071
- // Re-read the candidate graph and any external parents it identifies
1072
- // before taking the immutable graph snapshot. Unrelated rooms are not
1073
- // part of this operation and must not block deletion merely because
1074
- // their membership is not joined.
1075
- await refreshDeletionRooms(storage, tree.id, operation.signal);
1076
- const spaces = snapshotTreeSpaces(tree, client);
1077
- const activeSpaces = spaces.filter((space) => !removedRooms.has(space.id));
1078
- const ids = new Set(activeSpaces.map((space) => space.id));
1079
- if (!ids.has(tree.id)) {
1080
- if (removedRooms.has(tree.id)) {
1081
- return { id: treeId, deleted: true };
1082
- }
1083
- throw new StorageError("delete graph is unsafe");
1084
- }
1085
- assertTreeEmptyForDeletion(storage, tree, activeSpaces);
1086
- graph = validateDeletionGraph(client, tree, ids, removedRooms);
996
+ // Deletion owns one room. Refresh its current state and its one
997
+ // supported parent before checking files/folders or changing links.
998
+ externalParents = await refreshDeletionRooms(storage, tree.id, operation.signal);
999
+ assertTreeEmptyForDeletion(storage, tree);
1087
1000
  }
1088
1001
  catch (error) {
1089
1002
  if (error instanceof StorageError)
1090
1003
  throw error;
1091
1004
  throw new StorageError("delete failed", { cause: error });
1092
1005
  }
1093
- // Keep external parent links intact until the room deletion succeeds.
1094
- // Unlinking first would silently detach a live vault on failure.
1095
1006
  try {
1096
- await unlinkExternalParents(storage, tree.id, graph.externalParents, operation.signal);
1007
+ await unlinkExternalParents(storage, tree.id, externalParents, operation.signal);
1097
1008
  }
1098
1009
  catch (error) {
1099
1010
  if (error instanceof StorageError)
@@ -1104,11 +1015,8 @@ async function deleteTree(storage, treeId, options) {
1104
1015
  await deleteRoomDeterministically(storage, tree.id, operation.signal);
1105
1016
  }
1106
1017
  catch (error) {
1107
- // The root is still live when its relation is removed. If deletion then
1108
- // fails, restore both sides before reporting failure; otherwise a live
1109
- // vault would be silently detached from its external parent.
1110
1018
  try {
1111
- await relinkExternalParents(storage, tree.id, graph.externalParents, new AbortController().signal);
1019
+ await relinkExternalParents(storage, tree.id, externalParents, new AbortController().signal);
1112
1020
  }
1113
1021
  catch (cleanupError) {
1114
1022
  throw new StorageError("delete cleanup is incomplete", {
@@ -1201,9 +1109,17 @@ export async function deleteFile(storage, treeId, fileId, options) {
1201
1109
  const completedIds = [];
1202
1110
  try {
1203
1111
  const client = storage.getClient();
1112
+ // MSC3089 uses the file event ID as the branch state key, but the current
1113
+ // listing is a separate state event. Redact that current listing after
1114
+ // clearing the branch state; redacting only the file event leaves the
1115
+ // listing visible and is especially harmful when a user is suspended.
1116
+ const listingEventId = branch.indexEvent?.getId?.() ?? branch.id;
1117
+ if (typeof listingEventId !== "string" || listingEventId.length === 0) {
1118
+ throw new StorageError("encrypted file has no listing event identifier");
1119
+ }
1204
1120
  await withRateLimitRetry(() => withMatrixMutationAbort(() => client.sendStateEvent(tree.id, UNSTABLE_MSC3089_BRANCH.name, {}, branch.id), signal, "delete file state"), signal);
1205
- await withRateLimitRetry(() => withMatrixMutationAbort(() => client.redactEvent(tree.id, branch.id), signal, "delete file event"), signal);
1206
- completedIds.push(branch.id);
1121
+ await withRateLimitRetry(() => withMatrixMutationAbort(() => client.redactEvent(tree.id, listingEventId), signal, "delete file event"), signal);
1122
+ completedIds.push(listingEventId);
1207
1123
  }
1208
1124
  catch (error) {
1209
1125
  if (error instanceof MutationOutcomeUnknownError)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telecrypt-io/storage",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "license": "BUSL-1.1",
5
5
  "type": "module",
6
6
  "packageManager": "npm@11.19.0",