@telecrypt-io/storage 0.9.0 → 0.9.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/LICENSE +1 -1
- package/README.md +29 -0
- package/dist/TeleCryptIOStorage.d.ts +15 -1
- package/dist/TeleCryptIOStorage.js +317 -46
- package/dist/core/errors.d.ts +6 -2
- package/dist/core/errors.js +4 -1
- package/dist/core/operations.d.ts +2 -0
- package/dist/core/operations.js +125 -81
- package/dist/media.d.ts +3 -0
- package/dist/media.js +10 -0
- package/dist/sharing.d.ts +3 -0
- package/dist/sharing.js +18 -0
- package/package.json +1 -1
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -83,6 +83,18 @@ and a per-file key distributed through the room's Megolm session). The server st
|
|
|
83
83
|
media and Matrix events, but encryption does not hide all room, event, or other metadata from the
|
|
84
84
|
server or users who can read the room.
|
|
85
85
|
|
|
86
|
+
If an upload is rejected after media was accepted, the SDK removes that media and any acknowledged
|
|
87
|
+
attachment. A transport failure leaves accepted work intact. An upload `MutationPartialError`
|
|
88
|
+
retains `treeId` and `completedIds` in the order `[mediaId, attachmentEventId?]`. Keep those IDs;
|
|
89
|
+
do not repeat the upload blindly. To discard the partial upload, call
|
|
90
|
+
`core.cleanupFileUpload(storage, error.treeId, error.completedIds[0], error.completedIds[1])`.
|
|
91
|
+
Retry cleanup with the same IDs if it fails. A known attachment ID can also be passed to ordinary
|
|
92
|
+
`core.deleteFile` even when its listing was never committed. An initial media response lost before
|
|
93
|
+
the server returns any identifier still has an unknown outcome and cannot provide cleanup IDs.
|
|
94
|
+
Upload cancellation stops before the next write and waits for the current request's bounded
|
|
95
|
+
settlement so returned media/event IDs are retained. Keep observing the upload promise after
|
|
96
|
+
cancelling a UI wait or closing a view; persist any partial receipt for the originating account.
|
|
97
|
+
|
|
86
98
|
The Decryption Key Safe is mandatory before the public core storage operations. Inspect
|
|
87
99
|
`storage.keySafe.getStatus(signal?)`. For `setup-required`, call `setup(signal?)`, offer the returned
|
|
88
100
|
`recoveryKey` for copying/saving, explain that losing both this key and local decryption keys can
|
|
@@ -93,6 +105,12 @@ room keys and signs the current login using the existing account keys. Normal us
|
|
|
93
105
|
Setup and restore never replace an existing safe or account signing identity. Resetting the account
|
|
94
106
|
password cannot recover encrypted files.
|
|
95
107
|
|
|
108
|
+
Short-lived clients should call `storage.waitForKeyBackup(treeId, fileId?, options?)` after creating
|
|
109
|
+
or renaming encrypted metadata and before closing. Omit `fileId` for a vault or folder. This checks
|
|
110
|
+
the exact encryption session in the active server backup, including reused keys that produce no
|
|
111
|
+
new backup-progress event. If verification fails, retain the local profile and retry backup;
|
|
112
|
+
the metadata mutation has already committed.
|
|
113
|
+
|
|
96
114
|
Pending setup state is kept beside the login's IndexedDB crypto data. A platform that snapshots
|
|
97
115
|
IndexedDB to disk, such as the CLI, must supply `onKeySafeStateChanged` at creation and durably flush
|
|
98
116
|
its snapshot before resolving that callback. Key Safe methods accept an optional `AbortSignal`.
|
|
@@ -105,6 +123,17 @@ the SDK checks native recipient-device eligibility afterward and reports partial
|
|
|
105
123
|
keys could not be shared. An upload restriction can reject the encrypted history bundle upload;
|
|
106
124
|
the error is preserved, including when retrying an existing invitation.
|
|
107
125
|
|
|
126
|
+
`joinVault` accepts the vault and its linked folder invitations, including their history keys.
|
|
127
|
+
New folders inherit the parent's joined and invited readers; browsing accepts their folder
|
|
128
|
+
invitations. If reader access fails after folder creation, the partial-completion error retains
|
|
129
|
+
the created folder ID. Retry sharing the parent vault to complete access to that folder.
|
|
130
|
+
|
|
131
|
+
Storage spaces are private and only their creator changes room state. Loading owned trees or
|
|
132
|
+
refreshing their state repairs the permissive defaults emitted by older SDKs and restores
|
|
133
|
+
invite-only joining. The repair preserves membership, unrelated power settings and encryption
|
|
134
|
+
keys; readers do not write it. Owners must use an updated client for their existing trees to be
|
|
135
|
+
repaired. Disabled Storage markers and deleted trees are not restored automatically.
|
|
136
|
+
|
|
108
137
|
Deletion has strict ordering. Delete files before their folders or vaults because folders and
|
|
109
138
|
vaults must be empty. File deletion removes the encrypted media object before redacting its Matrix
|
|
110
139
|
event and requires TeleCrypt's Synapse storage extension; stock Synapse cannot provide that
|
|
@@ -21,7 +21,7 @@ export interface TreeSpace {
|
|
|
21
21
|
setPermissions(userId: string, role: string): Promise<void>;
|
|
22
22
|
getFile(fileEventId: string): FileBranch | null;
|
|
23
23
|
listFiles(): FileBranch[];
|
|
24
|
-
createFile(name: string, encryptedContents: ArrayBuffer | Uint8Array, info: Record<string, unknown>, additionalContent?: Record<string, unknown
|
|
24
|
+
createFile(name: string, encryptedContents: ArrayBuffer | Uint8Array, info: Record<string, unknown>, additionalContent?: Record<string, unknown>, signal?: AbortSignal): Promise<{
|
|
25
25
|
event_id: string;
|
|
26
26
|
}>;
|
|
27
27
|
}
|
|
@@ -172,8 +172,15 @@ export declare class TeleCryptIOStorage {
|
|
|
172
172
|
private sendEncryptedMetadataMessage;
|
|
173
173
|
private readEncryptedMetadataMessage;
|
|
174
174
|
private writeTreeName;
|
|
175
|
+
private getTreeMetadataEvent;
|
|
175
176
|
/** Resolves an encrypted vault/folder name through its pointer state event. */
|
|
176
177
|
getTreeName(roomId: string, options?: MatrixRequestOptions): Promise<string>;
|
|
178
|
+
/**
|
|
179
|
+
* Waits for the current encrypted vault/folder name, or file metadata, to
|
|
180
|
+
* have its session in the active native key backup. Checks the exact session
|
|
181
|
+
* even when encryption reuses a key and the backup engine emits no new event.
|
|
182
|
+
*/
|
|
183
|
+
waitForKeyBackup(treeId: string, fileId?: string, options?: MatrixRequestOptions): Promise<void>;
|
|
177
184
|
private writeFileName;
|
|
178
185
|
/** Resolves an encrypted file name from the MSC3089 index pointer. */
|
|
179
186
|
getFileName(treeId: string, fileId: string, options?: MatrixRequestOptions & {
|
|
@@ -186,6 +193,12 @@ export declare class TeleCryptIOStorage {
|
|
|
186
193
|
/** Fetches the original encrypted file message directly by event ID. */
|
|
187
194
|
getOriginalFileEvent(treeId: string, fileId: string, allowRedacted?: boolean): Promise<MatrixEvent>;
|
|
188
195
|
private createEncryptedFile;
|
|
196
|
+
/**
|
|
197
|
+
* Discards a partial upload using its MutationPartialError.completedIds:
|
|
198
|
+
* [mediaId, fileId?]. Explicit cleanup also covers an unacknowledged listing.
|
|
199
|
+
* Keep the same IDs after failure and retry this operation, not the upload.
|
|
200
|
+
*/
|
|
201
|
+
cleanupFileUpload(treeId: string, mediaId: string, fileId?: string, options?: MatrixRequestOptions): Promise<void>;
|
|
189
202
|
/**
|
|
190
203
|
* Refreshes a room's current state from the homeserver. The background sync
|
|
191
204
|
* loop is intentionally asynchronous, so a short-lived CLI process can
|
|
@@ -193,6 +206,7 @@ export declare class TeleCryptIOStorage {
|
|
|
193
206
|
* changed a file name or branch.
|
|
194
207
|
*/
|
|
195
208
|
refreshRoomState(roomId: string, options?: MatrixRequestOptions): Promise<void>;
|
|
209
|
+
private repairOwnedTreePermissions;
|
|
196
210
|
/**
|
|
197
211
|
* Recommended entry point: builds the MatrixClient with a persistent crypto
|
|
198
212
|
* store and the secret-storage callback wiring `keys.*` needs, starts the
|
|
@@ -1,15 +1,18 @@
|
|
|
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";
|
|
1
|
+
import { ClientEvent, createClient, EventType, JoinRule, 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";
|
|
5
5
|
import { decodeRecoveryKey } from "matrix-js-sdk/lib/crypto-api/recovery-key.js";
|
|
6
|
-
import { FileTooLargeError, RecoveryAlreadyConfiguredError, RecoveryRestoreError, RecoverySetupAmbiguousError, RecoverySetupError, RecoveryRestoreAmbiguousError, MutationOutcomeUnknownError, RoomCreationAmbiguousError, RoomCleanupIncompleteError, StorageError, UndecryptableFileError, } from "./core/errors.js";
|
|
6
|
+
import { FileTooLargeError, RecoveryAlreadyConfiguredError, RecoveryRestoreError, RecoverySetupAmbiguousError, RecoverySetupError, RecoveryRestoreAmbiguousError, MutationOutcomeUnknownError, MutationPartialError, RoomCreationAmbiguousError, RoomCleanupIncompleteError, StorageError, UndecryptableFileError, } from "./core/errors.js";
|
|
7
7
|
import { MAX_MEDIA_FILE_BYTES, MAX_MATRIX_IDENTIFIER_BYTES, validateCanonicalMatrixUserId, validateMatrixDeviceId, validateMatrixEventId, validateMatrixRoomId, } from "./core/constants.js";
|
|
8
8
|
import { cancelResponseBody, raceWithAbort, readMediaResponseBody, readResponseBody, ResponseBodyReadError, } from "./core/http.js";
|
|
9
9
|
import { sanitizeDiagnosticText } from "./core/oidc.js";
|
|
10
10
|
import { validateName } from "./core/validation.js";
|
|
11
|
+
import { ConditionTimeoutError, waitForCondition } from "./core/poll.js";
|
|
11
12
|
import { isTreeDeleted } from "./deletion-markers.js";
|
|
12
13
|
import { DecryptionKeySafe } from "./key-safe.js";
|
|
14
|
+
import { prepareReaderHistorySharing } from "./sharing.js";
|
|
15
|
+
import { deleteOwnedMedia } from "./media.js";
|
|
13
16
|
const STORAGE_METADATA_EVENT_TYPE = "io.telecrypt.storage.metadata";
|
|
14
17
|
const STORAGE_METADATA_MSGTYPE = "io.telecrypt.storage.metadata";
|
|
15
18
|
const GENERIC_TREE_NAME = "Encrypted storage";
|
|
@@ -357,7 +360,7 @@ function parseMatrixStateResponse(value) {
|
|
|
357
360
|
function parseMatrixPowerLevels(value) {
|
|
358
361
|
if (!isRecord(value))
|
|
359
362
|
throw new Error("invalid Matrix power-level response");
|
|
360
|
-
for (const name of ["users_default", "events_default"]) {
|
|
363
|
+
for (const name of ["users_default", "events_default", "state_default"]) {
|
|
361
364
|
const field = value[name];
|
|
362
365
|
if (field !== undefined && (typeof field !== "number" || !Number.isFinite(field))) {
|
|
363
366
|
throw new Error("invalid Matrix power-level response");
|
|
@@ -515,8 +518,7 @@ export class TeleCryptIOStorage {
|
|
|
515
518
|
await this.client.sendStateEvent(roomId, STORAGE_METADATA_EVENT_TYPE, { event_id: metadataEventId }, "");
|
|
516
519
|
this.treeNames.set(roomId, name);
|
|
517
520
|
}
|
|
518
|
-
|
|
519
|
-
async getTreeName(roomId, options) {
|
|
521
|
+
async getTreeMetadataEvent(roomId, options) {
|
|
520
522
|
if (options?.signal?.aborted)
|
|
521
523
|
throw new StorageError("operation cancelled");
|
|
522
524
|
await this.refreshRoomState(roomId, options);
|
|
@@ -529,9 +531,22 @@ export class TeleCryptIOStorage {
|
|
|
529
531
|
throw new StorageError("encrypted storage name is unavailable");
|
|
530
532
|
}
|
|
531
533
|
const metadataEventId = validateMatrixEventId(pointerContent.event_id, "storage metadata event ID");
|
|
534
|
+
const event = await this.readEncryptedMetadataMessage(roomId, metadataEventId);
|
|
535
|
+
const content = event.getContent();
|
|
536
|
+
if (!isRecord(content) ||
|
|
537
|
+
content.msgtype !== STORAGE_METADATA_MSGTYPE ||
|
|
538
|
+
typeof content.body !== "string" ||
|
|
539
|
+
Object.hasOwn(content, "file_event_id")) {
|
|
540
|
+
throw new StorageError("encrypted storage name is invalid or unavailable");
|
|
541
|
+
}
|
|
542
|
+
validateName(content.body, "name");
|
|
543
|
+
return event;
|
|
544
|
+
}
|
|
545
|
+
/** Resolves an encrypted vault/folder name through its pointer state event. */
|
|
546
|
+
async getTreeName(roomId, options) {
|
|
532
547
|
let event;
|
|
533
548
|
try {
|
|
534
|
-
event = await this.
|
|
549
|
+
event = await this.getTreeMetadataEvent(roomId, options);
|
|
535
550
|
}
|
|
536
551
|
catch (error) {
|
|
537
552
|
// A new device can list storage before restoring room keys. Keep the
|
|
@@ -541,16 +556,78 @@ export class TeleCryptIOStorage {
|
|
|
541
556
|
throw error;
|
|
542
557
|
}
|
|
543
558
|
const content = event.getContent();
|
|
544
|
-
if (!isRecord(content) ||
|
|
545
|
-
content.msgtype !== STORAGE_METADATA_MSGTYPE ||
|
|
546
|
-
typeof content.body !== "string" ||
|
|
547
|
-
Object.hasOwn(content, "file_event_id")) {
|
|
548
|
-
throw new StorageError("encrypted storage name is invalid or unavailable");
|
|
549
|
-
}
|
|
550
|
-
validateName(content.body, "name");
|
|
551
559
|
this.treeNames.set(roomId, content.body);
|
|
552
560
|
return content.body;
|
|
553
561
|
}
|
|
562
|
+
/**
|
|
563
|
+
* Waits for the current encrypted vault/folder name, or file metadata, to
|
|
564
|
+
* have its session in the active native key backup. Checks the exact session
|
|
565
|
+
* even when encryption reuses a key and the backup engine emits no new event.
|
|
566
|
+
*/
|
|
567
|
+
async waitForKeyBackup(treeId, fileId, options) {
|
|
568
|
+
validateMatrixRoomId(treeId);
|
|
569
|
+
if (fileId !== undefined)
|
|
570
|
+
validateMatrixEventId(fileId, "file event ID");
|
|
571
|
+
const timeoutMs = matrixRequestTimeout({ timeoutMs: options?.timeoutMs ?? options?.localTimeoutMs ?? 20_000 });
|
|
572
|
+
const signal = matrixRequestSignal(options);
|
|
573
|
+
const crypto = this.client.getCrypto();
|
|
574
|
+
if (!crypto)
|
|
575
|
+
throw new StorageError("crypto is not initialized");
|
|
576
|
+
let sessionId;
|
|
577
|
+
try {
|
|
578
|
+
await waitForCondition(async (signal) => {
|
|
579
|
+
if (!sessionId) {
|
|
580
|
+
let event;
|
|
581
|
+
if (fileId === undefined) {
|
|
582
|
+
event = await this.getTreeMetadataEvent(treeId, { signal });
|
|
583
|
+
}
|
|
584
|
+
else {
|
|
585
|
+
const metadataId = await this.getFileMetadataEventId(treeId, fileId, { signal });
|
|
586
|
+
if (!metadataId)
|
|
587
|
+
throw new StorageError("encrypted file name is unavailable");
|
|
588
|
+
event = metadataId === fileId
|
|
589
|
+
? await this.getOriginalFileEvent(treeId, fileId)
|
|
590
|
+
: await this.getFileRenameMetadataEvent(treeId, fileId, metadataId);
|
|
591
|
+
}
|
|
592
|
+
const wire = event.getWireContent();
|
|
593
|
+
if (event.isRedacted() || event.isDecryptionFailure() ||
|
|
594
|
+
wire.algorithm !== "m.megolm.v1.aes-sha2" || typeof wire.session_id !== "string" || !wire.session_id) {
|
|
595
|
+
throw new StorageError("encrypted storage session is unavailable");
|
|
596
|
+
}
|
|
597
|
+
sessionId = wire.session_id;
|
|
598
|
+
}
|
|
599
|
+
if (signal?.aborted)
|
|
600
|
+
throw new StorageError("operation cancelled");
|
|
601
|
+
const version = await crypto.getActiveSessionBackupVersion();
|
|
602
|
+
if (!version)
|
|
603
|
+
return null;
|
|
604
|
+
let backup;
|
|
605
|
+
try {
|
|
606
|
+
backup = await this.client.http.authedRequest(Method.Get, `/room_keys/keys/${encodeURIComponent(treeId)}/${encodeURIComponent(sessionId)}`, { version }, undefined, { prefix: ClientPrefix.V3, abortSignal: signal });
|
|
607
|
+
}
|
|
608
|
+
catch (error) {
|
|
609
|
+
if (error instanceof MatrixError && error.httpStatus === 404 && error.errcode === "M_NOT_FOUND")
|
|
610
|
+
return null;
|
|
611
|
+
throw error;
|
|
612
|
+
}
|
|
613
|
+
const sessionData = isRecord(backup) ? backup.session_data : undefined;
|
|
614
|
+
if (!isRecord(backup) || !Number.isSafeInteger(backup.first_message_index) ||
|
|
615
|
+
backup.first_message_index < 0 || !isRecord(sessionData) ||
|
|
616
|
+
["ciphertext", "ephemeral", "mac"].some((key) => typeof sessionData[key] !== "string" || !sessionData[key])) {
|
|
617
|
+
throw new StorageError("server key backup session is invalid");
|
|
618
|
+
}
|
|
619
|
+
return true;
|
|
620
|
+
}, { timeoutMs, signal });
|
|
621
|
+
}
|
|
622
|
+
catch (error) {
|
|
623
|
+
if (signal?.aborted)
|
|
624
|
+
throw new StorageError("operation cancelled", { cause: error });
|
|
625
|
+
if (error instanceof ConditionTimeoutError) {
|
|
626
|
+
throw new StorageError("key backup did not settle before timeout; retry key backup", { cause: error });
|
|
627
|
+
}
|
|
628
|
+
throw error;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
554
631
|
async writeFileName(treeId, fileId, name) {
|
|
555
632
|
validateName(name, "file name");
|
|
556
633
|
validateMatrixEventId(fileId, "file event ID");
|
|
@@ -661,30 +738,128 @@ export class TeleCryptIOStorage {
|
|
|
661
738
|
}
|
|
662
739
|
return event;
|
|
663
740
|
}
|
|
664
|
-
async createEncryptedFile(treeId, name, encryptedContents, info, additionalContent) {
|
|
741
|
+
async createEncryptedFile(treeId, name, encryptedContents, info, additionalContent, signal) {
|
|
665
742
|
validateFileName(name);
|
|
743
|
+
// Authoritative room refresh also repairs legacy owner permissions before
|
|
744
|
+
// publishing new encrypted content to an existing vault.
|
|
745
|
+
await this.refreshRoomState(treeId, { signal });
|
|
746
|
+
if (signal?.aborted)
|
|
747
|
+
throw new StorageError("operation cancelled");
|
|
666
748
|
const bytes = encryptedContents instanceof Uint8Array
|
|
667
749
|
? encryptedContents
|
|
668
750
|
: new Uint8Array(encryptedContents);
|
|
669
751
|
const body = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
752
|
+
let mediaId;
|
|
753
|
+
let fileId;
|
|
754
|
+
try {
|
|
755
|
+
const uploaded = await this.client.uploadContent(body, { includeFilename: false });
|
|
756
|
+
if (typeof uploaded.content_uri !== "string" || uploaded.content_uri.length === 0) {
|
|
757
|
+
throw new StorageError("encrypted media upload returned no content URI");
|
|
758
|
+
}
|
|
759
|
+
mediaId = uploaded.content_uri;
|
|
760
|
+
if (signal?.aborted)
|
|
761
|
+
throw new StorageError("operation cancelled");
|
|
762
|
+
const fileContent = {
|
|
763
|
+
...additionalContent,
|
|
764
|
+
msgtype: MsgType.File,
|
|
765
|
+
body: name,
|
|
766
|
+
url: mediaId,
|
|
767
|
+
file: { ...info, url: mediaId },
|
|
768
|
+
[UNSTABLE_MSC3089_LEAF.name]: {},
|
|
769
|
+
};
|
|
770
|
+
const sent = await this.client.sendMessage(treeId, fileContent);
|
|
771
|
+
fileId = validateMatrixEventId(sent?.event_id, "file upload response event ID");
|
|
772
|
+
if (signal?.aborted)
|
|
773
|
+
throw new StorageError("operation cancelled");
|
|
774
|
+
await this.client.sendStateEvent(treeId, UNSTABLE_MSC3089_BRANCH.name, { active: true, metadata_event_id: fileId }, fileId);
|
|
775
|
+
if (signal?.aborted)
|
|
776
|
+
throw new StorageError("operation cancelled");
|
|
777
|
+
this.fileNames.set(this.fileNameKey(treeId, fileId), name);
|
|
778
|
+
return { event_id: fileId };
|
|
779
|
+
}
|
|
780
|
+
catch (error) {
|
|
781
|
+
const rejected = error instanceof MatrixError &&
|
|
782
|
+
error.httpStatus !== undefined && error.httpStatus >= 400 && error.httpStatus < 500 && error.httpStatus !== 408;
|
|
783
|
+
if (!mediaId) {
|
|
784
|
+
if (rejected)
|
|
785
|
+
throw error;
|
|
786
|
+
throw new MutationOutcomeUnknownError("file upload", { cause: error });
|
|
787
|
+
}
|
|
788
|
+
let cause = error;
|
|
789
|
+
if (rejected) {
|
|
790
|
+
try {
|
|
791
|
+
await this.cleanupFileUpload(treeId, mediaId, fileId);
|
|
792
|
+
}
|
|
793
|
+
catch (cleanupError) {
|
|
794
|
+
cause = new AggregateError([error, cleanupError], "file upload failed and cleanup is incomplete");
|
|
795
|
+
}
|
|
796
|
+
if (cause === error) {
|
|
797
|
+
throw new StorageError("file upload was rejected; uploaded media and attachment were removed", { cause: error });
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
throw new MutationPartialError("file upload", fileId ? [mediaId, fileId] : [mediaId], `upload outcome needs reconciliation in ${treeId}; keep these media and attachment IDs and use cleanupFileUpload before uploading again`, { cause, treeId });
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* Discards a partial upload using its MutationPartialError.completedIds:
|
|
805
|
+
* [mediaId, fileId?]. Explicit cleanup also covers an unacknowledged listing.
|
|
806
|
+
* Keep the same IDs after failure and retry this operation, not the upload.
|
|
807
|
+
*/
|
|
808
|
+
async cleanupFileUpload(treeId, mediaId, fileId, options) {
|
|
809
|
+
validateMatrixRoomId(treeId);
|
|
810
|
+
if (!/^mxc:\/\/[^/]+\/[^/?#]+$/u.test(mediaId))
|
|
811
|
+
throw new StorageError("invalid media identifier");
|
|
812
|
+
if (fileId !== undefined)
|
|
813
|
+
validateMatrixEventId(fileId, "file upload event ID");
|
|
814
|
+
const controller = new AbortController();
|
|
815
|
+
const timeout = setTimeout(() => controller.abort(), matrixRequestTimeout(options));
|
|
816
|
+
const abort = () => controller.abort(options?.signal?.reason);
|
|
817
|
+
if (options?.signal?.aborted)
|
|
818
|
+
abort();
|
|
819
|
+
else
|
|
820
|
+
options?.signal?.addEventListener("abort", abort, { once: true });
|
|
821
|
+
try {
|
|
822
|
+
await raceWithAbort((async () => {
|
|
823
|
+
if (controller.signal.aborted)
|
|
824
|
+
throw new StorageError("operation cancelled");
|
|
825
|
+
let attachment;
|
|
826
|
+
let listing = null;
|
|
827
|
+
if (fileId) {
|
|
828
|
+
const raw = await this.client.fetchRoomEvent(treeId, fileId);
|
|
829
|
+
attachment = this.client.getEventMapper()(raw);
|
|
830
|
+
await this.client.decryptEventIfNeeded(attachment);
|
|
831
|
+
const content = attachment.getContent();
|
|
832
|
+
if (attachment.getId() !== fileId || attachment.getRoomId() !== treeId ||
|
|
833
|
+
attachment.getSender() !== this.client.getUserId() ||
|
|
834
|
+
(!attachment.isRedacted() && (attachment.getWireType() !== EventType.RoomMessageEncrypted ||
|
|
835
|
+
attachment.getType() !== EventType.RoomMessage || content.msgtype !== MsgType.File ||
|
|
836
|
+
!isRecord(content.file) || content.file.url !== mediaId))) {
|
|
837
|
+
throw new StorageError("partial upload attachment does not match the current user and media");
|
|
838
|
+
}
|
|
839
|
+
await this.refreshRoomState(treeId, { signal: controller.signal });
|
|
840
|
+
listing = this.getStateEvent(treeId, UNSTABLE_MSC3089_BRANCH.name, fileId);
|
|
841
|
+
if (listing && !listing.isRedacted() &&
|
|
842
|
+
(listing.getSender() !== this.client.getUserId() || listing.getContent().metadata_event_id !== fileId)) {
|
|
843
|
+
throw new StorageError("file changed after upload; use ordinary file deletion");
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
if (controller.signal.aborted)
|
|
847
|
+
throw new StorageError("operation cancelled");
|
|
848
|
+
await deleteOwnedMedia(this.client, [mediaId], controller.signal);
|
|
849
|
+
if (listing && !listing.isRedacted()) {
|
|
850
|
+
await withMatrixMutationAbort(() => this.client.redactEvent(treeId, listing.getId()), controller.signal);
|
|
851
|
+
}
|
|
852
|
+
if (attachment && !attachment.isRedacted()) {
|
|
853
|
+
await withMatrixMutationAbort(() => this.client.redactEvent(treeId, fileId), controller.signal);
|
|
854
|
+
}
|
|
855
|
+
if (fileId)
|
|
856
|
+
this.fileNames.delete(this.fileNameKey(treeId, fileId));
|
|
857
|
+
})(), controller.signal, () => new MutationOutcomeUnknownError("file upload cleanup"));
|
|
858
|
+
}
|
|
859
|
+
finally {
|
|
860
|
+
clearTimeout(timeout);
|
|
861
|
+
options?.signal?.removeEventListener("abort", abort);
|
|
862
|
+
}
|
|
688
863
|
}
|
|
689
864
|
/**
|
|
690
865
|
* Refreshes a room's current state from the homeserver. The background sync
|
|
@@ -703,6 +878,11 @@ export class TeleCryptIOStorage {
|
|
|
703
878
|
abortSignal: matrixRequestSignal(options),
|
|
704
879
|
});
|
|
705
880
|
const parsedStateEvents = parseMatrixStateResponse(stateEvents);
|
|
881
|
+
if (await this.repairOwnedTreePermissions(roomId, parsedStateEvents, options)) {
|
|
882
|
+
// Read the accepted native state back instead of fabricating a local
|
|
883
|
+
// permission snapshot. An interrupted repair retries on the next load.
|
|
884
|
+
return this.refreshRoomState(roomId, options);
|
|
885
|
+
}
|
|
706
886
|
if (typeof room.currentState.setStateEvents === "function") {
|
|
707
887
|
const refreshed = parsedStateEvents.map((event) => new MatrixEvent(event));
|
|
708
888
|
// RoomState.setStateEvents overwrites matching tuples, but deliberately
|
|
@@ -730,6 +910,49 @@ export class TeleCryptIOStorage {
|
|
|
730
910
|
room.currentState.setStateEvents(refreshed);
|
|
731
911
|
}
|
|
732
912
|
}
|
|
913
|
+
async repairOwnedTreePermissions(roomId, state, options) {
|
|
914
|
+
if (isTreeDeleted(this.client, roomId))
|
|
915
|
+
return false;
|
|
916
|
+
const event = (type, key = "") => state.find((item) => item.type === type && item.state_key === key);
|
|
917
|
+
const create = event(EventType.RoomCreate);
|
|
918
|
+
const purpose = event(UNSTABLE_MSC3088_PURPOSE.name, UNSTABLE_MSC3089_TREE_SUBTYPE.name);
|
|
919
|
+
if (create?.content?.[RoomCreateTypeField] !== RoomType.Space ||
|
|
920
|
+
purpose?.content?.[UNSTABLE_MSC3088_ENABLED.name] !== true)
|
|
921
|
+
return false;
|
|
922
|
+
const userId = this.client.getUserId();
|
|
923
|
+
if (!userId || create?.sender !== userId ||
|
|
924
|
+
event(EventType.RoomMember, userId)?.content?.membership !== "join") {
|
|
925
|
+
return false;
|
|
926
|
+
}
|
|
927
|
+
const rawPower = event(EventType.RoomPowerLevels)?.content;
|
|
928
|
+
const power = parseMatrixPowerLevels(rawPower);
|
|
929
|
+
const ownerLevel = power.events?.[EventType.RoomPowerLevels] ?? 100;
|
|
930
|
+
if ((power.users?.[userId] ?? power.users_default ?? 0) < ownerLevel)
|
|
931
|
+
return false;
|
|
932
|
+
const events = { ...power.events };
|
|
933
|
+
let changed = (power.state_default ?? 50) < 100;
|
|
934
|
+
for (const type of [EventType.RoomJoinRules, UNSTABLE_MSC3088_PURPOSE.name]) {
|
|
935
|
+
if (events[type] !== undefined && events[type] < 100) {
|
|
936
|
+
events[type] = 100;
|
|
937
|
+
changed = true;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
if (changed) {
|
|
941
|
+
await withMatrixMutationAbort(() => this.client.sendStateEvent(roomId, EventType.RoomPowerLevels, {
|
|
942
|
+
...rawPower,
|
|
943
|
+
state_default: Math.max(power.state_default ?? 50, 100),
|
|
944
|
+
events,
|
|
945
|
+
}, ""), matrixRequestSignal(options));
|
|
946
|
+
}
|
|
947
|
+
const joinRules = event(EventType.RoomJoinRules)?.content;
|
|
948
|
+
if (joinRules?.join_rule !== "invite") {
|
|
949
|
+
await withMatrixMutationAbort(() => this.client.sendStateEvent(roomId, EventType.RoomJoinRules, {
|
|
950
|
+
join_rule: JoinRule.Invite,
|
|
951
|
+
}, ""), matrixRequestSignal(options));
|
|
952
|
+
changed = true;
|
|
953
|
+
}
|
|
954
|
+
return changed;
|
|
955
|
+
}
|
|
733
956
|
/**
|
|
734
957
|
* Recommended entry point: builds the MatrixClient with a persistent crypto
|
|
735
958
|
* store and the secret-storage callback wiring `keys.*` needs, starts the
|
|
@@ -1307,7 +1530,7 @@ export class TeleCryptIOStorage {
|
|
|
1307
1530
|
kick: 100,
|
|
1308
1531
|
ban: 100,
|
|
1309
1532
|
redact: 100,
|
|
1310
|
-
state_default:
|
|
1533
|
+
state_default: 100,
|
|
1311
1534
|
events_default: 0,
|
|
1312
1535
|
users_default: 0,
|
|
1313
1536
|
events: {
|
|
@@ -1404,7 +1627,7 @@ export class TeleCryptIOStorage {
|
|
|
1404
1627
|
const branch = originalGetFile(fileId);
|
|
1405
1628
|
return branch ? this.decorateFileBranch(tree.id, branch) : null;
|
|
1406
1629
|
};
|
|
1407
|
-
tree.createFile = (name, encryptedContents, info, additionalContent) => this.createEncryptedFile(tree.id, name, encryptedContents, info, additionalContent);
|
|
1630
|
+
tree.createFile = (name, encryptedContents, info, additionalContent, signal) => this.createEncryptedFile(tree.id, name, encryptedContents, info, additionalContent, signal);
|
|
1408
1631
|
return tree;
|
|
1409
1632
|
}
|
|
1410
1633
|
decorateFileBranch(treeId, branch) {
|
|
@@ -1560,8 +1783,14 @@ export class TeleCryptIOStorage {
|
|
|
1560
1783
|
if (isTreeDeleted(this.client, room.roomId))
|
|
1561
1784
|
continue;
|
|
1562
1785
|
const tree = this.client.unstableGetFileTreeSpace(room.roomId);
|
|
1563
|
-
if (tree)
|
|
1786
|
+
if (tree) {
|
|
1787
|
+
// Existing owner sessions repair the persisted permissions on load;
|
|
1788
|
+
// readers never attempt this owner-only mutation.
|
|
1789
|
+
if (this.getStateEvent(room.roomId, EventType.RoomCreate, "")?.getSender() === this.client.getUserId()) {
|
|
1790
|
+
await this.refreshRoomState(room.roomId, { signal });
|
|
1791
|
+
}
|
|
1564
1792
|
trees.push(this.decorateTreeSpace(tree));
|
|
1793
|
+
}
|
|
1565
1794
|
}
|
|
1566
1795
|
return trees;
|
|
1567
1796
|
}
|
|
@@ -1573,6 +1802,9 @@ export class TeleCryptIOStorage {
|
|
|
1573
1802
|
const currentParent = this.getTree(parent.id);
|
|
1574
1803
|
if (!currentParent)
|
|
1575
1804
|
throw new Error("createSubtree: parent is unavailable");
|
|
1805
|
+
const readers = (await this.listMembers(currentParent, { signal: effectiveSignal }))
|
|
1806
|
+
.filter((member) => member.role === "viewer" &&
|
|
1807
|
+
(member.membership === "join" || member.membership === "invite"));
|
|
1576
1808
|
const tree = await this.createTreeSpace(name, "createSubtree", effectiveSignal);
|
|
1577
1809
|
let parentLinkAttempted = false;
|
|
1578
1810
|
let parentLinkEventId;
|
|
@@ -1599,7 +1831,6 @@ export class TeleCryptIOStorage {
|
|
|
1599
1831
|
if (this.client.getRoom(tree.id)) {
|
|
1600
1832
|
await this.refreshRoomState(tree.id, { signal: effectiveSignal });
|
|
1601
1833
|
}
|
|
1602
|
-
return tree;
|
|
1603
1834
|
}
|
|
1604
1835
|
catch (error) {
|
|
1605
1836
|
// Compensation is independent of the caller's cancelled deadline.
|
|
@@ -1691,6 +1922,22 @@ export class TeleCryptIOStorage {
|
|
|
1691
1922
|
}
|
|
1692
1923
|
throw error;
|
|
1693
1924
|
}
|
|
1925
|
+
// The folder and both links now exist. A failed invitation must leave
|
|
1926
|
+
// that known folder available for reconciliation instead of deleting a
|
|
1927
|
+
// room to which another reader may already have received access.
|
|
1928
|
+
try {
|
|
1929
|
+
for (const reader of readers) {
|
|
1930
|
+
if (effectiveSignal?.aborted)
|
|
1931
|
+
throw new StorageError("operation cancelled");
|
|
1932
|
+
const verifyHistorySharing = await prepareReaderHistorySharing(this.client, reader.userId);
|
|
1933
|
+
await withMatrixMutationAbort(() => tree.invite(reader.userId), effectiveSignal);
|
|
1934
|
+
await verifyHistorySharing();
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
catch (error) {
|
|
1938
|
+
throw new MutationPartialError("create folder", [tree.id], "the folder was created, but reader access is incomplete; retry sharing the parent vault with its readers", { cause: error });
|
|
1939
|
+
}
|
|
1940
|
+
return tree;
|
|
1694
1941
|
}, signal);
|
|
1695
1942
|
}
|
|
1696
1943
|
getTree(roomId) {
|
|
@@ -1779,19 +2026,19 @@ export class TeleCryptIOStorage {
|
|
|
1779
2026
|
throw new StorageError("operation cancelled");
|
|
1780
2027
|
let response;
|
|
1781
2028
|
try {
|
|
1782
|
-
|
|
2029
|
+
// Let the in-flight request settle so the uploader can retain returned
|
|
2030
|
+
// media/event IDs on cancellation, then stop before the next write.
|
|
2031
|
+
response = await tree.createFile(name, new Uint8Array(encrypted.data), encrypted.info, { info: { mimetype, size: data.byteLength } }, signal);
|
|
1783
2032
|
}
|
|
1784
2033
|
catch (error) {
|
|
1785
|
-
//
|
|
1786
|
-
|
|
1787
|
-
// this operation and create a duplicate file; only the explicit typed
|
|
1788
|
-
// mutation outcome is safe to propagate unchanged.
|
|
1789
|
-
if (error instanceof MutationOutcomeUnknownError)
|
|
2034
|
+
// Preserve acknowledged upload stages after cancellation as well.
|
|
2035
|
+
if (error instanceof MutationOutcomeUnknownError || error instanceof MutationPartialError)
|
|
1790
2036
|
throw error;
|
|
2037
|
+
if (error instanceof StorageError || error instanceof MatrixError)
|
|
2038
|
+
throw error;
|
|
2039
|
+
// An unclassified transport failure can still follow a committed event.
|
|
1791
2040
|
throw new MutationOutcomeUnknownError("file upload", { cause: error });
|
|
1792
2041
|
}
|
|
1793
|
-
if (signal?.aborted)
|
|
1794
|
-
throw new StorageError("operation cancelled");
|
|
1795
2042
|
try {
|
|
1796
2043
|
return validateMatrixEventId(response?.event_id, "file upload response event ID");
|
|
1797
2044
|
}
|
|
@@ -1832,7 +2079,7 @@ export class TeleCryptIOStorage {
|
|
|
1832
2079
|
const mimetype = eventMetadata.mimetype ?? "application/octet-stream";
|
|
1833
2080
|
const mxcUrl = info.url;
|
|
1834
2081
|
const clientAny = this.client;
|
|
1835
|
-
|
|
2082
|
+
let accessToken = clientAny.getAccessToken();
|
|
1836
2083
|
validateMatrixToken(accessToken);
|
|
1837
2084
|
let trustedOrigin;
|
|
1838
2085
|
try {
|
|
@@ -1866,6 +2113,7 @@ export class TeleCryptIOStorage {
|
|
|
1866
2113
|
else
|
|
1867
2114
|
signal?.addEventListener("abort", abortExternal, { once: true });
|
|
1868
2115
|
let ciphertext;
|
|
2116
|
+
let retriedAuthentication = false;
|
|
1869
2117
|
try {
|
|
1870
2118
|
for (;;) {
|
|
1871
2119
|
const res = await raceWithAbort(fetch(currentUrl, {
|
|
@@ -1908,13 +2156,22 @@ export class TeleCryptIOStorage {
|
|
|
1908
2156
|
if (!res.ok) {
|
|
1909
2157
|
const statusFailure = new Error(`media download failed: ${res.status}`);
|
|
1910
2158
|
let detail = "";
|
|
2159
|
+
let unknownToken = false;
|
|
1911
2160
|
try {
|
|
1912
2161
|
// Error responses are diagnostics, not product media. Consume the
|
|
1913
2162
|
// complete body so no provider detail is discarded, then apply
|
|
1914
2163
|
// the SDK's shared secret/control-character sanitizer before it
|
|
1915
2164
|
// crosses the SDK boundary.
|
|
1916
2165
|
const body = await readResponseBody(res, controller.signal);
|
|
1917
|
-
|
|
2166
|
+
const responseText = new TextDecoder().decode(body.bytes);
|
|
2167
|
+
detail = sanitizeDiagnosticText(responseText);
|
|
2168
|
+
if (res.status === 401) {
|
|
2169
|
+
try {
|
|
2170
|
+
const errorBody = JSON.parse(responseText);
|
|
2171
|
+
unknownToken = isRecord(errorBody) && errorBody.errcode === "M_UNKNOWN_TOKEN";
|
|
2172
|
+
}
|
|
2173
|
+
catch { /* Non-Matrix errors retain the complete diagnostic below. */ }
|
|
2174
|
+
}
|
|
1918
2175
|
}
|
|
1919
2176
|
catch (bodyError) {
|
|
1920
2177
|
const partial = bodyError instanceof ResponseBodyReadError
|
|
@@ -1925,6 +2182,20 @@ export class TeleCryptIOStorage {
|
|
|
1925
2182
|
: bodyError;
|
|
1926
2183
|
throw new AggregateError([statusFailure, readFailure], "media download failed while reading the response body", { cause: statusFailure });
|
|
1927
2184
|
}
|
|
2185
|
+
if (unknownToken && !retriedAuthentication) {
|
|
2186
|
+
retriedAuthentication = true;
|
|
2187
|
+
// Matrix's authenticated transport owns token refresh, including
|
|
2188
|
+
// concurrent refreshes and persistence. Its media request path
|
|
2189
|
+
// buffers the whole body and follows redirects, so use a small
|
|
2190
|
+
// authenticated read to renew the session, then retry our bounded
|
|
2191
|
+
// streaming download once with the current token.
|
|
2192
|
+
await raceWithAbort(this.client.whoami(), controller.signal, () => signal?.aborted
|
|
2193
|
+
? new StorageError("operation cancelled")
|
|
2194
|
+
: new Error("media download timed out"));
|
|
2195
|
+
accessToken = clientAny.getAccessToken();
|
|
2196
|
+
validateMatrixToken(accessToken);
|
|
2197
|
+
continue;
|
|
2198
|
+
}
|
|
1928
2199
|
throw new Error(`${statusFailure.message}${detail === "" ? "" : `: ${detail}`}`);
|
|
1929
2200
|
}
|
|
1930
2201
|
if (!res.body) {
|
package/dist/core/errors.d.ts
CHANGED
|
@@ -36,12 +36,16 @@ export declare class MutationOutcomeUnknownError extends StorageError {
|
|
|
36
36
|
readonly operation: string;
|
|
37
37
|
constructor(operation?: string, options?: ErrorOptions);
|
|
38
38
|
}
|
|
39
|
-
/** A multi-
|
|
39
|
+
/** A multi-step mutation committed some work before a later step failed. */
|
|
40
40
|
export declare class MutationPartialError extends StorageError {
|
|
41
41
|
readonly code: "MUTATION_PARTIAL";
|
|
42
42
|
readonly operation: string;
|
|
43
43
|
readonly completedIds: readonly string[];
|
|
44
|
-
|
|
44
|
+
/** Room containing the partial mutation, when the operation can target a nested folder. */
|
|
45
|
+
readonly treeId?: string;
|
|
46
|
+
constructor(operation: string, completedIds: readonly string[], detail?: string, options?: ErrorOptions & {
|
|
47
|
+
treeId?: string;
|
|
48
|
+
});
|
|
45
49
|
}
|
|
46
50
|
/** A vault or folder still contains files or child folders. */
|
|
47
51
|
export declare class NonEmptyTreeError extends StorageError {
|
package/dist/core/errors.js
CHANGED
|
@@ -58,16 +58,19 @@ export class MutationOutcomeUnknownError extends StorageError {
|
|
|
58
58
|
this.operation = operation;
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
|
-
/** A multi-
|
|
61
|
+
/** A multi-step mutation committed some work before a later step failed. */
|
|
62
62
|
export class MutationPartialError extends StorageError {
|
|
63
63
|
code = "MUTATION_PARTIAL";
|
|
64
64
|
operation;
|
|
65
65
|
completedIds;
|
|
66
|
+
/** Room containing the partial mutation, when the operation can target a nested folder. */
|
|
67
|
+
treeId;
|
|
66
68
|
constructor(operation, completedIds, detail, options) {
|
|
67
69
|
super(`${operation} failed after partial completion${detail ? `: ${detail}` : ""}; retry to reconcile current state`, options);
|
|
68
70
|
this.name = "MutationPartialError";
|
|
69
71
|
this.operation = operation;
|
|
70
72
|
this.completedIds = [...completedIds];
|
|
73
|
+
this.treeId = options?.treeId;
|
|
71
74
|
}
|
|
72
75
|
}
|
|
73
76
|
/** A vault or folder still contains files or child folders. */
|
|
@@ -50,6 +50,8 @@ export declare function deleteFolder(storage: TeleCryptIOStorage, folderId: stri
|
|
|
50
50
|
export declare function renameFile(storage: TeleCryptIOStorage, treeId: string, fileId: string, name: string, options?: OperationOptions): Promise<RenameResult>;
|
|
51
51
|
export declare function deleteFile(storage: TeleCryptIOStorage, treeId: string, fileId: string, options?: OperationOptions): Promise<DeleteResult>;
|
|
52
52
|
export declare function uploadFile(storage: TeleCryptIOStorage, treeId: string, name: string, bytes: Uint8Array, mimetype: string, options?: OperationOptions): Promise<FileInfo>;
|
|
53
|
+
/** Discard the acknowledged stages of a failed upload using its retained IDs. */
|
|
54
|
+
export declare function cleanupFileUpload(storage: TeleCryptIOStorage, treeId: string, mediaId: string, fileId?: string, options?: OperationOptions): Promise<void>;
|
|
53
55
|
export declare function downloadFile(storage: TeleCryptIOStorage, treeId: string, fileId: string, options?: OperationOptions): Promise<DownloadedFile>;
|
|
54
56
|
export declare function setupRecovery(storage: TeleCryptIOStorage, options?: OperationOptions): Promise<RecoverySetup>;
|
|
55
57
|
export declare function restoreRecovery(storage: TeleCryptIOStorage, recoveryKey: string, options?: OperationOptions): Promise<RecoveryRestore>;
|
package/dist/core/operations.js
CHANGED
|
@@ -12,12 +12,12 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { readFileEventMetadata, withMatrixMutationAbort, withTreeMutation, } from "../TeleCryptIOStorage.js";
|
|
14
14
|
import { EventType, MatrixError, RoomCreateTypeField, RoomType, UNSTABLE_MSC3088_ENABLED, UNSTABLE_MSC3088_PURPOSE, UNSTABLE_MSC3089_TREE_SUBTYPE, } from "matrix-js-sdk";
|
|
15
|
-
import {
|
|
16
|
-
import { Method } from "matrix-js-sdk/lib/http-api/method.js";
|
|
15
|
+
import { deleteOwnedMedia } from "../media.js";
|
|
17
16
|
import { validateMatrixEventId } from "./constants.js";
|
|
18
17
|
import { FileTooLargeError, MutationPartialError, MutationOutcomeUnknownError, NonEmptyTreeError, RoomCreationAmbiguousError, RoomCleanupIncompleteError, StorageError, } from "./errors.js";
|
|
19
18
|
import { ConditionTimeoutError, waitForCondition } from "./poll.js";
|
|
20
19
|
import { validateName } from "./validation.js";
|
|
20
|
+
import { prepareReaderHistorySharing } from "../sharing.js";
|
|
21
21
|
import { isFileDeleted, isTreeDeleted, markFileDeleted, markTreeDeleted, } from "../deletion-markers.js";
|
|
22
22
|
/**
|
|
23
23
|
* Production Synapse enforces the built-in rc_messages budget (per-account
|
|
@@ -100,6 +100,7 @@ function isRateLimited(error) {
|
|
|
100
100
|
}
|
|
101
101
|
function requiresMutationReconciliation(error) {
|
|
102
102
|
return (error instanceof MutationOutcomeUnknownError ||
|
|
103
|
+
error instanceof MutationPartialError ||
|
|
103
104
|
error instanceof RoomCreationAmbiguousError ||
|
|
104
105
|
error instanceof RoomCleanupIncompleteError);
|
|
105
106
|
}
|
|
@@ -325,7 +326,17 @@ async function activeTreeDirectories(storage, tree, signal) {
|
|
|
325
326
|
// MSC3089 TreeSpace projection includes that child. Resolve the exact
|
|
326
327
|
// room named by the active relation instead of treating that brief
|
|
327
328
|
// projection lag as corrupt folder state.
|
|
328
|
-
|
|
329
|
+
let joined = false;
|
|
330
|
+
return await waitForCondition(async () => {
|
|
331
|
+
const directory = storage.getTree(childId);
|
|
332
|
+
if (directory)
|
|
333
|
+
return directory;
|
|
334
|
+
if (!joined && client.getRoom?.(childId)?.getMyMembership?.() === "invite") {
|
|
335
|
+
await joinTreeRoom(storage, childId, signal ?? new AbortController().signal);
|
|
336
|
+
joined = true;
|
|
337
|
+
}
|
|
338
|
+
return null;
|
|
339
|
+
}, { timeoutMs: 15000, signal });
|
|
329
340
|
}
|
|
330
341
|
catch (error) {
|
|
331
342
|
if (signal?.aborted)
|
|
@@ -546,64 +557,98 @@ export function getMyVaultRole(storage, vaultId) {
|
|
|
546
557
|
return null;
|
|
547
558
|
return tree.getPermissions(userId);
|
|
548
559
|
}
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
560
|
+
async function joinTreeRoom(storage, vaultId, signal) {
|
|
561
|
+
let membership;
|
|
562
|
+
try {
|
|
563
|
+
membership = await storage.getRoomMembership(vaultId, undefined, { signal });
|
|
564
|
+
}
|
|
565
|
+
catch (err) {
|
|
566
|
+
if (signal.aborted)
|
|
567
|
+
throw new StorageError("operation cancelled");
|
|
568
|
+
// Synapse refuses GET /rooms/:room/members for an invited user with
|
|
569
|
+
// M_FORBIDDEN. That response means the membership preflight cannot
|
|
570
|
+
// inspect the room yet; it is not evidence that joining is forbidden.
|
|
571
|
+
// Attempt the idempotent join and let its own response decide access.
|
|
572
|
+
if (err instanceof MatrixError && err.errcode === "M_FORBIDDEN") {
|
|
573
|
+
membership = null;
|
|
574
|
+
}
|
|
575
|
+
else {
|
|
576
|
+
throw new StorageError("join failed", { cause: err });
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
if (membership === "join")
|
|
580
|
+
return;
|
|
581
|
+
const client = storage.getClient();
|
|
582
|
+
const userId = client.getUserId();
|
|
583
|
+
if (userId && client.getRoom(vaultId)?.getMember(userId)?.membership !== "invite") {
|
|
584
|
+
// MatrixClient.joinRoom reads the inviter from its local invite-room
|
|
585
|
+
// state. A to-device history bundle can arrive first in the initial
|
|
586
|
+
// sync, before that stripped invite state has reached the room store;
|
|
587
|
+
// joining without it skips Matrix JS SDK's pending-bundle acceptance.
|
|
553
588
|
try {
|
|
554
|
-
|
|
589
|
+
await waitForCondition(() => client.getRoom(vaultId)?.getMember(userId)?.membership === "invite" ? true : null, { timeoutMs: 15000, signal });
|
|
555
590
|
}
|
|
556
591
|
catch (err) {
|
|
557
592
|
if (signal.aborted)
|
|
558
593
|
throw new StorageError("operation cancelled");
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
// inspect the room yet; it is not evidence that joining is forbidden.
|
|
562
|
-
// Attempt the idempotent join and let its own response decide access.
|
|
563
|
-
if (err instanceof MatrixError && err.errcode === "M_FORBIDDEN") {
|
|
564
|
-
membership = null;
|
|
565
|
-
}
|
|
566
|
-
else {
|
|
567
|
-
throw new StorageError("join failed", { cause: err });
|
|
594
|
+
if (err instanceof ConditionTimeoutError) {
|
|
595
|
+
throw new StorageError("the room invitation has not reached this login yet; retry joining", { cause: err });
|
|
568
596
|
}
|
|
597
|
+
throw err;
|
|
569
598
|
}
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
}
|
|
582
|
-
catch (err) {
|
|
583
|
-
if (signal.aborted)
|
|
584
|
-
throw new StorageError("operation cancelled");
|
|
585
|
-
if (err instanceof ConditionTimeoutError) {
|
|
586
|
-
throw new StorageError("the room invitation has not reached this login yet; retry joining", { cause: err });
|
|
587
|
-
}
|
|
588
|
-
throw err;
|
|
589
|
-
}
|
|
599
|
+
}
|
|
600
|
+
try {
|
|
601
|
+
await withRateLimitRetry(() => withMatrixMutationAbort(() => client.joinRoom(vaultId), signal), signal);
|
|
602
|
+
}
|
|
603
|
+
catch (err) {
|
|
604
|
+
if (err instanceof MutationOutcomeUnknownError)
|
|
605
|
+
throw err;
|
|
606
|
+
if (err instanceof MatrixError && err.errcode === "M_FORBIDDEN") {
|
|
607
|
+
const afterForbidden = await storage.getRoomMembership(vaultId, undefined, { signal });
|
|
608
|
+
if (afterForbidden === "join")
|
|
609
|
+
return;
|
|
590
610
|
}
|
|
611
|
+
if (signal.aborted)
|
|
612
|
+
throw new StorageError("operation cancelled");
|
|
613
|
+
throw new StorageError("join failed", { cause: err });
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
export async function joinVault(storage, vaultId, options) {
|
|
617
|
+
await storage.keySafe.requireReady({ signal: options?.signal });
|
|
618
|
+
return withOperationDeadline(options, async (signal) => {
|
|
619
|
+
const joined = new Set();
|
|
620
|
+
const pending = [vaultId];
|
|
591
621
|
try {
|
|
592
|
-
|
|
622
|
+
while (pending.length > 0) {
|
|
623
|
+
ensureOperationActive(signal);
|
|
624
|
+
const roomId = pending.shift();
|
|
625
|
+
if (joined.has(roomId) || isMarkedTreeDeleted(storage, roomId))
|
|
626
|
+
continue;
|
|
627
|
+
// Keep native joinRoom's inviter/history-bundle handling for every
|
|
628
|
+
// child. Merely joining the root does not accept its child invites.
|
|
629
|
+
await joinTreeRoom(storage, roomId, signal);
|
|
630
|
+
joined.add(roomId);
|
|
631
|
+
await resolveTree(storage, roomId, signal);
|
|
632
|
+
await storage.refreshRoomState(roomId, { signal });
|
|
633
|
+
const children = readRelationEvents(storage.getClient(), roomId, EventType.SpaceChild);
|
|
634
|
+
if (children === null)
|
|
635
|
+
throw new StorageError("storage folder state is unavailable");
|
|
636
|
+
for (const child of children) {
|
|
637
|
+
if (!isActiveRelationEvent(child))
|
|
638
|
+
continue;
|
|
639
|
+
const childId = relationStateKey(child);
|
|
640
|
+
if (!childId)
|
|
641
|
+
throw new StorageError("storage folder state is inconsistent");
|
|
642
|
+
pending.push(childId);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
593
645
|
}
|
|
594
|
-
catch (
|
|
595
|
-
if (
|
|
596
|
-
throw
|
|
597
|
-
if (err instanceof MatrixError && err.errcode === "M_FORBIDDEN") {
|
|
598
|
-
const afterForbidden = await storage.getRoomMembership(vaultId, undefined, { signal });
|
|
599
|
-
if (afterForbidden === "join")
|
|
600
|
-
return { vaultId, joined: true };
|
|
646
|
+
catch (error) {
|
|
647
|
+
if (joined.size > 0) {
|
|
648
|
+
throw new MutationPartialError("join", [...joined], "some folders were joined; retry accepting the same vault to finish", { cause: error });
|
|
601
649
|
}
|
|
602
|
-
|
|
603
|
-
throw new StorageError("operation cancelled");
|
|
604
|
-
throw new StorageError("join failed", { cause: err });
|
|
650
|
+
throw error;
|
|
605
651
|
}
|
|
606
|
-
ensureOperationActive(signal);
|
|
607
652
|
return { vaultId, joined: true };
|
|
608
653
|
}, "mutation");
|
|
609
654
|
}
|
|
@@ -780,14 +825,7 @@ export async function shareVault(storage, vaultId, userId, role, options) {
|
|
|
780
825
|
throw new StorageError("share will not demote an existing owner");
|
|
781
826
|
}
|
|
782
827
|
const client = storage.getClient();
|
|
783
|
-
const
|
|
784
|
-
if (!crypto)
|
|
785
|
-
throw new StorageError("sharing requires initialized encryption");
|
|
786
|
-
const recipient = await client.downloadKeysForUsers([userId]);
|
|
787
|
-
const devices = Object.keys(recipient.device_keys?.[userId] ?? {});
|
|
788
|
-
if (!recipient.master_keys?.[userId] || !recipient.self_signing_keys?.[userId] || devices.length === 0) {
|
|
789
|
-
throw new StorageError("The reader must set up or unlock their Decryption Key Safe before sharing");
|
|
790
|
-
}
|
|
828
|
+
const verifyHistorySharing = await prepareReaderHistorySharing(client, userId);
|
|
791
829
|
for (const [index, space] of spaces.entries()) {
|
|
792
830
|
ensureOperationActive(operation.signal);
|
|
793
831
|
const members = index === 0
|
|
@@ -807,12 +845,9 @@ export async function shareVault(storage, vaultId, userId, role, options) {
|
|
|
807
845
|
// pending invitations too: an earlier attempt may have sent only an
|
|
808
846
|
// invitation. In particular, never swallow a media-upload 403 merely
|
|
809
847
|
// because an invitation already exists.
|
|
810
|
-
await withRateLimitRetry(() => withMatrixMutationAbort(() => space.invite(userId), operation.signal), operation.signal);
|
|
848
|
+
await withRateLimitRetry(() => withMatrixMutationAbort(() => space.invite(userId, false), operation.signal), operation.signal);
|
|
811
849
|
completedRoomIds.add(space.id);
|
|
812
|
-
|
|
813
|
-
if (!statuses.some((status) => status?.signedByOwner)) {
|
|
814
|
-
throw new StorageError("The invitation was sent, but history keys could not be shared; the reader must unlock their Decryption Key Safe, then retry sharing");
|
|
815
|
-
}
|
|
850
|
+
await verifyHistorySharing();
|
|
816
851
|
}
|
|
817
852
|
// Storage rooms have one immutable owner power level and readers use
|
|
818
853
|
// the room's default user level. Changing per-user power levels would create editors and
|
|
@@ -1121,18 +1156,10 @@ export async function renameFile(storage, treeId, fileId, name, options) {
|
|
|
1121
1156
|
return { id: fileId, name };
|
|
1122
1157
|
}, "mutation");
|
|
1123
1158
|
}
|
|
1124
|
-
function requireFileDeletionTransport(client) {
|
|
1125
|
-
const http = client.http;
|
|
1126
|
-
if (!http || typeof http.authedRequest !== "function") {
|
|
1127
|
-
throw new StorageError("Matrix HTTP transport unavailable");
|
|
1128
|
-
}
|
|
1129
|
-
return http;
|
|
1130
|
-
}
|
|
1131
1159
|
async function deleteFileMedia(storage, mediaIds, signal) {
|
|
1132
1160
|
const client = storage.getClient();
|
|
1133
|
-
const http = requireFileDeletionTransport(client);
|
|
1134
1161
|
try {
|
|
1135
|
-
await withRateLimitRetry(() => withMatrixMutationAbort(() =>
|
|
1162
|
+
await withRateLimitRetry(() => withMatrixMutationAbort(() => deleteOwnedMedia(client, mediaIds, signal), signal, "delete file media"), signal);
|
|
1136
1163
|
}
|
|
1137
1164
|
catch (error) {
|
|
1138
1165
|
if (error instanceof MutationOutcomeUnknownError)
|
|
@@ -1150,8 +1177,6 @@ export async function deleteFile(storage, treeId, fileId, options) {
|
|
|
1150
1177
|
return { id: fileId, deleted: true };
|
|
1151
1178
|
await storage.refreshRoomState(treeId, { signal });
|
|
1152
1179
|
const branch = tree.getFile(fileId);
|
|
1153
|
-
if (!branch)
|
|
1154
|
-
throw new StorageError("file not found");
|
|
1155
1180
|
let originalEvent;
|
|
1156
1181
|
try {
|
|
1157
1182
|
originalEvent = await storage.getOriginalFileEvent(tree.id, fileId, true);
|
|
@@ -1160,7 +1185,7 @@ export async function deleteFile(storage, treeId, fileId, options) {
|
|
|
1160
1185
|
throw new StorageError("could not resolve encrypted file safely", { cause: error });
|
|
1161
1186
|
}
|
|
1162
1187
|
if (originalEvent.isRedacted()) {
|
|
1163
|
-
if (branch.isActive === false) {
|
|
1188
|
+
if (!branch || branch.isActive === false) {
|
|
1164
1189
|
markFileDeleted(storage.getClient(), tree.id, fileId);
|
|
1165
1190
|
return { id: fileId, deleted: true };
|
|
1166
1191
|
}
|
|
@@ -1184,7 +1209,9 @@ export async function deleteFile(storage, treeId, fileId, options) {
|
|
|
1184
1209
|
const completedIds = [mediaId];
|
|
1185
1210
|
try {
|
|
1186
1211
|
const client = storage.getClient();
|
|
1187
|
-
const metadataEventId =
|
|
1212
|
+
const metadataEventId = branch
|
|
1213
|
+
? await storage.getFileMetadataEventId(tree.id, fileId, { signal })
|
|
1214
|
+
: null;
|
|
1188
1215
|
if (metadataEventId && metadataEventId !== fileId) {
|
|
1189
1216
|
const metadataEvent = await storage.getFileRenameMetadataEvent(tree.id, fileId, metadataEventId);
|
|
1190
1217
|
if (!metadataEvent.isRedacted()) {
|
|
@@ -1195,16 +1222,17 @@ export async function deleteFile(storage, treeId, fileId, options) {
|
|
|
1195
1222
|
const currentBranch = tree.getFile(fileId);
|
|
1196
1223
|
const indexEvent = currentBranch?.indexEvent;
|
|
1197
1224
|
const listingEventId = indexEvent?.getId();
|
|
1198
|
-
if (!indexEvent || typeof listingEventId !== "string" || listingEventId.length === 0) {
|
|
1225
|
+
if (currentBranch && (!indexEvent || typeof listingEventId !== "string" || listingEventId.length === 0)) {
|
|
1199
1226
|
throw new StorageError("encrypted file has no listing event identifier");
|
|
1200
1227
|
}
|
|
1201
|
-
if (!indexEvent.isRedacted()) {
|
|
1228
|
+
if (indexEvent && !indexEvent.isRedacted()) {
|
|
1202
1229
|
if (indexEvent.getSender() !== client.getUserId()) {
|
|
1203
1230
|
throw new StorageError("file listing was not authored by the owner");
|
|
1204
1231
|
}
|
|
1205
1232
|
await withRateLimitRetry(() => withMatrixMutationAbort(() => client.redactEvent(tree.id, listingEventId), signal, "delete file listing"), signal);
|
|
1206
1233
|
}
|
|
1207
|
-
|
|
1234
|
+
if (listingEventId)
|
|
1235
|
+
completedIds.push(listingEventId);
|
|
1208
1236
|
await withRateLimitRetry(() => withMatrixMutationAbort(() => client.redactEvent(tree.id, fileId), signal, "delete file attachment event"), signal);
|
|
1209
1237
|
completedIds.push(fileId);
|
|
1210
1238
|
}
|
|
@@ -1235,12 +1263,18 @@ export async function deleteFile(storage, treeId, fileId, options) {
|
|
|
1235
1263
|
}
|
|
1236
1264
|
export async function uploadFile(storage, treeId, name, bytes, mimetype, options) {
|
|
1237
1265
|
await storage.keySafe.requireReady({ signal: options?.signal });
|
|
1238
|
-
|
|
1266
|
+
const deadline = createOperationDeadline(options);
|
|
1267
|
+
try {
|
|
1268
|
+
const signal = deadline.signal;
|
|
1269
|
+
ensureOperationActive(signal);
|
|
1270
|
+
// Unlike a read, an upload must retain a receipt returned by an in-flight
|
|
1271
|
+
// mutation after cancellation. Native HTTP requests remain time-bounded;
|
|
1272
|
+
// the uploader stops before starting another write when this signal aborts.
|
|
1239
1273
|
const tree = await resolveTree(storage, treeId, signal);
|
|
1240
1274
|
const fileId = await withRateLimitRetry(() => storage.uploadFile(tree, name, toArrayBuffer(bytes), mimetype, signal), signal).catch((error) => {
|
|
1241
1275
|
if (error instanceof FileTooLargeError)
|
|
1242
1276
|
throw error;
|
|
1243
|
-
if (error instanceof MutationOutcomeUnknownError)
|
|
1277
|
+
if (error instanceof MutationOutcomeUnknownError || error instanceof MutationPartialError)
|
|
1244
1278
|
throw error;
|
|
1245
1279
|
if (signal.aborted)
|
|
1246
1280
|
throw new StorageError("operation cancelled");
|
|
@@ -1263,10 +1297,20 @@ export async function uploadFile(storage, treeId, name, bytes, mimetype, options
|
|
|
1263
1297
|
catch (error) {
|
|
1264
1298
|
if (error instanceof MutationOutcomeUnknownError)
|
|
1265
1299
|
throw error;
|
|
1266
|
-
throw new MutationPartialError("upload file", [fileId], "upload completed but the file could not be observed locally", { cause: error });
|
|
1300
|
+
throw new MutationPartialError("upload file", [fileId], "upload completed but the file could not be observed locally", { cause: error, treeId });
|
|
1267
1301
|
}
|
|
1268
1302
|
ensureOperationActive(signal);
|
|
1269
1303
|
return { id: fileId, name, mimetype };
|
|
1304
|
+
}
|
|
1305
|
+
finally {
|
|
1306
|
+
deadline.close();
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
/** Discard the acknowledged stages of a failed upload using its retained IDs. */
|
|
1310
|
+
export async function cleanupFileUpload(storage, treeId, mediaId, fileId, options) {
|
|
1311
|
+
await storage.keySafe.requireReady({ signal: options?.signal });
|
|
1312
|
+
return withOperationDeadline(options, async (signal) => {
|
|
1313
|
+
await storage.cleanupFileUpload(treeId, mediaId, fileId, { signal });
|
|
1270
1314
|
}, "mutation");
|
|
1271
1315
|
}
|
|
1272
1316
|
export async function downloadFile(storage, treeId, fileId, options) {
|
package/dist/media.d.ts
ADDED
package/dist/media.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Method } from "matrix-js-sdk/lib/http-api/method.js";
|
|
2
|
+
import { ClientPrefix } from "matrix-js-sdk/lib/http-api/prefix.js";
|
|
3
|
+
import { StorageError } from "./core/errors.js";
|
|
4
|
+
/** The policy endpoint authorizes deletion against the uploading user. */
|
|
5
|
+
export async function deleteOwnedMedia(client, mediaIds, signal) {
|
|
6
|
+
if (!client.http || typeof client.http.authedRequest !== "function") {
|
|
7
|
+
throw new StorageError("Matrix HTTP transport unavailable");
|
|
8
|
+
}
|
|
9
|
+
await client.http.authedRequest(Method.Post, "/io.telecrypt.storage/delete_media", undefined, { media_ids: [...mediaIds] }, { prefix: ClientPrefix.Unstable, rawResponseBody: true, abortSignal: signal });
|
|
10
|
+
}
|
package/dist/sharing.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { StorageError } from "./core/errors.js";
|
|
2
|
+
/** Use the same account/device requirements for explicit and inherited sharing. */
|
|
3
|
+
export async function prepareReaderHistorySharing(client, userId) {
|
|
4
|
+
const crypto = client.getCrypto();
|
|
5
|
+
if (!crypto)
|
|
6
|
+
throw new StorageError("sharing requires initialized encryption");
|
|
7
|
+
const recipient = await client.downloadKeysForUsers([userId]);
|
|
8
|
+
const devices = Object.keys(recipient.device_keys?.[userId] ?? {});
|
|
9
|
+
if (!recipient.master_keys?.[userId] || !recipient.self_signing_keys?.[userId] || devices.length === 0) {
|
|
10
|
+
throw new StorageError("The reader must set up or unlock their Decryption Key Safe before sharing");
|
|
11
|
+
}
|
|
12
|
+
return async () => {
|
|
13
|
+
const statuses = await Promise.all(devices.map((id) => crypto.getDeviceVerificationStatus(userId, id)));
|
|
14
|
+
if (!statuses.some((status) => status?.signedByOwner)) {
|
|
15
|
+
throw new StorageError("The invitation was sent, but history keys could not be shared; the reader must unlock their Decryption Key Safe, then retry sharing");
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
}
|