@telecrypt-io/storage 0.7.0 → 0.7.1

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/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  End-to-end encrypted file storage and sharing, built on Matrix.
6
6
 
7
7
  The current public TeleCrypt architecture, responsibilities, and product limits are authoritative at
8
- [telecrypt.io/llms.txt](https://telecrypt.io/llms.txt). This README documents this SDK's package API
8
+ [telecrypt-io.github.io/llms-authority/llms.txt](https://telecrypt-io.github.io/llms-authority/llms.txt). This README documents this SDK's package API
9
9
  and development workflow.
10
10
 
11
11
  Files are encrypted on the client before upload. The server stores only opaque ciphertext and
@@ -9,6 +9,8 @@ export declare const MAX_MATRIX_IDENTIFIER_BYTES = 255;
9
9
  * backend hostname that differs from the user-visible Matrix server name.
10
10
  */
11
11
  export declare function validateCanonicalMatrixUserId(value: unknown, serverName: unknown): string;
12
+ /** Validates a Matrix user ID without binding it to a particular homeserver. */
13
+ export declare function validateMatrixUserId(value: unknown): string;
12
14
  export declare function validateMatrixDeviceId(value: unknown): string;
13
15
  export declare function validateMatrixRoomId(value: unknown, name?: string): string;
14
16
  export declare function validateMatrixEventId(value: unknown, name?: string): string;
@@ -15,17 +15,25 @@ const MATRIX_EVENT_ID_PATTERN = /^\$[A-Za-z0-9._~:/+\-]+$/;
15
15
  * backend hostname that differs from the user-visible Matrix server name.
16
16
  */
17
17
  export function validateCanonicalMatrixUserId(value, serverName) {
18
- if (typeof value !== "string" ||
19
- new TextEncoder().encode(value).byteLength > MAX_MATRIX_IDENTIFIER_BYTES) {
20
- throw new Error("invalid Matrix user ID");
21
- }
18
+ const userId = validateMatrixUserId(value);
22
19
  if (typeof serverName !== "string" || !MATRIX_SERVER_NAME_REGEXP.test(serverName)) {
23
20
  throw new Error("invalid Matrix server name");
24
21
  }
25
- const match = value.match(MATRIX_USER_ID_PATTERN);
22
+ const match = userId.match(MATRIX_USER_ID_PATTERN);
26
23
  if (!match || match[2].toLowerCase() !== serverName.toLowerCase()) {
27
24
  throw new Error("invalid Matrix user ID for this homeserver");
28
25
  }
26
+ return userId;
27
+ }
28
+ /** Validates a Matrix user ID without binding it to a particular homeserver. */
29
+ export function validateMatrixUserId(value) {
30
+ if (typeof value !== "string" ||
31
+ new TextEncoder().encode(value).byteLength > MAX_MATRIX_IDENTIFIER_BYTES) {
32
+ throw new Error("invalid Matrix user ID");
33
+ }
34
+ if (!MATRIX_USER_ID_PATTERN.test(value)) {
35
+ throw new Error("invalid Matrix user ID");
36
+ }
29
37
  return value;
30
38
  }
31
39
  export function validateMatrixDeviceId(value) {
@@ -4,4 +4,6 @@ export * from "./operations.js";
4
4
  export * from "./errors.js";
5
5
  export * from "./oidc.js";
6
6
  export * from "./constants.js";
7
+ export { cancelResponseBody, readResponseBody, ResponseBodyReadError } from "./http.js";
8
+ export type { ResponseBody, ResponseBodyOptions } from "./http.js";
7
9
  export { isValidName, validateName } from "./validation.js";
@@ -4,4 +4,5 @@ export * from "./operations.js";
4
4
  export * from "./errors.js";
5
5
  export * from "./oidc.js";
6
6
  export * from "./constants.js";
7
+ export { cancelResponseBody, readResponseBody, ResponseBodyReadError } from "./http.js";
7
8
  export { isValidName, validateName } from "./validation.js";
@@ -34,6 +34,8 @@ export type OidcTokenEndpointMetadata = Pick<OidcClientConfig, "issuer" | "token
34
34
  * identifiers, or control characters from an untrusted response to cross the
35
35
  * SDK boundary. */
36
36
  export declare function sanitizeDiagnosticText(value: string): string;
37
+ /** Validates an endpoint against the configured homeserver and optional issuer path. */
38
+ export declare function assertOidcEndpoint(value: unknown, trustedHomeserver: string, name: string, issuer?: URL, allowQuery?: boolean): string;
37
39
  /**
38
40
  * Discovers the OIDC/MAS issuer and endpoints for a homeserver via the
39
41
  * Matrix client's stable `/auth_metadata` endpoint. Matrix 42 performs this
package/dist/core/oidc.js CHANGED
@@ -382,7 +382,7 @@ function abortableDelay(milliseconds, signal) {
382
382
  schedule();
383
383
  });
384
384
  }
385
- function parseHttpUrl(value, name) {
385
+ function parseHttpUrl(value, name, allowQuery = false) {
386
386
  const text = requireOpaqueString(value, name);
387
387
  let parsed;
388
388
  try {
@@ -396,7 +396,7 @@ function parseHttpUrl(value, name) {
396
396
  (parsed.protocol === "http:" && !isLoopbackHostname(parsed.hostname)) ||
397
397
  parsed.username !== "" ||
398
398
  parsed.password !== "" ||
399
- parsed.search !== "" ||
399
+ (!allowQuery && parsed.search !== "") ||
400
400
  parsed.hash !== "" ||
401
401
  (parsed.toString() !== text && !isRootWithoutSlash)) {
402
402
  throw new StorageError(`OIDC authorization context has an invalid ${name}`);
@@ -409,6 +409,15 @@ function isWithinIssuerPath(issuer, endpoint) {
409
409
  const prefix = issuer.pathname.endsWith("/") ? issuer.pathname : `${issuer.pathname}/`;
410
410
  return endpoint.pathname === issuer.pathname || endpoint.pathname.startsWith(prefix);
411
411
  }
412
+ /** Validates an endpoint against the configured homeserver and optional issuer path. */
413
+ export function assertOidcEndpoint(value, trustedHomeserver, name, issuer, allowQuery = false) {
414
+ const homeserver = parseHttpUrl(trustedHomeserver, "homeserver URL");
415
+ const endpoint = parseHttpUrl(value, name, allowQuery);
416
+ if (endpoint.origin !== homeserver.origin || (issuer && !isWithinIssuerPath(issuer, endpoint))) {
417
+ throw new StorageError(`${name} must remain on the configured OIDC origin and issuer path`);
418
+ }
419
+ return endpoint.toString();
420
+ }
412
421
  function matrixAuthMetadataEndpoint(homeserver) {
413
422
  const basePath = homeserver.pathname.replace(/\/+$/u, "");
414
423
  return new URL(`${basePath}/_matrix/client/v1/auth_metadata`, homeserver.origin).toString();
@@ -640,7 +649,12 @@ export async function discoverOidcIssuer(homeserverBaseUrl, signal) {
640
649
  throw failure;
641
650
  }
642
651
  ensureOidcNotCancelled(signal, "discovery");
643
- return validateTrustedAuthMetadata(body);
652
+ try {
653
+ return validateTrustedAuthMetadata(body);
654
+ }
655
+ catch (error) {
656
+ throw responseValidationFailure("discovery", response, responseText ?? "", error);
657
+ }
644
658
  }
645
659
  catch (err) {
646
660
  if (err instanceof AggregateError)
@@ -1100,14 +1114,20 @@ export async function whoAmI(homeserverUrl, accessToken, serverName, signal) {
1100
1114
  if (!isRecord(body)) {
1101
1115
  throw responseValidationFailure("identity confirmation", response, responseText ?? "", new StorageError("OIDC identity confirmation returned an invalid response"));
1102
1116
  }
1103
- const userId = validateMatrixUserId(body.user_id, serverName);
1117
+ let userId;
1118
+ try {
1119
+ userId = validateMatrixUserId(body.user_id, serverName);
1120
+ }
1121
+ catch (error) {
1122
+ throw responseValidationFailure("identity confirmation", response, responseText ?? "", error);
1123
+ }
1104
1124
  let deviceId = null;
1105
1125
  if (body.device_id !== undefined && body.device_id !== null) {
1106
1126
  try {
1107
1127
  deviceId = validateMatrixDeviceId(body.device_id);
1108
1128
  }
1109
1129
  catch (error) {
1110
- throw new StorageError("OIDC identity confirmation returned an invalid device ID", { cause: error });
1130
+ throw responseValidationFailure("identity confirmation", response, responseText ?? "", new StorageError("OIDC identity confirmation returned an invalid device ID", { cause: error }));
1111
1131
  }
1112
1132
  }
1113
1133
  if (signal?.aborted)
@@ -17,7 +17,7 @@ import { Method } from "matrix-js-sdk/lib/http-api/method.js";
17
17
  import { FileTooLargeError, MutationPartialError, MutationOutcomeUnknownError, NonEmptyTreeError, RoomCreationAmbiguousError, RoomCleanupIncompleteError, StorageError, } from "./errors.js";
18
18
  import { ConditionTimeoutError, waitForCondition } from "./poll.js";
19
19
  import { validateName } from "./validation.js";
20
- import { getDeletedTreeIds, isFileDeleted, isTreeDeleted, markFileDeleted, markTreeDeleted, } from "../deletion-markers.js";
20
+ import { isFileDeleted, isTreeDeleted, markFileDeleted, markTreeDeleted, } from "../deletion-markers.js";
21
21
  /**
22
22
  * Production Synapse enforces the built-in rc_messages budget (per-account
23
23
  * burst 10, refill 1/5s) on every room/state mutation the tree operations
@@ -204,9 +204,7 @@ async function resolveFile(storage, tree, fileId, signal) {
204
204
  * room. This also prevents a folder/vault delete from silently deleting a
205
205
  * nested shared tree.
206
206
  */
207
- function assertTreeEmptyForDeletion(storage, tree, spaces) {
208
- if (spaces.length !== 1)
209
- throw new NonEmptyTreeError(tree.id);
207
+ function assertTreeEmptyForDeletion(storage, tree) {
210
208
  let files;
211
209
  try {
212
210
  files = tree.listFiles().filter((file) => !isMarkedFileDeleted(storage, tree.id, file.id));
@@ -216,40 +214,74 @@ function assertTreeEmptyForDeletion(storage, tree, spaces) {
216
214
  }
217
215
  if (files.length > 0)
218
216
  throw new NonEmptyTreeError(tree.id);
217
+ const childEvents = readRelationEvents(storage.getClient(), tree.id, EventType.SpaceChild);
218
+ if (childEvents === null)
219
+ throw new StorageError("could not enumerate storage folders safely");
220
+ for (const event of childEvents) {
221
+ if (!isActiveRelationEvent(event))
222
+ continue;
223
+ const childId = relationStateKey(event);
224
+ if (!childId)
225
+ throw new StorageError("could not enumerate storage folders safely");
226
+ if (!isTreeDeleted(storage.getClient(), childId))
227
+ throw new NonEmptyTreeError(tree.id);
228
+ }
219
229
  }
220
230
  async function refreshDeletionRooms(storage, rootId, signal) {
221
231
  const client = storage.getClient();
222
- const http = client.http;
223
- // A real MatrixClient always exposes its authenticated HTTP transport. A
224
- // client without one cannot prove that its local relation graph is current.
225
- if (typeof http?.authedRequest !== "function") {
226
- throw new StorageError("delete graph refresh is unavailable");
227
- }
228
232
  await storage.refreshRoomState(rootId, { signal });
229
233
  if (signal?.aborted)
230
234
  throw new StorageError("operation cancelled");
231
- // The refreshed root owns its parent relation. Verify its one supported
232
- // external parent directly; unrelated local rooms are outside this deletion.
233
235
  const parentEvents = readRelationEvents(client, rootId, EventType.SpaceParent);
234
236
  if (parentEvents === null)
235
- throw new StorageError("delete graph is unsafe");
237
+ throw new StorageError("could not enumerate storage parent safely");
236
238
  const parentIds = new Set();
237
239
  for (const event of parentEvents) {
238
240
  if (!isActiveRelationEvent(event))
239
241
  continue;
240
242
  const parentId = relationStateKey(event);
241
243
  if (!parentId)
242
- throw new StorageError("delete graph is unsafe");
244
+ throw new StorageError("could not enumerate storage parent safely");
243
245
  if (parentId !== rootId)
244
246
  parentIds.add(parentId);
245
247
  }
246
248
  if (parentIds.size > 1)
247
- throw new StorageError("delete graph is unsafe");
248
- const parentId = parentIds.values().next().value;
249
- if (parentId)
250
- await storage.refreshRoomState(parentId, { signal });
249
+ throw new StorageError("storage room has multiple parents");
250
+ const parents = [...parentIds];
251
+ if (parents[0]) {
252
+ await storage.refreshRoomState(parents[0], { signal });
253
+ const childEvents = readRelationEvents(client, parents[0], EventType.SpaceChild, rootId);
254
+ if (childEvents === null ||
255
+ !childEvents.some((event) => isActiveRelationEvent(event) && relationStateKey(event, rootId) === rootId)) {
256
+ throw new StorageError("storage parent relation is inconsistent");
257
+ }
258
+ }
251
259
  if (signal?.aborted)
252
260
  throw new StorageError("operation cancelled");
261
+ return parents;
262
+ }
263
+ function snapshotTreeSpaces(root) {
264
+ const spaces = [];
265
+ const seen = new Set();
266
+ const pending = [root];
267
+ while (pending.length > 0) {
268
+ const tree = pending.pop();
269
+ if (!tree || seen.has(tree.id))
270
+ continue;
271
+ seen.add(tree.id);
272
+ spaces.push(tree);
273
+ let children;
274
+ try {
275
+ children = tree.getDirectories();
276
+ }
277
+ catch (error) {
278
+ if (error instanceof StorageError)
279
+ throw error;
280
+ throw new StorageError("could not enumerate storage folders", { cause: error });
281
+ }
282
+ pending.push(...children);
283
+ }
284
+ return spaces;
253
285
  }
254
286
  async function refreshTreeSpaces(storage, spaces, signal) {
255
287
  for (const space of spaces) {
@@ -280,126 +312,24 @@ function isActiveRelationEvent(event) {
280
312
  function activeTreeDirectories(client, tree) {
281
313
  const childEvents = readRelationEvents(client, tree.id, EventType.SpaceChild);
282
314
  if (childEvents === null)
283
- throw new StorageError("delete graph is unsafe");
315
+ throw new StorageError("storage folder state is unavailable");
284
316
  const activeChildIds = new Set();
285
317
  for (const event of childEvents) {
286
318
  if (!isActiveRelationEvent(event))
287
319
  continue;
288
320
  const childId = relationStateKey(event);
289
321
  if (!childId)
290
- throw new StorageError("delete graph is unsafe");
322
+ throw new StorageError("storage folder state is inconsistent");
291
323
  activeChildIds.add(childId);
292
324
  }
293
325
  const knownChildren = new Map(tree.getDirectories().map((child) => [child.id, child]));
294
326
  return [...activeChildIds].map((childId) => {
295
327
  const child = knownChildren.get(childId);
296
328
  if (!child)
297
- throw new StorageError("delete graph is unsafe");
329
+ throw new StorageError("storage folder state is inconsistent");
298
330
  return child;
299
331
  });
300
332
  }
301
- function snapshotTreeSpaces(root, activeRelationClient) {
302
- const spaces = [];
303
- const seen = new Set();
304
- const pending = [root];
305
- while (pending.length > 0) {
306
- const tree = pending.pop();
307
- if (!tree)
308
- continue;
309
- if (seen.has(tree.id))
310
- continue;
311
- seen.add(tree.id);
312
- spaces.push(tree);
313
- let children;
314
- try {
315
- children = activeRelationClient
316
- ? activeTreeDirectories(activeRelationClient, tree)
317
- : tree.getDirectories();
318
- }
319
- catch (error) {
320
- if (error instanceof StorageError)
321
- throw error;
322
- throw new StorageError("could not enumerate storage space descendants safely", { cause: error });
323
- }
324
- for (const child of children)
325
- pending.push(child);
326
- }
327
- return spaces;
328
- }
329
- /**
330
- * Deletion is intentionally narrower than MSC3089TreeSpace.delete(): a local
331
- * child graph is not proof that the rooms are exclusively owned by this tree.
332
- * Validate every observed edge in both directions and reject any parent or
333
- * child edge outside the exact deletion set before the first mutation.
334
- */
335
- function validateDeletionGraph(client, root, ids, deletedRooms) {
336
- const edges = new Set();
337
- const externalParents = [];
338
- const addEdge = (parentId, childId) => {
339
- if (deletedRooms.has(childId))
340
- return;
341
- if (!ids.has(childId))
342
- throw new StorageError("delete graph is unsafe");
343
- edges.add(`${parentId}\u0000${childId}`);
344
- };
345
- for (const id of ids) {
346
- const tree = client.unstableGetFileTreeSpace(id);
347
- if (!tree)
348
- throw new StorageError("delete graph is unsafe");
349
- const directories = activeTreeDirectories(client, tree);
350
- for (const child of directories)
351
- addEdge(id, child.id);
352
- const childEvents = readRelationEvents(client, id, EventType.SpaceChild);
353
- if (childEvents === null)
354
- throw new StorageError("delete graph is unsafe");
355
- for (const event of childEvents) {
356
- if (!isActiveRelationEvent(event))
357
- continue;
358
- const childId = relationStateKey(event);
359
- if (!childId)
360
- throw new StorageError("delete graph is unsafe");
361
- addEdge(id, childId);
362
- }
363
- }
364
- for (const edge of edges) {
365
- const separator = edge.indexOf("\u0000");
366
- const parentId = edge.slice(0, separator);
367
- const childId = edge.slice(separator + 1);
368
- const childLink = readRelationEvents(client, parentId, EventType.SpaceChild, childId);
369
- const parentLink = readRelationEvents(client, childId, EventType.SpaceParent, parentId);
370
- if (!childLink?.some((event) => isActiveRelationEvent(event) && relationStateKey(event, childId) === childId) ||
371
- !parentLink?.some((event) => isActiveRelationEvent(event) && relationStateKey(event, parentId) === parentId)) {
372
- throw new StorageError("delete graph is unsafe");
373
- }
374
- }
375
- for (const id of ids) {
376
- const parentEvents = readRelationEvents(client, id, EventType.SpaceParent);
377
- if (parentEvents === null)
378
- throw new StorageError("delete graph is unsafe");
379
- for (const event of parentEvents) {
380
- if (!isActiveRelationEvent(event))
381
- continue;
382
- const parentId = relationStateKey(event);
383
- if (!parentId)
384
- throw new StorageError("delete graph is unsafe");
385
- if (!ids.has(parentId)) {
386
- if (id !== root.id || externalParents.includes(parentId)) {
387
- throw new StorageError("delete graph is unsafe");
388
- }
389
- const childLink = readRelationEvents(client, parentId, EventType.SpaceChild, id);
390
- if (!childLink?.some((candidate) => isActiveRelationEvent(candidate) && relationStateKey(candidate, id) === id)) {
391
- throw new StorageError("delete graph is unsafe");
392
- }
393
- externalParents.push(parentId);
394
- }
395
- }
396
- }
397
- if (!ids.has(root.id))
398
- throw new StorageError("delete graph is unsafe");
399
- if (externalParents.length > 1)
400
- throw new StorageError("delete graph is unsafe");
401
- return { externalParents };
402
- }
403
333
  async function unlinkExternalParents(storage, rootId, externalParents, signal) {
404
334
  const client = storage.getClient();
405
335
  let mutationAttempted = false;
@@ -481,10 +411,10 @@ async function deleteRoomDeterministically(storage, roomId, signal) {
481
411
  const client = storage.getClient();
482
412
  const tree = storage.getTree(roomId);
483
413
  if (!tree)
484
- throw new StorageError("delete graph is unsafe");
414
+ throw new StorageError("storage room is unavailable for deletion");
485
415
  const self = client.getUserId();
486
416
  if (!self)
487
- throw new StorageError("delete graph is unsafe");
417
+ throw new StorageError("Matrix user identity is unavailable");
488
418
  const members = await storage.listMembers(tree, { signal });
489
419
  if (members.some((member) => member.userId !== self && member.role === "owner" &&
490
420
  (member.membership === "join" || member.membership === "invite" || member.membership === "knock"))) {
@@ -1062,38 +992,20 @@ async function deleteTree(storage, treeId, options) {
1062
992
  if (isTreeDeleted(client, treeId))
1063
993
  return { id: treeId, deleted: true };
1064
994
  const tree = await resolveTree(storage, treeId, operation.signal);
1065
- // Read reconciliation state after this operation reaches the single
1066
- // client queue; a preceding delete may have populated the set while we
1067
- // were waiting for its turn.
1068
- const removedRooms = getDeletedTreeIds(client);
1069
- let graph;
995
+ let externalParents;
1070
996
  try {
1071
- // Re-read the candidate graph and any external parents it identifies
1072
- // before taking the immutable graph snapshot. Unrelated rooms are not
1073
- // part of this operation and must not block deletion merely because
1074
- // their membership is not joined.
1075
- await refreshDeletionRooms(storage, tree.id, operation.signal);
1076
- const spaces = snapshotTreeSpaces(tree, client);
1077
- const activeSpaces = spaces.filter((space) => !removedRooms.has(space.id));
1078
- const ids = new Set(activeSpaces.map((space) => space.id));
1079
- if (!ids.has(tree.id)) {
1080
- if (removedRooms.has(tree.id)) {
1081
- return { id: treeId, deleted: true };
1082
- }
1083
- throw new StorageError("delete graph is unsafe");
1084
- }
1085
- assertTreeEmptyForDeletion(storage, tree, activeSpaces);
1086
- graph = validateDeletionGraph(client, tree, ids, removedRooms);
997
+ // Deletion owns one room. Refresh its current state and its one
998
+ // supported parent before checking files/folders or changing links.
999
+ externalParents = await refreshDeletionRooms(storage, tree.id, operation.signal);
1000
+ assertTreeEmptyForDeletion(storage, tree);
1087
1001
  }
1088
1002
  catch (error) {
1089
1003
  if (error instanceof StorageError)
1090
1004
  throw error;
1091
1005
  throw new StorageError("delete failed", { cause: error });
1092
1006
  }
1093
- // Keep external parent links intact until the room deletion succeeds.
1094
- // Unlinking first would silently detach a live vault on failure.
1095
1007
  try {
1096
- await unlinkExternalParents(storage, tree.id, graph.externalParents, operation.signal);
1008
+ await unlinkExternalParents(storage, tree.id, externalParents, operation.signal);
1097
1009
  }
1098
1010
  catch (error) {
1099
1011
  if (error instanceof StorageError)
@@ -1104,11 +1016,8 @@ async function deleteTree(storage, treeId, options) {
1104
1016
  await deleteRoomDeterministically(storage, tree.id, operation.signal);
1105
1017
  }
1106
1018
  catch (error) {
1107
- // The root is still live when its relation is removed. If deletion then
1108
- // fails, restore both sides before reporting failure; otherwise a live
1109
- // vault would be silently detached from its external parent.
1110
1019
  try {
1111
- await relinkExternalParents(storage, tree.id, graph.externalParents, new AbortController().signal);
1020
+ await relinkExternalParents(storage, tree.id, externalParents, new AbortController().signal);
1112
1021
  }
1113
1022
  catch (cleanupError) {
1114
1023
  throw new StorageError("delete cleanup is incomplete", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telecrypt-io/storage",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "license": "BUSL-1.1",
5
5
  "type": "module",
6
6
  "packageManager": "npm@11.19.0",