@vex-chat/libvex 5.4.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 (40) hide show
  1. package/README.md +25 -23
  2. package/dist/Client.d.ts +113 -77
  3. package/dist/Client.d.ts.map +1 -1
  4. package/dist/Client.js +311 -234
  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/codecs.d.ts +118 -0
  11. package/dist/codecs.d.ts.map +1 -1
  12. package/dist/codecs.js +41 -0
  13. package/dist/codecs.js.map +1 -1
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/storage/node/http-agents.d.ts +1 -1
  18. package/dist/storage/node/http-agents.d.ts.map +1 -1
  19. package/dist/storage/node/http-agents.js +4 -4
  20. package/dist/storage/node/http-agents.js.map +1 -1
  21. package/dist/storage/sqlite.d.ts +8 -8
  22. package/dist/storage/sqlite.d.ts.map +1 -1
  23. package/dist/storage/sqlite.js +16 -16
  24. package/dist/storage/sqlite.js.map +1 -1
  25. package/dist/utils/fipsMailExtra.d.ts +9 -9
  26. package/dist/utils/fipsMailExtra.d.ts.map +1 -1
  27. package/dist/utils/fipsMailExtra.js +47 -47
  28. package/dist/utils/fipsMailExtra.js.map +1 -1
  29. package/dist/utils/resolveAtRestAesKey.js +1 -1
  30. package/dist/utils/resolveAtRestAesKey.js.map +1 -1
  31. package/package.json +134 -152
  32. package/src/Client.ts +452 -306
  33. package/src/__tests__/harness/memory-storage.ts +1 -1
  34. package/src/__tests__/harness/shared-suite.ts +177 -177
  35. package/src/codecs.ts +52 -0
  36. package/src/index.ts +4 -0
  37. package/src/storage/node/http-agents.ts +7 -7
  38. package/src/storage/sqlite.ts +23 -23
  39. package/src/utils/fipsMailExtra.ts +80 -80
  40. 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,45 +106,8 @@ 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
- import { ActionTokenCodec, AuthResponseCodec, ChannelArrayCodec, ChannelCodec, ConnectResponseCodec, decodeAxios, DeviceArrayCodec, DeviceChallengeCodec, DeviceCodec, EmojiArrayCodec, EmojiCodec, FileSQLCodec, InviteArrayCodec, InviteCodec, KeyBundleCodec, OtkCountCodec, PermissionArrayCodec, PermissionCodec, ServerArrayCodec, ServerCodec, UserArrayCodec, UserCodec, WhoamiCodec, } from "./codecs.js";
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";
112
112
  import { sqlSessionToCrypto } from "./utils/sqlSessionToCrypto.js";
113
113
  import { uuidToUint8 } from "./utils/uint8uuid.js";
@@ -133,6 +133,14 @@ const mailInboxEntry = z.tuple([
133
133
  MailWSSchema,
134
134
  z.string(),
135
135
  ]);
136
+ const deviceRequestNotifyData = z.object({
137
+ requestID: z.string(),
138
+ status: z.union([
139
+ z.literal("approved"),
140
+ z.literal("pending"),
141
+ z.literal("rejected"),
142
+ ]),
143
+ });
136
144
  export class Client {
137
145
  /**
138
146
  * Decrypts a secret key from encrypted data produced by encryptKeyData().
@@ -193,8 +201,12 @@ export class Client {
193
201
  * Device management methods.
194
202
  */
195
203
  devices = {
204
+ approveRequest: this.approveDeviceRequest.bind(this),
196
205
  delete: this.deleteDevice.bind(this),
206
+ getRequest: this.getDeviceRegistrationRequest.bind(this),
207
+ listRequests: this.listDeviceRegistrationRequests.bind(this),
197
208
  register: this.registerDevice.bind(this),
209
+ rejectRequest: this.rejectDeviceRequest.bind(this),
198
210
  retrieve: this.getDeviceByID.bind(this),
199
211
  };
200
212
  /**
@@ -397,6 +409,7 @@ export class Client {
397
409
  */
398
410
  retrieve: this.fetchUser.bind(this),
399
411
  };
412
+ cryptoProfile;
400
413
  database;
401
414
  dbPath;
402
415
  device;
@@ -407,23 +420,18 @@ export class Client {
407
420
  firstMailFetch = true;
408
421
  forwarded = new Set();
409
422
  host;
410
- /**
411
- * Node-only: per-client HTTP(S) agents (see `init()` + `storage/node/http-agents`).
412
- * Dropped on `close()` so idle keep-alive sockets do not keep the process alive.
413
- */
414
- nodeHttpAgents;
423
+ http;
415
424
  /** Cancels in-flight axios work on `close()` so `postAuth`/`getMail` cannot hang forever. */
416
425
  httpAbortController = new AbortController();
417
- http;
418
426
  idKeys;
419
427
  isAlive = true;
420
428
  mailInterval;
421
429
  manuallyClosing = false;
422
430
  /**
423
- * Bumped when the WebSocket is torn down and re-opened so the previous
424
- * `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.
425
433
  */
426
- postAuthVersion = 0;
434
+ nodeHttpAgents;
427
435
  /* Retrieves the userID with the user identifier.
428
436
  user identifier is checked for userID, then signkey,
429
437
  and finally falls back to username. */
@@ -431,6 +439,11 @@ export class Client {
431
439
  notFoundUsers = new Map();
432
440
  options;
433
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;
434
447
  prefixes;
435
448
  reading = false;
436
449
  seenMailIDs = new Set();
@@ -442,7 +455,6 @@ export class Client {
442
455
  user;
443
456
  userRecords = {};
444
457
  xKeyRing;
445
- cryptoProfile;
446
458
  constructor(material, options, storage) {
447
459
  this.options = options;
448
460
  this.cryptoProfile = material.cryptoProfile;
@@ -605,27 +617,6 @@ export class Client {
605
617
  static getMnemonic(session) {
606
618
  return xMnemonic(xKDF(XUtils.decodeHex(session.fingerprint)));
607
619
  }
608
- /**
609
- * True when running under Node (has `process.versions`).
610
- * Uses indirect lookup so the bare `process` global never appears in
611
- * source that the platform-guard plugin scans.
612
- */
613
- static isNodeRuntime() {
614
- try {
615
- const g = Object.getOwnPropertyDescriptor(globalThis, "\u0070rocess");
616
- if (!g)
617
- return false;
618
- const proc = typeof g.get === "function" ? g.get() : g.value;
619
- if (typeof proc !== "object" || proc === null) {
620
- return false;
621
- }
622
- return ("versions" in proc &&
623
- typeof proc.versions === "object");
624
- }
625
- catch {
626
- return false;
627
- }
628
- }
629
620
  /**
630
621
  * Browser-safe NODE_ENV accessor.
631
622
  * Uses indirect lookup so the bare `process` global never appears in
@@ -663,12 +654,25 @@ export class Client {
663
654
  }
664
655
  }
665
656
  /**
666
- * Fresh read of the `manuallyClosing` flag for async loops — direct property checks
667
- * after `await` are flagged as always-false by control-flow analysis even though
668
- * `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.
669
660
  */
670
- isManualCloseInFlight() {
671
- 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
+ }
672
676
  }
673
677
  /**
674
678
  * Closes the client — disconnects the WebSocket, shuts down storage,
@@ -723,57 +727,6 @@ export class Client {
723
727
  await new Promise((r) => setTimeout(r, 0));
724
728
  await this.negotiateOTK();
725
729
  }
726
- /**
727
- * Tears down the current WebSocket and opens a new one, keeping the same
728
- * session (user + device in storage). Restarts the post-auth mail loop.
729
- * Use for long-running processes or e2e where a fresh socket matches a
730
- * newly-registered second device.
731
- */
732
- async reconnectWebsocket() {
733
- this.postAuthVersion++;
734
- if (this.pingInterval) {
735
- clearInterval(this.pingInterval);
736
- this.pingInterval = null;
737
- }
738
- this.socket.close();
739
- try {
740
- await new Promise((resolve, reject) => {
741
- const t = setTimeout(() => {
742
- this.off("connected", onC);
743
- reject(new Error("reconnectWebsocket: timed out waiting for authorized"));
744
- }, 15_000);
745
- const onC = () => {
746
- clearTimeout(t);
747
- this.off("connected", onC);
748
- resolve();
749
- };
750
- this.on("connected", onC);
751
- try {
752
- this.initSocket();
753
- }
754
- catch (err) {
755
- clearTimeout(t);
756
- this.off("connected", onC);
757
- const e = err instanceof Error
758
- ? err
759
- : new Error(String(err), { cause: err });
760
- reject(e);
761
- }
762
- });
763
- }
764
- catch (e) {
765
- throw e instanceof Error ? e : new Error(String(e), { cause: e });
766
- }
767
- await new Promise((r) => setTimeout(r, 0));
768
- await this.negotiateOTK();
769
- }
770
- /**
771
- * Triggers an immediate inbox sync by fetching `/mail` once.
772
- * Useful on mobile foreground resume where background work may pause.
773
- */
774
- async syncInboxNow() {
775
- await this.getMail();
776
- }
777
730
  /**
778
731
  * Delete all local data — message history, encryption sessions, and prekeys.
779
732
  * Closes the client afterward. Credentials (keychain) must be cleared by the consumer.
@@ -899,6 +852,50 @@ export class Client {
899
852
  this.emitter.once(event, fn, context);
900
853
  return this;
901
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
+ }
902
899
  /**
903
900
  * Registers a new account on the server.
904
901
  *
@@ -956,6 +953,13 @@ export class Client {
956
953
  this.emitter.removeAllListeners(event);
957
954
  return this;
958
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
+ }
959
963
  /**
960
964
  * Returns a compact `<username><deviceID>` debug label.
961
965
  */
@@ -981,6 +985,24 @@ export class Client {
981
985
  const whoami = decodeAxios(WhoamiCodec, res.data);
982
986
  return whoami;
983
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
+ }
984
1006
  censorPreKey(preKey) {
985
1007
  if (!preKey.index) {
986
1008
  throw new Error("Key index is required.");
@@ -1061,25 +1083,6 @@ export class Client {
1061
1083
  const res = await this.http.post(this.getHost() + "/server/" + globalThis.btoa(name));
1062
1084
  return decodeAxios(ServerCodec, res.data);
1063
1085
  }
1064
- /**
1065
- * `xDHAsync` and other helpers in `@vex-chat/crypto` use the process-wide
1066
- * active profile. When several {@link Client} instances use different
1067
- * `cryptoProfile` values, scope the global to this instance for the duration
1068
- * of that crypto work.
1069
- */
1070
- async runWithThisCryptoProfile(fn) {
1071
- const prev = getCryptoProfile();
1072
- if (prev === this.cryptoProfile) {
1073
- return await fn();
1074
- }
1075
- setCryptoProfile(this.cryptoProfile);
1076
- try {
1077
- return await fn();
1078
- }
1079
- finally {
1080
- setCryptoProfile(prev);
1081
- }
1082
- }
1083
1086
  async createSession(device, user, message, group,
1084
1087
  /* this is passed through if the first message is
1085
1088
  part of a group message */
@@ -1255,6 +1258,19 @@ export class Client {
1255
1258
  async deleteServer(serverID) {
1256
1259
  await this.http.delete(this.getHost() + "/server/" + serverID);
1257
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
+ }
1258
1274
  /**
1259
1275
  * Gets a list of permissions for a server.
1260
1276
  *
@@ -1295,6 +1311,50 @@ export class Client {
1295
1311
  return [null, isAxiosError(err) ? err : null];
1296
1312
  }
1297
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
+ }
1298
1358
  async forward(message) {
1299
1359
  if (this.isManualCloseInFlight()) {
1300
1360
  return;
@@ -1363,6 +1423,23 @@ export class Client {
1363
1423
  return null;
1364
1424
  }
1365
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
+ }
1366
1443
  /* Retrieves the current list of users you have sessions with. */
1367
1444
  async getFamiliars() {
1368
1445
  const sessions = await this.database.getAllSessions();
@@ -1415,8 +1492,8 @@ export class Client {
1415
1492
  }
1416
1493
  })();
1417
1494
  debugLibvexDm("getMail: inbox", {
1418
- deviceID: did,
1419
1495
  count: String(inbox.length),
1496
+ deviceID: did,
1420
1497
  });
1421
1498
  }
1422
1499
  for (const mailDetails of inbox) {
@@ -1425,8 +1502,8 @@ export class Client {
1425
1502
  if (libvexDebugDmEnabled()) {
1426
1503
  debugLibvexDm("getMail: readMail one", {
1427
1504
  mailID: mailBody.mailID,
1428
- type: String(mailBody.mailType),
1429
1505
  recipient: mailBody.recipient,
1506
+ type: String(mailBody.mailType),
1430
1507
  });
1431
1508
  }
1432
1509
  await this.readMail(mailHeader, mailBody, timestamp);
@@ -1526,19 +1603,6 @@ export class Client {
1526
1603
  }
1527
1604
  return this.user;
1528
1605
  }
1529
- deviceListFailureDetail(err) {
1530
- if (!isAxiosError(err)) {
1531
- return "";
1532
- }
1533
- const st = err.response?.status;
1534
- if (typeof st === "number") {
1535
- return ` (HTTP ${String(st)})`;
1536
- }
1537
- if (err.code !== undefined) {
1538
- return ` (${err.code})`;
1539
- }
1540
- return "";
1541
- }
1542
1606
  /**
1543
1607
  * Single GET for `/user/:id/devices`. On failure returns `null` (swallows errors)
1544
1608
  * — callers that need reliability should use `fetchUserDeviceListWithBackoff`.
@@ -1553,56 +1617,19 @@ export class Client {
1553
1617
  return null;
1554
1618
  }
1555
1619
  }
1556
- async fetchUserDeviceListOnce(userID) {
1557
- if (this.isManualCloseInFlight()) {
1558
- return [];
1559
- }
1560
- const res = await this.http.get(this.getHost() + "/user/" + userID + "/devices");
1561
- const devices = decodeAxios(DeviceArrayCodec, res.data);
1562
- for (const device of devices) {
1563
- this.deviceRecords[device.deviceID] = device;
1564
- }
1565
- return devices;
1566
- }
1567
- /**
1568
- * DM / forward paths need the peer’s (or self) device rows under load: bounded
1569
- * retries with exponential backoff (same shape as session pubkey hydration).
1570
- */
1571
- async fetchUserDeviceListWithBackoff(userID, label) {
1572
- const base = label === "own"
1573
- ? "Couldn't get own devices"
1574
- : "Couldn't get device list";
1575
- let lastErr;
1576
- for (let attempt = 0; attempt < 5; attempt++) {
1577
- if (this.isManualCloseInFlight()) {
1578
- return [];
1579
- }
1580
- if (attempt > 0) {
1581
- const delayMs = 100 * 2 ** (attempt - 1);
1582
- // Chunk the delay so close() can finish before we retry HTTP.
1583
- const chunkMs = 10;
1584
- for (let elapsed = 0; elapsed < delayMs; elapsed += chunkMs) {
1585
- if (this.isManualCloseInFlight()) {
1586
- return [];
1587
- }
1588
- await sleep(Math.min(chunkMs, delayMs - elapsed));
1589
- }
1590
- }
1591
- try {
1592
- return await this.fetchUserDeviceListOnce(userID);
1593
- }
1594
- catch (err) {
1595
- lastErr = err;
1596
- }
1597
- }
1598
- throw new Error(`${base}${this.deviceListFailureDetail(lastErr)}`);
1599
- }
1600
1620
  async getUserList(channelID) {
1601
1621
  const res = await this.http.post(this.getHost() + "/userList/" + channelID);
1602
1622
  return decodeAxios(UserArrayCodec, res.data);
1603
1623
  }
1604
1624
  async handleNotify(msg) {
1605
1625
  switch (msg.event) {
1626
+ case "deviceRequest": {
1627
+ const parsed = deviceRequestNotifyData.safeParse(msg.data);
1628
+ if (parsed.success) {
1629
+ this.emitter.emit("deviceRequest", parsed.data);
1630
+ }
1631
+ break;
1632
+ }
1606
1633
  case "mail":
1607
1634
  await this.getMail();
1608
1635
  this.fetchingMail = false;
@@ -1617,24 +1644,6 @@ export class Client {
1617
1644
  break;
1618
1645
  }
1619
1646
  }
1620
- /**
1621
- * Pipeline for decrypted messages — registered in `init`. After `close()` sets
1622
- * `manuallyClosing`, this becomes a no-op so fire-and-forget `forward` does not
1623
- * race HTTP teardown (we avoid `off()` here — it can interact badly with emit).
1624
- */
1625
- onInternalMessage = (message) => {
1626
- if (this.isManualCloseInFlight()) {
1627
- return;
1628
- }
1629
- if (message.direction === "outgoing" && !message.forward) {
1630
- void this.forward(message);
1631
- }
1632
- if (message.direction === "incoming" &&
1633
- message.recipient === message.sender) {
1634
- return;
1635
- }
1636
- void this.database.saveMessage(message);
1637
- };
1638
1647
  /**
1639
1648
  * Initializes the keyring. This must be called before anything else.
1640
1649
  */
@@ -1722,6 +1731,14 @@ export class Client {
1722
1731
  throw new Error("Error initiating websocket connection " + String(err));
1723
1732
  }
1724
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
+ }
1725
1742
  async kickUser(userID, serverID) {
1726
1743
  const permissionList = await this.fetchPermissionList(serverID);
1727
1744
  for (const permission of permissionList) {
@@ -1740,6 +1757,14 @@ export class Client {
1740
1757
  }
1741
1758
  }
1742
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
+ }
1743
1768
  async markSessionVerified(sessionID) {
1744
1769
  return this.database.markSessionVerified(sessionID);
1745
1770
  }
@@ -1760,6 +1785,24 @@ export class Client {
1760
1785
  }
1761
1786
  this.xKeyRing.ephemeralKeys = await xBoxKeyPairAsync();
1762
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
+ };
1763
1806
  ping() {
1764
1807
  if (!this.isAlive) {
1765
1808
  }
@@ -1880,7 +1923,7 @@ export class Client {
1880
1923
  const deviceEntry = await this.getDeviceByID(mail.sender);
1881
1924
  const [user, _err] = await this.fetchUser(mail.authorID);
1882
1925
  if (deviceEntry && user) {
1883
- 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);
1884
1927
  }
1885
1928
  };
1886
1929
  switch (mail.mailType) {
@@ -1901,8 +1944,8 @@ export class Client {
1901
1944
  try {
1902
1945
  debugLibvexDm("readMail initial: abort (otk index mismatch)", {
1903
1946
  mailID: mail.mailID,
1904
- preKeyIndex: String(preKeyIndex),
1905
1947
  otkIndex: String(otk?.index ?? "null"),
1948
+ preKeyIndex: String(preKeyIndex),
1906
1949
  thisDevice: this.getDevice().deviceID,
1907
1950
  });
1908
1951
  }
@@ -1929,8 +1972,8 @@ export class Client {
1929
1972
  if (libvexDebugDmEnabled()) {
1930
1973
  try {
1931
1974
  debugLibvexDm("readMail initial: abort (IK_A null, Ed→X25519?)", {
1932
- mailID: mail.mailID,
1933
1975
  fips: String(fipsRead),
1976
+ mailID: mail.mailID,
1934
1977
  thisDevice: this.getDevice().deviceID,
1935
1978
  });
1936
1979
  }
@@ -2024,9 +2067,9 @@ export class Client {
2024
2067
  try {
2025
2068
  debugLibvexDm("readMail initial: ok (emit message)", {
2026
2069
  mailID: mail.mailID,
2070
+ plaintextLen: String(plaintext.length),
2027
2071
  preKeyIndex: String(preKeyIndex),
2028
2072
  thisDevice: this.getDevice().deviceID,
2029
- plaintextLen: String(plaintext.length),
2030
2073
  });
2031
2074
  }
2032
2075
  catch {
@@ -2211,7 +2254,16 @@ export class Client {
2211
2254
  "/user/" +
2212
2255
  userDetails.userID +
2213
2256
  "/devices", msgpack.encode(devMsg), { headers: { "Content-Type": "application/msgpack" } });
2214
- return decodeAxios(DeviceCodec, res.data);
2257
+ return decodeAxios(DeviceRegistrationResultCodec, res.data);
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");
2215
2267
  }
2216
2268
  async respond(msg) {
2217
2269
  const response = {
@@ -2282,9 +2334,13 @@ export class Client {
2282
2334
  await this.database.purgeKeyData();
2283
2335
  await this.populateKeyRing();
2284
2336
  const newDevice = await this.registerDevice();
2285
- if (newDevice) {
2337
+ if (newDevice && "deviceID" in newDevice) {
2286
2338
  device = newDevice;
2287
2339
  }
2340
+ else if (newDevice && "status" in newDevice) {
2341
+ throw new Error("Device registration requires approval from an existing device. requestID=" +
2342
+ newDevice.requestID);
2343
+ }
2288
2344
  else {
2289
2345
  throw new Error("Error registering device.");
2290
2346
  }
@@ -2295,6 +2351,25 @@ export class Client {
2295
2351
  }
2296
2352
  return device;
2297
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
+ }
2298
2373
  /* header is 32 bytes and is either empty
2299
2374
  or contains an HMAC of the message with
2300
2375
  a derived SK */
@@ -2344,9 +2419,9 @@ export class Client {
2344
2419
  if (!session || retry) {
2345
2420
  if (libvexDebugDmEnabled()) {
2346
2421
  debugLibvexDm("sendMail: createSession path", {
2422
+ hasSession: String(!!session),
2347
2423
  peerDevice: device.deviceID,
2348
2424
  retry: String(retry),
2349
- hasSession: String(!!session),
2350
2425
  });
2351
2426
  }
2352
2427
  await this.createSession(device, user, msg, group, mailID, forward, false);
@@ -2466,11 +2541,11 @@ export class Client {
2466
2541
  const deviceList = [...deviceListRaw].sort((a, b) => a.deviceID.localeCompare(b.deviceID, "en"));
2467
2542
  if (libvexDebugDmEnabled()) {
2468
2543
  debugLibvexDm("sendMessage: peer device list (merged, sorted)", {
2469
- userID,
2470
2544
  nAfterBackoff: String(afterBackoff.length),
2471
2545
  nMerged: String(deviceListRaw.length),
2472
2546
  nSorted: String(deviceList.length),
2473
2547
  ourDevice: this.getDevice().deviceID,
2548
+ userID,
2474
2549
  });
2475
2550
  for (const [i, d] of deviceList.entries()) {
2476
2551
  debugLibvexDm(`sendMessage: device[${String(i)}]`, {
@@ -2480,16 +2555,18 @@ export class Client {
2480
2555
  }
2481
2556
  let lastErr;
2482
2557
  let failCount = 0;
2558
+ // One logical DM fan-outs to multiple recipient devices. Reuse a
2559
+ // single mailID so local/UI dedupe treats it as one message.
2560
+ const messageMailID = uuid.v4();
2483
2561
  for (const device of deviceList) {
2484
- const mailID = uuid.v4();
2485
2562
  try {
2486
2563
  if (libvexDebugDmEnabled()) {
2487
2564
  debugLibvexDm("sendMessage: sendMail start", {
2565
+ mailID: messageMailID,
2488
2566
  recipientDevice: device.deviceID,
2489
- mailID,
2490
2567
  });
2491
2568
  }
2492
- await this.sendMail(device, userEntry, XUtils.decodeUTF8(message), null, mailID, false);
2569
+ await this.sendMail(device, userEntry, XUtils.decodeUTF8(message), null, messageMailID, false);
2493
2570
  if (libvexDebugDmEnabled()) {
2494
2571
  debugLibvexDm("sendMessage: sendMail ok", {
2495
2572
  recipientDevice: device.deviceID,