@telecrypt-io/storage 0.9.0 → 0.9.3
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 +32 -0
- package/dist/TeleCryptIOStorage.d.ts +16 -1
- package/dist/TeleCryptIOStorage.js +352 -52
- package/dist/core/errors.d.ts +6 -2
- package/dist/core/errors.js +4 -1
- package/dist/core/oidc.d.ts +3 -3
- package/dist/core/oidc.js +17 -4
- package/dist/core/operations.d.ts +2 -0
- package/dist/core/operations.js +129 -93
- package/dist/core/validation.d.ts +15 -0
- package/dist/core/validation.js +45 -0
- 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 +3 -3
|
@@ -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
|
-
import { validateName } from "./core/validation.js";
|
|
10
|
+
import { isStorageTreeRoom, isStorageTreeState, STORAGE_ROOM_ENCRYPTION_ALGORITHM, 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");
|
|
@@ -481,9 +484,16 @@ export class TeleCryptIOStorage {
|
|
|
481
484
|
return ownerId;
|
|
482
485
|
}
|
|
483
486
|
async sendEncryptedMetadataMessage(roomId, content) {
|
|
487
|
+
await this.refreshRoomState(roomId);
|
|
488
|
+
this.requireEncryptedStorageRoom(roomId);
|
|
484
489
|
const response = await this.client.sendMessage(roomId, content);
|
|
485
490
|
return validateMatrixEventId(response?.event_id, "storage metadata response event ID");
|
|
486
491
|
}
|
|
492
|
+
requireEncryptedStorageRoom(roomId) {
|
|
493
|
+
if (!isStorageTreeRoom(this.client.getRoom(roomId))) {
|
|
494
|
+
throw new StorageError("storage room encryption is unavailable or unsupported");
|
|
495
|
+
}
|
|
496
|
+
}
|
|
487
497
|
async readEncryptedMetadataMessage(roomId, eventId, allowRedacted = false) {
|
|
488
498
|
const rawEvent = await this.client.fetchRoomEvent(roomId, eventId);
|
|
489
499
|
const event = this.client.getEventMapper()(rawEvent);
|
|
@@ -515,8 +525,7 @@ export class TeleCryptIOStorage {
|
|
|
515
525
|
await this.client.sendStateEvent(roomId, STORAGE_METADATA_EVENT_TYPE, { event_id: metadataEventId }, "");
|
|
516
526
|
this.treeNames.set(roomId, name);
|
|
517
527
|
}
|
|
518
|
-
|
|
519
|
-
async getTreeName(roomId, options) {
|
|
528
|
+
async getTreeMetadataEvent(roomId, options) {
|
|
520
529
|
if (options?.signal?.aborted)
|
|
521
530
|
throw new StorageError("operation cancelled");
|
|
522
531
|
await this.refreshRoomState(roomId, options);
|
|
@@ -529,9 +538,22 @@ export class TeleCryptIOStorage {
|
|
|
529
538
|
throw new StorageError("encrypted storage name is unavailable");
|
|
530
539
|
}
|
|
531
540
|
const metadataEventId = validateMatrixEventId(pointerContent.event_id, "storage metadata event ID");
|
|
541
|
+
const event = await this.readEncryptedMetadataMessage(roomId, metadataEventId);
|
|
542
|
+
const content = event.getContent();
|
|
543
|
+
if (!isRecord(content) ||
|
|
544
|
+
content.msgtype !== STORAGE_METADATA_MSGTYPE ||
|
|
545
|
+
typeof content.body !== "string" ||
|
|
546
|
+
Object.hasOwn(content, "file_event_id")) {
|
|
547
|
+
throw new StorageError("encrypted storage name is invalid or unavailable");
|
|
548
|
+
}
|
|
549
|
+
validateName(content.body, "name");
|
|
550
|
+
return event;
|
|
551
|
+
}
|
|
552
|
+
/** Resolves an encrypted vault/folder name through its pointer state event. */
|
|
553
|
+
async getTreeName(roomId, options) {
|
|
532
554
|
let event;
|
|
533
555
|
try {
|
|
534
|
-
event = await this.
|
|
556
|
+
event = await this.getTreeMetadataEvent(roomId, options);
|
|
535
557
|
}
|
|
536
558
|
catch (error) {
|
|
537
559
|
// A new device can list storage before restoring room keys. Keep the
|
|
@@ -541,16 +563,78 @@ export class TeleCryptIOStorage {
|
|
|
541
563
|
throw error;
|
|
542
564
|
}
|
|
543
565
|
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
566
|
this.treeNames.set(roomId, content.body);
|
|
552
567
|
return content.body;
|
|
553
568
|
}
|
|
569
|
+
/**
|
|
570
|
+
* Waits for the current encrypted vault/folder name, or file metadata, to
|
|
571
|
+
* have its session in the active native key backup. Checks the exact session
|
|
572
|
+
* even when encryption reuses a key and the backup engine emits no new event.
|
|
573
|
+
*/
|
|
574
|
+
async waitForKeyBackup(treeId, fileId, options) {
|
|
575
|
+
validateMatrixRoomId(treeId);
|
|
576
|
+
if (fileId !== undefined)
|
|
577
|
+
validateMatrixEventId(fileId, "file event ID");
|
|
578
|
+
const timeoutMs = matrixRequestTimeout({ timeoutMs: options?.timeoutMs ?? options?.localTimeoutMs ?? 20_000 });
|
|
579
|
+
const signal = matrixRequestSignal(options);
|
|
580
|
+
const crypto = this.client.getCrypto();
|
|
581
|
+
if (!crypto)
|
|
582
|
+
throw new StorageError("crypto is not initialized");
|
|
583
|
+
let sessionId;
|
|
584
|
+
try {
|
|
585
|
+
await waitForCondition(async (signal) => {
|
|
586
|
+
if (!sessionId) {
|
|
587
|
+
let event;
|
|
588
|
+
if (fileId === undefined) {
|
|
589
|
+
event = await this.getTreeMetadataEvent(treeId, { signal });
|
|
590
|
+
}
|
|
591
|
+
else {
|
|
592
|
+
const metadataId = await this.getFileMetadataEventId(treeId, fileId, { signal });
|
|
593
|
+
if (!metadataId)
|
|
594
|
+
throw new StorageError("encrypted file name is unavailable");
|
|
595
|
+
event = metadataId === fileId
|
|
596
|
+
? await this.getOriginalFileEvent(treeId, fileId)
|
|
597
|
+
: await this.getFileRenameMetadataEvent(treeId, fileId, metadataId);
|
|
598
|
+
}
|
|
599
|
+
const wire = event.getWireContent();
|
|
600
|
+
if (event.isRedacted() || event.isDecryptionFailure() ||
|
|
601
|
+
wire.algorithm !== STORAGE_ROOM_ENCRYPTION_ALGORITHM || typeof wire.session_id !== "string" || !wire.session_id) {
|
|
602
|
+
throw new StorageError("encrypted storage session is unavailable");
|
|
603
|
+
}
|
|
604
|
+
sessionId = wire.session_id;
|
|
605
|
+
}
|
|
606
|
+
if (signal?.aborted)
|
|
607
|
+
throw new StorageError("operation cancelled");
|
|
608
|
+
const version = await crypto.getActiveSessionBackupVersion();
|
|
609
|
+
if (!version)
|
|
610
|
+
return null;
|
|
611
|
+
let backup;
|
|
612
|
+
try {
|
|
613
|
+
backup = await this.client.http.authedRequest(Method.Get, `/room_keys/keys/${encodeURIComponent(treeId)}/${encodeURIComponent(sessionId)}`, { version }, undefined, { prefix: ClientPrefix.V3, abortSignal: signal });
|
|
614
|
+
}
|
|
615
|
+
catch (error) {
|
|
616
|
+
if (error instanceof MatrixError && error.httpStatus === 404 && error.errcode === "M_NOT_FOUND")
|
|
617
|
+
return null;
|
|
618
|
+
throw error;
|
|
619
|
+
}
|
|
620
|
+
const sessionData = isRecord(backup) ? backup.session_data : undefined;
|
|
621
|
+
if (!isRecord(backup) || !Number.isSafeInteger(backup.first_message_index) ||
|
|
622
|
+
backup.first_message_index < 0 || !isRecord(sessionData) ||
|
|
623
|
+
["ciphertext", "ephemeral", "mac"].some((key) => typeof sessionData[key] !== "string" || !sessionData[key])) {
|
|
624
|
+
throw new StorageError("server key backup session is invalid");
|
|
625
|
+
}
|
|
626
|
+
return true;
|
|
627
|
+
}, { timeoutMs, signal });
|
|
628
|
+
}
|
|
629
|
+
catch (error) {
|
|
630
|
+
if (signal?.aborted)
|
|
631
|
+
throw new StorageError("operation cancelled", { cause: error });
|
|
632
|
+
if (error instanceof ConditionTimeoutError) {
|
|
633
|
+
throw new StorageError("key backup did not settle before timeout; retry key backup", { cause: error });
|
|
634
|
+
}
|
|
635
|
+
throw error;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
554
638
|
async writeFileName(treeId, fileId, name) {
|
|
555
639
|
validateName(name, "file name");
|
|
556
640
|
validateMatrixEventId(fileId, "file event ID");
|
|
@@ -661,30 +745,129 @@ export class TeleCryptIOStorage {
|
|
|
661
745
|
}
|
|
662
746
|
return event;
|
|
663
747
|
}
|
|
664
|
-
async createEncryptedFile(treeId, name, encryptedContents, info, additionalContent) {
|
|
748
|
+
async createEncryptedFile(treeId, name, encryptedContents, info, additionalContent, signal) {
|
|
665
749
|
validateFileName(name);
|
|
750
|
+
// Authoritative room refresh also repairs legacy owner permissions before
|
|
751
|
+
// publishing new encrypted content to an existing vault.
|
|
752
|
+
await this.refreshRoomState(treeId, { signal });
|
|
753
|
+
if (signal?.aborted)
|
|
754
|
+
throw new StorageError("operation cancelled");
|
|
755
|
+
this.requireEncryptedStorageRoom(treeId);
|
|
666
756
|
const bytes = encryptedContents instanceof Uint8Array
|
|
667
757
|
? encryptedContents
|
|
668
758
|
: new Uint8Array(encryptedContents);
|
|
669
759
|
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
|
-
|
|
760
|
+
let mediaId;
|
|
761
|
+
let fileId;
|
|
762
|
+
try {
|
|
763
|
+
const uploaded = await this.client.uploadContent(body, { includeFilename: false });
|
|
764
|
+
if (typeof uploaded.content_uri !== "string" || uploaded.content_uri.length === 0) {
|
|
765
|
+
throw new StorageError("encrypted media upload returned no content URI");
|
|
766
|
+
}
|
|
767
|
+
mediaId = uploaded.content_uri;
|
|
768
|
+
if (signal?.aborted)
|
|
769
|
+
throw new StorageError("operation cancelled");
|
|
770
|
+
const fileContent = {
|
|
771
|
+
...additionalContent,
|
|
772
|
+
msgtype: MsgType.File,
|
|
773
|
+
body: name,
|
|
774
|
+
url: mediaId,
|
|
775
|
+
file: { ...info, url: mediaId },
|
|
776
|
+
[UNSTABLE_MSC3089_LEAF.name]: {},
|
|
777
|
+
};
|
|
778
|
+
const sent = await this.client.sendMessage(treeId, fileContent);
|
|
779
|
+
fileId = validateMatrixEventId(sent?.event_id, "file upload response event ID");
|
|
780
|
+
if (signal?.aborted)
|
|
781
|
+
throw new StorageError("operation cancelled");
|
|
782
|
+
await this.client.sendStateEvent(treeId, UNSTABLE_MSC3089_BRANCH.name, { active: true, metadata_event_id: fileId }, fileId);
|
|
783
|
+
if (signal?.aborted)
|
|
784
|
+
throw new StorageError("operation cancelled");
|
|
785
|
+
this.fileNames.set(this.fileNameKey(treeId, fileId), name);
|
|
786
|
+
return { event_id: fileId };
|
|
787
|
+
}
|
|
788
|
+
catch (error) {
|
|
789
|
+
const rejected = error instanceof MatrixError &&
|
|
790
|
+
error.httpStatus !== undefined && error.httpStatus >= 400 && error.httpStatus < 500 && error.httpStatus !== 408;
|
|
791
|
+
if (!mediaId) {
|
|
792
|
+
if (rejected)
|
|
793
|
+
throw error;
|
|
794
|
+
throw new MutationOutcomeUnknownError("file upload", { cause: error });
|
|
795
|
+
}
|
|
796
|
+
let cause = error;
|
|
797
|
+
if (rejected) {
|
|
798
|
+
try {
|
|
799
|
+
await this.cleanupFileUpload(treeId, mediaId, fileId);
|
|
800
|
+
}
|
|
801
|
+
catch (cleanupError) {
|
|
802
|
+
cause = new AggregateError([error, cleanupError], "file upload failed and cleanup is incomplete");
|
|
803
|
+
}
|
|
804
|
+
if (cause === error) {
|
|
805
|
+
throw new StorageError("file upload was rejected; uploaded media and attachment were removed", { cause: error });
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
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 });
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* Discards a partial upload using its MutationPartialError.completedIds:
|
|
813
|
+
* [mediaId, fileId?]. Explicit cleanup also covers an unacknowledged listing.
|
|
814
|
+
* Keep the same IDs after failure and retry this operation, not the upload.
|
|
815
|
+
*/
|
|
816
|
+
async cleanupFileUpload(treeId, mediaId, fileId, options) {
|
|
817
|
+
validateMatrixRoomId(treeId);
|
|
818
|
+
if (!/^mxc:\/\/[^/]+\/[^/?#]+$/u.test(mediaId))
|
|
819
|
+
throw new StorageError("invalid media identifier");
|
|
820
|
+
if (fileId !== undefined)
|
|
821
|
+
validateMatrixEventId(fileId, "file upload event ID");
|
|
822
|
+
const controller = new AbortController();
|
|
823
|
+
const timeout = setTimeout(() => controller.abort(), matrixRequestTimeout(options));
|
|
824
|
+
const abort = () => controller.abort(options?.signal?.reason);
|
|
825
|
+
if (options?.signal?.aborted)
|
|
826
|
+
abort();
|
|
827
|
+
else
|
|
828
|
+
options?.signal?.addEventListener("abort", abort, { once: true });
|
|
829
|
+
try {
|
|
830
|
+
await raceWithAbort((async () => {
|
|
831
|
+
if (controller.signal.aborted)
|
|
832
|
+
throw new StorageError("operation cancelled");
|
|
833
|
+
let attachment;
|
|
834
|
+
let listing = null;
|
|
835
|
+
if (fileId) {
|
|
836
|
+
const raw = await this.client.fetchRoomEvent(treeId, fileId);
|
|
837
|
+
attachment = this.client.getEventMapper()(raw);
|
|
838
|
+
await this.client.decryptEventIfNeeded(attachment);
|
|
839
|
+
const content = attachment.getContent();
|
|
840
|
+
if (attachment.getId() !== fileId || attachment.getRoomId() !== treeId ||
|
|
841
|
+
attachment.getSender() !== this.client.getUserId() ||
|
|
842
|
+
(!attachment.isRedacted() && (attachment.getWireType() !== EventType.RoomMessageEncrypted ||
|
|
843
|
+
attachment.getType() !== EventType.RoomMessage || content.msgtype !== MsgType.File ||
|
|
844
|
+
!isRecord(content.file) || content.file.url !== mediaId))) {
|
|
845
|
+
throw new StorageError("partial upload attachment does not match the current user and media");
|
|
846
|
+
}
|
|
847
|
+
await this.refreshRoomState(treeId, { signal: controller.signal });
|
|
848
|
+
listing = this.getStateEvent(treeId, UNSTABLE_MSC3089_BRANCH.name, fileId);
|
|
849
|
+
if (listing && !listing.isRedacted() &&
|
|
850
|
+
(listing.getSender() !== this.client.getUserId() || listing.getContent().metadata_event_id !== fileId)) {
|
|
851
|
+
throw new StorageError("file changed after upload; use ordinary file deletion");
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
if (controller.signal.aborted)
|
|
855
|
+
throw new StorageError("operation cancelled");
|
|
856
|
+
await deleteOwnedMedia(this.client, [mediaId], controller.signal);
|
|
857
|
+
if (listing && !listing.isRedacted()) {
|
|
858
|
+
await withMatrixMutationAbort(() => this.client.redactEvent(treeId, listing.getId()), controller.signal);
|
|
859
|
+
}
|
|
860
|
+
if (attachment && !attachment.isRedacted()) {
|
|
861
|
+
await withMatrixMutationAbort(() => this.client.redactEvent(treeId, fileId), controller.signal);
|
|
862
|
+
}
|
|
863
|
+
if (fileId)
|
|
864
|
+
this.fileNames.delete(this.fileNameKey(treeId, fileId));
|
|
865
|
+
})(), controller.signal, () => new MutationOutcomeUnknownError("file upload cleanup"));
|
|
866
|
+
}
|
|
867
|
+
finally {
|
|
868
|
+
clearTimeout(timeout);
|
|
869
|
+
options?.signal?.removeEventListener("abort", abort);
|
|
870
|
+
}
|
|
688
871
|
}
|
|
689
872
|
/**
|
|
690
873
|
* Refreshes a room's current state from the homeserver. The background sync
|
|
@@ -703,6 +886,11 @@ export class TeleCryptIOStorage {
|
|
|
703
886
|
abortSignal: matrixRequestSignal(options),
|
|
704
887
|
});
|
|
705
888
|
const parsedStateEvents = parseMatrixStateResponse(stateEvents);
|
|
889
|
+
if (await this.repairOwnedTreePermissions(roomId, parsedStateEvents, options)) {
|
|
890
|
+
// Read the accepted native state back instead of fabricating a local
|
|
891
|
+
// permission snapshot. An interrupted repair retries on the next load.
|
|
892
|
+
return this.refreshRoomState(roomId, options);
|
|
893
|
+
}
|
|
706
894
|
if (typeof room.currentState.setStateEvents === "function") {
|
|
707
895
|
const refreshed = parsedStateEvents.map((event) => new MatrixEvent(event));
|
|
708
896
|
// RoomState.setStateEvents overwrites matching tuples, but deliberately
|
|
@@ -730,6 +918,49 @@ export class TeleCryptIOStorage {
|
|
|
730
918
|
room.currentState.setStateEvents(refreshed);
|
|
731
919
|
}
|
|
732
920
|
}
|
|
921
|
+
async repairOwnedTreePermissions(roomId, state, options) {
|
|
922
|
+
if (isTreeDeleted(this.client, roomId))
|
|
923
|
+
return false;
|
|
924
|
+
const event = (type, key = "") => state.find((item) => item.type === type && item.state_key === key);
|
|
925
|
+
const create = event(EventType.RoomCreate);
|
|
926
|
+
const purpose = event(UNSTABLE_MSC3088_PURPOSE.name, UNSTABLE_MSC3089_TREE_SUBTYPE.name);
|
|
927
|
+
const encryption = event(EventType.RoomEncryption);
|
|
928
|
+
if (!isStorageTreeState(create?.content, purpose?.content, encryption?.content))
|
|
929
|
+
return false;
|
|
930
|
+
const userId = this.client.getUserId();
|
|
931
|
+
if (!userId || create?.sender !== userId ||
|
|
932
|
+
event(EventType.RoomMember, userId)?.content?.membership !== "join") {
|
|
933
|
+
return false;
|
|
934
|
+
}
|
|
935
|
+
const rawPower = event(EventType.RoomPowerLevels)?.content;
|
|
936
|
+
const power = parseMatrixPowerLevels(rawPower);
|
|
937
|
+
const ownerLevel = power.events?.[EventType.RoomPowerLevels] ?? 100;
|
|
938
|
+
if ((power.users?.[userId] ?? power.users_default ?? 0) < ownerLevel)
|
|
939
|
+
return false;
|
|
940
|
+
const events = { ...power.events };
|
|
941
|
+
let changed = (power.state_default ?? 50) < 100;
|
|
942
|
+
for (const type of [EventType.RoomJoinRules, UNSTABLE_MSC3088_PURPOSE.name]) {
|
|
943
|
+
if (events[type] !== undefined && events[type] < 100) {
|
|
944
|
+
events[type] = 100;
|
|
945
|
+
changed = true;
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
if (changed) {
|
|
949
|
+
await withMatrixMutationAbort(() => this.client.sendStateEvent(roomId, EventType.RoomPowerLevels, {
|
|
950
|
+
...rawPower,
|
|
951
|
+
state_default: Math.max(power.state_default ?? 50, 100),
|
|
952
|
+
events,
|
|
953
|
+
}, ""), matrixRequestSignal(options));
|
|
954
|
+
}
|
|
955
|
+
const joinRules = event(EventType.RoomJoinRules)?.content;
|
|
956
|
+
if (joinRules?.join_rule !== "invite") {
|
|
957
|
+
await withMatrixMutationAbort(() => this.client.sendStateEvent(roomId, EventType.RoomJoinRules, {
|
|
958
|
+
join_rule: JoinRule.Invite,
|
|
959
|
+
}, ""), matrixRequestSignal(options));
|
|
960
|
+
changed = true;
|
|
961
|
+
}
|
|
962
|
+
return changed;
|
|
963
|
+
}
|
|
733
964
|
/**
|
|
734
965
|
* Recommended entry point: builds the MatrixClient with a persistent crypto
|
|
735
966
|
* store and the secret-storage callback wiring `keys.*` needs, starts the
|
|
@@ -1307,7 +1538,7 @@ export class TeleCryptIOStorage {
|
|
|
1307
1538
|
kick: 100,
|
|
1308
1539
|
ban: 100,
|
|
1309
1540
|
redact: 100,
|
|
1310
|
-
state_default:
|
|
1541
|
+
state_default: 100,
|
|
1311
1542
|
events_default: 0,
|
|
1312
1543
|
users_default: 0,
|
|
1313
1544
|
events: {
|
|
@@ -1336,7 +1567,7 @@ export class TeleCryptIOStorage {
|
|
|
1336
1567
|
{
|
|
1337
1568
|
type: EventType.RoomEncryption,
|
|
1338
1569
|
state_key: "",
|
|
1339
|
-
content: { algorithm:
|
|
1570
|
+
content: { algorithm: STORAGE_ROOM_ENCRYPTION_ALGORITHM },
|
|
1340
1571
|
},
|
|
1341
1572
|
],
|
|
1342
1573
|
};
|
|
@@ -1349,8 +1580,9 @@ export class TeleCryptIOStorage {
|
|
|
1349
1580
|
throw new StorageError("operation cancelled");
|
|
1350
1581
|
try {
|
|
1351
1582
|
const tree = this.client.unstableGetFileTreeSpace(roomId);
|
|
1352
|
-
if (tree)
|
|
1583
|
+
if (tree && isStorageTreeRoom(this.client.getRoom(roomId))) {
|
|
1353
1584
|
return this.decorateTreeSpace(tree);
|
|
1585
|
+
}
|
|
1354
1586
|
}
|
|
1355
1587
|
catch (error) {
|
|
1356
1588
|
lastError = error;
|
|
@@ -1390,13 +1622,29 @@ export class TeleCryptIOStorage {
|
|
|
1390
1622
|
return tree;
|
|
1391
1623
|
this.decoratedTreeSpaces.add(tree);
|
|
1392
1624
|
const originalGetDirectories = tree.getDirectories.bind(tree);
|
|
1625
|
+
const originalGetDirectory = typeof tree.getDirectory === "function"
|
|
1626
|
+
? tree.getDirectory.bind(tree)
|
|
1627
|
+
: undefined;
|
|
1393
1628
|
const originalListFiles = typeof tree.listFiles === "function"
|
|
1394
1629
|
? tree.listFiles.bind(tree)
|
|
1395
1630
|
: () => [];
|
|
1396
1631
|
const originalGetFile = typeof tree.getFile === "function"
|
|
1397
1632
|
? tree.getFile.bind(tree)
|
|
1398
1633
|
: () => null;
|
|
1399
|
-
tree.getDirectories = () => originalGetDirectories()
|
|
1634
|
+
tree.getDirectories = () => originalGetDirectories()
|
|
1635
|
+
.filter((child) => !isTreeDeleted(this.client, child.id) &&
|
|
1636
|
+
isStorageTreeRoom(this.client.getRoom(child.id)))
|
|
1637
|
+
.map((child) => this.decorateTreeSpace(child));
|
|
1638
|
+
if (originalGetDirectory) {
|
|
1639
|
+
tree.getDirectory = (roomId) => {
|
|
1640
|
+
const child = originalGetDirectory(roomId);
|
|
1641
|
+
return child &&
|
|
1642
|
+
!isTreeDeleted(this.client, child.id) &&
|
|
1643
|
+
isStorageTreeRoom(this.client.getRoom(child.id))
|
|
1644
|
+
? this.decorateTreeSpace(child)
|
|
1645
|
+
: undefined;
|
|
1646
|
+
};
|
|
1647
|
+
}
|
|
1400
1648
|
tree.createDirectory = (name) => this.createSubtree(tree, name);
|
|
1401
1649
|
tree.setName = (name) => this.writeTreeName(tree.id, name);
|
|
1402
1650
|
tree.listFiles = () => originalListFiles().map((branch) => this.decorateFileBranch(tree.id, branch));
|
|
@@ -1404,7 +1652,7 @@ export class TeleCryptIOStorage {
|
|
|
1404
1652
|
const branch = originalGetFile(fileId);
|
|
1405
1653
|
return branch ? this.decorateFileBranch(tree.id, branch) : null;
|
|
1406
1654
|
};
|
|
1407
|
-
tree.createFile = (name, encryptedContents, info, additionalContent) => this.createEncryptedFile(tree.id, name, encryptedContents, info, additionalContent);
|
|
1655
|
+
tree.createFile = (name, encryptedContents, info, additionalContent, signal) => this.createEncryptedFile(tree.id, name, encryptedContents, info, additionalContent, signal);
|
|
1408
1656
|
return tree;
|
|
1409
1657
|
}
|
|
1410
1658
|
decorateFileBranch(treeId, branch) {
|
|
@@ -1559,9 +1807,17 @@ export class TeleCryptIOStorage {
|
|
|
1559
1807
|
throw new StorageError("operation cancelled");
|
|
1560
1808
|
if (isTreeDeleted(this.client, room.roomId))
|
|
1561
1809
|
continue;
|
|
1562
|
-
|
|
1563
|
-
if (tree)
|
|
1564
|
-
|
|
1810
|
+
let tree = this.getTree(room.roomId);
|
|
1811
|
+
if (tree) {
|
|
1812
|
+
// Existing owner sessions repair the persisted permissions on load;
|
|
1813
|
+
// readers never attempt this owner-only mutation.
|
|
1814
|
+
if (this.getStateEvent(room.roomId, EventType.RoomCreate, "")?.getSender() === this.client.getUserId()) {
|
|
1815
|
+
await this.refreshRoomState(room.roomId, { signal });
|
|
1816
|
+
tree = this.getTree(room.roomId);
|
|
1817
|
+
}
|
|
1818
|
+
if (tree)
|
|
1819
|
+
trees.push(tree);
|
|
1820
|
+
}
|
|
1565
1821
|
}
|
|
1566
1822
|
return trees;
|
|
1567
1823
|
}
|
|
@@ -1573,6 +1829,9 @@ export class TeleCryptIOStorage {
|
|
|
1573
1829
|
const currentParent = this.getTree(parent.id);
|
|
1574
1830
|
if (!currentParent)
|
|
1575
1831
|
throw new Error("createSubtree: parent is unavailable");
|
|
1832
|
+
const readers = (await this.listMembers(currentParent, { signal: effectiveSignal }))
|
|
1833
|
+
.filter((member) => member.role === "viewer" &&
|
|
1834
|
+
(member.membership === "join" || member.membership === "invite"));
|
|
1576
1835
|
const tree = await this.createTreeSpace(name, "createSubtree", effectiveSignal);
|
|
1577
1836
|
let parentLinkAttempted = false;
|
|
1578
1837
|
let parentLinkEventId;
|
|
@@ -1599,7 +1858,6 @@ export class TeleCryptIOStorage {
|
|
|
1599
1858
|
if (this.client.getRoom(tree.id)) {
|
|
1600
1859
|
await this.refreshRoomState(tree.id, { signal: effectiveSignal });
|
|
1601
1860
|
}
|
|
1602
|
-
return tree;
|
|
1603
1861
|
}
|
|
1604
1862
|
catch (error) {
|
|
1605
1863
|
// Compensation is independent of the caller's cancelled deadline.
|
|
@@ -1691,11 +1949,29 @@ export class TeleCryptIOStorage {
|
|
|
1691
1949
|
}
|
|
1692
1950
|
throw error;
|
|
1693
1951
|
}
|
|
1952
|
+
// The folder and both links now exist. A failed invitation must leave
|
|
1953
|
+
// that known folder available for reconciliation instead of deleting a
|
|
1954
|
+
// room to which another reader may already have received access.
|
|
1955
|
+
try {
|
|
1956
|
+
for (const reader of readers) {
|
|
1957
|
+
if (effectiveSignal?.aborted)
|
|
1958
|
+
throw new StorageError("operation cancelled");
|
|
1959
|
+
const verifyHistorySharing = await prepareReaderHistorySharing(this.client, reader.userId);
|
|
1960
|
+
await withMatrixMutationAbort(() => tree.invite(reader.userId), effectiveSignal);
|
|
1961
|
+
await verifyHistorySharing();
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
catch (error) {
|
|
1965
|
+
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 });
|
|
1966
|
+
}
|
|
1967
|
+
return tree;
|
|
1694
1968
|
}, signal);
|
|
1695
1969
|
}
|
|
1696
1970
|
getTree(roomId) {
|
|
1697
1971
|
if (isTreeDeleted(this.client, roomId))
|
|
1698
1972
|
return null;
|
|
1973
|
+
if (!isStorageTreeRoom(this.client.getRoom(roomId)))
|
|
1974
|
+
return null;
|
|
1699
1975
|
const tree = this.client.unstableGetFileTreeSpace(roomId);
|
|
1700
1976
|
return tree ? this.decorateTreeSpace(tree) : null;
|
|
1701
1977
|
}
|
|
@@ -1779,19 +2055,19 @@ export class TeleCryptIOStorage {
|
|
|
1779
2055
|
throw new StorageError("operation cancelled");
|
|
1780
2056
|
let response;
|
|
1781
2057
|
try {
|
|
1782
|
-
|
|
2058
|
+
// Let the in-flight request settle so the uploader can retain returned
|
|
2059
|
+
// media/event IDs on cancellation, then stop before the next write.
|
|
2060
|
+
response = await tree.createFile(name, new Uint8Array(encrypted.data), encrypted.info, { info: { mimetype, size: data.byteLength } }, signal);
|
|
1783
2061
|
}
|
|
1784
2062
|
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)
|
|
2063
|
+
// Preserve acknowledged upload stages after cancellation as well.
|
|
2064
|
+
if (error instanceof MutationOutcomeUnknownError || error instanceof MutationPartialError)
|
|
1790
2065
|
throw error;
|
|
2066
|
+
if (error instanceof StorageError || error instanceof MatrixError)
|
|
2067
|
+
throw error;
|
|
2068
|
+
// An unclassified transport failure can still follow a committed event.
|
|
1791
2069
|
throw new MutationOutcomeUnknownError("file upload", { cause: error });
|
|
1792
2070
|
}
|
|
1793
|
-
if (signal?.aborted)
|
|
1794
|
-
throw new StorageError("operation cancelled");
|
|
1795
2071
|
try {
|
|
1796
2072
|
return validateMatrixEventId(response?.event_id, "file upload response event ID");
|
|
1797
2073
|
}
|
|
@@ -1832,7 +2108,7 @@ export class TeleCryptIOStorage {
|
|
|
1832
2108
|
const mimetype = eventMetadata.mimetype ?? "application/octet-stream";
|
|
1833
2109
|
const mxcUrl = info.url;
|
|
1834
2110
|
const clientAny = this.client;
|
|
1835
|
-
|
|
2111
|
+
let accessToken = clientAny.getAccessToken();
|
|
1836
2112
|
validateMatrixToken(accessToken);
|
|
1837
2113
|
let trustedOrigin;
|
|
1838
2114
|
try {
|
|
@@ -1866,6 +2142,7 @@ export class TeleCryptIOStorage {
|
|
|
1866
2142
|
else
|
|
1867
2143
|
signal?.addEventListener("abort", abortExternal, { once: true });
|
|
1868
2144
|
let ciphertext;
|
|
2145
|
+
let retriedAuthentication = false;
|
|
1869
2146
|
try {
|
|
1870
2147
|
for (;;) {
|
|
1871
2148
|
const res = await raceWithAbort(fetch(currentUrl, {
|
|
@@ -1908,13 +2185,22 @@ export class TeleCryptIOStorage {
|
|
|
1908
2185
|
if (!res.ok) {
|
|
1909
2186
|
const statusFailure = new Error(`media download failed: ${res.status}`);
|
|
1910
2187
|
let detail = "";
|
|
2188
|
+
let unknownToken = false;
|
|
1911
2189
|
try {
|
|
1912
2190
|
// Error responses are diagnostics, not product media. Consume the
|
|
1913
2191
|
// complete body so no provider detail is discarded, then apply
|
|
1914
2192
|
// the SDK's shared secret/control-character sanitizer before it
|
|
1915
2193
|
// crosses the SDK boundary.
|
|
1916
2194
|
const body = await readResponseBody(res, controller.signal);
|
|
1917
|
-
|
|
2195
|
+
const responseText = new TextDecoder().decode(body.bytes);
|
|
2196
|
+
detail = sanitizeDiagnosticText(responseText);
|
|
2197
|
+
if (res.status === 401) {
|
|
2198
|
+
try {
|
|
2199
|
+
const errorBody = JSON.parse(responseText);
|
|
2200
|
+
unknownToken = isRecord(errorBody) && errorBody.errcode === "M_UNKNOWN_TOKEN";
|
|
2201
|
+
}
|
|
2202
|
+
catch { /* Non-Matrix errors retain the complete diagnostic below. */ }
|
|
2203
|
+
}
|
|
1918
2204
|
}
|
|
1919
2205
|
catch (bodyError) {
|
|
1920
2206
|
const partial = bodyError instanceof ResponseBodyReadError
|
|
@@ -1925,6 +2211,20 @@ export class TeleCryptIOStorage {
|
|
|
1925
2211
|
: bodyError;
|
|
1926
2212
|
throw new AggregateError([statusFailure, readFailure], "media download failed while reading the response body", { cause: statusFailure });
|
|
1927
2213
|
}
|
|
2214
|
+
if (unknownToken && !retriedAuthentication) {
|
|
2215
|
+
retriedAuthentication = true;
|
|
2216
|
+
// Matrix's authenticated transport owns token refresh, including
|
|
2217
|
+
// concurrent refreshes and persistence. Its media request path
|
|
2218
|
+
// buffers the whole body and follows redirects, so use a small
|
|
2219
|
+
// authenticated read to renew the session, then retry our bounded
|
|
2220
|
+
// streaming download once with the current token.
|
|
2221
|
+
await raceWithAbort(this.client.whoami(), controller.signal, () => signal?.aborted
|
|
2222
|
+
? new StorageError("operation cancelled")
|
|
2223
|
+
: new Error("media download timed out"));
|
|
2224
|
+
accessToken = clientAny.getAccessToken();
|
|
2225
|
+
validateMatrixToken(accessToken);
|
|
2226
|
+
continue;
|
|
2227
|
+
}
|
|
1928
2228
|
throw new Error(`${statusFailure.message}${detail === "" ? "" : `: ${detail}`}`);
|
|
1929
2229
|
}
|
|
1930
2230
|
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. */
|
package/dist/core/oidc.d.ts
CHANGED
|
@@ -30,9 +30,9 @@ export interface OidcRegistrationClientMetadata {
|
|
|
30
30
|
}
|
|
31
31
|
/** The issuer-bound endpoints accepted by the token refresh adapter. */
|
|
32
32
|
export type OidcTokenEndpointMetadata = Pick<OidcClientConfig, "issuer" | "token_endpoint" | "revocation_endpoint">;
|
|
33
|
-
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
33
|
+
/** Redact recognized secret fields and identifiers in provider diagnostics.
|
|
34
|
+
* Structured JSON uses decoded property names at every depth; the text fallback
|
|
35
|
+
* is limited to patterns recognized in unstructured input. */
|
|
36
36
|
export declare function sanitizeDiagnosticText(value: string): string;
|
|
37
37
|
/** Validates an endpoint against the configured homeserver and optional issuer path. */
|
|
38
38
|
export declare function assertOidcEndpoint(value: unknown, trustedHomeserver: string, name: string, issuer?: URL, allowQuery?: boolean): string;
|