@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/dist/Client.js CHANGED
@@ -11,12 +11,49 @@ import * as uuid from "uuid";
11
11
  import { z } from "zod/v4";
12
12
  import { WebSocketAdapter } from "./transport/websocket.js";
13
13
  import { decodeFipsInitialExtraV1, decodeFipsSubsequentExtraV1, encodeFipsInitialExtraV1, encodeFipsSubsequentExtraV1, fipsP256AdFromIdentityPubs, fipsP256PreKeySignPayload, isFipsInitialExtraV1, isFipsSubsequentExtraV1, } from "./utils/fipsMailExtra.js";
14
- function sleep(ms) {
15
- return new Promise((resolve) => setTimeout(resolve, ms));
14
+ function debugLibvexDm(msg, data) {
15
+ if (!libvexDebugDmEnabled()) {
16
+ return;
17
+ }
18
+ const payload = data ? `${msg} ${JSON.stringify(data)}` : msg;
19
+ // eslint-disable-next-line no-console -- gated by LIBVEX_DEBUG_DM; remove when debugging is done
20
+ console.error(`[libvex:debug-dm] ${payload}`);
16
21
  }
17
22
  function isRecord(x) {
18
23
  return typeof x === "object" && x !== null;
19
24
  }
25
+ /**
26
+ * Set `LIBVEX_DEBUG_DM=1` (e.g. in vitest / shell) to log DM multi-device / X3DH paths.
27
+ * Uses indirect `globalThis` lookup so the bare `process` global never appears in
28
+ * source that the platform-guard plugin scans (browser/RN/Tauri).
29
+ */
30
+ function libvexDebugDmEnabled() {
31
+ try {
32
+ const g = Object.getOwnPropertyDescriptor(globalThis, "\u0070rocess");
33
+ if (!g) {
34
+ return false;
35
+ }
36
+ const proc = typeof g.get === "function" ? g.get() : g.value;
37
+ if (typeof proc !== "object" || proc === null) {
38
+ return false;
39
+ }
40
+ const envDesc = Object.getOwnPropertyDescriptor(proc, "env");
41
+ if (!envDesc) {
42
+ return false;
43
+ }
44
+ const env = typeof envDesc.get === "function" ? envDesc.get() : envDesc.value;
45
+ if (typeof env !== "object" || env === null) {
46
+ return false;
47
+ }
48
+ return Reflect.get(env, "LIBVEX_DEBUG_DM") === "1";
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }
54
+ function sleep(ms) {
55
+ return new Promise((resolve) => setTimeout(resolve, ms));
56
+ }
20
57
  /**
21
58
  * Spire 5+ JSON error bodies use `{ "error": { "message", "requestId"?, "details"? } }`.
22
59
  * Responses are `arraybuffer` — decode UTF-8 and parse for a one-line `Error` message
@@ -69,43 +106,6 @@ function spireErrorBodyMessage(data, max = 8_000) {
69
106
  }
70
107
  return t.length > max ? t.slice(0, max) + "…" : t;
71
108
  }
72
- /**
73
- * Set `LIBVEX_DEBUG_DM=1` (e.g. in vitest / shell) to log DM multi-device / X3DH paths.
74
- * Uses indirect `globalThis` lookup so the bare `process` global never appears in
75
- * source that the platform-guard plugin scans (browser/RN/Tauri).
76
- */
77
- function libvexDebugDmEnabled() {
78
- try {
79
- const g = Object.getOwnPropertyDescriptor(globalThis, "\u0070rocess");
80
- if (!g) {
81
- return false;
82
- }
83
- const proc = typeof g.get === "function" ? g.get() : g.value;
84
- if (typeof proc !== "object" || proc === null) {
85
- return false;
86
- }
87
- const envDesc = Object.getOwnPropertyDescriptor(proc, "env");
88
- if (!envDesc) {
89
- return false;
90
- }
91
- const env = typeof envDesc.get === "function" ? envDesc.get() : envDesc.value;
92
- if (typeof env !== "object" || env === null) {
93
- return false;
94
- }
95
- return Reflect.get(env, "LIBVEX_DEBUG_DM") === "1";
96
- }
97
- catch {
98
- return false;
99
- }
100
- }
101
- function debugLibvexDm(msg, data) {
102
- if (!libvexDebugDmEnabled()) {
103
- return;
104
- }
105
- const payload = data ? `${msg} ${JSON.stringify(data)}` : msg;
106
- // eslint-disable-next-line no-console -- gated by LIBVEX_DEBUG_DM; remove when debugging is done
107
- console.error(`[libvex:debug-dm] ${payload}`);
108
- }
109
109
  import { msgpack } from "./codec.js";
110
110
  import { ActionTokenCodec, AuthResponseCodec, ChannelArrayCodec, ChannelCodec, ConnectResponseCodec, decodeAxios, DeviceArrayCodec, DeviceChallengeCodec, DeviceCodec, DeviceRegistrationResultCodec, EmojiArrayCodec, EmojiCodec, FileSQLCodec, InviteArrayCodec, InviteCodec, KeyBundleCodec, OtkCountCodec, PendingDeviceRequestArrayCodec, PendingDeviceRequestCodec, PermissionArrayCodec, PermissionCodec, ServerArrayCodec, ServerCodec, UserArrayCodec, UserCodec, WhoamiCodec, } from "./codecs.js";
111
111
  import { capitalize } from "./utils/capitalize.js";
@@ -141,6 +141,12 @@ const deviceRequestNotifyData = z.object({
141
141
  z.literal("rejected"),
142
142
  ]),
143
143
  });
144
+ const retryRequestNotifyData = z.union([
145
+ z.string(),
146
+ z.object({
147
+ mailID: z.string(),
148
+ }),
149
+ ]);
144
150
  export class Client {
145
151
  /**
146
152
  * Decrypts a secret key from encrypted data produced by encryptKeyData().
@@ -205,8 +211,8 @@ export class Client {
205
211
  delete: this.deleteDevice.bind(this),
206
212
  getRequest: this.getDeviceRegistrationRequest.bind(this),
207
213
  listRequests: this.listDeviceRegistrationRequests.bind(this),
208
- rejectRequest: this.rejectDeviceRequest.bind(this),
209
214
  register: this.registerDevice.bind(this),
215
+ rejectRequest: this.rejectDeviceRequest.bind(this),
210
216
  retrieve: this.getDeviceByID.bind(this),
211
217
  };
212
218
  /**
@@ -409,6 +415,7 @@ export class Client {
409
415
  */
410
416
  retrieve: this.fetchUser.bind(this),
411
417
  };
418
+ cryptoProfile;
412
419
  database;
413
420
  dbPath;
414
421
  device;
@@ -419,23 +426,18 @@ export class Client {
419
426
  firstMailFetch = true;
420
427
  forwarded = new Set();
421
428
  host;
422
- /**
423
- * Node-only: per-client HTTP(S) agents (see `init()` + `storage/node/http-agents`).
424
- * Dropped on `close()` so idle keep-alive sockets do not keep the process alive.
425
- */
426
- nodeHttpAgents;
429
+ http;
427
430
  /** Cancels in-flight axios work on `close()` so `postAuth`/`getMail` cannot hang forever. */
428
431
  httpAbortController = new AbortController();
429
- http;
430
432
  idKeys;
431
433
  isAlive = true;
432
434
  mailInterval;
433
435
  manuallyClosing = false;
434
436
  /**
435
- * Bumped when the WebSocket is torn down and re-opened so the previous
436
- * `postAuth` loop exits instead of overlapping a new one.
437
+ * Node-only: per-client HTTP(S) agents (see `init()` + `storage/node/http-agents`).
438
+ * Dropped on `close()` so idle keep-alive sockets do not keep the process alive.
437
439
  */
438
- postAuthVersion = 0;
440
+ nodeHttpAgents;
439
441
  /* Retrieves the userID with the user identifier.
440
442
  user identifier is checked for userID, then signkey,
441
443
  and finally falls back to username. */
@@ -443,6 +445,11 @@ export class Client {
443
445
  notFoundUsers = new Map();
444
446
  options;
445
447
  pingInterval = null;
448
+ /**
449
+ * Bumped when the WebSocket is torn down and re-opened so the previous
450
+ * `postAuth` loop exits instead of overlapping a new one.
451
+ */
452
+ postAuthVersion = 0;
446
453
  prefixes;
447
454
  reading = false;
448
455
  seenMailIDs = new Set();
@@ -454,7 +461,6 @@ export class Client {
454
461
  user;
455
462
  userRecords = {};
456
463
  xKeyRing;
457
- cryptoProfile;
458
464
  constructor(material, options, storage) {
459
465
  this.options = options;
460
466
  this.cryptoProfile = material.cryptoProfile;
@@ -617,27 +623,6 @@ export class Client {
617
623
  static getMnemonic(session) {
618
624
  return xMnemonic(xKDF(XUtils.decodeHex(session.fingerprint)));
619
625
  }
620
- /**
621
- * True when running under Node (has `process.versions`).
622
- * Uses indirect lookup so the bare `process` global never appears in
623
- * source that the platform-guard plugin scans.
624
- */
625
- static isNodeRuntime() {
626
- try {
627
- const g = Object.getOwnPropertyDescriptor(globalThis, "\u0070rocess");
628
- if (!g)
629
- return false;
630
- const proc = typeof g.get === "function" ? g.get() : g.value;
631
- if (typeof proc !== "object" || proc === null) {
632
- return false;
633
- }
634
- return ("versions" in proc &&
635
- typeof proc.versions === "object");
636
- }
637
- catch {
638
- return false;
639
- }
640
- }
641
626
  /**
642
627
  * Browser-safe NODE_ENV accessor.
643
628
  * Uses indirect lookup so the bare `process` global never appears in
@@ -675,12 +660,25 @@ export class Client {
675
660
  }
676
661
  }
677
662
  /**
678
- * Fresh read of the `manuallyClosing` flag for async loops — direct property checks
679
- * after `await` are flagged as always-false by control-flow analysis even though
680
- * `close()` can run concurrently.
663
+ * True when running under Node (has `process.versions`).
664
+ * Uses indirect lookup so the bare `process` global never appears in
665
+ * source that the platform-guard plugin scans.
681
666
  */
682
- isManualCloseInFlight() {
683
- return this.manuallyClosing;
667
+ static isNodeRuntime() {
668
+ try {
669
+ const g = Object.getOwnPropertyDescriptor(globalThis, "\u0070rocess");
670
+ if (!g)
671
+ return false;
672
+ const proc = typeof g.get === "function" ? g.get() : g.value;
673
+ if (typeof proc !== "object" || proc === null) {
674
+ return false;
675
+ }
676
+ return ("versions" in proc &&
677
+ typeof proc.versions === "object");
678
+ }
679
+ catch {
680
+ return false;
681
+ }
684
682
  }
685
683
  /**
686
684
  * Closes the client — disconnects the WebSocket, shuts down storage,
@@ -735,57 +733,6 @@ export class Client {
735
733
  await new Promise((r) => setTimeout(r, 0));
736
734
  await this.negotiateOTK();
737
735
  }
738
- /**
739
- * Tears down the current WebSocket and opens a new one, keeping the same
740
- * session (user + device in storage). Restarts the post-auth mail loop.
741
- * Use for long-running processes or e2e where a fresh socket matches a
742
- * newly-registered second device.
743
- */
744
- async reconnectWebsocket() {
745
- this.postAuthVersion++;
746
- if (this.pingInterval) {
747
- clearInterval(this.pingInterval);
748
- this.pingInterval = null;
749
- }
750
- this.socket.close();
751
- try {
752
- await new Promise((resolve, reject) => {
753
- const t = setTimeout(() => {
754
- this.off("connected", onC);
755
- reject(new Error("reconnectWebsocket: timed out waiting for authorized"));
756
- }, 15_000);
757
- const onC = () => {
758
- clearTimeout(t);
759
- this.off("connected", onC);
760
- resolve();
761
- };
762
- this.on("connected", onC);
763
- try {
764
- this.initSocket();
765
- }
766
- catch (err) {
767
- clearTimeout(t);
768
- this.off("connected", onC);
769
- const e = err instanceof Error
770
- ? err
771
- : new Error(String(err), { cause: err });
772
- reject(e);
773
- }
774
- });
775
- }
776
- catch (e) {
777
- throw e instanceof Error ? e : new Error(String(e), { cause: e });
778
- }
779
- await new Promise((r) => setTimeout(r, 0));
780
- await this.negotiateOTK();
781
- }
782
- /**
783
- * Triggers an immediate inbox sync by fetching `/mail` once.
784
- * Useful on mobile foreground resume where background work may pause.
785
- */
786
- async syncInboxNow() {
787
- await this.getMail();
788
- }
789
736
  /**
790
737
  * Delete all local data — message history, encryption sessions, and prekeys.
791
738
  * Closes the client afterward. Credentials (keychain) must be cleared by the consumer.
@@ -911,6 +858,50 @@ export class Client {
911
858
  this.emitter.once(event, fn, context);
912
859
  return this;
913
860
  }
861
+ /**
862
+ * Tears down the current WebSocket and opens a new one, keeping the same
863
+ * session (user + device in storage). Restarts the post-auth mail loop.
864
+ * Use for long-running processes or e2e where a fresh socket matches a
865
+ * newly-registered second device.
866
+ */
867
+ async reconnectWebsocket() {
868
+ this.postAuthVersion++;
869
+ if (this.pingInterval) {
870
+ clearInterval(this.pingInterval);
871
+ this.pingInterval = null;
872
+ }
873
+ this.socket.close();
874
+ try {
875
+ await new Promise((resolve, reject) => {
876
+ const t = setTimeout(() => {
877
+ this.off("connected", onC);
878
+ reject(new Error("reconnectWebsocket: timed out waiting for authorized"));
879
+ }, 15_000);
880
+ const onC = () => {
881
+ clearTimeout(t);
882
+ this.off("connected", onC);
883
+ resolve();
884
+ };
885
+ this.on("connected", onC);
886
+ try {
887
+ this.initSocket();
888
+ }
889
+ catch (err) {
890
+ clearTimeout(t);
891
+ this.off("connected", onC);
892
+ const e = err instanceof Error
893
+ ? err
894
+ : new Error(String(err), { cause: err });
895
+ reject(e);
896
+ }
897
+ });
898
+ }
899
+ catch (e) {
900
+ throw e instanceof Error ? e : new Error(String(e), { cause: e });
901
+ }
902
+ await new Promise((r) => setTimeout(r, 0));
903
+ await this.negotiateOTK();
904
+ }
914
905
  /**
915
906
  * Registers a new account on the server.
916
907
  *
@@ -968,6 +959,13 @@ export class Client {
968
959
  this.emitter.removeAllListeners(event);
969
960
  return this;
970
961
  }
962
+ /**
963
+ * Triggers an immediate inbox sync by fetching `/mail` once.
964
+ * Useful on mobile foreground resume where background work may pause.
965
+ */
966
+ async syncInboxNow() {
967
+ await this.getMail();
968
+ }
971
969
  /**
972
970
  * Returns a compact `<username><deviceID>` debug label.
973
971
  */
@@ -993,6 +991,24 @@ export class Client {
993
991
  const whoami = decodeAxios(WhoamiCodec, res.data);
994
992
  return whoami;
995
993
  }
994
+ async approveDeviceRequest(requestID) {
995
+ const req = await this.getDeviceRegistrationRequest(requestID);
996
+ if (!req) {
997
+ throw new Error("Device approval request not found.");
998
+ }
999
+ if (req.status !== "pending") {
1000
+ throw new Error("Device approval request is not pending: " + req.status);
1001
+ }
1002
+ const signed = XUtils.encodeHex(await xSignAsync(XUtils.decodeUTF8(requestID), this.signKeys.secretKey));
1003
+ const response = await this.http.post(this.prefixes.HTTP +
1004
+ this.host +
1005
+ "/user/" +
1006
+ this.getUser().userID +
1007
+ "/devices/requests/" +
1008
+ requestID +
1009
+ "/approve", msgpack.encode({ signed }), { headers: { "Content-Type": "application/msgpack" } });
1010
+ return decodeAxios(DeviceCodec, response.data);
1011
+ }
996
1012
  censorPreKey(preKey) {
997
1013
  if (!preKey.index) {
998
1014
  throw new Error("Key index is required.");
@@ -1073,25 +1089,6 @@ export class Client {
1073
1089
  const res = await this.http.post(this.getHost() + "/server/" + globalThis.btoa(name));
1074
1090
  return decodeAxios(ServerCodec, res.data);
1075
1091
  }
1076
- /**
1077
- * `xDHAsync` and other helpers in `@vex-chat/crypto` use the process-wide
1078
- * active profile. When several {@link Client} instances use different
1079
- * `cryptoProfile` values, scope the global to this instance for the duration
1080
- * of that crypto work.
1081
- */
1082
- async runWithThisCryptoProfile(fn) {
1083
- const prev = getCryptoProfile();
1084
- if (prev === this.cryptoProfile) {
1085
- return await fn();
1086
- }
1087
- setCryptoProfile(this.cryptoProfile);
1088
- try {
1089
- return await fn();
1090
- }
1091
- finally {
1092
- setCryptoProfile(prev);
1093
- }
1094
- }
1095
1092
  async createSession(device, user, message, group,
1096
1093
  /* this is passed through if the first message is
1097
1094
  part of a group message */
@@ -1244,71 +1241,19 @@ export class Client {
1244
1241
  });
1245
1242
  });
1246
1243
  }
1247
- async deleteChannel(channelID) {
1248
- await this.http.delete(this.getHost() + "/channel/" + channelID);
1249
- }
1250
- async approveDeviceRequest(requestID) {
1251
- const req = await this.getDeviceRegistrationRequest(requestID);
1252
- if (!req) {
1253
- throw new Error("Device approval request not found.");
1254
- }
1255
- if (req.status !== "pending") {
1256
- throw new Error("Device approval request is not pending: " + req.status);
1257
- }
1258
- const signed = XUtils.encodeHex(await xSignAsync(XUtils.decodeUTF8(requestID), this.signKeys.secretKey));
1259
- const response = await this.http.post(this.prefixes.HTTP +
1260
- this.host +
1261
- "/user/" +
1262
- this.getUser().userID +
1263
- "/devices/requests/" +
1264
- requestID +
1265
- "/approve", msgpack.encode({ signed }), { headers: { "Content-Type": "application/msgpack" } });
1266
- return decodeAxios(DeviceCodec, response.data);
1267
- }
1268
- async deleteDevice(deviceID) {
1269
- if (deviceID === this.getDevice().deviceID) {
1270
- throw new Error("You can't delete the device you're logged in to.");
1271
- }
1272
- await this.http.delete(this.prefixes.HTTP +
1273
- this.host +
1274
- "/user/" +
1275
- this.getUser().userID +
1276
- "/devices/" +
1277
- deviceID);
1278
- }
1279
- async getDeviceRegistrationRequest(requestID) {
1280
- try {
1281
- const response = await this.http.get(this.prefixes.HTTP +
1282
- this.host +
1283
- "/user/" +
1284
- this.getUser().userID +
1285
- "/devices/requests/" +
1286
- requestID);
1287
- return decodeAxios(PendingDeviceRequestCodec, response.data);
1288
- }
1289
- catch (err) {
1290
- if (isAxiosError(err) && err.response?.status === 404) {
1291
- return null;
1292
- }
1293
- throw err;
1294
- }
1295
- }
1296
- async listDeviceRegistrationRequests() {
1297
- const response = await this.http.get(this.prefixes.HTTP +
1298
- this.host +
1299
- "/user/" +
1300
- this.getUser().userID +
1301
- "/devices/requests");
1302
- return decodeAxios(PendingDeviceRequestArrayCodec, response.data);
1303
- }
1304
- async rejectDeviceRequest(requestID) {
1305
- await this.http.post(this.prefixes.HTTP +
1244
+ async deleteChannel(channelID) {
1245
+ await this.http.delete(this.getHost() + "/channel/" + channelID);
1246
+ }
1247
+ async deleteDevice(deviceID) {
1248
+ if (deviceID === this.getDevice().deviceID) {
1249
+ throw new Error("You can't delete the device you're logged in to.");
1250
+ }
1251
+ await this.http.delete(this.prefixes.HTTP +
1306
1252
  this.host +
1307
1253
  "/user/" +
1308
1254
  this.getUser().userID +
1309
- "/devices/requests/" +
1310
- requestID +
1311
- "/reject");
1255
+ "/devices/" +
1256
+ deviceID);
1312
1257
  }
1313
1258
  async deleteHistory(channelOrUserID) {
1314
1259
  await this.database.deleteHistory(channelOrUserID);
@@ -1319,6 +1264,19 @@ export class Client {
1319
1264
  async deleteServer(serverID) {
1320
1265
  await this.http.delete(this.getHost() + "/server/" + serverID);
1321
1266
  }
1267
+ deviceListFailureDetail(err) {
1268
+ if (!isAxiosError(err)) {
1269
+ return "";
1270
+ }
1271
+ const st = err.response?.status;
1272
+ if (typeof st === "number") {
1273
+ return ` (HTTP ${String(st)})`;
1274
+ }
1275
+ if (err.code !== undefined) {
1276
+ return ` (${err.code})`;
1277
+ }
1278
+ return "";
1279
+ }
1322
1280
  /**
1323
1281
  * Gets a list of permissions for a server.
1324
1282
  *
@@ -1359,6 +1317,50 @@ export class Client {
1359
1317
  return [null, isAxiosError(err) ? err : null];
1360
1318
  }
1361
1319
  }
1320
+ async fetchUserDeviceListOnce(userID) {
1321
+ if (this.isManualCloseInFlight()) {
1322
+ return [];
1323
+ }
1324
+ const res = await this.http.get(this.getHost() + "/user/" + userID + "/devices");
1325
+ const devices = decodeAxios(DeviceArrayCodec, res.data);
1326
+ for (const device of devices) {
1327
+ this.deviceRecords[device.deviceID] = device;
1328
+ }
1329
+ return devices;
1330
+ }
1331
+ /**
1332
+ * DM / forward paths need the peer’s (or self) device rows under load: bounded
1333
+ * retries with exponential backoff (same shape as session pubkey hydration).
1334
+ */
1335
+ async fetchUserDeviceListWithBackoff(userID, label) {
1336
+ const base = label === "own"
1337
+ ? "Couldn't get own devices"
1338
+ : "Couldn't get device list";
1339
+ let lastErr;
1340
+ for (let attempt = 0; attempt < 5; attempt++) {
1341
+ if (this.isManualCloseInFlight()) {
1342
+ return [];
1343
+ }
1344
+ if (attempt > 0) {
1345
+ const delayMs = 100 * 2 ** (attempt - 1);
1346
+ // Chunk the delay so close() can finish before we retry HTTP.
1347
+ const chunkMs = 10;
1348
+ for (let elapsed = 0; elapsed < delayMs; elapsed += chunkMs) {
1349
+ if (this.isManualCloseInFlight()) {
1350
+ return [];
1351
+ }
1352
+ await sleep(Math.min(chunkMs, delayMs - elapsed));
1353
+ }
1354
+ }
1355
+ try {
1356
+ return await this.fetchUserDeviceListOnce(userID);
1357
+ }
1358
+ catch (err) {
1359
+ lastErr = err;
1360
+ }
1361
+ }
1362
+ throw new Error(`${base}${this.deviceListFailureDetail(lastErr)}`);
1363
+ }
1362
1364
  async forward(message) {
1363
1365
  if (this.isManualCloseInFlight()) {
1364
1366
  return;
@@ -1427,6 +1429,23 @@ export class Client {
1427
1429
  return null;
1428
1430
  }
1429
1431
  }
1432
+ async getDeviceRegistrationRequest(requestID) {
1433
+ try {
1434
+ const response = await this.http.get(this.prefixes.HTTP +
1435
+ this.host +
1436
+ "/user/" +
1437
+ this.getUser().userID +
1438
+ "/devices/requests/" +
1439
+ requestID);
1440
+ return decodeAxios(PendingDeviceRequestCodec, response.data);
1441
+ }
1442
+ catch (err) {
1443
+ if (isAxiosError(err) && err.response?.status === 404) {
1444
+ return null;
1445
+ }
1446
+ throw err;
1447
+ }
1448
+ }
1430
1449
  /* Retrieves the current list of users you have sessions with. */
1431
1450
  async getFamiliars() {
1432
1451
  const sessions = await this.database.getAllSessions();
@@ -1479,8 +1498,8 @@ export class Client {
1479
1498
  }
1480
1499
  })();
1481
1500
  debugLibvexDm("getMail: inbox", {
1482
- deviceID: did,
1483
1501
  count: String(inbox.length),
1502
+ deviceID: did,
1484
1503
  });
1485
1504
  }
1486
1505
  for (const mailDetails of inbox) {
@@ -1489,8 +1508,8 @@ export class Client {
1489
1508
  if (libvexDebugDmEnabled()) {
1490
1509
  debugLibvexDm("getMail: readMail one", {
1491
1510
  mailID: mailBody.mailID,
1492
- type: String(mailBody.mailType),
1493
1511
  recipient: mailBody.recipient,
1512
+ type: String(mailBody.mailType),
1494
1513
  });
1495
1514
  }
1496
1515
  await this.readMail(mailHeader, mailBody, timestamp);
@@ -1590,19 +1609,6 @@ export class Client {
1590
1609
  }
1591
1610
  return this.user;
1592
1611
  }
1593
- deviceListFailureDetail(err) {
1594
- if (!isAxiosError(err)) {
1595
- return "";
1596
- }
1597
- const st = err.response?.status;
1598
- if (typeof st === "number") {
1599
- return ` (HTTP ${String(st)})`;
1600
- }
1601
- if (err.code !== undefined) {
1602
- return ` (${err.code})`;
1603
- }
1604
- return "";
1605
- }
1606
1612
  /**
1607
1613
  * Single GET for `/user/:id/devices`. On failure returns `null` (swallows errors)
1608
1614
  * — callers that need reliability should use `fetchUserDeviceListWithBackoff`.
@@ -1617,60 +1623,12 @@ export class Client {
1617
1623
  return null;
1618
1624
  }
1619
1625
  }
1620
- async fetchUserDeviceListOnce(userID) {
1621
- if (this.isManualCloseInFlight()) {
1622
- return [];
1623
- }
1624
- const res = await this.http.get(this.getHost() + "/user/" + userID + "/devices");
1625
- const devices = decodeAxios(DeviceArrayCodec, res.data);
1626
- for (const device of devices) {
1627
- this.deviceRecords[device.deviceID] = device;
1628
- }
1629
- return devices;
1630
- }
1631
- /**
1632
- * DM / forward paths need the peer’s (or self) device rows under load: bounded
1633
- * retries with exponential backoff (same shape as session pubkey hydration).
1634
- */
1635
- async fetchUserDeviceListWithBackoff(userID, label) {
1636
- const base = label === "own"
1637
- ? "Couldn't get own devices"
1638
- : "Couldn't get device list";
1639
- let lastErr;
1640
- for (let attempt = 0; attempt < 5; attempt++) {
1641
- if (this.isManualCloseInFlight()) {
1642
- return [];
1643
- }
1644
- if (attempt > 0) {
1645
- const delayMs = 100 * 2 ** (attempt - 1);
1646
- // Chunk the delay so close() can finish before we retry HTTP.
1647
- const chunkMs = 10;
1648
- for (let elapsed = 0; elapsed < delayMs; elapsed += chunkMs) {
1649
- if (this.isManualCloseInFlight()) {
1650
- return [];
1651
- }
1652
- await sleep(Math.min(chunkMs, delayMs - elapsed));
1653
- }
1654
- }
1655
- try {
1656
- return await this.fetchUserDeviceListOnce(userID);
1657
- }
1658
- catch (err) {
1659
- lastErr = err;
1660
- }
1661
- }
1662
- throw new Error(`${base}${this.deviceListFailureDetail(lastErr)}`);
1663
- }
1664
1626
  async getUserList(channelID) {
1665
1627
  const res = await this.http.post(this.getHost() + "/userList/" + channelID);
1666
1628
  return decodeAxios(UserArrayCodec, res.data);
1667
1629
  }
1668
1630
  async handleNotify(msg) {
1669
1631
  switch (msg.event) {
1670
- case "mail":
1671
- await this.getMail();
1672
- this.fetchingMail = false;
1673
- break;
1674
1632
  case "deviceRequest": {
1675
1633
  const parsed = deviceRequestNotifyData.safeParse(msg.data);
1676
1634
  if (parsed.success) {
@@ -1678,34 +1636,31 @@ export class Client {
1678
1636
  }
1679
1637
  break;
1680
1638
  }
1639
+ case "mail":
1640
+ await this.getMail();
1641
+ this.fetchingMail = false;
1642
+ break;
1681
1643
  case "permission":
1682
1644
  this.emitter.emit("permission", PermissionSchema.parse(msg.data));
1683
1645
  break;
1684
1646
  case "retryRequest":
1685
- // msg.data is the messageID for retry
1647
+ {
1648
+ const parsed = retryRequestNotifyData.safeParse(msg.data);
1649
+ if (parsed.success) {
1650
+ const mailID = typeof parsed.data === "string"
1651
+ ? parsed.data
1652
+ : parsed.data.mailID;
1653
+ this.emitter.emit("retryRequest", {
1654
+ mailID,
1655
+ source: "server_notify",
1656
+ });
1657
+ }
1658
+ }
1686
1659
  break;
1687
1660
  default:
1688
1661
  break;
1689
1662
  }
1690
1663
  }
1691
- /**
1692
- * Pipeline for decrypted messages — registered in `init`. After `close()` sets
1693
- * `manuallyClosing`, this becomes a no-op so fire-and-forget `forward` does not
1694
- * race HTTP teardown (we avoid `off()` here — it can interact badly with emit).
1695
- */
1696
- onInternalMessage = (message) => {
1697
- if (this.isManualCloseInFlight()) {
1698
- return;
1699
- }
1700
- if (message.direction === "outgoing" && !message.forward) {
1701
- void this.forward(message);
1702
- }
1703
- if (message.direction === "incoming" &&
1704
- message.recipient === message.sender) {
1705
- return;
1706
- }
1707
- void this.database.saveMessage(message);
1708
- };
1709
1664
  /**
1710
1665
  * Initializes the keyring. This must be called before anything else.
1711
1666
  */
@@ -1793,6 +1748,14 @@ export class Client {
1793
1748
  throw new Error("Error initiating websocket connection " + String(err));
1794
1749
  }
1795
1750
  }
1751
+ /**
1752
+ * Fresh read of the `manuallyClosing` flag for async loops — direct property checks
1753
+ * after `await` are flagged as always-false by control-flow analysis even though
1754
+ * `close()` can run concurrently.
1755
+ */
1756
+ isManualCloseInFlight() {
1757
+ return this.manuallyClosing;
1758
+ }
1796
1759
  async kickUser(userID, serverID) {
1797
1760
  const permissionList = await this.fetchPermissionList(serverID);
1798
1761
  for (const permission of permissionList) {
@@ -1811,6 +1774,14 @@ export class Client {
1811
1774
  }
1812
1775
  }
1813
1776
  }
1777
+ async listDeviceRegistrationRequests() {
1778
+ const response = await this.http.get(this.prefixes.HTTP +
1779
+ this.host +
1780
+ "/user/" +
1781
+ this.getUser().userID +
1782
+ "/devices/requests");
1783
+ return decodeAxios(PendingDeviceRequestArrayCodec, response.data);
1784
+ }
1814
1785
  async markSessionVerified(sessionID) {
1815
1786
  return this.database.markSessionVerified(sessionID);
1816
1787
  }
@@ -1831,6 +1802,24 @@ export class Client {
1831
1802
  }
1832
1803
  this.xKeyRing.ephemeralKeys = await xBoxKeyPairAsync();
1833
1804
  }
1805
+ /**
1806
+ * Pipeline for decrypted messages — registered in `init`. After `close()` sets
1807
+ * `manuallyClosing`, this becomes a no-op so fire-and-forget `forward` does not
1808
+ * race HTTP teardown (we avoid `off()` here — it can interact badly with emit).
1809
+ */
1810
+ onInternalMessage = (message) => {
1811
+ if (this.isManualCloseInFlight()) {
1812
+ return;
1813
+ }
1814
+ if (message.direction === "outgoing" && !message.forward) {
1815
+ void this.forward(message);
1816
+ }
1817
+ if (message.direction === "incoming" &&
1818
+ message.recipient === message.sender) {
1819
+ return;
1820
+ }
1821
+ void this.database.saveMessage(message);
1822
+ };
1834
1823
  ping() {
1835
1824
  if (!this.isAlive) {
1836
1825
  }
@@ -1951,7 +1940,7 @@ export class Client {
1951
1940
  const deviceEntry = await this.getDeviceByID(mail.sender);
1952
1941
  const [user, _err] = await this.fetchUser(mail.authorID);
1953
1942
  if (deviceEntry && user) {
1954
- void this.createSession(deviceEntry, user, XUtils.decodeUTF8(`��RETRY_REQUEST:${mail.mailID}��`), mail.group, uuid.v4(), false, true);
1943
+ void this.createSession(deviceEntry, user, new Uint8Array(), mail.group, uuid.v4(), false, true);
1955
1944
  }
1956
1945
  };
1957
1946
  switch (mail.mailType) {
@@ -1972,8 +1961,8 @@ export class Client {
1972
1961
  try {
1973
1962
  debugLibvexDm("readMail initial: abort (otk index mismatch)", {
1974
1963
  mailID: mail.mailID,
1975
- preKeyIndex: String(preKeyIndex),
1976
1964
  otkIndex: String(otk?.index ?? "null"),
1965
+ preKeyIndex: String(preKeyIndex),
1977
1966
  thisDevice: this.getDevice().deviceID,
1978
1967
  });
1979
1968
  }
@@ -2000,8 +1989,8 @@ export class Client {
2000
1989
  if (libvexDebugDmEnabled()) {
2001
1990
  try {
2002
1991
  debugLibvexDm("readMail initial: abort (IK_A null, Ed→X25519?)", {
2003
- mailID: mail.mailID,
2004
1992
  fips: String(fipsRead),
1993
+ mailID: mail.mailID,
2005
1994
  thisDevice: this.getDevice().deviceID,
2006
1995
  });
2007
1996
  }
@@ -2095,9 +2084,9 @@ export class Client {
2095
2084
  try {
2096
2085
  debugLibvexDm("readMail initial: ok (emit message)", {
2097
2086
  mailID: mail.mailID,
2087
+ plaintextLen: String(plaintext.length),
2098
2088
  preKeyIndex: String(preKeyIndex),
2099
2089
  thisDevice: this.getDevice().deviceID,
2100
- plaintextLen: String(plaintext.length),
2101
2090
  });
2102
2091
  }
2103
2092
  catch {
@@ -2209,24 +2198,10 @@ export class Client {
2209
2198
  }
2210
2199
  else {
2211
2200
  void healSession();
2212
- // emit the message
2213
- const message = {
2214
- authorID: mail.authorID,
2215
- decrypted: false,
2216
- direction: "incoming",
2217
- forward: mail.forward,
2218
- group: mail.group
2219
- ? uuid.stringify(mail.group)
2220
- : null,
2201
+ this.emitter.emit("retryRequest", {
2221
2202
  mailID: mail.mailID,
2222
- message: "",
2223
- nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
2224
- readerID: mail.readerID,
2225
- recipient: mail.recipient,
2226
- sender: mail.sender,
2227
- timestamp: timestamp,
2228
- };
2229
- this.emitter.emit("message", message);
2203
+ source: "decrypt_failure",
2204
+ });
2230
2205
  }
2231
2206
  break;
2232
2207
  }
@@ -2284,6 +2259,15 @@ export class Client {
2284
2259
  "/devices", msgpack.encode(devMsg), { headers: { "Content-Type": "application/msgpack" } });
2285
2260
  return decodeAxios(DeviceRegistrationResultCodec, res.data);
2286
2261
  }
2262
+ async rejectDeviceRequest(requestID) {
2263
+ await this.http.post(this.prefixes.HTTP +
2264
+ this.host +
2265
+ "/user/" +
2266
+ this.getUser().userID +
2267
+ "/devices/requests/" +
2268
+ requestID +
2269
+ "/reject");
2270
+ }
2287
2271
  async respond(msg) {
2288
2272
  const response = {
2289
2273
  signed: await xSignAsync(new Uint8Array(msg.challenge), this.signKeys.secretKey),
@@ -2370,6 +2354,25 @@ export class Client {
2370
2354
  }
2371
2355
  return device;
2372
2356
  }
2357
+ /**
2358
+ * `xDHAsync` and other helpers in `@vex-chat/crypto` use the process-wide
2359
+ * active profile. When several {@link Client} instances use different
2360
+ * `cryptoProfile` values, scope the global to this instance for the duration
2361
+ * of that crypto work.
2362
+ */
2363
+ async runWithThisCryptoProfile(fn) {
2364
+ const prev = getCryptoProfile();
2365
+ if (prev === this.cryptoProfile) {
2366
+ return await fn();
2367
+ }
2368
+ setCryptoProfile(this.cryptoProfile);
2369
+ try {
2370
+ return await fn();
2371
+ }
2372
+ finally {
2373
+ setCryptoProfile(prev);
2374
+ }
2375
+ }
2373
2376
  /* header is 32 bytes and is either empty
2374
2377
  or contains an HMAC of the message with
2375
2378
  a derived SK */
@@ -2419,9 +2422,9 @@ export class Client {
2419
2422
  if (!session || retry) {
2420
2423
  if (libvexDebugDmEnabled()) {
2421
2424
  debugLibvexDm("sendMail: createSession path", {
2425
+ hasSession: String(!!session),
2422
2426
  peerDevice: device.deviceID,
2423
2427
  retry: String(retry),
2424
- hasSession: String(!!session),
2425
2428
  });
2426
2429
  }
2427
2430
  await this.createSession(device, user, msg, group, mailID, forward, false);
@@ -2541,11 +2544,11 @@ export class Client {
2541
2544
  const deviceList = [...deviceListRaw].sort((a, b) => a.deviceID.localeCompare(b.deviceID, "en"));
2542
2545
  if (libvexDebugDmEnabled()) {
2543
2546
  debugLibvexDm("sendMessage: peer device list (merged, sorted)", {
2544
- userID,
2545
2547
  nAfterBackoff: String(afterBackoff.length),
2546
2548
  nMerged: String(deviceListRaw.length),
2547
2549
  nSorted: String(deviceList.length),
2548
2550
  ourDevice: this.getDevice().deviceID,
2551
+ userID,
2549
2552
  });
2550
2553
  for (const [i, d] of deviceList.entries()) {
2551
2554
  debugLibvexDm(`sendMessage: device[${String(i)}]`, {
@@ -2562,8 +2565,8 @@ export class Client {
2562
2565
  try {
2563
2566
  if (libvexDebugDmEnabled()) {
2564
2567
  debugLibvexDm("sendMessage: sendMail start", {
2565
- recipientDevice: device.deviceID,
2566
2568
  mailID: messageMailID,
2569
+ recipientDevice: device.deviceID,
2567
2570
  });
2568
2571
  }
2569
2572
  await this.sendMail(device, userEntry, XUtils.decodeUTF8(message), null, messageMailID, false);