@vex-chat/libvex 5.5.0 → 5.5.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.
Files changed (34) hide show
  1. package/README.md +25 -23
  2. package/dist/Client.d.ts +114 -103
  3. package/dist/Client.d.ts.map +1 -1
  4. package/dist/Client.js +317 -314
  5. package/dist/Client.js.map +1 -1
  6. package/dist/__tests__/harness/memory-storage.d.ts +1 -1
  7. package/dist/__tests__/harness/memory-storage.d.ts.map +1 -1
  8. package/dist/__tests__/harness/memory-storage.js +1 -1
  9. package/dist/__tests__/harness/memory-storage.js.map +1 -1
  10. package/dist/index.d.ts +1 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/storage/node/http-agents.d.ts +1 -1
  13. package/dist/storage/node/http-agents.d.ts.map +1 -1
  14. package/dist/storage/node/http-agents.js +4 -4
  15. package/dist/storage/node/http-agents.js.map +1 -1
  16. package/dist/storage/sqlite.d.ts +8 -8
  17. package/dist/storage/sqlite.d.ts.map +1 -1
  18. package/dist/storage/sqlite.js +16 -16
  19. package/dist/storage/sqlite.js.map +1 -1
  20. package/dist/utils/fipsMailExtra.d.ts +9 -9
  21. package/dist/utils/fipsMailExtra.d.ts.map +1 -1
  22. package/dist/utils/fipsMailExtra.js +47 -47
  23. package/dist/utils/fipsMailExtra.js.map +1 -1
  24. package/dist/utils/resolveAtRestAesKey.js +1 -1
  25. package/dist/utils/resolveAtRestAesKey.js.map +1 -1
  26. package/package.json +134 -152
  27. package/src/Client.ts +448 -437
  28. package/src/__tests__/harness/memory-storage.ts +1 -1
  29. package/src/__tests__/harness/shared-suite.ts +177 -177
  30. package/src/index.ts +1 -1
  31. package/src/storage/node/http-agents.ts +7 -7
  32. package/src/storage/sqlite.ts +23 -23
  33. package/src/utils/fipsMailExtra.ts +80 -80
  34. package/src/utils/resolveAtRestAesKey.ts +1 -1
package/src/Client.ts CHANGED
@@ -89,14 +89,56 @@ import {
89
89
  isFipsSubsequentExtraV1,
90
90
  } from "./utils/fipsMailExtra.js";
91
91
 
92
- function sleep(ms: number): Promise<void> {
93
- return new Promise((resolve) => setTimeout(resolve, ms));
92
+ function debugLibvexDm(
93
+ msg: string,
94
+ data?: Record<string, boolean | null | number | string | undefined>,
95
+ ): void {
96
+ if (!libvexDebugDmEnabled()) {
97
+ return;
98
+ }
99
+ const payload = data ? `${msg} ${JSON.stringify(data)}` : msg;
100
+ // eslint-disable-next-line no-console -- gated by LIBVEX_DEBUG_DM; remove when debugging is done
101
+ console.error(`[libvex:debug-dm] ${payload}`);
94
102
  }
95
103
 
96
104
  function isRecord(x: unknown): x is Record<string, unknown> {
97
105
  return typeof x === "object" && x !== null;
98
106
  }
99
107
 
108
+ /**
109
+ * Set `LIBVEX_DEBUG_DM=1` (e.g. in vitest / shell) to log DM multi-device / X3DH paths.
110
+ * Uses indirect `globalThis` lookup so the bare `process` global never appears in
111
+ * source that the platform-guard plugin scans (browser/RN/Tauri).
112
+ */
113
+ function libvexDebugDmEnabled(): boolean {
114
+ try {
115
+ const g = Object.getOwnPropertyDescriptor(globalThis, "\u0070rocess");
116
+ if (!g) {
117
+ return false;
118
+ }
119
+ const proc: unknown = typeof g.get === "function" ? g.get() : g.value;
120
+ if (typeof proc !== "object" || proc === null) {
121
+ return false;
122
+ }
123
+ const envDesc = Object.getOwnPropertyDescriptor(proc, "env");
124
+ if (!envDesc) {
125
+ return false;
126
+ }
127
+ const env: unknown =
128
+ typeof envDesc.get === "function" ? envDesc.get() : envDesc.value;
129
+ if (typeof env !== "object" || env === null) {
130
+ return false;
131
+ }
132
+ return Reflect.get(env, "LIBVEX_DEBUG_DM") === "1";
133
+ } catch {
134
+ return false;
135
+ }
136
+ }
137
+
138
+ function sleep(ms: number): Promise<void> {
139
+ return new Promise((resolve) => setTimeout(resolve, ms));
140
+ }
141
+
100
142
  /**
101
143
  * Spire 5+ JSON error bodies use `{ "error": { "message", "requestId"?, "details"? } }`.
102
144
  * Responses are `arraybuffer` — decode UTF-8 and parse for a one-line `Error` message
@@ -149,48 +191,6 @@ function spireErrorBodyMessage(data: unknown, max = 8_000): string {
149
191
  return t.length > max ? t.slice(0, max) + "…" : t;
150
192
  }
151
193
 
152
- /**
153
- * Set `LIBVEX_DEBUG_DM=1` (e.g. in vitest / shell) to log DM multi-device / X3DH paths.
154
- * Uses indirect `globalThis` lookup so the bare `process` global never appears in
155
- * source that the platform-guard plugin scans (browser/RN/Tauri).
156
- */
157
- function libvexDebugDmEnabled(): boolean {
158
- try {
159
- const g = Object.getOwnPropertyDescriptor(globalThis, "\u0070rocess");
160
- if (!g) {
161
- return false;
162
- }
163
- const proc: unknown = typeof g.get === "function" ? g.get() : g.value;
164
- if (typeof proc !== "object" || proc === null) {
165
- return false;
166
- }
167
- const envDesc = Object.getOwnPropertyDescriptor(proc, "env");
168
- if (!envDesc) {
169
- return false;
170
- }
171
- const env: unknown =
172
- typeof envDesc.get === "function" ? envDesc.get() : envDesc.value;
173
- if (typeof env !== "object" || env === null) {
174
- return false;
175
- }
176
- return Reflect.get(env, "LIBVEX_DEBUG_DM") === "1";
177
- } catch {
178
- return false;
179
- }
180
- }
181
-
182
- function debugLibvexDm(
183
- msg: string,
184
- data?: Record<string, string | number | boolean | null | undefined>,
185
- ): void {
186
- if (!libvexDebugDmEnabled()) {
187
- return;
188
- }
189
- const payload = data ? `${msg} ${JSON.stringify(data)}` : msg;
190
- // eslint-disable-next-line no-console -- gated by LIBVEX_DEBUG_DM; remove when debugging is done
191
- console.error(`[libvex:debug-dm] ${payload}`);
192
- }
193
-
194
194
  import { msgpack } from "./codec.js";
195
195
  import {
196
196
  ActionTokenCodec,
@@ -267,33 +267,6 @@ export interface Channels {
267
267
  */
268
268
  export type { Device } from "@vex-chat/types";
269
269
 
270
- export type PendingDeviceApprovalStatus =
271
- | "approved"
272
- | "expired"
273
- | "pending"
274
- | "rejected";
275
-
276
- export interface PendingDeviceRequest {
277
- approvedDeviceID?: string | undefined;
278
- createdAt: string;
279
- deviceName: string;
280
- error?: string | undefined;
281
- expiresAt: string;
282
- requestID: string;
283
- signKey: string;
284
- status: PendingDeviceApprovalStatus;
285
- username: string;
286
- }
287
-
288
- export interface PendingDeviceRegistration {
289
- challenge: string;
290
- expiresAt: string;
291
- requestID: string;
292
- status: "pending_approval";
293
- }
294
-
295
- export type DeviceRegistrationResult = Device | PendingDeviceRegistration;
296
-
297
270
  /**
298
271
  * ClientOptions are the options you can pass into the client.
299
272
  */
@@ -306,6 +279,12 @@ export interface ClientOptions {
306
279
  cryptoProfile?: "fips" | "tweetnacl";
307
280
  /** Folder path where the sqlite file is created. */
308
281
  dbFolder?: string;
282
+ /**
283
+ * When set (non-empty), sent as `x-dev-api-key` on every HTTP request.
284
+ * Spire omits in-process rate limits when this matches the server's `DEV_API_KEY`
285
+ * (local / load-testing only — never use in production).
286
+ */
287
+ devApiKey?: string;
309
288
  /** Platform label for device registration (e.g. "ios", "macos", "linux"). */
310
289
  deviceName?: string;
311
290
  /** API host without protocol. Defaults to `api.vex.wtf`. */
@@ -316,14 +295,10 @@ export interface ClientOptions {
316
295
  saveHistory?: boolean;
317
296
  /** Use `http/ws` instead of `https/wss`. Intended for local/dev environments. */
318
297
  unsafeHttp?: boolean;
319
- /**
320
- * When set (non-empty), sent as `x-dev-api-key` on every HTTP request.
321
- * Spire omits in-process rate limits when this matches the server's `DEV_API_KEY`
322
- * (local / load-testing only — never use in production).
323
- */
324
- devApiKey?: string;
325
298
  }
326
299
 
300
+ export type DeviceRegistrationResult = Device | PendingDeviceRegistration;
301
+
327
302
  /**
328
303
  * @ignore
329
304
  */
@@ -336,34 +311,14 @@ export interface Devices {
336
311
  getRequest: (requestID: string) => Promise<null | PendingDeviceRequest>;
337
312
  /** Lists pending/processed registration requests for the current user. */
338
313
  listRequests: () => Promise<PendingDeviceRequest[]>;
339
- /** Rejects a pending device registration request as the current device. */
340
- rejectRequest: (requestID: string) => Promise<void>;
341
314
  /** Registers the current key material as a new device. */
342
315
  register: () => Promise<DeviceRegistrationResult | null>;
316
+ /** Rejects a pending device registration request as the current device. */
317
+ rejectRequest: (requestID: string) => Promise<void>;
343
318
  /** Fetches one device by ID. */
344
319
  retrieve: (deviceIdentifier: string) => Promise<Device | null>;
345
320
  }
346
321
 
347
- /**
348
- * Channel is a chat channel on a server.
349
- *
350
- * Common fields:
351
- * - `channelID`
352
- * - `serverID`
353
- * - `name`
354
- */
355
- export type { Channel } from "@vex-chat/types";
356
-
357
- /**
358
- * Server is a single chat server.
359
- *
360
- * Common fields:
361
- * - `serverID`
362
- * - `name`
363
- * - `icon` (optional URL/data)
364
- */
365
- export type { Server } from "@vex-chat/types";
366
-
367
322
  /**
368
323
  * @ignore
369
324
  */
@@ -417,6 +372,26 @@ export interface FileProgress {
417
372
  */
418
373
  export type FileRes = FileResponse;
419
374
 
375
+ /**
376
+ * Channel is a chat channel on a server.
377
+ *
378
+ * Common fields:
379
+ * - `channelID`
380
+ * - `serverID`
381
+ * - `name`
382
+ */
383
+ export type { Channel } from "@vex-chat/types";
384
+
385
+ /**
386
+ * Server is a single chat server.
387
+ *
388
+ * Common fields:
389
+ * - `serverID`
390
+ * - `name`
391
+ * - `icon` (optional URL/data)
392
+ */
393
+ export type { Server } from "@vex-chat/types";
394
+
420
395
  /**
421
396
  * @ignore
422
397
  */
@@ -492,6 +467,41 @@ export interface Message {
492
467
  timestamp: string;
493
468
  }
494
469
 
470
+ export type PendingDeviceApprovalStatus =
471
+ | "approved"
472
+ | "expired"
473
+ | "pending"
474
+ | "rejected";
475
+
476
+ export interface PendingDeviceRegistration {
477
+ challenge: string;
478
+ expiresAt: string;
479
+ requestID: string;
480
+ status: "pending_approval";
481
+ }
482
+
483
+ export interface PendingDeviceRequest {
484
+ approvedDeviceID?: string | undefined;
485
+ createdAt: string;
486
+ deviceName: string;
487
+ error?: string | undefined;
488
+ expiresAt: string;
489
+ requestID: string;
490
+ signKey: string;
491
+ status: PendingDeviceApprovalStatus;
492
+ username: string;
493
+ }
494
+
495
+ /**
496
+ * Retry request emitted when message decryption fails and session healing starts.
497
+ */
498
+ export interface RetryRequest {
499
+ /** Mail ID that should be retried after session healing. */
500
+ mailID: string;
501
+ /** Origin of the retry signal. */
502
+ source: "decrypt_failure" | "server_notify";
503
+ }
504
+
495
505
  /** Zod schema matching the {@link Message} interface for forwarded-message decode. */
496
506
  const messageSchema: z.ZodType<Message> = z.object({
497
507
  authorID: z.string(),
@@ -522,6 +532,12 @@ const deviceRequestNotifyData = z.object({
522
532
  z.literal("rejected"),
523
533
  ]),
524
534
  });
535
+ const retryRequestNotifyData = z.union([
536
+ z.string(),
537
+ z.object({
538
+ mailID: z.string(),
539
+ }),
540
+ ]);
525
541
 
526
542
  /**
527
543
  * Event signatures emitted by {@link Client}.
@@ -536,8 +552,6 @@ export interface ClientEvents {
536
552
  connected: () => void;
537
553
  /** Mail decryption pass is in progress. */
538
554
  decryptingMail: () => void;
539
- /** WebSocket connection lost. */
540
- disconnect: () => void;
541
555
  /** Device approval queue changed (pending/approved/rejected). */
542
556
  deviceRequest: (update: {
543
557
  requestID: string;
@@ -546,6 +560,8 @@ export interface ClientEvents {
546
560
  "approved" | "pending" | "rejected"
547
561
  >;
548
562
  }) => void;
563
+ /** WebSocket connection lost. */
564
+ disconnect: () => void;
549
565
  /** Progress update for a file upload or download. */
550
566
  fileProgress: (progress: FileProgress) => void;
551
567
  /** A direct or group message was sent or received. */
@@ -554,6 +570,8 @@ export interface ClientEvents {
554
570
  permission: (permission: Permission) => void;
555
571
  /** Post-auth setup complete — safe to call messaging/user APIs. */
556
572
  ready: () => void;
573
+ /** Session healing requested a retry for a specific mail ID. */
574
+ retryRequest: (retry: RetryRequest) => void;
557
575
  /** A new encryption session was established with a peer device. */
558
576
  session: (session: Session, user: User) => void;
559
577
  }
@@ -806,8 +824,8 @@ export class Client {
806
824
  delete: this.deleteDevice.bind(this),
807
825
  getRequest: this.getDeviceRegistrationRequest.bind(this),
808
826
  listRequests: this.listDeviceRegistrationRequests.bind(this),
809
- rejectRequest: this.rejectDeviceRequest.bind(this),
810
827
  register: this.registerDevice.bind(this),
828
+ rejectRequest: this.rejectDeviceRequest.bind(this),
811
829
  retrieve: this.getDeviceByID.bind(this),
812
830
  };
813
831
 
@@ -1025,6 +1043,8 @@ export class Client {
1025
1043
  retrieve: this.fetchUser.bind(this),
1026
1044
  };
1027
1045
 
1046
+ private readonly cryptoProfile: CryptoProfile;
1047
+
1028
1048
  private readonly database: Storage;
1029
1049
 
1030
1050
  private readonly dbPath: string;
@@ -1035,34 +1055,28 @@ export class Client {
1035
1055
 
1036
1056
  // ── Event subscription (composition over inheritance) ───────────────
1037
1057
  private readonly emitter = new EventEmitter<ClientEvents>();
1038
-
1039
1058
  private fetchingMail: boolean = false;
1059
+
1040
1060
  private firstMailFetch = true;
1041
1061
 
1042
1062
  private readonly forwarded = new Set<string>();
1043
-
1044
1063
  private readonly host: string;
1045
- /**
1046
- * Node-only: per-client HTTP(S) agents (see `init()` + `storage/node/http-agents`).
1047
- * Dropped on `close()` so idle keep-alive sockets do not keep the process alive.
1048
- */
1049
- private nodeHttpAgents?: {
1050
- http: { destroy(): void };
1051
- https: { destroy(): void };
1052
- };
1064
+ private readonly http: AxiosInstance;
1053
1065
  /** Cancels in-flight axios work on `close()` so `postAuth`/`getMail` cannot hang forever. */
1054
1066
  private readonly httpAbortController = new AbortController();
1055
- private readonly http: AxiosInstance;
1056
1067
  private readonly idKeys: KeyPair | null;
1057
1068
  private isAlive: boolean = true;
1058
1069
  private readonly mailInterval?: NodeJS.Timeout;
1059
1070
 
1060
1071
  private manuallyClosing: boolean = false;
1061
1072
  /**
1062
- * Bumped when the WebSocket is torn down and re-opened so the previous
1063
- * `postAuth` loop exits instead of overlapping a new one.
1073
+ * Node-only: per-client HTTP(S) agents (see `init()` + `storage/node/http-agents`).
1074
+ * Dropped on `close()` so idle keep-alive sockets do not keep the process alive.
1064
1075
  */
1065
- private postAuthVersion = 0;
1076
+ private nodeHttpAgents?: {
1077
+ http: { destroy(): void };
1078
+ https: { destroy(): void };
1079
+ };
1066
1080
  /* Retrieves the userID with the user identifier.
1067
1081
  user identifier is checked for userID, then signkey,
1068
1082
  and finally falls back to username. */
@@ -1072,24 +1086,28 @@ export class Client {
1072
1086
  private readonly options?: ClientOptions | undefined;
1073
1087
 
1074
1088
  private pingInterval: null | ReturnType<typeof setTimeout> = null;
1089
+ /**
1090
+ * Bumped when the WebSocket is torn down and re-opened so the previous
1091
+ * `postAuth` loop exits instead of overlapping a new one.
1092
+ */
1093
+ private postAuthVersion = 0;
1094
+
1075
1095
  private readonly prefixes:
1076
1096
  | { HTTP: "http://"; WS: "ws://" }
1077
1097
  | { HTTP: "https://"; WS: "wss://" };
1078
-
1079
1098
  private reading: boolean = false;
1080
1099
  private readonly seenMailIDs: Set<string> = new Set();
1081
1100
  private sessionRecords: Record<string, SessionCrypto> = {};
1101
+
1082
1102
  // these are created from one set of sign keys
1083
1103
  private readonly signKeys: KeyPair;
1084
-
1085
1104
  private socket: WebSocketLike;
1086
1105
  private token: null | string = null;
1106
+
1087
1107
  private user?: User;
1088
1108
 
1089
1109
  private userRecords: Record<string, User> = {};
1090
-
1091
1110
  private xKeyRing?: XKeyRing;
1092
- private readonly cryptoProfile: CryptoProfile;
1093
1111
 
1094
1112
  private constructor(
1095
1113
  material: {
@@ -1305,48 +1323,22 @@ export class Client {
1305
1323
  }
1306
1324
 
1307
1325
  /**
1308
- * True when running under Node (has `process.versions`).
1326
+ * Browser-safe NODE_ENV accessor.
1309
1327
  * Uses indirect lookup so the bare `process` global never appears in
1310
1328
  * source that the platform-guard plugin scans.
1311
1329
  */
1312
- private static isNodeRuntime(): boolean {
1330
+ private static getNodeEnv(): string | undefined {
1313
1331
  try {
1314
1332
  const g = Object.getOwnPropertyDescriptor(
1315
1333
  globalThis,
1316
1334
  "\u0070rocess",
1317
1335
  );
1318
- if (!g) return false;
1336
+ if (!g) return undefined;
1337
+ // Node 24+ exposes `process` as an accessor (get/set), not a value.
1319
1338
  const proc: unknown =
1320
1339
  typeof g.get === "function" ? g.get() : g.value;
1321
1340
  if (typeof proc !== "object" || proc === null) {
1322
- return false;
1323
- }
1324
- return (
1325
- "versions" in proc &&
1326
- typeof (proc as { versions?: unknown }).versions === "object"
1327
- );
1328
- } catch {
1329
- return false;
1330
- }
1331
- }
1332
-
1333
- /**
1334
- * Browser-safe NODE_ENV accessor.
1335
- * Uses indirect lookup so the bare `process` global never appears in
1336
- * source that the platform-guard plugin scans.
1337
- */
1338
- private static getNodeEnv(): string | undefined {
1339
- try {
1340
- const g = Object.getOwnPropertyDescriptor(
1341
- globalThis,
1342
- "\u0070rocess",
1343
- );
1344
- if (!g) return undefined;
1345
- // Node 24+ exposes `process` as an accessor (get/set), not a value.
1346
- const proc: unknown =
1347
- typeof g.get === "function" ? g.get() : g.value;
1348
- if (typeof proc !== "object" || proc === null) {
1349
- return undefined;
1341
+ return undefined;
1350
1342
  }
1351
1343
  const envDesc = Object.getOwnPropertyDescriptor(proc, "env");
1352
1344
  if (!envDesc) return undefined;
@@ -1370,12 +1362,29 @@ export class Client {
1370
1362
  }
1371
1363
 
1372
1364
  /**
1373
- * Fresh read of the `manuallyClosing` flag for async loops — direct property checks
1374
- * after `await` are flagged as always-false by control-flow analysis even though
1375
- * `close()` can run concurrently.
1365
+ * True when running under Node (has `process.versions`).
1366
+ * Uses indirect lookup so the bare `process` global never appears in
1367
+ * source that the platform-guard plugin scans.
1376
1368
  */
1377
- private isManualCloseInFlight(): boolean {
1378
- return this.manuallyClosing;
1369
+ private static isNodeRuntime(): boolean {
1370
+ try {
1371
+ const g = Object.getOwnPropertyDescriptor(
1372
+ globalThis,
1373
+ "\u0070rocess",
1374
+ );
1375
+ if (!g) return false;
1376
+ const proc: unknown =
1377
+ typeof g.get === "function" ? g.get() : g.value;
1378
+ if (typeof proc !== "object" || proc === null) {
1379
+ return false;
1380
+ }
1381
+ return (
1382
+ "versions" in proc &&
1383
+ typeof (proc as { versions?: unknown }).versions === "object"
1384
+ );
1385
+ } catch {
1386
+ return false;
1387
+ }
1379
1388
  }
1380
1389
 
1381
1390
  /**
@@ -1450,62 +1459,6 @@ export class Client {
1450
1459
  await this.negotiateOTK();
1451
1460
  }
1452
1461
 
1453
- /**
1454
- * Tears down the current WebSocket and opens a new one, keeping the same
1455
- * session (user + device in storage). Restarts the post-auth mail loop.
1456
- * Use for long-running processes or e2e where a fresh socket matches a
1457
- * newly-registered second device.
1458
- */
1459
- public async reconnectWebsocket(): Promise<void> {
1460
- this.postAuthVersion++;
1461
- if (this.pingInterval) {
1462
- clearInterval(this.pingInterval);
1463
- this.pingInterval = null;
1464
- }
1465
- this.socket.close();
1466
- try {
1467
- await new Promise<void>((resolve, reject) => {
1468
- const t = setTimeout(() => {
1469
- this.off("connected", onC);
1470
- reject(
1471
- new Error(
1472
- "reconnectWebsocket: timed out waiting for authorized",
1473
- ),
1474
- );
1475
- }, 15_000);
1476
- const onC = () => {
1477
- clearTimeout(t);
1478
- this.off("connected", onC);
1479
- resolve();
1480
- };
1481
- this.on("connected", onC);
1482
- try {
1483
- this.initSocket();
1484
- } catch (err: unknown) {
1485
- clearTimeout(t);
1486
- this.off("connected", onC);
1487
- const e =
1488
- err instanceof Error
1489
- ? err
1490
- : new Error(String(err), { cause: err });
1491
- reject(e);
1492
- }
1493
- });
1494
- } catch (e: unknown) {
1495
- throw e instanceof Error ? e : new Error(String(e), { cause: e });
1496
- }
1497
- await new Promise((r) => setTimeout(r, 0));
1498
- await this.negotiateOTK();
1499
- }
1500
-
1501
- /**
1502
- * Triggers an immediate inbox sync by fetching `/mail` once.
1503
- * Useful on mobile foreground resume where background work may pause.
1504
- */
1505
- public async syncInboxNow(): Promise<void> {
1506
- await this.getMail();
1507
- }
1508
-
1509
1462
  /**
1510
1463
  * Delete all local data — message history, encryption sessions, and prekeys.
1511
1464
  * Closes the client afterward. Credentials (keychain) must be cleared by the consumer.
@@ -1684,6 +1637,54 @@ export class Client {
1684
1637
  return this;
1685
1638
  }
1686
1639
 
1640
+ /**
1641
+ * Tears down the current WebSocket and opens a new one, keeping the same
1642
+ * session (user + device in storage). Restarts the post-auth mail loop.
1643
+ * Use for long-running processes or e2e where a fresh socket matches a
1644
+ * newly-registered second device.
1645
+ */
1646
+ public async reconnectWebsocket(): Promise<void> {
1647
+ this.postAuthVersion++;
1648
+ if (this.pingInterval) {
1649
+ clearInterval(this.pingInterval);
1650
+ this.pingInterval = null;
1651
+ }
1652
+ this.socket.close();
1653
+ try {
1654
+ await new Promise<void>((resolve, reject) => {
1655
+ const t = setTimeout(() => {
1656
+ this.off("connected", onC);
1657
+ reject(
1658
+ new Error(
1659
+ "reconnectWebsocket: timed out waiting for authorized",
1660
+ ),
1661
+ );
1662
+ }, 15_000);
1663
+ const onC = () => {
1664
+ clearTimeout(t);
1665
+ this.off("connected", onC);
1666
+ resolve();
1667
+ };
1668
+ this.on("connected", onC);
1669
+ try {
1670
+ this.initSocket();
1671
+ } catch (err: unknown) {
1672
+ clearTimeout(t);
1673
+ this.off("connected", onC);
1674
+ const e =
1675
+ err instanceof Error
1676
+ ? err
1677
+ : new Error(String(err), { cause: err });
1678
+ reject(e);
1679
+ }
1680
+ });
1681
+ } catch (e: unknown) {
1682
+ throw e instanceof Error ? e : new Error(String(e), { cause: e });
1683
+ }
1684
+ await new Promise((r) => setTimeout(r, 0));
1685
+ await this.negotiateOTK();
1686
+ }
1687
+
1687
1688
  /**
1688
1689
  * Registers a new account on the server.
1689
1690
  *
@@ -1757,6 +1758,14 @@ export class Client {
1757
1758
  return this;
1758
1759
  }
1759
1760
 
1761
+ /**
1762
+ * Triggers an immediate inbox sync by fetching `/mail` once.
1763
+ * Useful on mobile foreground resume where background work may pause.
1764
+ */
1765
+ public async syncInboxNow(): Promise<void> {
1766
+ await this.getMail();
1767
+ }
1768
+
1760
1769
  /**
1761
1770
  * Returns a compact `<username><deviceID>` debug label.
1762
1771
  */
@@ -1790,6 +1799,36 @@ export class Client {
1790
1799
  return whoami;
1791
1800
  }
1792
1801
 
1802
+ private async approveDeviceRequest(requestID: string): Promise<Device> {
1803
+ const req = await this.getDeviceRegistrationRequest(requestID);
1804
+ if (!req) {
1805
+ throw new Error("Device approval request not found.");
1806
+ }
1807
+ if (req.status !== "pending") {
1808
+ throw new Error(
1809
+ "Device approval request is not pending: " + req.status,
1810
+ );
1811
+ }
1812
+ const signed = XUtils.encodeHex(
1813
+ await xSignAsync(
1814
+ XUtils.decodeUTF8(requestID),
1815
+ this.signKeys.secretKey,
1816
+ ),
1817
+ );
1818
+ const response = await this.http.post(
1819
+ this.prefixes.HTTP +
1820
+ this.host +
1821
+ "/user/" +
1822
+ this.getUser().userID +
1823
+ "/devices/requests/" +
1824
+ requestID +
1825
+ "/approve",
1826
+ msgpack.encode({ signed }),
1827
+ { headers: { "Content-Type": "application/msgpack" } },
1828
+ );
1829
+ return decodeAxios(DeviceCodec, response.data);
1830
+ }
1831
+
1793
1832
  private censorPreKey(preKey: PreKeysSQL): PreKeysWS {
1794
1833
  if (!preKey.index) {
1795
1834
  throw new Error("Key index is required.");
@@ -1916,27 +1955,6 @@ export class Client {
1916
1955
  return decodeAxios(ServerCodec, res.data);
1917
1956
  }
1918
1957
 
1919
- /**
1920
- * `xDHAsync` and other helpers in `@vex-chat/crypto` use the process-wide
1921
- * active profile. When several {@link Client} instances use different
1922
- * `cryptoProfile` values, scope the global to this instance for the duration
1923
- * of that crypto work.
1924
- */
1925
- private async runWithThisCryptoProfile<T>(
1926
- fn: () => Promise<T>,
1927
- ): Promise<T> {
1928
- const prev = getCryptoProfile();
1929
- if (prev === this.cryptoProfile) {
1930
- return await fn();
1931
- }
1932
- setCryptoProfile(this.cryptoProfile);
1933
- try {
1934
- return await fn();
1935
- } finally {
1936
- setCryptoProfile(prev);
1937
- }
1938
- }
1939
-
1940
1958
  private async createSession(
1941
1959
  device: Device,
1942
1960
  user: User,
@@ -2144,36 +2162,6 @@ export class Client {
2144
2162
  await this.http.delete(this.getHost() + "/channel/" + channelID);
2145
2163
  }
2146
2164
 
2147
- private async approveDeviceRequest(requestID: string): Promise<Device> {
2148
- const req = await this.getDeviceRegistrationRequest(requestID);
2149
- if (!req) {
2150
- throw new Error("Device approval request not found.");
2151
- }
2152
- if (req.status !== "pending") {
2153
- throw new Error(
2154
- "Device approval request is not pending: " + req.status,
2155
- );
2156
- }
2157
- const signed = XUtils.encodeHex(
2158
- await xSignAsync(
2159
- XUtils.decodeUTF8(requestID),
2160
- this.signKeys.secretKey,
2161
- ),
2162
- );
2163
- const response = await this.http.post(
2164
- this.prefixes.HTTP +
2165
- this.host +
2166
- "/user/" +
2167
- this.getUser().userID +
2168
- "/devices/requests/" +
2169
- requestID +
2170
- "/approve",
2171
- msgpack.encode({ signed }),
2172
- { headers: { "Content-Type": "application/msgpack" } },
2173
- );
2174
- return decodeAxios(DeviceCodec, response.data);
2175
- }
2176
-
2177
2165
  private async deleteDevice(deviceID: string): Promise<void> {
2178
2166
  if (deviceID === this.getDevice().deviceID) {
2179
2167
  throw new Error("You can't delete the device you're logged in to.");
@@ -2188,52 +2176,6 @@ export class Client {
2188
2176
  );
2189
2177
  }
2190
2178
 
2191
- private async getDeviceRegistrationRequest(
2192
- requestID: string,
2193
- ): Promise<null | PendingDeviceRequest> {
2194
- try {
2195
- const response = await this.http.get(
2196
- this.prefixes.HTTP +
2197
- this.host +
2198
- "/user/" +
2199
- this.getUser().userID +
2200
- "/devices/requests/" +
2201
- requestID,
2202
- );
2203
- return decodeAxios(PendingDeviceRequestCodec, response.data);
2204
- } catch (err: unknown) {
2205
- if (isAxiosError(err) && err.response?.status === 404) {
2206
- return null;
2207
- }
2208
- throw err;
2209
- }
2210
- }
2211
-
2212
- private async listDeviceRegistrationRequests(): Promise<
2213
- PendingDeviceRequest[]
2214
- > {
2215
- const response = await this.http.get(
2216
- this.prefixes.HTTP +
2217
- this.host +
2218
- "/user/" +
2219
- this.getUser().userID +
2220
- "/devices/requests",
2221
- );
2222
- return decodeAxios(PendingDeviceRequestArrayCodec, response.data);
2223
- }
2224
-
2225
- private async rejectDeviceRequest(requestID: string): Promise<void> {
2226
- await this.http.post(
2227
- this.prefixes.HTTP +
2228
- this.host +
2229
- "/user/" +
2230
- this.getUser().userID +
2231
- "/devices/requests/" +
2232
- requestID +
2233
- "/reject",
2234
- );
2235
- }
2236
-
2237
2179
  private async deleteHistory(channelOrUserID: string): Promise<void> {
2238
2180
  await this.database.deleteHistory(channelOrUserID);
2239
2181
  }
@@ -2245,6 +2187,21 @@ export class Client {
2245
2187
  private async deleteServer(serverID: string): Promise<void> {
2246
2188
  await this.http.delete(this.getHost() + "/server/" + serverID);
2247
2189
  }
2190
+
2191
+ private deviceListFailureDetail(err: unknown): string {
2192
+ if (!isAxiosError(err)) {
2193
+ return "";
2194
+ }
2195
+ const st = err.response?.status;
2196
+ if (typeof st === "number") {
2197
+ return ` (HTTP ${String(st)})`;
2198
+ }
2199
+ if (err.code !== undefined) {
2200
+ return ` (${err.code})`;
2201
+ }
2202
+ return "";
2203
+ }
2204
+
2248
2205
  /**
2249
2206
  * Gets a list of permissions for a server.
2250
2207
  *
@@ -2289,11 +2246,61 @@ export class Client {
2289
2246
  this.notFoundUsers.set(userIdentifier, Date.now());
2290
2247
  return [null, err];
2291
2248
  }
2292
- // Transient (5xx, network error) — don't cache, caller can retry
2293
- return [null, isAxiosError(err) ? err : null];
2249
+ // Transient (5xx, network error) — don't cache, caller can retry
2250
+ return [null, isAxiosError(err) ? err : null];
2251
+ }
2252
+ }
2253
+
2254
+ private async fetchUserDeviceListOnce(userID: string): Promise<Device[]> {
2255
+ if (this.isManualCloseInFlight()) {
2256
+ return [];
2257
+ }
2258
+ const res = await this.http.get(
2259
+ this.getHost() + "/user/" + userID + "/devices",
2260
+ );
2261
+ const devices = decodeAxios(DeviceArrayCodec, res.data);
2262
+ for (const device of devices) {
2263
+ this.deviceRecords[device.deviceID] = device;
2264
+ }
2265
+ return devices;
2266
+ }
2267
+
2268
+ /**
2269
+ * DM / forward paths need the peer’s (or self) device rows under load: bounded
2270
+ * retries with exponential backoff (same shape as session pubkey hydration).
2271
+ */
2272
+ private async fetchUserDeviceListWithBackoff(
2273
+ userID: string,
2274
+ label: "own" | "peer",
2275
+ ): Promise<Device[]> {
2276
+ const base =
2277
+ label === "own"
2278
+ ? "Couldn't get own devices"
2279
+ : "Couldn't get device list";
2280
+ let lastErr: unknown;
2281
+ for (let attempt = 0; attempt < 5; attempt++) {
2282
+ if (this.isManualCloseInFlight()) {
2283
+ return [];
2284
+ }
2285
+ if (attempt > 0) {
2286
+ const delayMs = 100 * 2 ** (attempt - 1);
2287
+ // Chunk the delay so close() can finish before we retry HTTP.
2288
+ const chunkMs = 10;
2289
+ for (let elapsed = 0; elapsed < delayMs; elapsed += chunkMs) {
2290
+ if (this.isManualCloseInFlight()) {
2291
+ return [];
2292
+ }
2293
+ await sleep(Math.min(chunkMs, delayMs - elapsed));
2294
+ }
2295
+ }
2296
+ try {
2297
+ return await this.fetchUserDeviceListOnce(userID);
2298
+ } catch (err: unknown) {
2299
+ lastErr = err;
2300
+ }
2294
2301
  }
2302
+ throw new Error(`${base}${this.deviceListFailureDetail(lastErr)}`);
2295
2303
  }
2296
-
2297
2304
  private async forward(message: Message) {
2298
2305
  if (this.isManualCloseInFlight()) {
2299
2306
  return;
@@ -2386,6 +2393,27 @@ export class Client {
2386
2393
  }
2387
2394
  }
2388
2395
 
2396
+ private async getDeviceRegistrationRequest(
2397
+ requestID: string,
2398
+ ): Promise<null | PendingDeviceRequest> {
2399
+ try {
2400
+ const response = await this.http.get(
2401
+ this.prefixes.HTTP +
2402
+ this.host +
2403
+ "/user/" +
2404
+ this.getUser().userID +
2405
+ "/devices/requests/" +
2406
+ requestID,
2407
+ );
2408
+ return decodeAxios(PendingDeviceRequestCodec, response.data);
2409
+ } catch (err: unknown) {
2410
+ if (isAxiosError(err) && err.response?.status === 404) {
2411
+ return null;
2412
+ }
2413
+ throw err;
2414
+ }
2415
+ }
2416
+
2389
2417
  /* Retrieves the current list of users you have sessions with. */
2390
2418
  private async getFamiliars(): Promise<User[]> {
2391
2419
  const sessions = await this.database.getAllSessions();
@@ -2448,8 +2476,8 @@ export class Client {
2448
2476
  }
2449
2477
  })();
2450
2478
  debugLibvexDm("getMail: inbox", {
2451
- deviceID: did,
2452
2479
  count: String(inbox.length),
2480
+ deviceID: did,
2453
2481
  });
2454
2482
  }
2455
2483
 
@@ -2459,8 +2487,8 @@ export class Client {
2459
2487
  if (libvexDebugDmEnabled()) {
2460
2488
  debugLibvexDm("getMail: readMail one", {
2461
2489
  mailID: mailBody.mailID,
2462
- type: String(mailBody.mailType),
2463
2490
  recipient: mailBody.recipient,
2491
+ type: String(mailBody.mailType),
2464
2492
  });
2465
2493
  }
2466
2494
  await this.readMail(mailHeader, mailBody, timestamp);
@@ -2598,20 +2626,6 @@ export class Client {
2598
2626
  return this.user;
2599
2627
  }
2600
2628
 
2601
- private deviceListFailureDetail(err: unknown): string {
2602
- if (!isAxiosError(err)) {
2603
- return "";
2604
- }
2605
- const st = err.response?.status;
2606
- if (typeof st === "number") {
2607
- return ` (HTTP ${String(st)})`;
2608
- }
2609
- if (err.code !== undefined) {
2610
- return ` (${err.code})`;
2611
- }
2612
- return "";
2613
- }
2614
-
2615
2629
  /**
2616
2630
  * Single GET for `/user/:id/devices`. On failure returns `null` (swallows errors)
2617
2631
  * — callers that need reliability should use `fetchUserDeviceListWithBackoff`.
@@ -2626,57 +2640,6 @@ export class Client {
2626
2640
  }
2627
2641
  }
2628
2642
 
2629
- private async fetchUserDeviceListOnce(userID: string): Promise<Device[]> {
2630
- if (this.isManualCloseInFlight()) {
2631
- return [];
2632
- }
2633
- const res = await this.http.get(
2634
- this.getHost() + "/user/" + userID + "/devices",
2635
- );
2636
- const devices = decodeAxios(DeviceArrayCodec, res.data);
2637
- for (const device of devices) {
2638
- this.deviceRecords[device.deviceID] = device;
2639
- }
2640
- return devices;
2641
- }
2642
-
2643
- /**
2644
- * DM / forward paths need the peer’s (or self) device rows under load: bounded
2645
- * retries with exponential backoff (same shape as session pubkey hydration).
2646
- */
2647
- private async fetchUserDeviceListWithBackoff(
2648
- userID: string,
2649
- label: "peer" | "own",
2650
- ): Promise<Device[]> {
2651
- const base =
2652
- label === "own"
2653
- ? "Couldn't get own devices"
2654
- : "Couldn't get device list";
2655
- let lastErr: unknown;
2656
- for (let attempt = 0; attempt < 5; attempt++) {
2657
- if (this.isManualCloseInFlight()) {
2658
- return [];
2659
- }
2660
- if (attempt > 0) {
2661
- const delayMs = 100 * 2 ** (attempt - 1);
2662
- // Chunk the delay so close() can finish before we retry HTTP.
2663
- const chunkMs = 10;
2664
- for (let elapsed = 0; elapsed < delayMs; elapsed += chunkMs) {
2665
- if (this.isManualCloseInFlight()) {
2666
- return [];
2667
- }
2668
- await sleep(Math.min(chunkMs, delayMs - elapsed));
2669
- }
2670
- }
2671
- try {
2672
- return await this.fetchUserDeviceListOnce(userID);
2673
- } catch (err: unknown) {
2674
- lastErr = err;
2675
- }
2676
- }
2677
- throw new Error(`${base}${this.deviceListFailureDetail(lastErr)}`);
2678
- }
2679
-
2680
2643
  private async getUserList(channelID: string): Promise<User[]> {
2681
2644
  const res = await this.http.post(
2682
2645
  this.getHost() + "/userList/" + channelID,
@@ -2686,10 +2649,6 @@ export class Client {
2686
2649
 
2687
2650
  private async handleNotify(msg: NotifyMsg) {
2688
2651
  switch (msg.event) {
2689
- case "mail":
2690
- await this.getMail();
2691
- this.fetchingMail = false;
2692
- break;
2693
2652
  case "deviceRequest": {
2694
2653
  const parsed = deviceRequestNotifyData.safeParse(msg.data);
2695
2654
  if (parsed.success) {
@@ -2697,6 +2656,10 @@ export class Client {
2697
2656
  }
2698
2657
  break;
2699
2658
  }
2659
+ case "mail":
2660
+ await this.getMail();
2661
+ this.fetchingMail = false;
2662
+ break;
2700
2663
  case "permission":
2701
2664
  this.emitter.emit(
2702
2665
  "permission",
@@ -2704,35 +2667,25 @@ export class Client {
2704
2667
  );
2705
2668
  break;
2706
2669
  case "retryRequest":
2707
- // msg.data is the messageID for retry
2670
+ {
2671
+ const parsed = retryRequestNotifyData.safeParse(msg.data);
2672
+ if (parsed.success) {
2673
+ const mailID =
2674
+ typeof parsed.data === "string"
2675
+ ? parsed.data
2676
+ : parsed.data.mailID;
2677
+ this.emitter.emit("retryRequest", {
2678
+ mailID,
2679
+ source: "server_notify",
2680
+ });
2681
+ }
2682
+ }
2708
2683
  break;
2709
2684
  default:
2710
2685
  break;
2711
2686
  }
2712
2687
  }
2713
2688
 
2714
- /**
2715
- * Pipeline for decrypted messages — registered in `init`. After `close()` sets
2716
- * `manuallyClosing`, this becomes a no-op so fire-and-forget `forward` does not
2717
- * race HTTP teardown (we avoid `off()` here — it can interact badly with emit).
2718
- */
2719
- private readonly onInternalMessage = (message: Message): void => {
2720
- if (this.isManualCloseInFlight()) {
2721
- return;
2722
- }
2723
- if (message.direction === "outgoing" && !message.forward) {
2724
- void this.forward(message);
2725
- }
2726
-
2727
- if (
2728
- message.direction === "incoming" &&
2729
- message.recipient === message.sender
2730
- ) {
2731
- return;
2732
- }
2733
- void this.database.saveMessage(message);
2734
- };
2735
-
2736
2689
  /**
2737
2690
  * Initializes the keyring. This must be called before anything else.
2738
2691
  */
@@ -2834,6 +2787,15 @@ export class Client {
2834
2787
  }
2835
2788
  }
2836
2789
 
2790
+ /**
2791
+ * Fresh read of the `manuallyClosing` flag for async loops — direct property checks
2792
+ * after `await` are flagged as always-false by control-flow analysis even though
2793
+ * `close()` can run concurrently.
2794
+ */
2795
+ private isManualCloseInFlight(): boolean {
2796
+ return this.manuallyClosing;
2797
+ }
2798
+
2837
2799
  private async kickUser(userID: string, serverID: string): Promise<void> {
2838
2800
  const permissionList = await this.fetchPermissionList(serverID);
2839
2801
  for (const permission of permissionList) {
@@ -2854,6 +2816,19 @@ export class Client {
2854
2816
  }
2855
2817
  }
2856
2818
 
2819
+ private async listDeviceRegistrationRequests(): Promise<
2820
+ PendingDeviceRequest[]
2821
+ > {
2822
+ const response = await this.http.get(
2823
+ this.prefixes.HTTP +
2824
+ this.host +
2825
+ "/user/" +
2826
+ this.getUser().userID +
2827
+ "/devices/requests",
2828
+ );
2829
+ return decodeAxios(PendingDeviceRequestArrayCodec, response.data);
2830
+ }
2831
+
2857
2832
  private async markSessionVerified(sessionID: string) {
2858
2833
  return this.database.markSessionVerified(sessionID);
2859
2834
  }
@@ -2878,6 +2853,28 @@ export class Client {
2878
2853
  this.xKeyRing.ephemeralKeys = await xBoxKeyPairAsync();
2879
2854
  }
2880
2855
 
2856
+ /**
2857
+ * Pipeline for decrypted messages — registered in `init`. After `close()` sets
2858
+ * `manuallyClosing`, this becomes a no-op so fire-and-forget `forward` does not
2859
+ * race HTTP teardown (we avoid `off()` here — it can interact badly with emit).
2860
+ */
2861
+ private readonly onInternalMessage = (message: Message): void => {
2862
+ if (this.isManualCloseInFlight()) {
2863
+ return;
2864
+ }
2865
+ if (message.direction === "outgoing" && !message.forward) {
2866
+ void this.forward(message);
2867
+ }
2868
+
2869
+ if (
2870
+ message.direction === "incoming" &&
2871
+ message.recipient === message.sender
2872
+ ) {
2873
+ return;
2874
+ }
2875
+ void this.database.saveMessage(message);
2876
+ };
2877
+
2881
2878
  private ping() {
2882
2879
  if (!this.isAlive) {
2883
2880
  }
@@ -3022,9 +3019,7 @@ export class Client {
3022
3019
  void this.createSession(
3023
3020
  deviceEntry,
3024
3021
  user,
3025
- XUtils.decodeUTF8(
3026
- `��RETRY_REQUEST:${mail.mailID}��`,
3027
- ),
3022
+ new Uint8Array(),
3028
3023
  mail.group,
3029
3024
  uuid.v4(),
3030
3025
  false,
@@ -3064,10 +3059,10 @@ export class Client {
3064
3059
  "readMail initial: abort (otk index mismatch)",
3065
3060
  {
3066
3061
  mailID: mail.mailID,
3067
- preKeyIndex: String(preKeyIndex),
3068
3062
  otkIndex: String(
3069
3063
  otk?.index ?? "null",
3070
3064
  ),
3065
+ preKeyIndex: String(preKeyIndex),
3071
3066
  thisDevice:
3072
3067
  this.getDevice().deviceID,
3073
3068
  },
@@ -3104,8 +3099,8 @@ export class Client {
3104
3099
  debugLibvexDm(
3105
3100
  "readMail initial: abort (IK_A null, Ed→X25519?)",
3106
3101
  {
3107
- mailID: mail.mailID,
3108
3102
  fips: String(fipsRead),
3103
+ mailID: mail.mailID,
3109
3104
  thisDevice:
3110
3105
  this.getDevice().deviceID,
3111
3106
  },
@@ -3232,12 +3227,12 @@ export class Client {
3232
3227
  "readMail initial: ok (emit message)",
3233
3228
  {
3234
3229
  mailID: mail.mailID,
3235
- preKeyIndex: String(preKeyIndex),
3236
- thisDevice:
3237
- this.getDevice().deviceID,
3238
3230
  plaintextLen: String(
3239
3231
  plaintext.length,
3240
3232
  ),
3233
+ preKeyIndex: String(preKeyIndex),
3234
+ thisDevice:
3235
+ this.getDevice().deviceID,
3241
3236
  },
3242
3237
  );
3243
3238
  } catch {
@@ -3384,27 +3379,10 @@ export class Client {
3384
3379
  );
3385
3380
  } else {
3386
3381
  void healSession();
3387
-
3388
- // emit the message
3389
- const message: Message = {
3390
- authorID: mail.authorID,
3391
- decrypted: false,
3392
- direction: "incoming",
3393
- forward: mail.forward,
3394
- group: mail.group
3395
- ? uuid.stringify(mail.group)
3396
- : null,
3382
+ this.emitter.emit("retryRequest", {
3397
3383
  mailID: mail.mailID,
3398
- message: "",
3399
- nonce: XUtils.encodeHex(
3400
- new Uint8Array(mail.nonce),
3401
- ),
3402
- readerID: mail.readerID,
3403
- recipient: mail.recipient,
3404
- sender: mail.sender,
3405
- timestamp: timestamp,
3406
- };
3407
- this.emitter.emit("message", message);
3384
+ source: "decrypt_failure",
3385
+ });
3408
3386
  }
3409
3387
  break;
3410
3388
  }
@@ -3480,6 +3458,18 @@ export class Client {
3480
3458
  return decodeAxios(DeviceRegistrationResultCodec, res.data);
3481
3459
  }
3482
3460
 
3461
+ private async rejectDeviceRequest(requestID: string): Promise<void> {
3462
+ await this.http.post(
3463
+ this.prefixes.HTTP +
3464
+ this.host +
3465
+ "/user/" +
3466
+ this.getUser().userID +
3467
+ "/devices/requests/" +
3468
+ requestID +
3469
+ "/reject",
3470
+ );
3471
+ }
3472
+
3483
3473
  private async respond(msg: ChallMsg) {
3484
3474
  const response: RespMsg = {
3485
3475
  signed: await xSignAsync(
@@ -3602,6 +3592,27 @@ export class Client {
3602
3592
  return device;
3603
3593
  }
3604
3594
 
3595
+ /**
3596
+ * `xDHAsync` and other helpers in `@vex-chat/crypto` use the process-wide
3597
+ * active profile. When several {@link Client} instances use different
3598
+ * `cryptoProfile` values, scope the global to this instance for the duration
3599
+ * of that crypto work.
3600
+ */
3601
+ private async runWithThisCryptoProfile<T>(
3602
+ fn: () => Promise<T>,
3603
+ ): Promise<T> {
3604
+ const prev = getCryptoProfile();
3605
+ if (prev === this.cryptoProfile) {
3606
+ return await fn();
3607
+ }
3608
+ setCryptoProfile(this.cryptoProfile);
3609
+ try {
3610
+ return await fn();
3611
+ } finally {
3612
+ setCryptoProfile(prev);
3613
+ }
3614
+ }
3615
+
3605
3616
  /* header is 32 bytes and is either empty
3606
3617
  or contains an HMAC of the message with
3607
3618
  a derived SK */
@@ -3679,9 +3690,9 @@ export class Client {
3679
3690
  if (!session || retry) {
3680
3691
  if (libvexDebugDmEnabled()) {
3681
3692
  debugLibvexDm("sendMail: createSession path", {
3693
+ hasSession: String(!!session),
3682
3694
  peerDevice: device.deviceID,
3683
3695
  retry: String(retry),
3684
- hasSession: String(!!session),
3685
3696
  });
3686
3697
  }
3687
3698
  await this.createSession(
@@ -3830,11 +3841,11 @@ export class Client {
3830
3841
  debugLibvexDm(
3831
3842
  "sendMessage: peer device list (merged, sorted)",
3832
3843
  {
3833
- userID,
3834
3844
  nAfterBackoff: String(afterBackoff.length),
3835
3845
  nMerged: String(deviceListRaw.length),
3836
3846
  nSorted: String(deviceList.length),
3837
3847
  ourDevice: this.getDevice().deviceID,
3848
+ userID,
3838
3849
  },
3839
3850
  );
3840
3851
  for (const [i, d] of deviceList.entries()) {
@@ -3852,8 +3863,8 @@ export class Client {
3852
3863
  try {
3853
3864
  if (libvexDebugDmEnabled()) {
3854
3865
  debugLibvexDm("sendMessage: sendMail start", {
3855
- recipientDevice: device.deviceID,
3856
3866
  mailID: messageMailID,
3867
+ recipientDevice: device.deviceID,
3857
3868
  });
3858
3869
  }
3859
3870
  await this.sendMail(