@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 +59 -109
- package/dist/TeleCryptIOStorage.d.ts +2 -0
- package/dist/TeleCryptIOStorage.js +11 -8
- package/dist/core/constants.d.ts +2 -0
- package/dist/core/constants.js +13 -5
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +1 -0
- package/dist/core/oidc.d.ts +2 -0
- package/dist/core/oidc.js +25 -5
- package/dist/core/operations.d.ts +1 -1
- package/dist/core/operations.js +86 -170
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,19 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@telecrypt-io/storage)
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
## Quick start
|
|
15
|
+
## Browser quick start
|
|
28
16
|
|
|
29
|
-
|
|
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
|
-
|
|
39
|
-
await core.
|
|
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
|
-
|
|
43
|
-
and
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
133
|
-
Apache License 2.0 on 2030-07-20.
|
|
79
|
+
## Source repositories
|
|
134
80
|
|
|
135
|
-
|
|
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
|
-
##
|
|
84
|
+
## License
|
|
138
85
|
|
|
139
|
-
|
|
140
|
-
|
|
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:
|
|
1040
|
-
state_default:
|
|
1041
|
-
events_default:
|
|
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]:
|
|
1049
|
-
[EventType.RoomMessage]:
|
|
1050
|
-
[EventType.RoomMessageEncrypted]:
|
|
1051
|
-
[EventType.Sticker]:
|
|
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
|
},
|
package/dist/core/constants.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/constants.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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) {
|
package/dist/core/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/core/index.js
CHANGED
|
@@ -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";
|
package/dist/core/oidc.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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[]>;
|
package/dist/core/operations.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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("
|
|
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("
|
|
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("
|
|
248
|
-
const
|
|
249
|
-
if (
|
|
250
|
-
await storage.refreshRoomState(
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
414
|
+
throw new StorageError("storage room is unavailable for deletion");
|
|
485
415
|
const self = client.getUserId();
|
|
486
416
|
if (!self)
|
|
487
|
-
throw new StorageError("
|
|
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
|
|
731
|
+
/** Invites `userId` to the vault as a reader. */
|
|
802
732
|
export async function shareVault(storage, vaultId, userId, role, options) {
|
|
803
|
-
if (role !== "viewer"
|
|
804
|
-
throw new StorageError("
|
|
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
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1072
|
-
// before
|
|
1073
|
-
|
|
1074
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
1206
|
-
completedIds.push(
|
|
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)
|