@telecrypt-io/storage 0.7.1 → 0.8.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 +68 -109
- package/dist/TeleCryptIOStorage.d.ts +27 -0
- package/dist/TeleCryptIOStorage.js +305 -32
- package/dist/core/operations.d.ts +1 -1
- package/dist/core/operations.js +165 -126
- 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.github.io/llms-authority/llms.txt](https://telecrypt-io.github.io/llms-authority/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,88 @@ maintained in its own repository.
|
|
|
22
12
|
npm install @telecrypt-io/storage
|
|
23
13
|
```
|
|
24
14
|
|
|
25
|
-
|
|
15
|
+
## Browser quick start
|
|
26
16
|
|
|
27
|
-
|
|
28
|
-
|
|
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:
|
|
56
|
+
|
|
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.
|
|
117
61
|
|
|
118
|
-
|
|
119
|
-
|
|
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.
|
|
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.
|
|
122
64
|
|
|
123
|
-
|
|
65
|
+
Vault and folder names, and file names, are stored in encrypted room messages. Matrix room names
|
|
66
|
+
remain the generic `Encrypted storage`; file-listing state stores only an event pointer, and media
|
|
67
|
+
uploads omit the original filename. Use the asynchronous operations in `@telecrypt-io/storage/core`
|
|
68
|
+
to read names. The synchronous Matrix SDK tree helper cannot decrypt on demand and exposes a generic
|
|
69
|
+
file label until an async operation resolves the name. This is a TeleCrypt metadata format: standard
|
|
70
|
+
MSC3089 clients will not display the private names or understand these file indexes. SDK 0.8 starts
|
|
71
|
+
this format without a reader or migration for trees written by older SDK versions; those old trees
|
|
72
|
+
may be ignored or removed.
|
|
124
73
|
|
|
125
|
-
|
|
126
|
-
|
|
74
|
+
File bytes are encrypted in the client before upload with Matrix attachment encryption (AES-CTR
|
|
75
|
+
and a per-file key distributed through the room's Megolm session). The server stores encrypted
|
|
76
|
+
media and Matrix events, but encryption does not hide all room, event, or other metadata from the
|
|
77
|
+
server or users who can read the room.
|
|
127
78
|
|
|
128
|
-
|
|
79
|
+
Recovery is explicit. Use `core.setupRecovery(storage)` to create a recovery key and keep that key
|
|
80
|
+
securely; use `core.restoreRecovery(storage, recoveryKey)` on a new device. Losing the recovery key
|
|
81
|
+
can prevent encrypted keys from being restored.
|
|
129
82
|
|
|
130
|
-
|
|
83
|
+
Deletion has strict ordering. Delete files before their folders or vaults because folders and
|
|
84
|
+
vaults must be empty. File deletion removes the encrypted media object before redacting its Matrix
|
|
85
|
+
event and requires TeleCrypt's Synapse storage extension; stock Synapse cannot provide that
|
|
86
|
+
deletion contract.
|
|
131
87
|
|
|
132
|
-
|
|
133
|
-
Apache License 2.0 on 2030-07-20.
|
|
88
|
+
## Source repositories
|
|
134
89
|
|
|
135
|
-
|
|
90
|
+
- Web application: [`TeleCrypt-io/storage`](https://github.com/TeleCrypt-io/storage)
|
|
91
|
+
- Command-line client: [`cli/`](https://github.com/TeleCrypt-io/storage/tree/main/cli)
|
|
136
92
|
|
|
137
|
-
##
|
|
93
|
+
## License
|
|
138
94
|
|
|
139
|
-
|
|
140
|
-
|
|
95
|
+
See [`LICENSE`](./LICENSE) for the complete terms. The current license is Business Source License
|
|
96
|
+
1.1 with an additional grant for non-commercial use; it is not an open-source license. The stated
|
|
97
|
+
Change Date is 2030-07-20, and the Change License is Apache License 2.0. The license also changes
|
|
98
|
+
on the fourth anniversary of the first public distribution of a specific version if that occurs
|
|
99
|
+
before its stated Change Date. The full license controls in case of any discrepancy.
|
|
@@ -26,6 +26,9 @@ export interface TreeSpace {
|
|
|
26
26
|
}
|
|
27
27
|
export interface FileBranch {
|
|
28
28
|
readonly id: string;
|
|
29
|
+
readonly roomId: string;
|
|
30
|
+
/** Current MSC3089 branch/listing event. Its event ID is distinct from `id` (the state key). */
|
|
31
|
+
readonly indexEvent?: Pick<MatrixEvent, "getId" | "getContent" | "getSender" | "isRedacted">;
|
|
29
32
|
/** Matrix SDK keeps a redacted branch in state as an inactive object. */
|
|
30
33
|
readonly isActive?: boolean;
|
|
31
34
|
getName(): string;
|
|
@@ -142,9 +145,32 @@ export declare function withTreeMutation<T>(client: MatrixClient, operation: (si
|
|
|
142
145
|
export declare class TeleCryptIOStorage {
|
|
143
146
|
private client;
|
|
144
147
|
private readonly decoratedTreeSpaces;
|
|
148
|
+
private readonly decoratedFileBranches;
|
|
149
|
+
private readonly treeNames;
|
|
150
|
+
private readonly fileNames;
|
|
145
151
|
constructor(client: MatrixClient);
|
|
146
152
|
/** The underlying matrix-js-sdk client (e.g. to stop it, or for advanced/interop use). */
|
|
147
153
|
getClient(): MatrixClient;
|
|
154
|
+
private fileNameKey;
|
|
155
|
+
private getStateEvent;
|
|
156
|
+
private getTreeOwnerId;
|
|
157
|
+
private sendEncryptedMetadataMessage;
|
|
158
|
+
private readEncryptedMetadataMessage;
|
|
159
|
+
private writeTreeName;
|
|
160
|
+
/** Resolves an encrypted vault/folder name through its pointer state event. */
|
|
161
|
+
getTreeName(roomId: string, options?: MatrixRequestOptions): Promise<string>;
|
|
162
|
+
private writeFileName;
|
|
163
|
+
/** Resolves an encrypted file name from the MSC3089 index pointer. */
|
|
164
|
+
getFileName(treeId: string, fileId: string, options?: MatrixRequestOptions & {
|
|
165
|
+
refreshState?: boolean;
|
|
166
|
+
}): Promise<string>;
|
|
167
|
+
/** Returns the current file metadata pointer, including on inactive branches. */
|
|
168
|
+
getFileMetadataEventId(treeId: string, fileId: string, options?: MatrixRequestOptions): Promise<string | null>;
|
|
169
|
+
/** Fetches and validates a current encrypted rename event, allowing retries after redaction. */
|
|
170
|
+
getFileRenameMetadataEvent(treeId: string, fileId: string, metadataEventId: string): Promise<MatrixEvent>;
|
|
171
|
+
/** Fetches the original encrypted file message directly by event ID. */
|
|
172
|
+
getOriginalFileEvent(treeId: string, fileId: string, allowRedacted?: boolean): Promise<MatrixEvent>;
|
|
173
|
+
private createEncryptedFile;
|
|
148
174
|
/**
|
|
149
175
|
* Refreshes a room's current state from the homeserver. The background sync
|
|
150
176
|
* loop is intentionally asynchronous, so a short-lived CLI process can
|
|
@@ -247,6 +273,7 @@ export declare class TeleCryptIOStorage {
|
|
|
247
273
|
* which refreshes the exact parent/child state before reporting success.
|
|
248
274
|
*/
|
|
249
275
|
private decorateTreeSpace;
|
|
276
|
+
private decorateFileBranch;
|
|
250
277
|
/** Cleans up only a room created by this operation and reports incomplete cleanup. */
|
|
251
278
|
private cleanupCreatedRoom;
|
|
252
279
|
private removeRelationLink;
|
|
@@ -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, MsgType, Preset, RoomCreateTypeField, RoomType, SyncState, UNSTABLE_MSC3088_ENABLED, UNSTABLE_MSC3088_PURPOSE, UNSTABLE_MSC3089_BRANCH, UNSTABLE_MSC3089_LEAF, 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";
|
|
@@ -9,6 +9,10 @@ import { cancelResponseBody, raceWithAbort, readMediaResponseBody, readResponseB
|
|
|
9
9
|
import { sanitizeDiagnosticText } from "./core/oidc.js";
|
|
10
10
|
import { validateName } from "./core/validation.js";
|
|
11
11
|
import { isTreeDeleted } from "./deletion-markers.js";
|
|
12
|
+
const STORAGE_METADATA_EVENT_TYPE = "io.telecrypt.storage.metadata";
|
|
13
|
+
const STORAGE_METADATA_MSGTYPE = "io.telecrypt.storage.metadata";
|
|
14
|
+
const GENERIC_TREE_NAME = "Encrypted storage";
|
|
15
|
+
const GENERIC_FILE_NAME = "Encrypted file";
|
|
12
16
|
const recoveryMutationQueues = new WeakMap();
|
|
13
17
|
const treeMutationQueues = new WeakMap();
|
|
14
18
|
const TREE_SYNC_TIMEOUT_MS = 15000;
|
|
@@ -235,12 +239,6 @@ export function readFileEventMetadata(content) {
|
|
|
235
239
|
function isRecord(value) {
|
|
236
240
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
237
241
|
}
|
|
238
|
-
async function isUndecryptableFilePlaceholder(branch) {
|
|
239
|
-
// matrix-js-sdk replaces failed encrypted event content with m.bad.encrypted;
|
|
240
|
-
// its public failure flag is the authoritative state and avoids guessing
|
|
241
|
-
// from that display content (or misclassifying malformed plaintext events).
|
|
242
|
-
return (await branch.getFileEvent()).isDecryptionFailure();
|
|
243
|
-
}
|
|
244
242
|
function validateSecretStorageStatus(value) {
|
|
245
243
|
if (!isRecord(value))
|
|
246
244
|
throw new RecoverySetupAmbiguousError();
|
|
@@ -409,6 +407,9 @@ export async function withTreeMutation(client, operation, signal) {
|
|
|
409
407
|
export class TeleCryptIOStorage {
|
|
410
408
|
client;
|
|
411
409
|
decoratedTreeSpaces = new WeakSet();
|
|
410
|
+
decoratedFileBranches = new WeakMap();
|
|
411
|
+
treeNames = new Map();
|
|
412
|
+
fileNames = new Map();
|
|
412
413
|
constructor(client) {
|
|
413
414
|
this.client = client;
|
|
414
415
|
// Advanced callers may construct a MatrixClient themselves. The SDK does
|
|
@@ -420,6 +421,229 @@ export class TeleCryptIOStorage {
|
|
|
420
421
|
getClient() {
|
|
421
422
|
return this.client;
|
|
422
423
|
}
|
|
424
|
+
fileNameKey(treeId, fileId) {
|
|
425
|
+
return `${treeId}\u0000${fileId}`;
|
|
426
|
+
}
|
|
427
|
+
getStateEvent(roomId, eventType, stateKey) {
|
|
428
|
+
const state = this.client.getRoom(roomId)?.currentState;
|
|
429
|
+
const raw = state?.getStateEvents(eventType, stateKey);
|
|
430
|
+
if (Array.isArray(raw))
|
|
431
|
+
return raw[0] ?? null;
|
|
432
|
+
return raw ?? null;
|
|
433
|
+
}
|
|
434
|
+
getTreeOwnerId(roomId) {
|
|
435
|
+
const create = this.getStateEvent(roomId, EventType.RoomCreate, "");
|
|
436
|
+
const ownerId = create?.getSender();
|
|
437
|
+
if (!ownerId)
|
|
438
|
+
throw new StorageError("storage owner is unavailable");
|
|
439
|
+
return ownerId;
|
|
440
|
+
}
|
|
441
|
+
async sendEncryptedMetadataMessage(roomId, content) {
|
|
442
|
+
const response = await this.client.sendMessage(roomId, content);
|
|
443
|
+
return validateMatrixEventId(response?.event_id, "storage metadata response event ID");
|
|
444
|
+
}
|
|
445
|
+
async readEncryptedMetadataMessage(roomId, eventId, allowRedacted = false) {
|
|
446
|
+
const rawEvent = await this.client.fetchRoomEvent(roomId, eventId);
|
|
447
|
+
const event = this.client.getEventMapper()(rawEvent);
|
|
448
|
+
await this.client.decryptEventIfNeeded(event);
|
|
449
|
+
if (event.isDecryptionFailure())
|
|
450
|
+
throw new UndecryptableFileError();
|
|
451
|
+
if (allowRedacted &&
|
|
452
|
+
event.getId() === eventId &&
|
|
453
|
+
event.getRoomId() === roomId &&
|
|
454
|
+
event.getSender() === this.getTreeOwnerId(roomId) &&
|
|
455
|
+
event.isRedacted()) {
|
|
456
|
+
return event;
|
|
457
|
+
}
|
|
458
|
+
if (event.getId() !== eventId ||
|
|
459
|
+
event.getRoomId() !== roomId ||
|
|
460
|
+
event.getSender() !== this.getTreeOwnerId(roomId) ||
|
|
461
|
+
event.getWireType() !== EventType.RoomMessageEncrypted ||
|
|
462
|
+
event.getType() !== EventType.RoomMessage) {
|
|
463
|
+
throw new StorageError("encrypted storage metadata is invalid or unavailable");
|
|
464
|
+
}
|
|
465
|
+
return event;
|
|
466
|
+
}
|
|
467
|
+
async writeTreeName(roomId, name) {
|
|
468
|
+
validateName(name, "name");
|
|
469
|
+
const metadataEventId = await this.sendEncryptedMetadataMessage(roomId, {
|
|
470
|
+
msgtype: STORAGE_METADATA_MSGTYPE,
|
|
471
|
+
body: name,
|
|
472
|
+
});
|
|
473
|
+
await this.client.sendStateEvent(roomId, STORAGE_METADATA_EVENT_TYPE, { event_id: metadataEventId }, "");
|
|
474
|
+
this.treeNames.set(roomId, name);
|
|
475
|
+
}
|
|
476
|
+
/** Resolves an encrypted vault/folder name through its pointer state event. */
|
|
477
|
+
async getTreeName(roomId, options) {
|
|
478
|
+
if (options?.signal?.aborted)
|
|
479
|
+
throw new StorageError("operation cancelled");
|
|
480
|
+
await this.refreshRoomState(roomId, options);
|
|
481
|
+
const pointer = this.getStateEvent(roomId, STORAGE_METADATA_EVENT_TYPE, "");
|
|
482
|
+
const pointerContent = pointer?.getContent();
|
|
483
|
+
if (!pointer ||
|
|
484
|
+
pointer.getSender() !== this.getTreeOwnerId(roomId) ||
|
|
485
|
+
!isRecord(pointerContent) ||
|
|
486
|
+
typeof pointerContent.event_id !== "string") {
|
|
487
|
+
throw new StorageError("encrypted storage name is unavailable");
|
|
488
|
+
}
|
|
489
|
+
const metadataEventId = validateMatrixEventId(pointerContent.event_id, "storage metadata event ID");
|
|
490
|
+
let event;
|
|
491
|
+
try {
|
|
492
|
+
event = await this.readEncryptedMetadataMessage(roomId, metadataEventId);
|
|
493
|
+
}
|
|
494
|
+
catch (error) {
|
|
495
|
+
// A new device can list storage before restoring room keys. Keep the
|
|
496
|
+
// room visible with a generic label; a later call retries decryption.
|
|
497
|
+
if (error instanceof UndecryptableFileError)
|
|
498
|
+
return GENERIC_TREE_NAME;
|
|
499
|
+
throw error;
|
|
500
|
+
}
|
|
501
|
+
const content = event.getContent();
|
|
502
|
+
if (!isRecord(content) ||
|
|
503
|
+
content.msgtype !== STORAGE_METADATA_MSGTYPE ||
|
|
504
|
+
typeof content.body !== "string" ||
|
|
505
|
+
Object.hasOwn(content, "file_event_id")) {
|
|
506
|
+
throw new StorageError("encrypted storage name is invalid or unavailable");
|
|
507
|
+
}
|
|
508
|
+
validateName(content.body, "name");
|
|
509
|
+
this.treeNames.set(roomId, content.body);
|
|
510
|
+
return content.body;
|
|
511
|
+
}
|
|
512
|
+
async writeFileName(treeId, fileId, name) {
|
|
513
|
+
validateName(name, "file name");
|
|
514
|
+
validateMatrixEventId(fileId, "file event ID");
|
|
515
|
+
const metadataEventId = await this.sendEncryptedMetadataMessage(treeId, {
|
|
516
|
+
msgtype: STORAGE_METADATA_MSGTYPE,
|
|
517
|
+
body: name,
|
|
518
|
+
file_event_id: fileId,
|
|
519
|
+
});
|
|
520
|
+
await this.client.sendStateEvent(treeId, UNSTABLE_MSC3089_BRANCH.name, { active: true, metadata_event_id: metadataEventId }, fileId);
|
|
521
|
+
this.fileNames.set(this.fileNameKey(treeId, fileId), name);
|
|
522
|
+
}
|
|
523
|
+
/** Resolves an encrypted file name from the MSC3089 index pointer. */
|
|
524
|
+
async getFileName(treeId, fileId, options) {
|
|
525
|
+
if (options?.signal?.aborted)
|
|
526
|
+
throw new StorageError("operation cancelled");
|
|
527
|
+
if (options?.refreshState !== false)
|
|
528
|
+
await this.refreshRoomState(treeId, options);
|
|
529
|
+
const tree = this.getTree(treeId);
|
|
530
|
+
const branch = tree?.getFile(fileId);
|
|
531
|
+
if (!branch || branch.isActive === false)
|
|
532
|
+
throw new StorageError("file not found");
|
|
533
|
+
const indexEvent = branch.indexEvent;
|
|
534
|
+
const indexContent = indexEvent?.getContent();
|
|
535
|
+
if (!indexEvent ||
|
|
536
|
+
indexEvent.getSender() !== this.getTreeOwnerId(treeId) ||
|
|
537
|
+
!isRecord(indexContent) ||
|
|
538
|
+
indexContent.active !== true ||
|
|
539
|
+
typeof indexContent.metadata_event_id !== "string") {
|
|
540
|
+
throw new StorageError("encrypted file name is unavailable");
|
|
541
|
+
}
|
|
542
|
+
const metadataEventId = validateMatrixEventId(indexContent.metadata_event_id, "file metadata event ID");
|
|
543
|
+
let event;
|
|
544
|
+
try {
|
|
545
|
+
event = await this.readEncryptedMetadataMessage(treeId, metadataEventId);
|
|
546
|
+
}
|
|
547
|
+
catch (error) {
|
|
548
|
+
// File IDs remain listable on a fresh device while its room keys are
|
|
549
|
+
// being restored. The caller can resolve the private name on retry.
|
|
550
|
+
if (error instanceof UndecryptableFileError)
|
|
551
|
+
return GENERIC_FILE_NAME;
|
|
552
|
+
throw error;
|
|
553
|
+
}
|
|
554
|
+
const content = event.getContent();
|
|
555
|
+
if (!isRecord(content) || typeof content.body !== "string") {
|
|
556
|
+
throw new StorageError("encrypted file name is invalid or unavailable");
|
|
557
|
+
}
|
|
558
|
+
if (content.msgtype === MsgType.File) {
|
|
559
|
+
if (metadataEventId !== fileId || !isRecord(content.file)) {
|
|
560
|
+
throw new StorageError("encrypted file metadata does not match its listing");
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
else if (content.msgtype !== STORAGE_METADATA_MSGTYPE ||
|
|
564
|
+
content.file_event_id !== fileId) {
|
|
565
|
+
throw new StorageError("encrypted file metadata does not match its listing");
|
|
566
|
+
}
|
|
567
|
+
validateName(content.body, "file name");
|
|
568
|
+
this.fileNames.set(this.fileNameKey(treeId, fileId), content.body);
|
|
569
|
+
return content.body;
|
|
570
|
+
}
|
|
571
|
+
/** Returns the current file metadata pointer, including on inactive branches. */
|
|
572
|
+
async getFileMetadataEventId(treeId, fileId, options) {
|
|
573
|
+
if (options?.signal?.aborted)
|
|
574
|
+
throw new StorageError("operation cancelled");
|
|
575
|
+
await this.refreshRoomState(treeId, options);
|
|
576
|
+
const branch = this.getTree(treeId)?.getFile(fileId);
|
|
577
|
+
if (!branch)
|
|
578
|
+
throw new StorageError("file not found");
|
|
579
|
+
const indexEvent = branch.indexEvent;
|
|
580
|
+
const content = indexEvent?.getContent();
|
|
581
|
+
if (!indexEvent || indexEvent.getSender() !== this.getTreeOwnerId(treeId)) {
|
|
582
|
+
throw new StorageError("encrypted file listing is unavailable");
|
|
583
|
+
}
|
|
584
|
+
if (!isRecord(content) ||
|
|
585
|
+
typeof content.metadata_event_id !== "string") {
|
|
586
|
+
if (branch.isActive === false || indexEvent.isRedacted())
|
|
587
|
+
return null;
|
|
588
|
+
throw new StorageError("encrypted file name is unavailable");
|
|
589
|
+
}
|
|
590
|
+
return validateMatrixEventId(content.metadata_event_id, "file metadata event ID");
|
|
591
|
+
}
|
|
592
|
+
/** Fetches and validates a current encrypted rename event, allowing retries after redaction. */
|
|
593
|
+
async getFileRenameMetadataEvent(treeId, fileId, metadataEventId) {
|
|
594
|
+
const event = await this.readEncryptedMetadataMessage(treeId, metadataEventId, true);
|
|
595
|
+
if (event.isRedacted())
|
|
596
|
+
return event;
|
|
597
|
+
const content = event.getContent();
|
|
598
|
+
if (!isRecord(content) ||
|
|
599
|
+
content.msgtype !== STORAGE_METADATA_MSGTYPE ||
|
|
600
|
+
content.file_event_id !== fileId ||
|
|
601
|
+
typeof content.body !== "string") {
|
|
602
|
+
throw new StorageError("encrypted file metadata does not match its listing");
|
|
603
|
+
}
|
|
604
|
+
validateName(content.body, "file name");
|
|
605
|
+
return event;
|
|
606
|
+
}
|
|
607
|
+
/** Fetches the original encrypted file message directly by event ID. */
|
|
608
|
+
async getOriginalFileEvent(treeId, fileId, allowRedacted = false) {
|
|
609
|
+
const event = await this.readEncryptedMetadataMessage(treeId, fileId, allowRedacted);
|
|
610
|
+
if (event.isRedacted() && allowRedacted)
|
|
611
|
+
return event;
|
|
612
|
+
const content = event.getContent();
|
|
613
|
+
if (!isRecord(content) ||
|
|
614
|
+
content.msgtype !== MsgType.File ||
|
|
615
|
+
typeof content.body !== "string" ||
|
|
616
|
+
!isRecord(content.file) ||
|
|
617
|
+
typeof content.file.url !== "string") {
|
|
618
|
+
throw new StorageError("encrypted file message is invalid or unavailable");
|
|
619
|
+
}
|
|
620
|
+
return event;
|
|
621
|
+
}
|
|
622
|
+
async createEncryptedFile(treeId, name, encryptedContents, info, additionalContent) {
|
|
623
|
+
validateFileName(name);
|
|
624
|
+
const bytes = encryptedContents instanceof Uint8Array
|
|
625
|
+
? encryptedContents
|
|
626
|
+
: new Uint8Array(encryptedContents);
|
|
627
|
+
const body = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
628
|
+
const uploaded = await this.client.uploadContent(body, { includeFilename: false });
|
|
629
|
+
if (typeof uploaded.content_uri !== "string" || uploaded.content_uri.length === 0) {
|
|
630
|
+
throw new StorageError("encrypted media upload returned no content URI");
|
|
631
|
+
}
|
|
632
|
+
const fileInfo = { ...info, url: uploaded.content_uri };
|
|
633
|
+
const fileContent = {
|
|
634
|
+
...additionalContent,
|
|
635
|
+
msgtype: MsgType.File,
|
|
636
|
+
body: name,
|
|
637
|
+
url: uploaded.content_uri,
|
|
638
|
+
file: fileInfo,
|
|
639
|
+
[UNSTABLE_MSC3089_LEAF.name]: {},
|
|
640
|
+
};
|
|
641
|
+
const sent = await this.client.sendMessage(treeId, fileContent);
|
|
642
|
+
const fileEventId = validateMatrixEventId(sent?.event_id, "file upload response event ID");
|
|
643
|
+
await this.client.sendStateEvent(treeId, UNSTABLE_MSC3089_BRANCH.name, { active: true, metadata_event_id: fileEventId }, fileEventId);
|
|
644
|
+
this.fileNames.set(this.fileNameKey(treeId, fileEventId), name);
|
|
645
|
+
return { event_id: fileEventId };
|
|
646
|
+
}
|
|
423
647
|
/**
|
|
424
648
|
* Refreshes a room's current state from the homeserver. The background sync
|
|
425
649
|
* loop is intentionally asynchronous, so a short-lived CLI process can
|
|
@@ -1030,25 +1254,29 @@ export class TeleCryptIOStorage {
|
|
|
1030
1254
|
if (!userId)
|
|
1031
1255
|
throw new Error("createTree: Matrix client has no user ID");
|
|
1032
1256
|
return {
|
|
1033
|
-
name,
|
|
1257
|
+
name: GENERIC_TREE_NAME,
|
|
1034
1258
|
preset: Preset.PrivateChat,
|
|
1035
1259
|
power_level_content_override: {
|
|
1036
1260
|
invite: 100,
|
|
1037
1261
|
kick: 100,
|
|
1038
1262
|
ban: 100,
|
|
1039
|
-
redact:
|
|
1040
|
-
state_default:
|
|
1041
|
-
events_default:
|
|
1263
|
+
redact: 100,
|
|
1264
|
+
state_default: 0,
|
|
1265
|
+
events_default: 0,
|
|
1042
1266
|
users_default: 0,
|
|
1043
1267
|
events: {
|
|
1044
1268
|
[EventType.RoomPowerLevels]: 100,
|
|
1045
1269
|
[EventType.RoomHistoryVisibility]: 100,
|
|
1046
1270
|
[EventType.RoomTombstone]: 100,
|
|
1047
1271
|
[EventType.RoomEncryption]: 100,
|
|
1048
|
-
[EventType.RoomName]:
|
|
1049
|
-
[EventType.RoomMessage]:
|
|
1050
|
-
[EventType.RoomMessageEncrypted]:
|
|
1051
|
-
[EventType.Sticker]:
|
|
1272
|
+
[EventType.RoomName]: 100,
|
|
1273
|
+
[EventType.RoomMessage]: 0,
|
|
1274
|
+
[EventType.RoomMessageEncrypted]: 0,
|
|
1275
|
+
[EventType.Sticker]: 0,
|
|
1276
|
+
[EventType.SpaceChild]: 100,
|
|
1277
|
+
[EventType.SpaceParent]: 100,
|
|
1278
|
+
[UNSTABLE_MSC3089_BRANCH.name]: 100,
|
|
1279
|
+
[STORAGE_METADATA_EVENT_TYPE]: 100,
|
|
1052
1280
|
},
|
|
1053
1281
|
users: { [userId]: 100 },
|
|
1054
1282
|
},
|
|
@@ -1116,10 +1344,46 @@ export class TeleCryptIOStorage {
|
|
|
1116
1344
|
return tree;
|
|
1117
1345
|
this.decoratedTreeSpaces.add(tree);
|
|
1118
1346
|
const originalGetDirectories = tree.getDirectories.bind(tree);
|
|
1347
|
+
const originalListFiles = typeof tree.listFiles === "function"
|
|
1348
|
+
? tree.listFiles.bind(tree)
|
|
1349
|
+
: () => [];
|
|
1350
|
+
const originalGetFile = typeof tree.getFile === "function"
|
|
1351
|
+
? tree.getFile.bind(tree)
|
|
1352
|
+
: () => null;
|
|
1119
1353
|
tree.getDirectories = () => originalGetDirectories().map((child) => this.decorateTreeSpace(child));
|
|
1120
1354
|
tree.createDirectory = (name) => this.createSubtree(tree, name);
|
|
1355
|
+
tree.setName = (name) => this.writeTreeName(tree.id, name);
|
|
1356
|
+
tree.listFiles = () => originalListFiles().map((branch) => this.decorateFileBranch(tree.id, branch));
|
|
1357
|
+
tree.getFile = (fileId) => {
|
|
1358
|
+
const branch = originalGetFile(fileId);
|
|
1359
|
+
return branch ? this.decorateFileBranch(tree.id, branch) : null;
|
|
1360
|
+
};
|
|
1361
|
+
tree.createFile = (name, encryptedContents, info, additionalContent) => this.createEncryptedFile(tree.id, name, encryptedContents, info, additionalContent);
|
|
1121
1362
|
return tree;
|
|
1122
1363
|
}
|
|
1364
|
+
decorateFileBranch(treeId, branch) {
|
|
1365
|
+
const existing = this.decoratedFileBranches.get(branch);
|
|
1366
|
+
if (existing)
|
|
1367
|
+
return existing;
|
|
1368
|
+
const decorated = Object.create(branch);
|
|
1369
|
+
const nameKey = this.fileNameKey(treeId, branch.id);
|
|
1370
|
+
Object.defineProperties(decorated, {
|
|
1371
|
+
getName: {
|
|
1372
|
+
value: () => this.fileNames.get(nameKey) ?? GENERIC_FILE_NAME,
|
|
1373
|
+
},
|
|
1374
|
+
setName: {
|
|
1375
|
+
value: (name) => this.writeFileName(treeId, branch.id, name),
|
|
1376
|
+
},
|
|
1377
|
+
delete: {
|
|
1378
|
+
value: async () => {
|
|
1379
|
+
const { deleteFile } = await import("./core/operations.js");
|
|
1380
|
+
await deleteFile(this, treeId, branch.id);
|
|
1381
|
+
},
|
|
1382
|
+
},
|
|
1383
|
+
});
|
|
1384
|
+
this.decoratedFileBranches.set(branch, decorated);
|
|
1385
|
+
return decorated;
|
|
1386
|
+
}
|
|
1123
1387
|
/** Cleans up only a room created by this operation and reports incomplete cleanup. */
|
|
1124
1388
|
async cleanupCreatedRoom(roomId) {
|
|
1125
1389
|
// Cleanup is compensating work. It must not inherit the caller's already
|
|
@@ -1200,9 +1464,26 @@ export class TeleCryptIOStorage {
|
|
|
1200
1464
|
throw new RoomCreationAmbiguousError(operation, error);
|
|
1201
1465
|
}
|
|
1202
1466
|
try {
|
|
1203
|
-
|
|
1467
|
+
const tree = await this.waitForTreeSpace(roomId, operation, signal);
|
|
1468
|
+
try {
|
|
1469
|
+
await this.writeTreeName(roomId, name);
|
|
1470
|
+
}
|
|
1471
|
+
catch (error) {
|
|
1472
|
+
try {
|
|
1473
|
+
await this.cleanupCreatedRoom(roomId);
|
|
1474
|
+
}
|
|
1475
|
+
catch (cleanupError) {
|
|
1476
|
+
throwWithCleanupDetail(error, roomId, cleanupError);
|
|
1477
|
+
}
|
|
1478
|
+
throw new StorageError(`${operation}: encrypted name initialization failed`, { cause: error });
|
|
1479
|
+
}
|
|
1480
|
+
return tree;
|
|
1204
1481
|
}
|
|
1205
1482
|
catch (error) {
|
|
1483
|
+
if (error instanceof StorageError &&
|
|
1484
|
+
error.message === `${operation}: encrypted name initialization failed`) {
|
|
1485
|
+
throw error;
|
|
1486
|
+
}
|
|
1206
1487
|
try {
|
|
1207
1488
|
await this.cleanupCreatedRoom(roomId);
|
|
1208
1489
|
}
|
|
@@ -1406,7 +1687,9 @@ export class TeleCryptIOStorage {
|
|
|
1406
1687
|
const members = parseMatrixMembersResponse(membersBody);
|
|
1407
1688
|
const pls = parseMatrixPowerLevels(plsRaw);
|
|
1408
1689
|
const viewLevel = pls.users_default ?? 0;
|
|
1409
|
-
|
|
1690
|
+
// Match matrix-js-sdk's MSC3089TreeSpace.getPermissions() default. An
|
|
1691
|
+
// explicit zero still means regular users lack the editor threshold.
|
|
1692
|
+
const editLevel = pls.events_default || 50;
|
|
1410
1693
|
const adminLevel = pls.events?.["m.room.power_levels"] ?? 100;
|
|
1411
1694
|
const roleFor = (userId) => {
|
|
1412
1695
|
const userLevel = pls.users?.[userId] ?? viewLevel;
|
|
@@ -1476,27 +1759,17 @@ export class TeleCryptIOStorage {
|
|
|
1476
1759
|
if (signal?.aborted)
|
|
1477
1760
|
throw new StorageError("operation cancelled");
|
|
1478
1761
|
let info;
|
|
1762
|
+
let fileEvent;
|
|
1479
1763
|
try {
|
|
1480
|
-
|
|
1764
|
+
fileEvent = await this.getOriginalFileEvent(branch.roomId, branch.id);
|
|
1765
|
+
const content = fileEvent.getContent();
|
|
1766
|
+
info = isRecord(content) && isRecord(content.file) ? content.file : undefined;
|
|
1481
1767
|
}
|
|
1482
1768
|
catch (error) {
|
|
1483
1769
|
if (signal?.aborted)
|
|
1484
1770
|
throw new StorageError("operation cancelled");
|
|
1485
1771
|
if (error instanceof StorageError || error instanceof MatrixError)
|
|
1486
1772
|
throw error;
|
|
1487
|
-
// matrix-js-sdk's file-info accessor reads an attachment absent from
|
|
1488
|
-
// failed-decryption events. Use its explicit decryption status, not a
|
|
1489
|
-
// guessed content shape; preserve other SDK/transport failures.
|
|
1490
|
-
let isPlaceholder = false;
|
|
1491
|
-
try {
|
|
1492
|
-
isPlaceholder = await isUndecryptableFilePlaceholder(branch);
|
|
1493
|
-
}
|
|
1494
|
-
catch (placeholderError) {
|
|
1495
|
-
throw new AggregateError([error, placeholderError], "file metadata lookup and placeholder inspection both failed", { cause: error });
|
|
1496
|
-
}
|
|
1497
|
-
if (isPlaceholder) {
|
|
1498
|
-
throw new UndecryptableFileError();
|
|
1499
|
-
}
|
|
1500
1773
|
throw error;
|
|
1501
1774
|
}
|
|
1502
1775
|
// Missing metadata alone does not establish a decryption failure.
|
|
@@ -1506,7 +1779,7 @@ export class TeleCryptIOStorage {
|
|
|
1506
1779
|
// `info` is the encrypted attachment descriptor. Its shape is owned by
|
|
1507
1780
|
// matrix-encrypt-attachment and does not carry the plaintext size. The
|
|
1508
1781
|
// event's `info` block is the sole source of user-facing file metadata.
|
|
1509
|
-
const eventMetadata = readFileEventMetadata(
|
|
1782
|
+
const eventMetadata = readFileEventMetadata(fileEvent.getContent());
|
|
1510
1783
|
if (signal?.aborted)
|
|
1511
1784
|
throw new StorageError("operation cancelled");
|
|
1512
1785
|
const declaredSize = eventMetadata.size;
|
|
@@ -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
|
@@ -11,9 +11,10 @@
|
|
|
11
11
|
* platform-specific and stays with the caller.
|
|
12
12
|
*/
|
|
13
13
|
import { readFileEventMetadata, withMatrixMutationAbort, withTreeMutation, } from "../TeleCryptIOStorage.js";
|
|
14
|
-
import { EventType, MatrixError, RoomCreateTypeField, RoomType,
|
|
14
|
+
import { EventType, MatrixError, RoomCreateTypeField, RoomType, UNSTABLE_MSC3088_ENABLED, UNSTABLE_MSC3088_PURPOSE, UNSTABLE_MSC3089_TREE_SUBTYPE, } from "matrix-js-sdk";
|
|
15
15
|
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
|
+
import { validateMatrixEventId } from "./constants.js";
|
|
17
18
|
import { FileTooLargeError, MutationPartialError, MutationOutcomeUnknownError, NonEmptyTreeError, RoomCreationAmbiguousError, RoomCleanupIncompleteError, StorageError, } from "./errors.js";
|
|
18
19
|
import { ConditionTimeoutError, waitForCondition } from "./poll.js";
|
|
19
20
|
import { validateName } from "./validation.js";
|
|
@@ -237,8 +238,6 @@ async function refreshDeletionRooms(storage, rootId, signal) {
|
|
|
237
238
|
throw new StorageError("could not enumerate storage parent safely");
|
|
238
239
|
const parentIds = new Set();
|
|
239
240
|
for (const event of parentEvents) {
|
|
240
|
-
if (!isActiveRelationEvent(event))
|
|
241
|
-
continue;
|
|
242
241
|
const parentId = relationStateKey(event);
|
|
243
242
|
if (!parentId)
|
|
244
243
|
throw new StorageError("could not enumerate storage parent safely");
|
|
@@ -250,11 +249,6 @@ async function refreshDeletionRooms(storage, rootId, signal) {
|
|
|
250
249
|
const parents = [...parentIds];
|
|
251
250
|
if (parents[0]) {
|
|
252
251
|
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
252
|
}
|
|
259
253
|
if (signal?.aborted)
|
|
260
254
|
throw new StorageError("operation cancelled");
|
|
@@ -332,25 +326,46 @@ function activeTreeDirectories(client, tree) {
|
|
|
332
326
|
}
|
|
333
327
|
async function unlinkExternalParents(storage, rootId, externalParents, signal) {
|
|
334
328
|
const client = storage.getClient();
|
|
335
|
-
|
|
329
|
+
const completedRelationEventIds = [];
|
|
330
|
+
const redactRelation = async (roomId, eventType, stateKey) => {
|
|
331
|
+
await storage.refreshRoomState(roomId, { signal });
|
|
332
|
+
if (signal?.aborted)
|
|
333
|
+
throw new StorageError("operation cancelled");
|
|
334
|
+
const events = readRelationEvents(client, roomId, eventType, stateKey);
|
|
335
|
+
if (events === null)
|
|
336
|
+
throw new StorageError("storage folder state is unavailable");
|
|
337
|
+
const event = events[0];
|
|
338
|
+
if (!event || !isActiveRelationEvent(event))
|
|
339
|
+
return;
|
|
340
|
+
const currentUserId = client.getUserId();
|
|
341
|
+
if (!currentUserId || event.getSender?.() !== currentUserId) {
|
|
342
|
+
throw new StorageError("storage folder relation was not authored by the owner");
|
|
343
|
+
}
|
|
344
|
+
const eventId = validateMatrixEventId(event.getId?.(), "storage folder relation event ID");
|
|
345
|
+
await withRateLimitRetry(() => withMatrixMutationAbort(() => client.redactEvent(roomId, eventId), signal), signal);
|
|
346
|
+
if (!completedRelationEventIds.includes(eventId))
|
|
347
|
+
completedRelationEventIds.push(eventId);
|
|
348
|
+
await storage.refreshRoomState(roomId, { signal });
|
|
349
|
+
const remaining = readRelationEvents(client, roomId, eventType, stateKey);
|
|
350
|
+
if (remaining === null || remaining.some((candidate) => isActiveRelationEvent(candidate))) {
|
|
351
|
+
throw new StorageError("delete graph unlink could not be verified");
|
|
352
|
+
}
|
|
353
|
+
};
|
|
336
354
|
try {
|
|
337
355
|
for (const parentId of externalParents) {
|
|
338
356
|
if (signal?.aborted)
|
|
339
357
|
throw new StorageError("operation cancelled");
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
// the relation
|
|
343
|
-
|
|
344
|
-
await
|
|
345
|
-
if (signal?.aborted)
|
|
346
|
-
throw new StorageError("operation cancelled");
|
|
347
|
-
await withRateLimitRetry(() => withMatrixMutationAbort(() => client.sendStateEvent(rootId, EventType.SpaceParent, {}, parentId), signal), signal);
|
|
358
|
+
// Redact owner-authored relations one room at a time. If the process stops
|
|
359
|
+
// after the parent-side redaction, the redacted state key remains visible
|
|
360
|
+
// and the next call continues with the child-side relation.
|
|
361
|
+
await redactRelation(parentId, EventType.SpaceChild, rootId);
|
|
362
|
+
await redactRelation(rootId, EventType.SpaceParent, parentId);
|
|
348
363
|
await storage.refreshRoomState(parentId, { signal });
|
|
349
364
|
await storage.refreshRoomState(rootId, { signal });
|
|
350
365
|
const childLink = readRelationEvents(client, parentId, EventType.SpaceChild, rootId);
|
|
351
366
|
const parentLink = readRelationEvents(client, rootId, EventType.SpaceParent, parentId);
|
|
352
|
-
if (
|
|
353
|
-
|
|
367
|
+
if (childLink === null ||
|
|
368
|
+
parentLink === null ||
|
|
354
369
|
childLink.some((event) => isActiveRelationEvent(event)) ||
|
|
355
370
|
parentLink.some((event) => isActiveRelationEvent(event))) {
|
|
356
371
|
throw new StorageError("delete graph unlink could not be verified");
|
|
@@ -358,45 +373,14 @@ async function unlinkExternalParents(storage, rootId, externalParents, signal) {
|
|
|
358
373
|
}
|
|
359
374
|
}
|
|
360
375
|
catch (error) {
|
|
361
|
-
if (
|
|
376
|
+
if (error instanceof MutationOutcomeUnknownError || error instanceof MutationPartialError)
|
|
362
377
|
throw error;
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
// not leave a still-live vault detached from its external parent. Best
|
|
366
|
-
// effort is insufficient here: surface a distinct failure if the repair
|
|
367
|
-
// itself cannot be completed so callers never mistake a partial mutation
|
|
368
|
-
// for a successful delete.
|
|
369
|
-
try {
|
|
370
|
-
await relinkExternalParents(storage, rootId, externalParents, new AbortController().signal);
|
|
371
|
-
}
|
|
372
|
-
catch (cleanupError) {
|
|
373
|
-
throw new StorageError("delete graph unlink cleanup is incomplete", {
|
|
374
|
-
cause: new AggregateError([error, cleanupError], "delete graph unlink and relink both failed", { cause: error }),
|
|
375
|
-
});
|
|
378
|
+
if (completedRelationEventIds.length > 0) {
|
|
379
|
+
throw new MutationPartialError("delete folder links", completedRelationEventIds, "some parent links were removed; retry deleting the same folder to finish unlinking", { cause: error });
|
|
376
380
|
}
|
|
377
381
|
throw error;
|
|
378
382
|
}
|
|
379
383
|
}
|
|
380
|
-
async function relinkExternalParents(storage, rootId, externalParents, signal) {
|
|
381
|
-
const client = storage.getClient();
|
|
382
|
-
const via = client.getDomain();
|
|
383
|
-
if (!via)
|
|
384
|
-
throw new StorageError("delete graph relink is unavailable");
|
|
385
|
-
for (const parentId of externalParents) {
|
|
386
|
-
if (signal?.aborted)
|
|
387
|
-
throw new StorageError("operation cancelled");
|
|
388
|
-
await withRateLimitRetry(() => withMatrixMutationAbort(() => client.sendStateEvent(parentId, EventType.SpaceChild, { via: [via] }, rootId), signal), signal);
|
|
389
|
-
await withRateLimitRetry(() => withMatrixMutationAbort(() => client.sendStateEvent(rootId, EventType.SpaceParent, { via: [via] }, parentId), signal), signal);
|
|
390
|
-
await storage.refreshRoomState(parentId, { signal });
|
|
391
|
-
await storage.refreshRoomState(rootId, { signal });
|
|
392
|
-
const childLink = readRelationEvents(client, parentId, EventType.SpaceChild, rootId);
|
|
393
|
-
const parentLink = readRelationEvents(client, rootId, EventType.SpaceParent, parentId);
|
|
394
|
-
if (!childLink?.some((event) => isActiveRelationEvent(event)) ||
|
|
395
|
-
!parentLink?.some((event) => isActiveRelationEvent(event))) {
|
|
396
|
-
throw new StorageError("delete graph relink could not be verified");
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
384
|
function isGoneError(error) {
|
|
401
385
|
return (error instanceof MatrixError &&
|
|
402
386
|
(error.errcode === "M_NOT_FOUND" || error.errcode === "M_UNKNOWN"));
|
|
@@ -409,12 +393,25 @@ async function deleteRoomDeterministically(storage, roomId, signal) {
|
|
|
409
393
|
};
|
|
410
394
|
try {
|
|
411
395
|
const client = storage.getClient();
|
|
412
|
-
const tree = storage.getTree(roomId);
|
|
413
|
-
if (!tree)
|
|
414
|
-
throw new StorageError("storage room is unavailable for deletion");
|
|
415
396
|
const self = client.getUserId();
|
|
416
397
|
if (!self)
|
|
417
398
|
throw new StorageError("Matrix user identity is unavailable");
|
|
399
|
+
const ownMembership = await storage.getRoomMembership(roomId, undefined, { signal });
|
|
400
|
+
if (ownMembership === "leave" || ownMembership === "ban") {
|
|
401
|
+
try {
|
|
402
|
+
await withRateLimitRetry(() => withMatrixMutationAbort(() => client.forget(roomId), signal), signal);
|
|
403
|
+
}
|
|
404
|
+
catch (error) {
|
|
405
|
+
if (!isGoneError(error))
|
|
406
|
+
throw error;
|
|
407
|
+
}
|
|
408
|
+
removeRoomFromLocalStore(client, roomId);
|
|
409
|
+
markTreeDeleted(client, roomId);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
const tree = storage.getTree(roomId);
|
|
413
|
+
if (!tree)
|
|
414
|
+
throw new StorageError("storage room is unavailable for deletion");
|
|
418
415
|
const members = await storage.listMembers(tree, { signal });
|
|
419
416
|
if (members.some((member) => member.userId !== self && member.role === "owner" &&
|
|
420
417
|
(member.membership === "join" || member.membership === "invite" || member.membership === "knock"))) {
|
|
@@ -444,7 +441,6 @@ async function deleteRoomDeterministically(storage, roomId, signal) {
|
|
|
444
441
|
throw error;
|
|
445
442
|
}
|
|
446
443
|
}
|
|
447
|
-
const ownMembership = await storage.getRoomMembership(roomId, undefined, { signal });
|
|
448
444
|
if (ownMembership === "join" || ownMembership === "invite" || ownMembership === "knock") {
|
|
449
445
|
try {
|
|
450
446
|
await withRateLimitRetry(() => withMatrixMutationAbort(() => client.leave(roomId), signal), signal);
|
|
@@ -522,7 +518,11 @@ export async function createVault(storage, name, options) {
|
|
|
522
518
|
export async function listVaults(storage, options) {
|
|
523
519
|
return withOperationDeadline(options, async (signal) => {
|
|
524
520
|
const trees = await storage.listTrees(signal);
|
|
525
|
-
|
|
521
|
+
const topLevel = trees.filter((tree) => tree.isTopLevel);
|
|
522
|
+
return Promise.all(topLevel.map(async (tree) => ({
|
|
523
|
+
id: tree.id,
|
|
524
|
+
name: await storage.getTreeName(tree.id, { signal }),
|
|
525
|
+
})));
|
|
526
526
|
});
|
|
527
527
|
}
|
|
528
528
|
/** Current account's effective role for a vault, or null while it is unavailable. */
|
|
@@ -574,17 +574,8 @@ export async function joinVault(storage, vaultId, options) {
|
|
|
574
574
|
return { vaultId, joined: true };
|
|
575
575
|
}, "mutation");
|
|
576
576
|
}
|
|
577
|
-
function roomDisplayName(
|
|
578
|
-
|
|
579
|
-
const room = client.getRoom(roomId);
|
|
580
|
-
if (fallbackName?.trim())
|
|
581
|
-
return fallbackName.trim();
|
|
582
|
-
if (room?.name)
|
|
583
|
-
return room.name;
|
|
584
|
-
const userId = client.getUserId();
|
|
585
|
-
if (room && userId)
|
|
586
|
-
return room.getDefaultRoomName(userId);
|
|
587
|
-
return roomId;
|
|
577
|
+
function roomDisplayName(_storage, _roomId, _fallbackName) {
|
|
578
|
+
return "Encrypted storage";
|
|
588
579
|
}
|
|
589
580
|
function hasActiveSpaceParent(room) {
|
|
590
581
|
const result = room.currentState?.getStateEvents?.(EventType.SpaceParent);
|
|
@@ -728,10 +719,10 @@ export async function declineInvite(storage, vaultId, options) {
|
|
|
728
719
|
return { vaultId, declined: true };
|
|
729
720
|
}, "mutation");
|
|
730
721
|
}
|
|
731
|
-
/** Invites `userId` to the vault
|
|
722
|
+
/** Invites `userId` to the vault as a reader. */
|
|
732
723
|
export async function shareVault(storage, vaultId, userId, role, options) {
|
|
733
|
-
if (role !== "viewer"
|
|
734
|
-
throw new StorageError("
|
|
724
|
+
if (role !== "viewer") {
|
|
725
|
+
throw new StorageError("storage sharing supports viewers only");
|
|
735
726
|
}
|
|
736
727
|
const operation = createOperationDeadline(options);
|
|
737
728
|
const completedRoomIds = new Set();
|
|
@@ -754,6 +745,12 @@ export async function shareVault(storage, vaultId, userId, role, options) {
|
|
|
754
745
|
}
|
|
755
746
|
for (const [index, space] of spaces.entries()) {
|
|
756
747
|
ensureOperationActive(operation.signal);
|
|
748
|
+
const members = index === 0
|
|
749
|
+
? currentMembers
|
|
750
|
+
: await withRateLimitRetry(() => storage.listMembers(space, { signal: operation.signal }), operation.signal);
|
|
751
|
+
if (members.some((member) => member.userId === userId && member.role === "owner")) {
|
|
752
|
+
throw new StorageError("share will not demote an existing owner");
|
|
753
|
+
}
|
|
757
754
|
const currentMembership = index === 0
|
|
758
755
|
? currentMembers.find((member) => member.userId === userId)?.membership
|
|
759
756
|
: await storage.getRoomMembership(space.id, userId, {
|
|
@@ -778,17 +775,10 @@ export async function shareVault(storage, vaultId, userId, role, options) {
|
|
|
778
775
|
throw new StorageError("share failed");
|
|
779
776
|
}
|
|
780
777
|
}
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
: await withRateLimitRetry(() => storage.listMembers(space, { signal: operation.signal }), operation.signal);
|
|
786
|
-
if (members.some((member) => member.userId === userId && member.role === "owner")) {
|
|
787
|
-
throw new StorageError("share will not demote an existing owner");
|
|
788
|
-
}
|
|
789
|
-
await withRateLimitRetry(() => withMatrixMutationAbort(() => space.setPermissions(userId, role), operation.signal), operation.signal);
|
|
790
|
-
completedRoomIds.add(space.id);
|
|
791
|
-
}
|
|
778
|
+
// Storage rooms have one immutable owner power level and readers use
|
|
779
|
+
// the room's default user level. Inviting is the complete sharing
|
|
780
|
+
// operation; changing per-user power levels would create editors and
|
|
781
|
+
// let metadata authorship drift away from the owner.
|
|
792
782
|
}
|
|
793
783
|
catch (error) {
|
|
794
784
|
if (error instanceof MutationOutcomeUnknownError)
|
|
@@ -805,7 +795,7 @@ export async function shareVault(storage, vaultId, userId, role, options) {
|
|
|
805
795
|
}
|
|
806
796
|
throw new StorageError("share failed", { cause: error });
|
|
807
797
|
}
|
|
808
|
-
return { vaultId, userId, role };
|
|
798
|
+
return { vaultId, userId, role: "viewer" };
|
|
809
799
|
}, operation.signal);
|
|
810
800
|
return await raceOperationDeadline(operation, pending, "mutation");
|
|
811
801
|
}
|
|
@@ -909,7 +899,10 @@ export async function listFiles(storage, treeId, options) {
|
|
|
909
899
|
const files = tree
|
|
910
900
|
.listFiles()
|
|
911
901
|
.filter((file) => !isMarkedFileDeleted(storage, tree.id, file.id));
|
|
912
|
-
return files.map((
|
|
902
|
+
return await Promise.all(files.map(async (file) => ({
|
|
903
|
+
id: file.id,
|
|
904
|
+
name: await storage.getFileName(tree.id, file.id, { signal, refreshState: false }),
|
|
905
|
+
})));
|
|
913
906
|
}
|
|
914
907
|
catch (error) {
|
|
915
908
|
if (signal.aborted)
|
|
@@ -926,7 +919,10 @@ export async function listSubfolders(storage, parentId, options) {
|
|
|
926
919
|
ensureOperationActive(signal);
|
|
927
920
|
const directories = activeTreeDirectories(storage.getClient(), tree)
|
|
928
921
|
.filter((directory) => !isMarkedTreeDeleted(storage, directory.id));
|
|
929
|
-
return directories.map((
|
|
922
|
+
return Promise.all(directories.map(async (directory) => ({
|
|
923
|
+
id: directory.id,
|
|
924
|
+
name: await storage.getTreeName(directory.id, { signal }),
|
|
925
|
+
})));
|
|
930
926
|
});
|
|
931
927
|
}
|
|
932
928
|
export async function createSubfolder(storage, parentId, name, options) {
|
|
@@ -958,11 +954,9 @@ async function renameTree(storage, treeId, name, options) {
|
|
|
958
954
|
await waitForCondition(async () => {
|
|
959
955
|
ensureOperationActive(signal);
|
|
960
956
|
const current = storage.getTree(treeId);
|
|
961
|
-
if (current
|
|
962
|
-
return
|
|
963
|
-
await storage.
|
|
964
|
-
const refreshed = storage.getTree(treeId);
|
|
965
|
-
return refreshed?.room.name === name ? refreshed : null;
|
|
957
|
+
if (!current)
|
|
958
|
+
return null;
|
|
959
|
+
return (await storage.getTreeName(treeId, { signal })) === name ? current : null;
|
|
966
960
|
}, { timeoutMs: 15000, signal });
|
|
967
961
|
}
|
|
968
962
|
catch (error) {
|
|
@@ -991,6 +985,20 @@ async function deleteTree(storage, treeId, options) {
|
|
|
991
985
|
const pending = withTreeMutation(client, async () => {
|
|
992
986
|
if (isTreeDeleted(client, treeId))
|
|
993
987
|
return { id: treeId, deleted: true };
|
|
988
|
+
const ownMembership = await storage.getRoomMembership(treeId, undefined, {
|
|
989
|
+
signal: operation.signal,
|
|
990
|
+
});
|
|
991
|
+
if (ownMembership === "leave" || ownMembership === "ban") {
|
|
992
|
+
try {
|
|
993
|
+
await deleteRoomDeterministically(storage, treeId, operation.signal);
|
|
994
|
+
}
|
|
995
|
+
catch (error) {
|
|
996
|
+
if (error instanceof StorageError)
|
|
997
|
+
throw error;
|
|
998
|
+
throw new StorageError("delete cleanup failed", { cause: error });
|
|
999
|
+
}
|
|
1000
|
+
return { id: treeId, deleted: true };
|
|
1001
|
+
}
|
|
994
1002
|
const tree = await resolveTree(storage, treeId, operation.signal);
|
|
995
1003
|
let externalParents;
|
|
996
1004
|
try {
|
|
@@ -1016,14 +1024,6 @@ async function deleteTree(storage, treeId, options) {
|
|
|
1016
1024
|
await deleteRoomDeterministically(storage, tree.id, operation.signal);
|
|
1017
1025
|
}
|
|
1018
1026
|
catch (error) {
|
|
1019
|
-
try {
|
|
1020
|
-
await relinkExternalParents(storage, tree.id, externalParents, new AbortController().signal);
|
|
1021
|
-
}
|
|
1022
|
-
catch (cleanupError) {
|
|
1023
|
-
throw new StorageError("delete cleanup is incomplete", {
|
|
1024
|
-
cause: new AggregateError([error, cleanupError], "delete and external-parent relink both failed", { cause: error }),
|
|
1025
|
-
});
|
|
1026
|
-
}
|
|
1027
1027
|
if (error instanceof StorageError)
|
|
1028
1028
|
throw error;
|
|
1029
1029
|
throw new StorageError("delete failed", { cause: error });
|
|
@@ -1055,9 +1055,11 @@ export async function renameFile(storage, treeId, fileId, name, options) {
|
|
|
1055
1055
|
// fresh CLI/UI process can still read the previous local room state for a
|
|
1056
1056
|
// short time. Do not report success until this client has observed the new
|
|
1057
1057
|
// name through its normal sync loop.
|
|
1058
|
-
await waitForCondition(() => {
|
|
1058
|
+
await waitForCondition(async () => {
|
|
1059
1059
|
const current = tree.getFile(fileId);
|
|
1060
|
-
|
|
1060
|
+
if (!current)
|
|
1061
|
+
return null;
|
|
1062
|
+
return (await storage.getFileName(treeId, fileId, { signal })) === name ? current : null;
|
|
1061
1063
|
}, { timeoutMs: 15000, signal });
|
|
1062
1064
|
}
|
|
1063
1065
|
catch (error) {
|
|
@@ -1094,43 +1096,80 @@ async function deleteFileMedia(storage, mediaIds, signal) {
|
|
|
1094
1096
|
export async function deleteFile(storage, treeId, fileId, options) {
|
|
1095
1097
|
return withOperationDeadline(options, async (signal) => {
|
|
1096
1098
|
const tree = await resolveTree(storage, treeId, signal);
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
+
if (isMarkedFileDeleted(storage, tree.id, fileId))
|
|
1100
|
+
return { id: fileId, deleted: true };
|
|
1101
|
+
await storage.refreshRoomState(treeId, { signal });
|
|
1102
|
+
const branch = tree.getFile(fileId);
|
|
1103
|
+
if (!branch)
|
|
1104
|
+
throw new StorageError("file not found");
|
|
1105
|
+
let originalEvent;
|
|
1099
1106
|
try {
|
|
1100
|
-
|
|
1107
|
+
originalEvent = await storage.getOriginalFileEvent(tree.id, fileId, true);
|
|
1101
1108
|
}
|
|
1102
1109
|
catch (error) {
|
|
1103
1110
|
throw new StorageError("could not resolve encrypted file safely", { cause: error });
|
|
1104
1111
|
}
|
|
1105
|
-
|
|
1112
|
+
if (originalEvent.isRedacted()) {
|
|
1113
|
+
if (branch.isActive === false) {
|
|
1114
|
+
markFileDeleted(storage.getClient(), tree.id, fileId);
|
|
1115
|
+
return { id: fileId, deleted: true };
|
|
1116
|
+
}
|
|
1117
|
+
throw new StorageError("file listing is active but its attachment event is redacted");
|
|
1118
|
+
}
|
|
1119
|
+
const originalContent = originalEvent.getContent();
|
|
1120
|
+
const fileDescriptor = typeof originalContent === "object" &&
|
|
1121
|
+
originalContent !== null &&
|
|
1122
|
+
!Array.isArray(originalContent) &&
|
|
1123
|
+
"file" in originalContent &&
|
|
1124
|
+
typeof originalContent.file === "object" &&
|
|
1125
|
+
originalContent.file !== null &&
|
|
1126
|
+
!Array.isArray(originalContent.file)
|
|
1127
|
+
? originalContent.file
|
|
1128
|
+
: undefined;
|
|
1129
|
+
const mediaId = fileDescriptor?.url;
|
|
1106
1130
|
if (typeof mediaId !== "string" || mediaId.length === 0) {
|
|
1107
1131
|
throw new StorageError("encrypted file has no media identifier");
|
|
1108
1132
|
}
|
|
1109
1133
|
await deleteFileMedia(storage, [mediaId], signal);
|
|
1110
|
-
const completedIds = [];
|
|
1134
|
+
const completedIds = [mediaId];
|
|
1111
1135
|
try {
|
|
1112
1136
|
const client = storage.getClient();
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1137
|
+
const metadataEventId = await storage.getFileMetadataEventId(tree.id, fileId, { signal });
|
|
1138
|
+
if (metadataEventId && metadataEventId !== fileId) {
|
|
1139
|
+
const metadataEvent = await storage.getFileRenameMetadataEvent(tree.id, fileId, metadataEventId);
|
|
1140
|
+
if (!metadataEvent.isRedacted()) {
|
|
1141
|
+
await withRateLimitRetry(() => withMatrixMutationAbort(() => client.redactEvent(tree.id, metadataEventId), signal, "delete file rename metadata"), signal);
|
|
1142
|
+
}
|
|
1143
|
+
completedIds.push(metadataEventId);
|
|
1144
|
+
}
|
|
1145
|
+
const currentBranch = tree.getFile(fileId);
|
|
1146
|
+
const indexEvent = currentBranch?.indexEvent;
|
|
1147
|
+
const listingEventId = indexEvent?.getId();
|
|
1148
|
+
if (!indexEvent || typeof listingEventId !== "string" || listingEventId.length === 0) {
|
|
1149
|
+
throw new StorageError("encrypted file has no listing event identifier");
|
|
1150
|
+
}
|
|
1151
|
+
if (!indexEvent.isRedacted()) {
|
|
1152
|
+
if (indexEvent.getSender() !== client.getUserId()) {
|
|
1153
|
+
throw new StorageError("file listing was not authored by the owner");
|
|
1154
|
+
}
|
|
1155
|
+
await withRateLimitRetry(() => withMatrixMutationAbort(() => client.redactEvent(tree.id, listingEventId), signal, "delete file listing"), signal);
|
|
1156
|
+
}
|
|
1157
|
+
completedIds.push(listingEventId);
|
|
1158
|
+
await withRateLimitRetry(() => withMatrixMutationAbort(() => client.redactEvent(tree.id, fileId), signal, "delete file attachment event"), signal);
|
|
1159
|
+
completedIds.push(fileId);
|
|
1116
1160
|
}
|
|
1117
1161
|
catch (error) {
|
|
1118
|
-
|
|
1119
|
-
throw error;
|
|
1120
|
-
if (signal.aborted) {
|
|
1121
|
-
throw new MutationPartialError("delete file", completedIds, "media was deleted but Matrix event cleanup was cancelled", { cause: error });
|
|
1122
|
-
}
|
|
1123
|
-
throw new MutationPartialError("delete file", completedIds, "media was deleted but Matrix event cleanup stopped", { cause: error });
|
|
1162
|
+
throw new MutationPartialError("delete file", completedIds, "file media and the listed completed steps were deleted; retry with the original file ID to finish Matrix cleanup", { cause: error });
|
|
1124
1163
|
}
|
|
1125
1164
|
try {
|
|
1126
1165
|
await waitForCondition(async () => {
|
|
1127
1166
|
await storage.refreshRoomState(tree.id, { signal });
|
|
1128
1167
|
ensureOperationActive(signal);
|
|
1129
1168
|
const current = tree.getFile(fileId);
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
return
|
|
1169
|
+
if (current && current.isActive !== false)
|
|
1170
|
+
return null;
|
|
1171
|
+
const attachment = await storage.getOriginalFileEvent(tree.id, fileId, true);
|
|
1172
|
+
return attachment.isRedacted() ? true : null;
|
|
1134
1173
|
}, { timeoutMs: 15000, signal });
|
|
1135
1174
|
}
|
|
1136
1175
|
catch (error) {
|
|
@@ -1139,7 +1178,7 @@ export async function deleteFile(storage, treeId, fileId, options) {
|
|
|
1139
1178
|
const detail = "Matrix deletion completed but the file state could not be verified";
|
|
1140
1179
|
throw new MutationPartialError("delete file", completedIds, detail, { cause: error });
|
|
1141
1180
|
}
|
|
1142
|
-
markFileDeleted(storage.getClient(), tree.id,
|
|
1181
|
+
markFileDeleted(storage.getClient(), tree.id, fileId);
|
|
1143
1182
|
ensureOperationActive(signal);
|
|
1144
1183
|
return { id: fileId, deleted: true };
|
|
1145
1184
|
}, "mutation");
|
|
@@ -1198,7 +1237,7 @@ export async function downloadFile(storage, treeId, fileId, options) {
|
|
|
1198
1237
|
return {
|
|
1199
1238
|
bytes: new Uint8Array(result.data),
|
|
1200
1239
|
mimetype: result.mimetype,
|
|
1201
|
-
name:
|
|
1240
|
+
name: await storage.getFileName(tree.id, fileId, { signal }),
|
|
1202
1241
|
};
|
|
1203
1242
|
});
|
|
1204
1243
|
}
|
|
@@ -1216,15 +1255,15 @@ function tsToIso(ts) {
|
|
|
1216
1255
|
export async function getFileDetails(storage, treeId, fileId, options) {
|
|
1217
1256
|
return withOperationDeadline(options, async (signal) => {
|
|
1218
1257
|
const tree = await resolveTree(storage, treeId, signal);
|
|
1219
|
-
|
|
1220
|
-
const name =
|
|
1258
|
+
await resolveFile(storage, tree, fileId, signal);
|
|
1259
|
+
const name = await storage.getFileName(tree.id, fileId, { signal });
|
|
1221
1260
|
let mimetype = null;
|
|
1222
1261
|
let size = null;
|
|
1223
1262
|
let createdAt = null;
|
|
1224
1263
|
let updatedAt = null;
|
|
1225
1264
|
try {
|
|
1226
1265
|
ensureOperationActive(signal);
|
|
1227
|
-
const event = await
|
|
1266
|
+
const event = await storage.getOriginalFileEvent(tree.id, fileId);
|
|
1228
1267
|
const metadata = readFileEventMetadata(event.getContent());
|
|
1229
1268
|
mimetype = metadata.mimetype;
|
|
1230
1269
|
size = metadata.size;
|
|
@@ -1243,7 +1282,7 @@ export async function getFileDetails(storage, treeId, fileId, options) {
|
|
|
1243
1282
|
});
|
|
1244
1283
|
}
|
|
1245
1284
|
async function getTreeDetails(storage, treeId, signal) {
|
|
1246
|
-
|
|
1285
|
+
await resolveTree(storage, treeId, signal);
|
|
1247
1286
|
if (signal?.aborted)
|
|
1248
1287
|
throw new StorageError("operation cancelled");
|
|
1249
1288
|
const client = storage.getClient();
|
|
@@ -1261,7 +1300,7 @@ async function getTreeDetails(storage, treeId, signal) {
|
|
|
1261
1300
|
}
|
|
1262
1301
|
}
|
|
1263
1302
|
return {
|
|
1264
|
-
name:
|
|
1303
|
+
name: await storage.getTreeName(treeId, { signal }),
|
|
1265
1304
|
id: treeId,
|
|
1266
1305
|
createdAt,
|
|
1267
1306
|
memberCount,
|