@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/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
- /** Keep provider diagnostics useful without allowing credentials, customer
68
- * identifiers, or control characters from an untrusted response to cross the
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>;
@@ -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, RoomCreateTypeField, RoomType, UNSTABLE_MSC3088_ENABLED, UNSTABLE_MSC3088_PURPOSE, UNSTABLE_MSC3089_TREE_SUBTYPE, } from "matrix-js-sdk";
15
- import { ClientPrefix } from "matrix-js-sdk/lib/http-api/prefix.js";
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
- return await waitForCondition(() => storage.getTree(childId), { timeoutMs: 15000, signal });
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
- export async function joinVault(storage, vaultId, options) {
550
- await storage.keySafe.requireReady({ signal: options?.signal });
551
- return withOperationDeadline(options, async (signal) => {
552
- let membership;
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
- membership = await storage.getRoomMembership(vaultId, undefined, { signal });
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
- // Synapse refuses GET /rooms/:room/members for an invited user with
560
- // M_FORBIDDEN. That response means the membership preflight cannot
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
- if (membership === "join")
571
- return { vaultId, joined: true };
572
- const client = storage.getClient();
573
- const userId = client.getUserId();
574
- if (userId && client.getRoom(vaultId)?.getMember(userId)?.membership !== "invite") {
575
- // MatrixClient.joinRoom reads the inviter from its local invite-room
576
- // state. A to-device history bundle can arrive first in the initial
577
- // sync, before that stripped invite state has reached the room store;
578
- // joining without it skips Matrix JS SDK's pending-bundle acceptance.
579
- try {
580
- await waitForCondition(() => client.getRoom(vaultId)?.getMember(userId)?.membership === "invite" ? true : null, { timeoutMs: 15000, signal });
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
- await withRateLimitRetry(() => withMatrixMutationAbort(() => client.joinRoom(vaultId), signal), signal);
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 (err) {
595
- if (err instanceof MutationOutcomeUnknownError)
596
- throw err;
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
- if (signal.aborted)
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 (isReviewedStorageTreeRoom(room)) {
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
- !isReviewedStorageTreeRoom(currentRoom)) {
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 crypto = client.getCrypto();
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
- const statuses = await Promise.all(devices.map((id) => crypto.getDeviceVerificationStatus(userId, id)));
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(() => http.authedRequest(Method.Post, "/io.telecrypt.storage/delete_media", undefined, { media_ids: [...mediaIds] }, { prefix: ClientPrefix.Unstable, rawResponseBody: true, abortSignal: signal }), signal, "delete file media"), signal);
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 = await storage.getFileMetadataEventId(tree.id, fileId, { signal });
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
- completedIds.push(listingEventId);
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
- return withOperationDeadline(options, async (signal) => {
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 {};
@@ -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
+ }
@@ -0,0 +1,3 @@
1
+ import type { MatrixClient } from "matrix-js-sdk";
2
+ /** The policy endpoint authorizes deletion against the uploading user. */
3
+ export declare function deleteOwnedMedia(client: MatrixClient, mediaIds: readonly string[], signal?: AbortSignal): Promise<void>;
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
+ }
@@ -0,0 +1,3 @@
1
+ import type { MatrixClient } from "matrix-js-sdk";
2
+ /** Use the same account/device requirements for explicit and inherited sharing. */
3
+ export declare function prepareReaderHistorySharing(client: MatrixClient, userId: string): Promise<() => Promise<void>>;
@@ -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.0",
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.20.0"
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.11"
51
+ "vitest": "4.1.12"
52
52
  },
53
53
  "repository": {
54
54
  "type": "git",