@vex-chat/libvex 5.5.0 → 5.5.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.
Files changed (34) hide show
  1. package/README.md +25 -23
  2. package/dist/Client.d.ts +103 -103
  3. package/dist/Client.d.ts.map +1 -1
  4. package/dist/Client.js +295 -295
  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 +411 -413
  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";
@@ -205,8 +205,8 @@ export class Client {
205
205
  delete: this.deleteDevice.bind(this),
206
206
  getRequest: this.getDeviceRegistrationRequest.bind(this),
207
207
  listRequests: this.listDeviceRegistrationRequests.bind(this),
208
- rejectRequest: this.rejectDeviceRequest.bind(this),
209
208
  register: this.registerDevice.bind(this),
209
+ rejectRequest: this.rejectDeviceRequest.bind(this),
210
210
  retrieve: this.getDeviceByID.bind(this),
211
211
  };
212
212
  /**
@@ -409,6 +409,7 @@ export class Client {
409
409
  */
410
410
  retrieve: this.fetchUser.bind(this),
411
411
  };
412
+ cryptoProfile;
412
413
  database;
413
414
  dbPath;
414
415
  device;
@@ -419,23 +420,18 @@ export class Client {
419
420
  firstMailFetch = true;
420
421
  forwarded = new Set();
421
422
  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;
423
+ http;
427
424
  /** Cancels in-flight axios work on `close()` so `postAuth`/`getMail` cannot hang forever. */
428
425
  httpAbortController = new AbortController();
429
- http;
430
426
  idKeys;
431
427
  isAlive = true;
432
428
  mailInterval;
433
429
  manuallyClosing = false;
434
430
  /**
435
- * Bumped when the WebSocket is torn down and re-opened so the previous
436
- * `postAuth` loop exits instead of overlapping a new one.
431
+ * Node-only: per-client HTTP(S) agents (see `init()` + `storage/node/http-agents`).
432
+ * Dropped on `close()` so idle keep-alive sockets do not keep the process alive.
437
433
  */
438
- postAuthVersion = 0;
434
+ nodeHttpAgents;
439
435
  /* Retrieves the userID with the user identifier.
440
436
  user identifier is checked for userID, then signkey,
441
437
  and finally falls back to username. */
@@ -443,6 +439,11 @@ export class Client {
443
439
  notFoundUsers = new Map();
444
440
  options;
445
441
  pingInterval = null;
442
+ /**
443
+ * Bumped when the WebSocket is torn down and re-opened so the previous
444
+ * `postAuth` loop exits instead of overlapping a new one.
445
+ */
446
+ postAuthVersion = 0;
446
447
  prefixes;
447
448
  reading = false;
448
449
  seenMailIDs = new Set();
@@ -454,7 +455,6 @@ export class Client {
454
455
  user;
455
456
  userRecords = {};
456
457
  xKeyRing;
457
- cryptoProfile;
458
458
  constructor(material, options, storage) {
459
459
  this.options = options;
460
460
  this.cryptoProfile = material.cryptoProfile;
@@ -617,27 +617,6 @@ export class Client {
617
617
  static getMnemonic(session) {
618
618
  return xMnemonic(xKDF(XUtils.decodeHex(session.fingerprint)));
619
619
  }
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
620
  /**
642
621
  * Browser-safe NODE_ENV accessor.
643
622
  * Uses indirect lookup so the bare `process` global never appears in
@@ -675,12 +654,25 @@ export class Client {
675
654
  }
676
655
  }
677
656
  /**
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.
657
+ * True when running under Node (has `process.versions`).
658
+ * Uses indirect lookup so the bare `process` global never appears in
659
+ * source that the platform-guard plugin scans.
681
660
  */
682
- isManualCloseInFlight() {
683
- return this.manuallyClosing;
661
+ static isNodeRuntime() {
662
+ try {
663
+ const g = Object.getOwnPropertyDescriptor(globalThis, "\u0070rocess");
664
+ if (!g)
665
+ return false;
666
+ const proc = typeof g.get === "function" ? g.get() : g.value;
667
+ if (typeof proc !== "object" || proc === null) {
668
+ return false;
669
+ }
670
+ return ("versions" in proc &&
671
+ typeof proc.versions === "object");
672
+ }
673
+ catch {
674
+ return false;
675
+ }
684
676
  }
685
677
  /**
686
678
  * Closes the client — disconnects the WebSocket, shuts down storage,
@@ -735,57 +727,6 @@ export class Client {
735
727
  await new Promise((r) => setTimeout(r, 0));
736
728
  await this.negotiateOTK();
737
729
  }
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
730
  /**
790
731
  * Delete all local data — message history, encryption sessions, and prekeys.
791
732
  * Closes the client afterward. Credentials (keychain) must be cleared by the consumer.
@@ -911,6 +852,50 @@ export class Client {
911
852
  this.emitter.once(event, fn, context);
912
853
  return this;
913
854
  }
855
+ /**
856
+ * Tears down the current WebSocket and opens a new one, keeping the same
857
+ * session (user + device in storage). Restarts the post-auth mail loop.
858
+ * Use for long-running processes or e2e where a fresh socket matches a
859
+ * newly-registered second device.
860
+ */
861
+ async reconnectWebsocket() {
862
+ this.postAuthVersion++;
863
+ if (this.pingInterval) {
864
+ clearInterval(this.pingInterval);
865
+ this.pingInterval = null;
866
+ }
867
+ this.socket.close();
868
+ try {
869
+ await new Promise((resolve, reject) => {
870
+ const t = setTimeout(() => {
871
+ this.off("connected", onC);
872
+ reject(new Error("reconnectWebsocket: timed out waiting for authorized"));
873
+ }, 15_000);
874
+ const onC = () => {
875
+ clearTimeout(t);
876
+ this.off("connected", onC);
877
+ resolve();
878
+ };
879
+ this.on("connected", onC);
880
+ try {
881
+ this.initSocket();
882
+ }
883
+ catch (err) {
884
+ clearTimeout(t);
885
+ this.off("connected", onC);
886
+ const e = err instanceof Error
887
+ ? err
888
+ : new Error(String(err), { cause: err });
889
+ reject(e);
890
+ }
891
+ });
892
+ }
893
+ catch (e) {
894
+ throw e instanceof Error ? e : new Error(String(e), { cause: e });
895
+ }
896
+ await new Promise((r) => setTimeout(r, 0));
897
+ await this.negotiateOTK();
898
+ }
914
899
  /**
915
900
  * Registers a new account on the server.
916
901
  *
@@ -968,6 +953,13 @@ export class Client {
968
953
  this.emitter.removeAllListeners(event);
969
954
  return this;
970
955
  }
956
+ /**
957
+ * Triggers an immediate inbox sync by fetching `/mail` once.
958
+ * Useful on mobile foreground resume where background work may pause.
959
+ */
960
+ async syncInboxNow() {
961
+ await this.getMail();
962
+ }
971
963
  /**
972
964
  * Returns a compact `<username><deviceID>` debug label.
973
965
  */
@@ -993,6 +985,24 @@ export class Client {
993
985
  const whoami = decodeAxios(WhoamiCodec, res.data);
994
986
  return whoami;
995
987
  }
988
+ async approveDeviceRequest(requestID) {
989
+ const req = await this.getDeviceRegistrationRequest(requestID);
990
+ if (!req) {
991
+ throw new Error("Device approval request not found.");
992
+ }
993
+ if (req.status !== "pending") {
994
+ throw new Error("Device approval request is not pending: " + req.status);
995
+ }
996
+ const signed = XUtils.encodeHex(await xSignAsync(XUtils.decodeUTF8(requestID), this.signKeys.secretKey));
997
+ const response = await this.http.post(this.prefixes.HTTP +
998
+ this.host +
999
+ "/user/" +
1000
+ this.getUser().userID +
1001
+ "/devices/requests/" +
1002
+ requestID +
1003
+ "/approve", msgpack.encode({ signed }), { headers: { "Content-Type": "application/msgpack" } });
1004
+ return decodeAxios(DeviceCodec, response.data);
1005
+ }
996
1006
  censorPreKey(preKey) {
997
1007
  if (!preKey.index) {
998
1008
  throw new Error("Key index is required.");
@@ -1073,25 +1083,6 @@ export class Client {
1073
1083
  const res = await this.http.post(this.getHost() + "/server/" + globalThis.btoa(name));
1074
1084
  return decodeAxios(ServerCodec, res.data);
1075
1085
  }
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
1086
  async createSession(device, user, message, group,
1096
1087
  /* this is passed through if the first message is
1097
1088
  part of a group message */
@@ -1244,71 +1235,19 @@ export class Client {
1244
1235
  });
1245
1236
  });
1246
1237
  }
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);
1238
+ async deleteChannel(channelID) {
1239
+ await this.http.delete(this.getHost() + "/channel/" + channelID);
1303
1240
  }
1304
- async rejectDeviceRequest(requestID) {
1305
- await this.http.post(this.prefixes.HTTP +
1241
+ async deleteDevice(deviceID) {
1242
+ if (deviceID === this.getDevice().deviceID) {
1243
+ throw new Error("You can't delete the device you're logged in to.");
1244
+ }
1245
+ await this.http.delete(this.prefixes.HTTP +
1306
1246
  this.host +
1307
1247
  "/user/" +
1308
1248
  this.getUser().userID +
1309
- "/devices/requests/" +
1310
- requestID +
1311
- "/reject");
1249
+ "/devices/" +
1250
+ deviceID);
1312
1251
  }
1313
1252
  async deleteHistory(channelOrUserID) {
1314
1253
  await this.database.deleteHistory(channelOrUserID);
@@ -1319,6 +1258,19 @@ export class Client {
1319
1258
  async deleteServer(serverID) {
1320
1259
  await this.http.delete(this.getHost() + "/server/" + serverID);
1321
1260
  }
1261
+ deviceListFailureDetail(err) {
1262
+ if (!isAxiosError(err)) {
1263
+ return "";
1264
+ }
1265
+ const st = err.response?.status;
1266
+ if (typeof st === "number") {
1267
+ return ` (HTTP ${String(st)})`;
1268
+ }
1269
+ if (err.code !== undefined) {
1270
+ return ` (${err.code})`;
1271
+ }
1272
+ return "";
1273
+ }
1322
1274
  /**
1323
1275
  * Gets a list of permissions for a server.
1324
1276
  *
@@ -1359,6 +1311,50 @@ export class Client {
1359
1311
  return [null, isAxiosError(err) ? err : null];
1360
1312
  }
1361
1313
  }
1314
+ async fetchUserDeviceListOnce(userID) {
1315
+ if (this.isManualCloseInFlight()) {
1316
+ return [];
1317
+ }
1318
+ const res = await this.http.get(this.getHost() + "/user/" + userID + "/devices");
1319
+ const devices = decodeAxios(DeviceArrayCodec, res.data);
1320
+ for (const device of devices) {
1321
+ this.deviceRecords[device.deviceID] = device;
1322
+ }
1323
+ return devices;
1324
+ }
1325
+ /**
1326
+ * DM / forward paths need the peer’s (or self) device rows under load: bounded
1327
+ * retries with exponential backoff (same shape as session pubkey hydration).
1328
+ */
1329
+ async fetchUserDeviceListWithBackoff(userID, label) {
1330
+ const base = label === "own"
1331
+ ? "Couldn't get own devices"
1332
+ : "Couldn't get device list";
1333
+ let lastErr;
1334
+ for (let attempt = 0; attempt < 5; attempt++) {
1335
+ if (this.isManualCloseInFlight()) {
1336
+ return [];
1337
+ }
1338
+ if (attempt > 0) {
1339
+ const delayMs = 100 * 2 ** (attempt - 1);
1340
+ // Chunk the delay so close() can finish before we retry HTTP.
1341
+ const chunkMs = 10;
1342
+ for (let elapsed = 0; elapsed < delayMs; elapsed += chunkMs) {
1343
+ if (this.isManualCloseInFlight()) {
1344
+ return [];
1345
+ }
1346
+ await sleep(Math.min(chunkMs, delayMs - elapsed));
1347
+ }
1348
+ }
1349
+ try {
1350
+ return await this.fetchUserDeviceListOnce(userID);
1351
+ }
1352
+ catch (err) {
1353
+ lastErr = err;
1354
+ }
1355
+ }
1356
+ throw new Error(`${base}${this.deviceListFailureDetail(lastErr)}`);
1357
+ }
1362
1358
  async forward(message) {
1363
1359
  if (this.isManualCloseInFlight()) {
1364
1360
  return;
@@ -1427,6 +1423,23 @@ export class Client {
1427
1423
  return null;
1428
1424
  }
1429
1425
  }
1426
+ async getDeviceRegistrationRequest(requestID) {
1427
+ try {
1428
+ const response = await this.http.get(this.prefixes.HTTP +
1429
+ this.host +
1430
+ "/user/" +
1431
+ this.getUser().userID +
1432
+ "/devices/requests/" +
1433
+ requestID);
1434
+ return decodeAxios(PendingDeviceRequestCodec, response.data);
1435
+ }
1436
+ catch (err) {
1437
+ if (isAxiosError(err) && err.response?.status === 404) {
1438
+ return null;
1439
+ }
1440
+ throw err;
1441
+ }
1442
+ }
1430
1443
  /* Retrieves the current list of users you have sessions with. */
1431
1444
  async getFamiliars() {
1432
1445
  const sessions = await this.database.getAllSessions();
@@ -1479,8 +1492,8 @@ export class Client {
1479
1492
  }
1480
1493
  })();
1481
1494
  debugLibvexDm("getMail: inbox", {
1482
- deviceID: did,
1483
1495
  count: String(inbox.length),
1496
+ deviceID: did,
1484
1497
  });
1485
1498
  }
1486
1499
  for (const mailDetails of inbox) {
@@ -1489,8 +1502,8 @@ export class Client {
1489
1502
  if (libvexDebugDmEnabled()) {
1490
1503
  debugLibvexDm("getMail: readMail one", {
1491
1504
  mailID: mailBody.mailID,
1492
- type: String(mailBody.mailType),
1493
1505
  recipient: mailBody.recipient,
1506
+ type: String(mailBody.mailType),
1494
1507
  });
1495
1508
  }
1496
1509
  await this.readMail(mailHeader, mailBody, timestamp);
@@ -1590,19 +1603,6 @@ export class Client {
1590
1603
  }
1591
1604
  return this.user;
1592
1605
  }
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
1606
  /**
1607
1607
  * Single GET for `/user/:id/devices`. On failure returns `null` (swallows errors)
1608
1608
  * — callers that need reliability should use `fetchUserDeviceListWithBackoff`.
@@ -1617,60 +1617,12 @@ export class Client {
1617
1617
  return null;
1618
1618
  }
1619
1619
  }
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
1620
  async getUserList(channelID) {
1665
1621
  const res = await this.http.post(this.getHost() + "/userList/" + channelID);
1666
1622
  return decodeAxios(UserArrayCodec, res.data);
1667
1623
  }
1668
1624
  async handleNotify(msg) {
1669
1625
  switch (msg.event) {
1670
- case "mail":
1671
- await this.getMail();
1672
- this.fetchingMail = false;
1673
- break;
1674
1626
  case "deviceRequest": {
1675
1627
  const parsed = deviceRequestNotifyData.safeParse(msg.data);
1676
1628
  if (parsed.success) {
@@ -1678,6 +1630,10 @@ export class Client {
1678
1630
  }
1679
1631
  break;
1680
1632
  }
1633
+ case "mail":
1634
+ await this.getMail();
1635
+ this.fetchingMail = false;
1636
+ break;
1681
1637
  case "permission":
1682
1638
  this.emitter.emit("permission", PermissionSchema.parse(msg.data));
1683
1639
  break;
@@ -1688,24 +1644,6 @@ export class Client {
1688
1644
  break;
1689
1645
  }
1690
1646
  }
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
1647
  /**
1710
1648
  * Initializes the keyring. This must be called before anything else.
1711
1649
  */
@@ -1793,6 +1731,14 @@ export class Client {
1793
1731
  throw new Error("Error initiating websocket connection " + String(err));
1794
1732
  }
1795
1733
  }
1734
+ /**
1735
+ * Fresh read of the `manuallyClosing` flag for async loops — direct property checks
1736
+ * after `await` are flagged as always-false by control-flow analysis even though
1737
+ * `close()` can run concurrently.
1738
+ */
1739
+ isManualCloseInFlight() {
1740
+ return this.manuallyClosing;
1741
+ }
1796
1742
  async kickUser(userID, serverID) {
1797
1743
  const permissionList = await this.fetchPermissionList(serverID);
1798
1744
  for (const permission of permissionList) {
@@ -1811,6 +1757,14 @@ export class Client {
1811
1757
  }
1812
1758
  }
1813
1759
  }
1760
+ async listDeviceRegistrationRequests() {
1761
+ const response = await this.http.get(this.prefixes.HTTP +
1762
+ this.host +
1763
+ "/user/" +
1764
+ this.getUser().userID +
1765
+ "/devices/requests");
1766
+ return decodeAxios(PendingDeviceRequestArrayCodec, response.data);
1767
+ }
1814
1768
  async markSessionVerified(sessionID) {
1815
1769
  return this.database.markSessionVerified(sessionID);
1816
1770
  }
@@ -1831,6 +1785,24 @@ export class Client {
1831
1785
  }
1832
1786
  this.xKeyRing.ephemeralKeys = await xBoxKeyPairAsync();
1833
1787
  }
1788
+ /**
1789
+ * Pipeline for decrypted messages — registered in `init`. After `close()` sets
1790
+ * `manuallyClosing`, this becomes a no-op so fire-and-forget `forward` does not
1791
+ * race HTTP teardown (we avoid `off()` here — it can interact badly with emit).
1792
+ */
1793
+ onInternalMessage = (message) => {
1794
+ if (this.isManualCloseInFlight()) {
1795
+ return;
1796
+ }
1797
+ if (message.direction === "outgoing" && !message.forward) {
1798
+ void this.forward(message);
1799
+ }
1800
+ if (message.direction === "incoming" &&
1801
+ message.recipient === message.sender) {
1802
+ return;
1803
+ }
1804
+ void this.database.saveMessage(message);
1805
+ };
1834
1806
  ping() {
1835
1807
  if (!this.isAlive) {
1836
1808
  }
@@ -1951,7 +1923,7 @@ export class Client {
1951
1923
  const deviceEntry = await this.getDeviceByID(mail.sender);
1952
1924
  const [user, _err] = await this.fetchUser(mail.authorID);
1953
1925
  if (deviceEntry && user) {
1954
- void this.createSession(deviceEntry, user, XUtils.decodeUTF8(`��RETRY_REQUEST:${mail.mailID}��`), mail.group, uuid.v4(), false, true);
1926
+ void this.createSession(deviceEntry, user, new Uint8Array(), mail.group, uuid.v4(), false, true);
1955
1927
  }
1956
1928
  };
1957
1929
  switch (mail.mailType) {
@@ -1972,8 +1944,8 @@ export class Client {
1972
1944
  try {
1973
1945
  debugLibvexDm("readMail initial: abort (otk index mismatch)", {
1974
1946
  mailID: mail.mailID,
1975
- preKeyIndex: String(preKeyIndex),
1976
1947
  otkIndex: String(otk?.index ?? "null"),
1948
+ preKeyIndex: String(preKeyIndex),
1977
1949
  thisDevice: this.getDevice().deviceID,
1978
1950
  });
1979
1951
  }
@@ -2000,8 +1972,8 @@ export class Client {
2000
1972
  if (libvexDebugDmEnabled()) {
2001
1973
  try {
2002
1974
  debugLibvexDm("readMail initial: abort (IK_A null, Ed→X25519?)", {
2003
- mailID: mail.mailID,
2004
1975
  fips: String(fipsRead),
1976
+ mailID: mail.mailID,
2005
1977
  thisDevice: this.getDevice().deviceID,
2006
1978
  });
2007
1979
  }
@@ -2095,9 +2067,9 @@ export class Client {
2095
2067
  try {
2096
2068
  debugLibvexDm("readMail initial: ok (emit message)", {
2097
2069
  mailID: mail.mailID,
2070
+ plaintextLen: String(plaintext.length),
2098
2071
  preKeyIndex: String(preKeyIndex),
2099
2072
  thisDevice: this.getDevice().deviceID,
2100
- plaintextLen: String(plaintext.length),
2101
2073
  });
2102
2074
  }
2103
2075
  catch {
@@ -2284,6 +2256,15 @@ export class Client {
2284
2256
  "/devices", msgpack.encode(devMsg), { headers: { "Content-Type": "application/msgpack" } });
2285
2257
  return decodeAxios(DeviceRegistrationResultCodec, res.data);
2286
2258
  }
2259
+ async rejectDeviceRequest(requestID) {
2260
+ await this.http.post(this.prefixes.HTTP +
2261
+ this.host +
2262
+ "/user/" +
2263
+ this.getUser().userID +
2264
+ "/devices/requests/" +
2265
+ requestID +
2266
+ "/reject");
2267
+ }
2287
2268
  async respond(msg) {
2288
2269
  const response = {
2289
2270
  signed: await xSignAsync(new Uint8Array(msg.challenge), this.signKeys.secretKey),
@@ -2370,6 +2351,25 @@ export class Client {
2370
2351
  }
2371
2352
  return device;
2372
2353
  }
2354
+ /**
2355
+ * `xDHAsync` and other helpers in `@vex-chat/crypto` use the process-wide
2356
+ * active profile. When several {@link Client} instances use different
2357
+ * `cryptoProfile` values, scope the global to this instance for the duration
2358
+ * of that crypto work.
2359
+ */
2360
+ async runWithThisCryptoProfile(fn) {
2361
+ const prev = getCryptoProfile();
2362
+ if (prev === this.cryptoProfile) {
2363
+ return await fn();
2364
+ }
2365
+ setCryptoProfile(this.cryptoProfile);
2366
+ try {
2367
+ return await fn();
2368
+ }
2369
+ finally {
2370
+ setCryptoProfile(prev);
2371
+ }
2372
+ }
2373
2373
  /* header is 32 bytes and is either empty
2374
2374
  or contains an HMAC of the message with
2375
2375
  a derived SK */
@@ -2419,9 +2419,9 @@ export class Client {
2419
2419
  if (!session || retry) {
2420
2420
  if (libvexDebugDmEnabled()) {
2421
2421
  debugLibvexDm("sendMail: createSession path", {
2422
+ hasSession: String(!!session),
2422
2423
  peerDevice: device.deviceID,
2423
2424
  retry: String(retry),
2424
- hasSession: String(!!session),
2425
2425
  });
2426
2426
  }
2427
2427
  await this.createSession(device, user, msg, group, mailID, forward, false);
@@ -2541,11 +2541,11 @@ export class Client {
2541
2541
  const deviceList = [...deviceListRaw].sort((a, b) => a.deviceID.localeCompare(b.deviceID, "en"));
2542
2542
  if (libvexDebugDmEnabled()) {
2543
2543
  debugLibvexDm("sendMessage: peer device list (merged, sorted)", {
2544
- userID,
2545
2544
  nAfterBackoff: String(afterBackoff.length),
2546
2545
  nMerged: String(deviceListRaw.length),
2547
2546
  nSorted: String(deviceList.length),
2548
2547
  ourDevice: this.getDevice().deviceID,
2548
+ userID,
2549
2549
  });
2550
2550
  for (const [i, d] of deviceList.entries()) {
2551
2551
  debugLibvexDm(`sendMessage: device[${String(i)}]`, {
@@ -2562,8 +2562,8 @@ export class Client {
2562
2562
  try {
2563
2563
  if (libvexDebugDmEnabled()) {
2564
2564
  debugLibvexDm("sendMessage: sendMail start", {
2565
- recipientDevice: device.deviceID,
2566
2565
  mailID: messageMailID,
2566
+ recipientDevice: device.deviceID,
2567
2567
  });
2568
2568
  }
2569
2569
  await this.sendMail(device, userEntry, XUtils.decodeUTF8(message), null, messageMailID, false);