@telecrypt-io/storage 0.8.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 +57 -3
- package/dist/TeleCryptIOStorage.d.ts +32 -3
- package/dist/TeleCryptIOStorage.js +387 -70
- 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 +178 -79
- package/dist/core/types.d.ts +1 -0
- package/dist/key-safe.d.ts +62 -0
- package/dist/key-safe.js +349 -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 +2 -2
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
|
}
|
|
@@ -303,7 +304,8 @@ function isActiveRelationEvent(event) {
|
|
|
303
304
|
!Array.isArray(content) &&
|
|
304
305
|
Object.keys(content).length > 0);
|
|
305
306
|
}
|
|
306
|
-
function activeTreeDirectories(
|
|
307
|
+
async function activeTreeDirectories(storage, tree, signal) {
|
|
308
|
+
const client = storage.getClient();
|
|
307
309
|
const childEvents = readRelationEvents(client, tree.id, EventType.SpaceChild);
|
|
308
310
|
if (childEvents === null)
|
|
309
311
|
throw new StorageError("storage folder state is unavailable");
|
|
@@ -316,13 +318,36 @@ function activeTreeDirectories(client, tree) {
|
|
|
316
318
|
throw new StorageError("storage folder state is inconsistent");
|
|
317
319
|
activeChildIds.add(childId);
|
|
318
320
|
}
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
321
|
+
const directories = await Promise.all([...activeChildIds].map(async (childId) => {
|
|
322
|
+
if (isMarkedTreeDeleted(storage, childId))
|
|
323
|
+
return null;
|
|
324
|
+
try {
|
|
325
|
+
// The parent room's relationship state can arrive before Matrix SDK's
|
|
326
|
+
// MSC3089 TreeSpace projection includes that child. Resolve the exact
|
|
327
|
+
// room named by the active relation instead of treating that brief
|
|
328
|
+
// projection lag as corrupt folder state.
|
|
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 });
|
|
340
|
+
}
|
|
341
|
+
catch (error) {
|
|
342
|
+
if (signal?.aborted)
|
|
343
|
+
throw new StorageError("operation cancelled");
|
|
344
|
+
if (error instanceof ConditionTimeoutError) {
|
|
345
|
+
throw new StorageError("storage folder state is inconsistent");
|
|
346
|
+
}
|
|
347
|
+
throw new StorageError("storage folder state lookup failed", { cause: error });
|
|
348
|
+
}
|
|
349
|
+
}));
|
|
350
|
+
return directories.filter((directory) => directory !== null);
|
|
326
351
|
}
|
|
327
352
|
async function unlinkExternalParents(storage, rootId, externalParents, signal) {
|
|
328
353
|
const client = storage.getClient();
|
|
@@ -479,9 +504,6 @@ async function deleteRoomDeterministically(storage, roomId, signal) {
|
|
|
479
504
|
function removeRoomFromLocalStore(client, roomId) {
|
|
480
505
|
client.store?.removeRoom(roomId);
|
|
481
506
|
}
|
|
482
|
-
function isActiveMembership(membership) {
|
|
483
|
-
return membership === "join" || membership === "invite";
|
|
484
|
-
}
|
|
485
507
|
function isRevocableMembership(membership) {
|
|
486
508
|
return membership === "join" || membership === "invite" || membership === "knock";
|
|
487
509
|
}
|
|
@@ -497,6 +519,7 @@ function toArrayBuffer(bytes) {
|
|
|
497
519
|
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
498
520
|
}
|
|
499
521
|
export async function createVault(storage, name, options) {
|
|
522
|
+
await storage.keySafe.requireReady({ signal: options?.signal });
|
|
500
523
|
return withOperationDeadline(options, async (signal) => {
|
|
501
524
|
let tree;
|
|
502
525
|
try {
|
|
@@ -516,6 +539,7 @@ export async function createVault(storage, name, options) {
|
|
|
516
539
|
}
|
|
517
540
|
/** Top-level vaults only — excludes subdirectories of an existing tree. */
|
|
518
541
|
export async function listVaults(storage, options) {
|
|
542
|
+
await storage.keySafe.requireReady({ signal: options?.signal });
|
|
519
543
|
return withOperationDeadline(options, async (signal) => {
|
|
520
544
|
const trees = await storage.listTrees(signal);
|
|
521
545
|
const topLevel = trees.filter((tree) => tree.isTopLevel);
|
|
@@ -533,44 +557,98 @@ export function getMyVaultRole(storage, vaultId) {
|
|
|
533
557
|
return null;
|
|
534
558
|
return tree.getPermissions(userId);
|
|
535
559
|
}
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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.
|
|
539
588
|
try {
|
|
540
|
-
|
|
589
|
+
await waitForCondition(() => client.getRoom(vaultId)?.getMember(userId)?.membership === "invite" ? true : null, { timeoutMs: 15000, signal });
|
|
541
590
|
}
|
|
542
591
|
catch (err) {
|
|
543
592
|
if (signal.aborted)
|
|
544
593
|
throw new StorageError("operation cancelled");
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
// inspect the room yet; it is not evidence that joining is forbidden.
|
|
548
|
-
// Attempt the idempotent join and let its own response decide access.
|
|
549
|
-
if (err instanceof MatrixError && err.errcode === "M_FORBIDDEN") {
|
|
550
|
-
membership = null;
|
|
551
|
-
}
|
|
552
|
-
else {
|
|
553
|
-
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 });
|
|
554
596
|
}
|
|
597
|
+
throw err;
|
|
555
598
|
}
|
|
556
|
-
|
|
557
|
-
|
|
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;
|
|
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];
|
|
558
621
|
try {
|
|
559
|
-
|
|
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
|
+
}
|
|
560
645
|
}
|
|
561
|
-
catch (
|
|
562
|
-
if (
|
|
563
|
-
throw
|
|
564
|
-
if (err instanceof MatrixError && err.errcode === "M_FORBIDDEN") {
|
|
565
|
-
const afterForbidden = await storage.getRoomMembership(vaultId, undefined, { signal });
|
|
566
|
-
if (afterForbidden === "join")
|
|
567
|
-
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 });
|
|
568
649
|
}
|
|
569
|
-
|
|
570
|
-
throw new StorageError("operation cancelled");
|
|
571
|
-
throw new StorageError("join failed", { cause: err });
|
|
650
|
+
throw error;
|
|
572
651
|
}
|
|
573
|
-
ensureOperationActive(signal);
|
|
574
652
|
return { vaultId, joined: true };
|
|
575
653
|
}, "mutation");
|
|
576
654
|
}
|
|
@@ -592,6 +670,7 @@ function isReviewedStorageTreeRoom(room) {
|
|
|
592
670
|
}
|
|
593
671
|
/** Rooms where this account is invited and the room looks like a file tree. */
|
|
594
672
|
export async function listPendingInvites(storage, options) {
|
|
673
|
+
await storage.keySafe.requireReady({ signal: options?.signal });
|
|
595
674
|
return withOperationDeadline(options, async (signal) => {
|
|
596
675
|
const client = storage.getClient();
|
|
597
676
|
const rooms = client.getRooms();
|
|
@@ -628,6 +707,7 @@ export async function listPendingInvites(storage, options) {
|
|
|
628
707
|
}
|
|
629
708
|
/** Decline a vault invite (same as leaving before join). */
|
|
630
709
|
export async function declineInvite(storage, vaultId, options) {
|
|
710
|
+
await storage.keySafe.requireReady({ signal: options?.signal });
|
|
631
711
|
return withOperationDeadline(options, async (signal) => {
|
|
632
712
|
const client = storage.getClient();
|
|
633
713
|
try {
|
|
@@ -721,6 +801,7 @@ export async function declineInvite(storage, vaultId, options) {
|
|
|
721
801
|
}
|
|
722
802
|
/** Invites `userId` to the vault as a reader. */
|
|
723
803
|
export async function shareVault(storage, vaultId, userId, role, options) {
|
|
804
|
+
await storage.keySafe.requireReady({ signal: options?.signal });
|
|
724
805
|
if (role !== "viewer") {
|
|
725
806
|
throw new StorageError("storage sharing supports viewers only");
|
|
726
807
|
}
|
|
@@ -743,6 +824,8 @@ export async function shareVault(storage, vaultId, userId, role, options) {
|
|
|
743
824
|
if (currentMembers.some((member) => member.userId === userId && member.role === "owner")) {
|
|
744
825
|
throw new StorageError("share will not demote an existing owner");
|
|
745
826
|
}
|
|
827
|
+
const client = storage.getClient();
|
|
828
|
+
const verifyHistorySharing = await prepareReaderHistorySharing(client, userId);
|
|
746
829
|
for (const [index, space] of spaces.entries()) {
|
|
747
830
|
ensureOperationActive(operation.signal);
|
|
748
831
|
const members = index === 0
|
|
@@ -756,28 +839,18 @@ export async function shareVault(storage, vaultId, userId, role, options) {
|
|
|
756
839
|
: await storage.getRoomMembership(space.id, userId, {
|
|
757
840
|
signal: operation.signal,
|
|
758
841
|
});
|
|
759
|
-
if (
|
|
842
|
+
if (currentMembership === "join")
|
|
760
843
|
continue;
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
// Synapse may race another invite/join and answer M_FORBIDDEN. Only
|
|
769
|
-
// suppress that typed condition after re-reading authoritative
|
|
770
|
-
// membership; never infer it from provider-controlled error text.
|
|
771
|
-
const membership = await storage.getRoomMembership(space.id, userId, {
|
|
772
|
-
signal: operation.signal,
|
|
773
|
-
});
|
|
774
|
-
if (!isActiveMembership(membership ?? ""))
|
|
775
|
-
throw new StorageError("share failed");
|
|
776
|
-
}
|
|
844
|
+
// Native invite shares the encrypted history before inviting. Retry
|
|
845
|
+
// pending invitations too: an earlier attempt may have sent only an
|
|
846
|
+
// invitation. In particular, never swallow a media-upload 403 merely
|
|
847
|
+
// because an invitation already exists.
|
|
848
|
+
await withRateLimitRetry(() => withMatrixMutationAbort(() => space.invite(userId, false), operation.signal), operation.signal);
|
|
849
|
+
completedRoomIds.add(space.id);
|
|
850
|
+
await verifyHistorySharing();
|
|
777
851
|
}
|
|
778
852
|
// Storage rooms have one immutable owner power level and readers use
|
|
779
|
-
// the room's default user level.
|
|
780
|
-
// operation; changing per-user power levels would create editors and
|
|
853
|
+
// the room's default user level. Changing per-user power levels would create editors and
|
|
781
854
|
// let metadata authorship drift away from the owner.
|
|
782
855
|
}
|
|
783
856
|
catch (error) {
|
|
@@ -790,6 +863,7 @@ export async function shareVault(storage, vaultId, userId, role, options) {
|
|
|
790
863
|
(error.message === "share failed" ||
|
|
791
864
|
error.message.includes("owner") ||
|
|
792
865
|
error.message.includes("current user") ||
|
|
866
|
+
error.message.includes("Decryption Key Safe") ||
|
|
793
867
|
error.message === "operation cancelled")) {
|
|
794
868
|
throw error;
|
|
795
869
|
}
|
|
@@ -804,6 +878,7 @@ export async function shareVault(storage, vaultId, userId, role, options) {
|
|
|
804
878
|
}
|
|
805
879
|
}
|
|
806
880
|
export async function unshareVault(storage, vaultId, userId, options) {
|
|
881
|
+
await storage.keySafe.requireReady({ signal: options?.signal });
|
|
807
882
|
const operation = createOperationDeadline(options);
|
|
808
883
|
const completedRoomIds = new Set();
|
|
809
884
|
try {
|
|
@@ -878,6 +953,7 @@ export async function unshareVault(storage, vaultId, userId, options) {
|
|
|
878
953
|
}
|
|
879
954
|
}
|
|
880
955
|
export async function listMembers(storage, vaultId, options) {
|
|
956
|
+
await storage.keySafe.requireReady({ signal: options?.signal });
|
|
881
957
|
return withOperationDeadline(options, async (signal) => {
|
|
882
958
|
const tree = await resolveTree(storage, vaultId, signal);
|
|
883
959
|
try {
|
|
@@ -891,6 +967,7 @@ export async function listMembers(storage, vaultId, options) {
|
|
|
891
967
|
});
|
|
892
968
|
}
|
|
893
969
|
export async function listFiles(storage, treeId, options) {
|
|
970
|
+
await storage.keySafe.requireReady({ signal: options?.signal });
|
|
894
971
|
return withOperationDeadline(options, async (signal) => {
|
|
895
972
|
const tree = await resolveTree(storage, treeId, signal);
|
|
896
973
|
try {
|
|
@@ -913,11 +990,12 @@ export async function listFiles(storage, treeId, options) {
|
|
|
913
990
|
}
|
|
914
991
|
/** Lists the direct child folders of a vault or folder. */
|
|
915
992
|
export async function listSubfolders(storage, parentId, options) {
|
|
993
|
+
await storage.keySafe.requireReady({ signal: options?.signal });
|
|
916
994
|
return withOperationDeadline(options, async (signal) => {
|
|
917
995
|
const tree = await resolveTree(storage, parentId, signal);
|
|
918
996
|
await storage.refreshRoomState(parentId, { signal });
|
|
919
997
|
ensureOperationActive(signal);
|
|
920
|
-
const directories = activeTreeDirectories(storage
|
|
998
|
+
const directories = (await activeTreeDirectories(storage, tree, signal))
|
|
921
999
|
.filter((directory) => !isMarkedTreeDeleted(storage, directory.id));
|
|
922
1000
|
return Promise.all(directories.map(async (directory) => ({
|
|
923
1001
|
id: directory.id,
|
|
@@ -926,6 +1004,7 @@ export async function listSubfolders(storage, parentId, options) {
|
|
|
926
1004
|
});
|
|
927
1005
|
}
|
|
928
1006
|
export async function createSubfolder(storage, parentId, name, options) {
|
|
1007
|
+
await storage.keySafe.requireReady({ signal: options?.signal });
|
|
929
1008
|
validateName(name, "name");
|
|
930
1009
|
return withOperationDeadline(options, async (signal) => {
|
|
931
1010
|
const tree = await resolveTree(storage, parentId, signal);
|
|
@@ -971,10 +1050,12 @@ async function renameTree(storage, treeId, name, options) {
|
|
|
971
1050
|
}
|
|
972
1051
|
/** Renames a top-level Vault. */
|
|
973
1052
|
export async function renameVault(storage, vaultId, name, options) {
|
|
1053
|
+
await storage.keySafe.requireReady({ signal: options?.signal });
|
|
974
1054
|
return renameTree(storage, vaultId, name, options);
|
|
975
1055
|
}
|
|
976
1056
|
/** Renames a nested folder. */
|
|
977
1057
|
export async function renameFolder(storage, folderId, name, options) {
|
|
1058
|
+
await storage.keySafe.requireReady({ signal: options?.signal });
|
|
978
1059
|
return renameTree(storage, folderId, name, options);
|
|
979
1060
|
}
|
|
980
1061
|
async function deleteTree(storage, treeId, options) {
|
|
@@ -1038,13 +1119,16 @@ async function deleteTree(storage, treeId, options) {
|
|
|
1038
1119
|
}
|
|
1039
1120
|
/** Deletes a top-level Vault. */
|
|
1040
1121
|
export async function deleteVault(storage, vaultId, options) {
|
|
1122
|
+
await storage.keySafe.requireReady({ signal: options?.signal });
|
|
1041
1123
|
return deleteTree(storage, vaultId, options);
|
|
1042
1124
|
}
|
|
1043
1125
|
/** Deletes a nested folder. */
|
|
1044
1126
|
export async function deleteFolder(storage, folderId, options) {
|
|
1127
|
+
await storage.keySafe.requireReady({ signal: options?.signal });
|
|
1045
1128
|
return deleteTree(storage, folderId, options);
|
|
1046
1129
|
}
|
|
1047
1130
|
export async function renameFile(storage, treeId, fileId, name, options) {
|
|
1131
|
+
await storage.keySafe.requireReady({ signal: options?.signal });
|
|
1048
1132
|
validateName(name, "file name");
|
|
1049
1133
|
return withOperationDeadline(options, async (signal) => {
|
|
1050
1134
|
const tree = await resolveTree(storage, treeId, signal);
|
|
@@ -1072,18 +1156,10 @@ export async function renameFile(storage, treeId, fileId, name, options) {
|
|
|
1072
1156
|
return { id: fileId, name };
|
|
1073
1157
|
}, "mutation");
|
|
1074
1158
|
}
|
|
1075
|
-
function requireFileDeletionTransport(client) {
|
|
1076
|
-
const http = client.http;
|
|
1077
|
-
if (!http || typeof http.authedRequest !== "function") {
|
|
1078
|
-
throw new StorageError("Matrix HTTP transport unavailable");
|
|
1079
|
-
}
|
|
1080
|
-
return http;
|
|
1081
|
-
}
|
|
1082
1159
|
async function deleteFileMedia(storage, mediaIds, signal) {
|
|
1083
1160
|
const client = storage.getClient();
|
|
1084
|
-
const http = requireFileDeletionTransport(client);
|
|
1085
1161
|
try {
|
|
1086
|
-
await withRateLimitRetry(() => withMatrixMutationAbort(() =>
|
|
1162
|
+
await withRateLimitRetry(() => withMatrixMutationAbort(() => deleteOwnedMedia(client, mediaIds, signal), signal, "delete file media"), signal);
|
|
1087
1163
|
}
|
|
1088
1164
|
catch (error) {
|
|
1089
1165
|
if (error instanceof MutationOutcomeUnknownError)
|
|
@@ -1094,14 +1170,13 @@ async function deleteFileMedia(storage, mediaIds, signal) {
|
|
|
1094
1170
|
}
|
|
1095
1171
|
}
|
|
1096
1172
|
export async function deleteFile(storage, treeId, fileId, options) {
|
|
1173
|
+
await storage.keySafe.requireReady({ signal: options?.signal });
|
|
1097
1174
|
return withOperationDeadline(options, async (signal) => {
|
|
1098
1175
|
const tree = await resolveTree(storage, treeId, signal);
|
|
1099
1176
|
if (isMarkedFileDeleted(storage, tree.id, fileId))
|
|
1100
1177
|
return { id: fileId, deleted: true };
|
|
1101
1178
|
await storage.refreshRoomState(treeId, { signal });
|
|
1102
1179
|
const branch = tree.getFile(fileId);
|
|
1103
|
-
if (!branch)
|
|
1104
|
-
throw new StorageError("file not found");
|
|
1105
1180
|
let originalEvent;
|
|
1106
1181
|
try {
|
|
1107
1182
|
originalEvent = await storage.getOriginalFileEvent(tree.id, fileId, true);
|
|
@@ -1110,7 +1185,7 @@ export async function deleteFile(storage, treeId, fileId, options) {
|
|
|
1110
1185
|
throw new StorageError("could not resolve encrypted file safely", { cause: error });
|
|
1111
1186
|
}
|
|
1112
1187
|
if (originalEvent.isRedacted()) {
|
|
1113
|
-
if (branch.isActive === false) {
|
|
1188
|
+
if (!branch || branch.isActive === false) {
|
|
1114
1189
|
markFileDeleted(storage.getClient(), tree.id, fileId);
|
|
1115
1190
|
return { id: fileId, deleted: true };
|
|
1116
1191
|
}
|
|
@@ -1134,7 +1209,9 @@ export async function deleteFile(storage, treeId, fileId, options) {
|
|
|
1134
1209
|
const completedIds = [mediaId];
|
|
1135
1210
|
try {
|
|
1136
1211
|
const client = storage.getClient();
|
|
1137
|
-
const metadataEventId =
|
|
1212
|
+
const metadataEventId = branch
|
|
1213
|
+
? await storage.getFileMetadataEventId(tree.id, fileId, { signal })
|
|
1214
|
+
: null;
|
|
1138
1215
|
if (metadataEventId && metadataEventId !== fileId) {
|
|
1139
1216
|
const metadataEvent = await storage.getFileRenameMetadataEvent(tree.id, fileId, metadataEventId);
|
|
1140
1217
|
if (!metadataEvent.isRedacted()) {
|
|
@@ -1145,16 +1222,17 @@ export async function deleteFile(storage, treeId, fileId, options) {
|
|
|
1145
1222
|
const currentBranch = tree.getFile(fileId);
|
|
1146
1223
|
const indexEvent = currentBranch?.indexEvent;
|
|
1147
1224
|
const listingEventId = indexEvent?.getId();
|
|
1148
|
-
if (!indexEvent || typeof listingEventId !== "string" || listingEventId.length === 0) {
|
|
1225
|
+
if (currentBranch && (!indexEvent || typeof listingEventId !== "string" || listingEventId.length === 0)) {
|
|
1149
1226
|
throw new StorageError("encrypted file has no listing event identifier");
|
|
1150
1227
|
}
|
|
1151
|
-
if (!indexEvent.isRedacted()) {
|
|
1228
|
+
if (indexEvent && !indexEvent.isRedacted()) {
|
|
1152
1229
|
if (indexEvent.getSender() !== client.getUserId()) {
|
|
1153
1230
|
throw new StorageError("file listing was not authored by the owner");
|
|
1154
1231
|
}
|
|
1155
1232
|
await withRateLimitRetry(() => withMatrixMutationAbort(() => client.redactEvent(tree.id, listingEventId), signal, "delete file listing"), signal);
|
|
1156
1233
|
}
|
|
1157
|
-
|
|
1234
|
+
if (listingEventId)
|
|
1235
|
+
completedIds.push(listingEventId);
|
|
1158
1236
|
await withRateLimitRetry(() => withMatrixMutationAbort(() => client.redactEvent(tree.id, fileId), signal, "delete file attachment event"), signal);
|
|
1159
1237
|
completedIds.push(fileId);
|
|
1160
1238
|
}
|
|
@@ -1184,12 +1262,19 @@ export async function deleteFile(storage, treeId, fileId, options) {
|
|
|
1184
1262
|
}, "mutation");
|
|
1185
1263
|
}
|
|
1186
1264
|
export async function uploadFile(storage, treeId, name, bytes, mimetype, options) {
|
|
1187
|
-
|
|
1265
|
+
await storage.keySafe.requireReady({ signal: options?.signal });
|
|
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.
|
|
1188
1273
|
const tree = await resolveTree(storage, treeId, signal);
|
|
1189
1274
|
const fileId = await withRateLimitRetry(() => storage.uploadFile(tree, name, toArrayBuffer(bytes), mimetype, signal), signal).catch((error) => {
|
|
1190
1275
|
if (error instanceof FileTooLargeError)
|
|
1191
1276
|
throw error;
|
|
1192
|
-
if (error instanceof MutationOutcomeUnknownError)
|
|
1277
|
+
if (error instanceof MutationOutcomeUnknownError || error instanceof MutationPartialError)
|
|
1193
1278
|
throw error;
|
|
1194
1279
|
if (signal.aborted)
|
|
1195
1280
|
throw new StorageError("operation cancelled");
|
|
@@ -1212,13 +1297,24 @@ export async function uploadFile(storage, treeId, name, bytes, mimetype, options
|
|
|
1212
1297
|
catch (error) {
|
|
1213
1298
|
if (error instanceof MutationOutcomeUnknownError)
|
|
1214
1299
|
throw error;
|
|
1215
|
-
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 });
|
|
1216
1301
|
}
|
|
1217
1302
|
ensureOperationActive(signal);
|
|
1218
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 });
|
|
1219
1314
|
}, "mutation");
|
|
1220
1315
|
}
|
|
1221
1316
|
export async function downloadFile(storage, treeId, fileId, options) {
|
|
1317
|
+
await storage.keySafe.requireReady({ signal: options?.signal });
|
|
1222
1318
|
return withOperationDeadline(options, async (signal) => {
|
|
1223
1319
|
const tree = await resolveTree(storage, treeId, signal);
|
|
1224
1320
|
const branch = await resolveFile(storage, tree, fileId, signal);
|
|
@@ -1253,6 +1349,7 @@ function tsToIso(ts) {
|
|
|
1253
1349
|
return new Date(ts).toISOString();
|
|
1254
1350
|
}
|
|
1255
1351
|
export async function getFileDetails(storage, treeId, fileId, options) {
|
|
1352
|
+
await storage.keySafe.requireReady({ signal: options?.signal });
|
|
1256
1353
|
return withOperationDeadline(options, async (signal) => {
|
|
1257
1354
|
const tree = await resolveTree(storage, treeId, signal);
|
|
1258
1355
|
await resolveFile(storage, tree, fileId, signal);
|
|
@@ -1308,9 +1405,11 @@ async function getTreeDetails(storage, treeId, signal) {
|
|
|
1308
1405
|
}
|
|
1309
1406
|
/** Returns details for a top-level Vault. */
|
|
1310
1407
|
export async function getVaultDetails(storage, vaultId, options) {
|
|
1408
|
+
await storage.keySafe.requireReady({ signal: options?.signal });
|
|
1311
1409
|
return withOperationDeadline(options, (signal) => getTreeDetails(storage, vaultId, signal));
|
|
1312
1410
|
}
|
|
1313
1411
|
/** Returns details for a nested folder. */
|
|
1314
1412
|
export async function getFolderDetails(storage, folderId, options) {
|
|
1413
|
+
await storage.keySafe.requireReady({ signal: options?.signal });
|
|
1315
1414
|
return withOperationDeadline(options, (signal) => getTreeDetails(storage, folderId, signal));
|
|
1316
1415
|
}
|
package/dist/core/types.d.ts
CHANGED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { type MatrixClient } from "matrix-js-sdk";
|
|
2
|
+
export interface KeySafeOptions {
|
|
3
|
+
signal?: AbortSignal;
|
|
4
|
+
}
|
|
5
|
+
export interface KeySafeStatus {
|
|
6
|
+
state: "setup-required" | "confirmation-required" | "restore-required" | "ready";
|
|
7
|
+
recoveryKey?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface KeySafePersistenceOptions {
|
|
10
|
+
persistentCryptoStore?: boolean;
|
|
11
|
+
cryptoDatabasePrefix?: string;
|
|
12
|
+
/** Flush platform crypto persistence before continuing a setup mutation. */
|
|
13
|
+
onKeySafeStateChanged?: () => Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
type WithKey = <T>(key: Uint8Array<ArrayBuffer>, fn: () => Promise<T>, signal?: AbortSignal) => Promise<T>;
|
|
16
|
+
/** The local saved-key acknowledgement and Matrix's existing encryption setup. */
|
|
17
|
+
export declare class DecryptionKeySafe {
|
|
18
|
+
private client;
|
|
19
|
+
private withKey;
|
|
20
|
+
private options;
|
|
21
|
+
private startSync?;
|
|
22
|
+
private memory;
|
|
23
|
+
private tail;
|
|
24
|
+
private secretRecordsRefreshed;
|
|
25
|
+
private refreshedKeyDescriptorId;
|
|
26
|
+
constructor(client: MatrixClient, withKey: WithKey, options?: KeySafePersistenceOptions, startSync?: ((signal?: AbortSignal) => Promise<void>) | undefined);
|
|
27
|
+
private active;
|
|
28
|
+
private crypto;
|
|
29
|
+
private local;
|
|
30
|
+
private save;
|
|
31
|
+
private serial;
|
|
32
|
+
private refreshAccountData;
|
|
33
|
+
/**
|
|
34
|
+
* MatrixClient.getAccountDataFromServer reads its local store after initial
|
|
35
|
+
* sync. A resumed sync can complete before account-data events reach that
|
|
36
|
+
* store, so refresh the existing Safe records from the authenticated server
|
|
37
|
+
* API before native secret-storage code reads them. Seed those exact records
|
|
38
|
+
* through the public Store API used by Matrix sync itself.
|
|
39
|
+
*/
|
|
40
|
+
private refreshSecretStorageAccountData;
|
|
41
|
+
private topology;
|
|
42
|
+
private serverBackup;
|
|
43
|
+
private signingReady;
|
|
44
|
+
getStatus(signal?: AbortSignal): Promise<KeySafeStatus>;
|
|
45
|
+
requireReady(options?: KeySafeOptions): Promise<void>;
|
|
46
|
+
/** Re-publish the same native signed records after an interrupted upload. */
|
|
47
|
+
private publishSigning;
|
|
48
|
+
private checkSigningCanResume;
|
|
49
|
+
private configure;
|
|
50
|
+
setup(signal?: AbortSignal): Promise<{
|
|
51
|
+
recoveryKey: string;
|
|
52
|
+
}>;
|
|
53
|
+
confirmSaved(signal?: AbortSignal): Promise<{
|
|
54
|
+
state: "ready";
|
|
55
|
+
}>;
|
|
56
|
+
restore(recoveryKey: string, signal?: AbortSignal): Promise<{
|
|
57
|
+
imported: number;
|
|
58
|
+
total: number;
|
|
59
|
+
state: "ready";
|
|
60
|
+
}>;
|
|
61
|
+
}
|
|
62
|
+
export {};
|