@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
package/dist/core/oidc.js
CHANGED
|
@@ -64,10 +64,8 @@ const DIAGNOSTIC_SECRET_FIELD = /(?:access[_-]?token|refresh[_-]?token|id[_-]?to
|
|
|
64
64
|
const DIAGNOSTIC_EMAIL = /[A-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Z0-9-]+(?:\.[A-Z0-9-]+)+/giu;
|
|
65
65
|
const DIAGNOSTIC_MXID = /@[A-Z0-9._=+\-/]+:[A-Z0-9.-]+/giu;
|
|
66
66
|
const DIAGNOSTIC_ULID = /\b[0-9A-HJKMNP-TV-Z]{26}\b/giu;
|
|
67
|
-
/**
|
|
68
|
-
|
|
69
|
-
* SDK boundary. */
|
|
70
|
-
export function sanitizeDiagnosticText(value) {
|
|
67
|
+
/** Existing fallback for plain or malformed provider diagnostics. */
|
|
68
|
+
function sanitizeUnstructuredDiagnosticText(value) {
|
|
71
69
|
const quotedValue = `"(?:\\\\.|[^"\\\\])*"|'(?:\\\\.|[^'\\\\])*'|[^\\s,};\\]]+`;
|
|
72
70
|
return value
|
|
73
71
|
.replace(/(Bearer\s+)(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s"',}]+)/giu, "$1<redacted>")
|
|
@@ -81,6 +79,21 @@ export function sanitizeDiagnosticText(value) {
|
|
|
81
79
|
.replace(/[\r\n\u0000-\u001f\u007f-\u009f\u2028\u2029]/gu, " ")
|
|
82
80
|
.trim();
|
|
83
81
|
}
|
|
82
|
+
/** Redact recognized secret fields and identifiers in provider diagnostics.
|
|
83
|
+
* Structured JSON uses decoded property names at every depth; the text fallback
|
|
84
|
+
* is limited to patterns recognized in unstructured input. */
|
|
85
|
+
export function sanitizeDiagnosticText(value) {
|
|
86
|
+
try {
|
|
87
|
+
return JSON.stringify(JSON.parse(value), (key, item) => {
|
|
88
|
+
if (DIAGNOSTIC_SECRET_FIELD.test(key))
|
|
89
|
+
return "<redacted>";
|
|
90
|
+
return typeof item === "string" ? sanitizeUnstructuredDiagnosticText(item) : item;
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return sanitizeUnstructuredDiagnosticText(value);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
84
97
|
class OidcResponseError extends Error {
|
|
85
98
|
status;
|
|
86
99
|
constructor(operation, status, body, cause) {
|
|
@@ -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
|
@@ -11,13 +11,13 @@
|
|
|
11
11
|
* platform-specific and stays with the caller.
|
|
12
12
|
*/
|
|
13
13
|
import { readFileEventMetadata, withMatrixMutationAbort, withTreeMutation, } from "../TeleCryptIOStorage.js";
|
|
14
|
-
import { EventType, MatrixError,
|
|
15
|
-
import {
|
|
16
|
-
import { Method } from "matrix-js-sdk/lib/http-api/method.js";
|
|
14
|
+
import { EventType, MatrixError, } from "matrix-js-sdk";
|
|
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
|
-
import { validateName } from "./validation.js";
|
|
19
|
+
import { hasStorageTreeMarkersRoom, isStorageTreeRoom, 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
|
}
|
|
@@ -615,14 +660,6 @@ function hasActiveSpaceParent(room) {
|
|
|
615
660
|
const events = result == null ? [] : Array.isArray(result) ? result : [result];
|
|
616
661
|
return events.some((event) => isActiveRelationEvent(event));
|
|
617
662
|
}
|
|
618
|
-
function isReviewedStorageTreeRoom(room) {
|
|
619
|
-
const createEvent = room?.currentState?.getStateEvents?.(EventType.RoomCreate, "");
|
|
620
|
-
const createContent = createEvent?.getContent?.();
|
|
621
|
-
const purposeEvent = room?.currentState?.getStateEvents?.(UNSTABLE_MSC3088_PURPOSE.name, UNSTABLE_MSC3089_TREE_SUBTYPE.name);
|
|
622
|
-
const purposeContent = purposeEvent?.getContent?.();
|
|
623
|
-
return (createContent?.[RoomCreateTypeField] === RoomType.Space &&
|
|
624
|
-
purposeContent?.[UNSTABLE_MSC3088_ENABLED.name] === true);
|
|
625
|
-
}
|
|
626
663
|
/** Rooms where this account is invited and the room looks like a file tree. */
|
|
627
664
|
export async function listPendingInvites(storage, options) {
|
|
628
665
|
await storage.keySafe.requireReady({ signal: options?.signal });
|
|
@@ -650,7 +687,7 @@ export async function listPendingInvites(storage, options) {
|
|
|
650
687
|
}
|
|
651
688
|
// Invite state may not have MSC3089 tree metadata yet — accept rooms whose
|
|
652
689
|
// create event marks them as a file tree space.
|
|
653
|
-
if (
|
|
690
|
+
if (isStorageTreeRoom(room)) {
|
|
654
691
|
invites.push({
|
|
655
692
|
id: room.roomId,
|
|
656
693
|
name: roomDisplayName(storage, room.roomId),
|
|
@@ -689,7 +726,7 @@ export async function declineInvite(storage, vaultId, options) {
|
|
|
689
726
|
// requiring a joined TreeSpace object.
|
|
690
727
|
if (!currentRoom ||
|
|
691
728
|
hasActiveSpaceParent(currentRoom) ||
|
|
692
|
-
!
|
|
729
|
+
!hasStorageTreeMarkersRoom(currentRoom)) {
|
|
693
730
|
throw new StorageError("decline failed");
|
|
694
731
|
}
|
|
695
732
|
let membership;
|
|
@@ -780,14 +817,7 @@ export async function shareVault(storage, vaultId, userId, role, options) {
|
|
|
780
817
|
throw new StorageError("share will not demote an existing owner");
|
|
781
818
|
}
|
|
782
819
|
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
|
-
}
|
|
820
|
+
const verifyHistorySharing = await prepareReaderHistorySharing(client, userId);
|
|
791
821
|
for (const [index, space] of spaces.entries()) {
|
|
792
822
|
ensureOperationActive(operation.signal);
|
|
793
823
|
const members = index === 0
|
|
@@ -807,12 +837,9 @@ export async function shareVault(storage, vaultId, userId, role, options) {
|
|
|
807
837
|
// pending invitations too: an earlier attempt may have sent only an
|
|
808
838
|
// invitation. In particular, never swallow a media-upload 403 merely
|
|
809
839
|
// because an invitation already exists.
|
|
810
|
-
await withRateLimitRetry(() => withMatrixMutationAbort(() => space.invite(userId), operation.signal), operation.signal);
|
|
840
|
+
await withRateLimitRetry(() => withMatrixMutationAbort(() => space.invite(userId, false), operation.signal), operation.signal);
|
|
811
841
|
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
|
-
}
|
|
842
|
+
await verifyHistorySharing();
|
|
816
843
|
}
|
|
817
844
|
// Storage rooms have one immutable owner power level and readers use
|
|
818
845
|
// the room's default user level. Changing per-user power levels would create editors and
|
|
@@ -1121,18 +1148,10 @@ export async function renameFile(storage, treeId, fileId, name, options) {
|
|
|
1121
1148
|
return { id: fileId, name };
|
|
1122
1149
|
}, "mutation");
|
|
1123
1150
|
}
|
|
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
1151
|
async function deleteFileMedia(storage, mediaIds, signal) {
|
|
1132
1152
|
const client = storage.getClient();
|
|
1133
|
-
const http = requireFileDeletionTransport(client);
|
|
1134
1153
|
try {
|
|
1135
|
-
await withRateLimitRetry(() => withMatrixMutationAbort(() =>
|
|
1154
|
+
await withRateLimitRetry(() => withMatrixMutationAbort(() => deleteOwnedMedia(client, mediaIds, signal), signal, "delete file media"), signal);
|
|
1136
1155
|
}
|
|
1137
1156
|
catch (error) {
|
|
1138
1157
|
if (error instanceof MutationOutcomeUnknownError)
|
|
@@ -1150,8 +1169,6 @@ export async function deleteFile(storage, treeId, fileId, options) {
|
|
|
1150
1169
|
return { id: fileId, deleted: true };
|
|
1151
1170
|
await storage.refreshRoomState(treeId, { signal });
|
|
1152
1171
|
const branch = tree.getFile(fileId);
|
|
1153
|
-
if (!branch)
|
|
1154
|
-
throw new StorageError("file not found");
|
|
1155
1172
|
let originalEvent;
|
|
1156
1173
|
try {
|
|
1157
1174
|
originalEvent = await storage.getOriginalFileEvent(tree.id, fileId, true);
|
|
@@ -1160,7 +1177,7 @@ export async function deleteFile(storage, treeId, fileId, options) {
|
|
|
1160
1177
|
throw new StorageError("could not resolve encrypted file safely", { cause: error });
|
|
1161
1178
|
}
|
|
1162
1179
|
if (originalEvent.isRedacted()) {
|
|
1163
|
-
if (branch.isActive === false) {
|
|
1180
|
+
if (!branch || branch.isActive === false) {
|
|
1164
1181
|
markFileDeleted(storage.getClient(), tree.id, fileId);
|
|
1165
1182
|
return { id: fileId, deleted: true };
|
|
1166
1183
|
}
|
|
@@ -1184,7 +1201,9 @@ export async function deleteFile(storage, treeId, fileId, options) {
|
|
|
1184
1201
|
const completedIds = [mediaId];
|
|
1185
1202
|
try {
|
|
1186
1203
|
const client = storage.getClient();
|
|
1187
|
-
const metadataEventId =
|
|
1204
|
+
const metadataEventId = branch
|
|
1205
|
+
? await storage.getFileMetadataEventId(tree.id, fileId, { signal })
|
|
1206
|
+
: null;
|
|
1188
1207
|
if (metadataEventId && metadataEventId !== fileId) {
|
|
1189
1208
|
const metadataEvent = await storage.getFileRenameMetadataEvent(tree.id, fileId, metadataEventId);
|
|
1190
1209
|
if (!metadataEvent.isRedacted()) {
|
|
@@ -1195,16 +1214,17 @@ export async function deleteFile(storage, treeId, fileId, options) {
|
|
|
1195
1214
|
const currentBranch = tree.getFile(fileId);
|
|
1196
1215
|
const indexEvent = currentBranch?.indexEvent;
|
|
1197
1216
|
const listingEventId = indexEvent?.getId();
|
|
1198
|
-
if (!indexEvent || typeof listingEventId !== "string" || listingEventId.length === 0) {
|
|
1217
|
+
if (currentBranch && (!indexEvent || typeof listingEventId !== "string" || listingEventId.length === 0)) {
|
|
1199
1218
|
throw new StorageError("encrypted file has no listing event identifier");
|
|
1200
1219
|
}
|
|
1201
|
-
if (!indexEvent.isRedacted()) {
|
|
1220
|
+
if (indexEvent && !indexEvent.isRedacted()) {
|
|
1202
1221
|
if (indexEvent.getSender() !== client.getUserId()) {
|
|
1203
1222
|
throw new StorageError("file listing was not authored by the owner");
|
|
1204
1223
|
}
|
|
1205
1224
|
await withRateLimitRetry(() => withMatrixMutationAbort(() => client.redactEvent(tree.id, listingEventId), signal, "delete file listing"), signal);
|
|
1206
1225
|
}
|
|
1207
|
-
|
|
1226
|
+
if (listingEventId)
|
|
1227
|
+
completedIds.push(listingEventId);
|
|
1208
1228
|
await withRateLimitRetry(() => withMatrixMutationAbort(() => client.redactEvent(tree.id, fileId), signal, "delete file attachment event"), signal);
|
|
1209
1229
|
completedIds.push(fileId);
|
|
1210
1230
|
}
|
|
@@ -1235,12 +1255,18 @@ export async function deleteFile(storage, treeId, fileId, options) {
|
|
|
1235
1255
|
}
|
|
1236
1256
|
export async function uploadFile(storage, treeId, name, bytes, mimetype, options) {
|
|
1237
1257
|
await storage.keySafe.requireReady({ signal: options?.signal });
|
|
1238
|
-
|
|
1258
|
+
const deadline = createOperationDeadline(options);
|
|
1259
|
+
try {
|
|
1260
|
+
const signal = deadline.signal;
|
|
1261
|
+
ensureOperationActive(signal);
|
|
1262
|
+
// Unlike a read, an upload must retain a receipt returned by an in-flight
|
|
1263
|
+
// mutation after cancellation. Native HTTP requests remain time-bounded;
|
|
1264
|
+
// the uploader stops before starting another write when this signal aborts.
|
|
1239
1265
|
const tree = await resolveTree(storage, treeId, signal);
|
|
1240
1266
|
const fileId = await withRateLimitRetry(() => storage.uploadFile(tree, name, toArrayBuffer(bytes), mimetype, signal), signal).catch((error) => {
|
|
1241
1267
|
if (error instanceof FileTooLargeError)
|
|
1242
1268
|
throw error;
|
|
1243
|
-
if (error instanceof MutationOutcomeUnknownError)
|
|
1269
|
+
if (error instanceof MutationOutcomeUnknownError || error instanceof MutationPartialError)
|
|
1244
1270
|
throw error;
|
|
1245
1271
|
if (signal.aborted)
|
|
1246
1272
|
throw new StorageError("operation cancelled");
|
|
@@ -1263,10 +1289,20 @@ export async function uploadFile(storage, treeId, name, bytes, mimetype, options
|
|
|
1263
1289
|
catch (error) {
|
|
1264
1290
|
if (error instanceof MutationOutcomeUnknownError)
|
|
1265
1291
|
throw error;
|
|
1266
|
-
throw new MutationPartialError("upload file", [fileId], "upload completed but the file could not be observed locally", { cause: error });
|
|
1292
|
+
throw new MutationPartialError("upload file", [fileId], "upload completed but the file could not be observed locally", { cause: error, treeId });
|
|
1267
1293
|
}
|
|
1268
1294
|
ensureOperationActive(signal);
|
|
1269
1295
|
return { id: fileId, name, mimetype };
|
|
1296
|
+
}
|
|
1297
|
+
finally {
|
|
1298
|
+
deadline.close();
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
/** Discard the acknowledged stages of a failed upload using its retained IDs. */
|
|
1302
|
+
export async function cleanupFileUpload(storage, treeId, mediaId, fileId, options) {
|
|
1303
|
+
await storage.keySafe.requireReady({ signal: options?.signal });
|
|
1304
|
+
return withOperationDeadline(options, async (signal) => {
|
|
1305
|
+
await storage.cleanupFileUpload(treeId, mediaId, fileId, { signal });
|
|
1270
1306
|
}, "mutation");
|
|
1271
1307
|
}
|
|
1272
1308
|
export async function downloadFile(storage, treeId, fileId, options) {
|
|
@@ -1,3 +1,18 @@
|
|
|
1
1
|
/** Shared name contract used by SDK inputs and remote projections. */
|
|
2
2
|
export declare function isValidName(value: unknown): value is string;
|
|
3
3
|
export declare function validateName(value: unknown, label: string): asserts value is string;
|
|
4
|
+
export declare const STORAGE_ROOM_ENCRYPTION_ALGORITHM = "m.megolm.v1.aes-sha2";
|
|
5
|
+
interface StorageTreeRoomLike {
|
|
6
|
+
currentState?: {
|
|
7
|
+
getStateEvents?: (eventType: string, stateKey?: string) => unknown;
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
/** The shared Storage-room contract for both synced rooms and refreshed state. */
|
|
11
|
+
export declare function isStorageTreeState(createContent: unknown, purposeContent: unknown, encryptionContent: unknown): boolean;
|
|
12
|
+
/** The room markers needed for explicit invite cleanup before encryption is available. */
|
|
13
|
+
export declare function hasStorageTreeMarkers(createContent: unknown, purposeContent: unknown): boolean;
|
|
14
|
+
/** Checks the existing Storage markers and Megolm room encryption together. */
|
|
15
|
+
export declare function isStorageTreeRoom(room: StorageTreeRoomLike | null | undefined): boolean;
|
|
16
|
+
/** Checks only the room markers for explicit cleanup paths such as declining an invite. */
|
|
17
|
+
export declare function hasStorageTreeMarkersRoom(room: StorageTreeRoomLike | null | undefined): boolean;
|
|
18
|
+
export {};
|
package/dist/core/validation.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { EventType, RoomCreateTypeField, RoomType, UNSTABLE_MSC3088_ENABLED, UNSTABLE_MSC3088_PURPOSE, UNSTABLE_MSC3089_TREE_SUBTYPE, } from "matrix-js-sdk";
|
|
1
2
|
/** Shared name contract used by SDK inputs and remote projections. */
|
|
2
3
|
export function isValidName(value) {
|
|
3
4
|
return (typeof value === "string" &&
|
|
@@ -9,3 +10,47 @@ export function validateName(value, label) {
|
|
|
9
10
|
throw new Error(`invalid ${label}`);
|
|
10
11
|
}
|
|
11
12
|
}
|
|
13
|
+
export const STORAGE_ROOM_ENCRYPTION_ALGORITHM = "m.megolm.v1.aes-sha2";
|
|
14
|
+
function isRecord(value) {
|
|
15
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
16
|
+
}
|
|
17
|
+
/** The shared Storage-room contract for both synced rooms and refreshed state. */
|
|
18
|
+
export function isStorageTreeState(createContent, purposeContent, encryptionContent) {
|
|
19
|
+
return hasStorageTreeMarkers(createContent, purposeContent) &&
|
|
20
|
+
isRecord(encryptionContent) &&
|
|
21
|
+
encryptionContent.algorithm === STORAGE_ROOM_ENCRYPTION_ALGORITHM;
|
|
22
|
+
}
|
|
23
|
+
/** The room markers needed for explicit invite cleanup before encryption is available. */
|
|
24
|
+
export function hasStorageTreeMarkers(createContent, purposeContent) {
|
|
25
|
+
return (isRecord(createContent) &&
|
|
26
|
+
createContent[RoomCreateTypeField] === RoomType.Space &&
|
|
27
|
+
isRecord(purposeContent) &&
|
|
28
|
+
purposeContent[UNSTABLE_MSC3088_ENABLED.name] === true);
|
|
29
|
+
}
|
|
30
|
+
function getStorageTreeState(room) {
|
|
31
|
+
const getContent = (eventType, stateKey) => {
|
|
32
|
+
const raw = room?.currentState?.getStateEvents?.(eventType, stateKey);
|
|
33
|
+
const event = Array.isArray(raw) ? raw[0] : raw;
|
|
34
|
+
if (typeof event !== "object" || event === null)
|
|
35
|
+
return undefined;
|
|
36
|
+
const getEventContent = event.getContent;
|
|
37
|
+
return typeof getEventContent === "function"
|
|
38
|
+
? getEventContent.call(event)
|
|
39
|
+
: undefined;
|
|
40
|
+
};
|
|
41
|
+
return {
|
|
42
|
+
createContent: getContent(EventType.RoomCreate, ""),
|
|
43
|
+
purposeContent: getContent(UNSTABLE_MSC3088_PURPOSE.name, UNSTABLE_MSC3089_TREE_SUBTYPE.name),
|
|
44
|
+
encryptionContent: getContent(EventType.RoomEncryption, ""),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** Checks the existing Storage markers and Megolm room encryption together. */
|
|
48
|
+
export function isStorageTreeRoom(room) {
|
|
49
|
+
const state = getStorageTreeState(room);
|
|
50
|
+
return isStorageTreeState(state.createContent, state.purposeContent, state.encryptionContent);
|
|
51
|
+
}
|
|
52
|
+
/** Checks only the room markers for explicit cleanup paths such as declining an invite. */
|
|
53
|
+
export function hasStorageTreeMarkersRoom(room) {
|
|
54
|
+
const state = getStorageTreeState(room);
|
|
55
|
+
return hasStorageTreeMarkers(state.createContent, state.purposeContent);
|
|
56
|
+
}
|
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@telecrypt-io/storage",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.3",
|
|
4
4
|
"license": "BUSL-1.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "npm@11.19.0",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"LICENSE"
|
|
23
23
|
],
|
|
24
24
|
"engines": {
|
|
25
|
-
"node": "24.
|
|
25
|
+
"node": "24.21.0"
|
|
26
26
|
},
|
|
27
27
|
"publishConfig": {
|
|
28
28
|
"access": "public"
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"eslint": "10.9.0",
|
|
49
49
|
"fake-indexeddb": "6.2.5",
|
|
50
50
|
"typescript": "5.9.3",
|
|
51
|
-
"vitest": "4.1.
|
|
51
|
+
"vitest": "4.1.12"
|
|
52
52
|
},
|
|
53
53
|
"repository": {
|
|
54
54
|
"type": "git",
|