@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/src/Client.ts CHANGED
@@ -89,14 +89,56 @@ import {
89
89
  isFipsSubsequentExtraV1,
90
90
  } from "./utils/fipsMailExtra.js";
91
91
 
92
- function sleep(ms: number): Promise<void> {
93
- return new Promise((resolve) => setTimeout(resolve, ms));
92
+ function debugLibvexDm(
93
+ msg: string,
94
+ data?: Record<string, boolean | null | number | string | undefined>,
95
+ ): void {
96
+ if (!libvexDebugDmEnabled()) {
97
+ return;
98
+ }
99
+ const payload = data ? `${msg} ${JSON.stringify(data)}` : msg;
100
+ // eslint-disable-next-line no-console -- gated by LIBVEX_DEBUG_DM; remove when debugging is done
101
+ console.error(`[libvex:debug-dm] ${payload}`);
94
102
  }
95
103
 
96
104
  function isRecord(x: unknown): x is Record<string, unknown> {
97
105
  return typeof x === "object" && x !== null;
98
106
  }
99
107
 
108
+ /**
109
+ * Set `LIBVEX_DEBUG_DM=1` (e.g. in vitest / shell) to log DM multi-device / X3DH paths.
110
+ * Uses indirect `globalThis` lookup so the bare `process` global never appears in
111
+ * source that the platform-guard plugin scans (browser/RN/Tauri).
112
+ */
113
+ function libvexDebugDmEnabled(): boolean {
114
+ try {
115
+ const g = Object.getOwnPropertyDescriptor(globalThis, "\u0070rocess");
116
+ if (!g) {
117
+ return false;
118
+ }
119
+ const proc: unknown = typeof g.get === "function" ? g.get() : g.value;
120
+ if (typeof proc !== "object" || proc === null) {
121
+ return false;
122
+ }
123
+ const envDesc = Object.getOwnPropertyDescriptor(proc, "env");
124
+ if (!envDesc) {
125
+ return false;
126
+ }
127
+ const env: unknown =
128
+ typeof envDesc.get === "function" ? envDesc.get() : envDesc.value;
129
+ if (typeof env !== "object" || env === null) {
130
+ return false;
131
+ }
132
+ return Reflect.get(env, "LIBVEX_DEBUG_DM") === "1";
133
+ } catch {
134
+ return false;
135
+ }
136
+ }
137
+
138
+ function sleep(ms: number): Promise<void> {
139
+ return new Promise((resolve) => setTimeout(resolve, ms));
140
+ }
141
+
100
142
  /**
101
143
  * Spire 5+ JSON error bodies use `{ "error": { "message", "requestId"?, "details"? } }`.
102
144
  * Responses are `arraybuffer` — decode UTF-8 and parse for a one-line `Error` message
@@ -149,48 +191,6 @@ function spireErrorBodyMessage(data: unknown, max = 8_000): string {
149
191
  return t.length > max ? t.slice(0, max) + "…" : t;
150
192
  }
151
193
 
152
- /**
153
- * Set `LIBVEX_DEBUG_DM=1` (e.g. in vitest / shell) to log DM multi-device / X3DH paths.
154
- * Uses indirect `globalThis` lookup so the bare `process` global never appears in
155
- * source that the platform-guard plugin scans (browser/RN/Tauri).
156
- */
157
- function libvexDebugDmEnabled(): boolean {
158
- try {
159
- const g = Object.getOwnPropertyDescriptor(globalThis, "\u0070rocess");
160
- if (!g) {
161
- return false;
162
- }
163
- const proc: unknown = typeof g.get === "function" ? g.get() : g.value;
164
- if (typeof proc !== "object" || proc === null) {
165
- return false;
166
- }
167
- const envDesc = Object.getOwnPropertyDescriptor(proc, "env");
168
- if (!envDesc) {
169
- return false;
170
- }
171
- const env: unknown =
172
- typeof envDesc.get === "function" ? envDesc.get() : envDesc.value;
173
- if (typeof env !== "object" || env === null) {
174
- return false;
175
- }
176
- return Reflect.get(env, "LIBVEX_DEBUG_DM") === "1";
177
- } catch {
178
- return false;
179
- }
180
- }
181
-
182
- function debugLibvexDm(
183
- msg: string,
184
- data?: Record<string, string | number | boolean | null | undefined>,
185
- ): void {
186
- if (!libvexDebugDmEnabled()) {
187
- return;
188
- }
189
- const payload = data ? `${msg} ${JSON.stringify(data)}` : msg;
190
- // eslint-disable-next-line no-console -- gated by LIBVEX_DEBUG_DM; remove when debugging is done
191
- console.error(`[libvex:debug-dm] ${payload}`);
192
- }
193
-
194
194
  import { msgpack } from "./codec.js";
195
195
  import {
196
196
  ActionTokenCodec,
@@ -267,33 +267,6 @@ export interface Channels {
267
267
  */
268
268
  export type { Device } from "@vex-chat/types";
269
269
 
270
- export type PendingDeviceApprovalStatus =
271
- | "approved"
272
- | "expired"
273
- | "pending"
274
- | "rejected";
275
-
276
- export interface PendingDeviceRequest {
277
- approvedDeviceID?: string | undefined;
278
- createdAt: string;
279
- deviceName: string;
280
- error?: string | undefined;
281
- expiresAt: string;
282
- requestID: string;
283
- signKey: string;
284
- status: PendingDeviceApprovalStatus;
285
- username: string;
286
- }
287
-
288
- export interface PendingDeviceRegistration {
289
- challenge: string;
290
- expiresAt: string;
291
- requestID: string;
292
- status: "pending_approval";
293
- }
294
-
295
- export type DeviceRegistrationResult = Device | PendingDeviceRegistration;
296
-
297
270
  /**
298
271
  * ClientOptions are the options you can pass into the client.
299
272
  */
@@ -306,6 +279,12 @@ export interface ClientOptions {
306
279
  cryptoProfile?: "fips" | "tweetnacl";
307
280
  /** Folder path where the sqlite file is created. */
308
281
  dbFolder?: string;
282
+ /**
283
+ * When set (non-empty), sent as `x-dev-api-key` on every HTTP request.
284
+ * Spire omits in-process rate limits when this matches the server's `DEV_API_KEY`
285
+ * (local / load-testing only — never use in production).
286
+ */
287
+ devApiKey?: string;
309
288
  /** Platform label for device registration (e.g. "ios", "macos", "linux"). */
310
289
  deviceName?: string;
311
290
  /** API host without protocol. Defaults to `api.vex.wtf`. */
@@ -316,14 +295,10 @@ export interface ClientOptions {
316
295
  saveHistory?: boolean;
317
296
  /** Use `http/ws` instead of `https/wss`. Intended for local/dev environments. */
318
297
  unsafeHttp?: boolean;
319
- /**
320
- * When set (non-empty), sent as `x-dev-api-key` on every HTTP request.
321
- * Spire omits in-process rate limits when this matches the server's `DEV_API_KEY`
322
- * (local / load-testing only — never use in production).
323
- */
324
- devApiKey?: string;
325
298
  }
326
299
 
300
+ export type DeviceRegistrationResult = Device | PendingDeviceRegistration;
301
+
327
302
  /**
328
303
  * @ignore
329
304
  */
@@ -336,34 +311,14 @@ export interface Devices {
336
311
  getRequest: (requestID: string) => Promise<null | PendingDeviceRequest>;
337
312
  /** Lists pending/processed registration requests for the current user. */
338
313
  listRequests: () => Promise<PendingDeviceRequest[]>;
339
- /** Rejects a pending device registration request as the current device. */
340
- rejectRequest: (requestID: string) => Promise<void>;
341
314
  /** Registers the current key material as a new device. */
342
315
  register: () => Promise<DeviceRegistrationResult | null>;
316
+ /** Rejects a pending device registration request as the current device. */
317
+ rejectRequest: (requestID: string) => Promise<void>;
343
318
  /** Fetches one device by ID. */
344
319
  retrieve: (deviceIdentifier: string) => Promise<Device | null>;
345
320
  }
346
321
 
347
- /**
348
- * Channel is a chat channel on a server.
349
- *
350
- * Common fields:
351
- * - `channelID`
352
- * - `serverID`
353
- * - `name`
354
- */
355
- export type { Channel } from "@vex-chat/types";
356
-
357
- /**
358
- * Server is a single chat server.
359
- *
360
- * Common fields:
361
- * - `serverID`
362
- * - `name`
363
- * - `icon` (optional URL/data)
364
- */
365
- export type { Server } from "@vex-chat/types";
366
-
367
322
  /**
368
323
  * @ignore
369
324
  */
@@ -417,6 +372,26 @@ export interface FileProgress {
417
372
  */
418
373
  export type FileRes = FileResponse;
419
374
 
375
+ /**
376
+ * Channel is a chat channel on a server.
377
+ *
378
+ * Common fields:
379
+ * - `channelID`
380
+ * - `serverID`
381
+ * - `name`
382
+ */
383
+ export type { Channel } from "@vex-chat/types";
384
+
385
+ /**
386
+ * Server is a single chat server.
387
+ *
388
+ * Common fields:
389
+ * - `serverID`
390
+ * - `name`
391
+ * - `icon` (optional URL/data)
392
+ */
393
+ export type { Server } from "@vex-chat/types";
394
+
420
395
  /**
421
396
  * @ignore
422
397
  */
@@ -492,6 +467,31 @@ export interface Message {
492
467
  timestamp: string;
493
468
  }
494
469
 
470
+ export type PendingDeviceApprovalStatus =
471
+ | "approved"
472
+ | "expired"
473
+ | "pending"
474
+ | "rejected";
475
+
476
+ export interface PendingDeviceRegistration {
477
+ challenge: string;
478
+ expiresAt: string;
479
+ requestID: string;
480
+ status: "pending_approval";
481
+ }
482
+
483
+ export interface PendingDeviceRequest {
484
+ approvedDeviceID?: string | undefined;
485
+ createdAt: string;
486
+ deviceName: string;
487
+ error?: string | undefined;
488
+ expiresAt: string;
489
+ requestID: string;
490
+ signKey: string;
491
+ status: PendingDeviceApprovalStatus;
492
+ username: string;
493
+ }
494
+
495
495
  /** Zod schema matching the {@link Message} interface for forwarded-message decode. */
496
496
  const messageSchema: z.ZodType<Message> = z.object({
497
497
  authorID: z.string(),
@@ -536,8 +536,6 @@ export interface ClientEvents {
536
536
  connected: () => void;
537
537
  /** Mail decryption pass is in progress. */
538
538
  decryptingMail: () => void;
539
- /** WebSocket connection lost. */
540
- disconnect: () => void;
541
539
  /** Device approval queue changed (pending/approved/rejected). */
542
540
  deviceRequest: (update: {
543
541
  requestID: string;
@@ -546,6 +544,8 @@ export interface ClientEvents {
546
544
  "approved" | "pending" | "rejected"
547
545
  >;
548
546
  }) => void;
547
+ /** WebSocket connection lost. */
548
+ disconnect: () => void;
549
549
  /** Progress update for a file upload or download. */
550
550
  fileProgress: (progress: FileProgress) => void;
551
551
  /** A direct or group message was sent or received. */
@@ -806,8 +806,8 @@ export class Client {
806
806
  delete: this.deleteDevice.bind(this),
807
807
  getRequest: this.getDeviceRegistrationRequest.bind(this),
808
808
  listRequests: this.listDeviceRegistrationRequests.bind(this),
809
- rejectRequest: this.rejectDeviceRequest.bind(this),
810
809
  register: this.registerDevice.bind(this),
810
+ rejectRequest: this.rejectDeviceRequest.bind(this),
811
811
  retrieve: this.getDeviceByID.bind(this),
812
812
  };
813
813
 
@@ -1025,6 +1025,8 @@ export class Client {
1025
1025
  retrieve: this.fetchUser.bind(this),
1026
1026
  };
1027
1027
 
1028
+ private readonly cryptoProfile: CryptoProfile;
1029
+
1028
1030
  private readonly database: Storage;
1029
1031
 
1030
1032
  private readonly dbPath: string;
@@ -1035,34 +1037,28 @@ export class Client {
1035
1037
 
1036
1038
  // ── Event subscription (composition over inheritance) ───────────────
1037
1039
  private readonly emitter = new EventEmitter<ClientEvents>();
1038
-
1039
1040
  private fetchingMail: boolean = false;
1041
+
1040
1042
  private firstMailFetch = true;
1041
1043
 
1042
1044
  private readonly forwarded = new Set<string>();
1043
-
1044
1045
  private readonly host: string;
1045
- /**
1046
- * Node-only: per-client HTTP(S) agents (see `init()` + `storage/node/http-agents`).
1047
- * Dropped on `close()` so idle keep-alive sockets do not keep the process alive.
1048
- */
1049
- private nodeHttpAgents?: {
1050
- http: { destroy(): void };
1051
- https: { destroy(): void };
1052
- };
1046
+ private readonly http: AxiosInstance;
1053
1047
  /** Cancels in-flight axios work on `close()` so `postAuth`/`getMail` cannot hang forever. */
1054
1048
  private readonly httpAbortController = new AbortController();
1055
- private readonly http: AxiosInstance;
1056
1049
  private readonly idKeys: KeyPair | null;
1057
1050
  private isAlive: boolean = true;
1058
1051
  private readonly mailInterval?: NodeJS.Timeout;
1059
1052
 
1060
1053
  private manuallyClosing: boolean = false;
1061
1054
  /**
1062
- * Bumped when the WebSocket is torn down and re-opened so the previous
1063
- * `postAuth` loop exits instead of overlapping a new one.
1055
+ * Node-only: per-client HTTP(S) agents (see `init()` + `storage/node/http-agents`).
1056
+ * Dropped on `close()` so idle keep-alive sockets do not keep the process alive.
1064
1057
  */
1065
- private postAuthVersion = 0;
1058
+ private nodeHttpAgents?: {
1059
+ http: { destroy(): void };
1060
+ https: { destroy(): void };
1061
+ };
1066
1062
  /* Retrieves the userID with the user identifier.
1067
1063
  user identifier is checked for userID, then signkey,
1068
1064
  and finally falls back to username. */
@@ -1072,24 +1068,28 @@ export class Client {
1072
1068
  private readonly options?: ClientOptions | undefined;
1073
1069
 
1074
1070
  private pingInterval: null | ReturnType<typeof setTimeout> = null;
1071
+ /**
1072
+ * Bumped when the WebSocket is torn down and re-opened so the previous
1073
+ * `postAuth` loop exits instead of overlapping a new one.
1074
+ */
1075
+ private postAuthVersion = 0;
1076
+
1075
1077
  private readonly prefixes:
1076
1078
  | { HTTP: "http://"; WS: "ws://" }
1077
1079
  | { HTTP: "https://"; WS: "wss://" };
1078
-
1079
1080
  private reading: boolean = false;
1080
1081
  private readonly seenMailIDs: Set<string> = new Set();
1081
1082
  private sessionRecords: Record<string, SessionCrypto> = {};
1083
+
1082
1084
  // these are created from one set of sign keys
1083
1085
  private readonly signKeys: KeyPair;
1084
-
1085
1086
  private socket: WebSocketLike;
1086
1087
  private token: null | string = null;
1088
+
1087
1089
  private user?: User;
1088
1090
 
1089
1091
  private userRecords: Record<string, User> = {};
1090
-
1091
1092
  private xKeyRing?: XKeyRing;
1092
- private readonly cryptoProfile: CryptoProfile;
1093
1093
 
1094
1094
  private constructor(
1095
1095
  material: {
@@ -1305,48 +1305,22 @@ export class Client {
1305
1305
  }
1306
1306
 
1307
1307
  /**
1308
- * True when running under Node (has `process.versions`).
1308
+ * Browser-safe NODE_ENV accessor.
1309
1309
  * Uses indirect lookup so the bare `process` global never appears in
1310
1310
  * source that the platform-guard plugin scans.
1311
1311
  */
1312
- private static isNodeRuntime(): boolean {
1312
+ private static getNodeEnv(): string | undefined {
1313
1313
  try {
1314
1314
  const g = Object.getOwnPropertyDescriptor(
1315
1315
  globalThis,
1316
1316
  "\u0070rocess",
1317
1317
  );
1318
- if (!g) return false;
1318
+ if (!g) return undefined;
1319
+ // Node 24+ exposes `process` as an accessor (get/set), not a value.
1319
1320
  const proc: unknown =
1320
1321
  typeof g.get === "function" ? g.get() : g.value;
1321
1322
  if (typeof proc !== "object" || proc === null) {
1322
- return false;
1323
- }
1324
- return (
1325
- "versions" in proc &&
1326
- typeof (proc as { versions?: unknown }).versions === "object"
1327
- );
1328
- } catch {
1329
- return false;
1330
- }
1331
- }
1332
-
1333
- /**
1334
- * Browser-safe NODE_ENV accessor.
1335
- * Uses indirect lookup so the bare `process` global never appears in
1336
- * source that the platform-guard plugin scans.
1337
- */
1338
- private static getNodeEnv(): string | undefined {
1339
- try {
1340
- const g = Object.getOwnPropertyDescriptor(
1341
- globalThis,
1342
- "\u0070rocess",
1343
- );
1344
- if (!g) return undefined;
1345
- // Node 24+ exposes `process` as an accessor (get/set), not a value.
1346
- const proc: unknown =
1347
- typeof g.get === "function" ? g.get() : g.value;
1348
- if (typeof proc !== "object" || proc === null) {
1349
- return undefined;
1323
+ return undefined;
1350
1324
  }
1351
1325
  const envDesc = Object.getOwnPropertyDescriptor(proc, "env");
1352
1326
  if (!envDesc) return undefined;
@@ -1370,12 +1344,29 @@ export class Client {
1370
1344
  }
1371
1345
 
1372
1346
  /**
1373
- * Fresh read of the `manuallyClosing` flag for async loops — direct property checks
1374
- * after `await` are flagged as always-false by control-flow analysis even though
1375
- * `close()` can run concurrently.
1347
+ * True when running under Node (has `process.versions`).
1348
+ * Uses indirect lookup so the bare `process` global never appears in
1349
+ * source that the platform-guard plugin scans.
1376
1350
  */
1377
- private isManualCloseInFlight(): boolean {
1378
- return this.manuallyClosing;
1351
+ private static isNodeRuntime(): boolean {
1352
+ try {
1353
+ const g = Object.getOwnPropertyDescriptor(
1354
+ globalThis,
1355
+ "\u0070rocess",
1356
+ );
1357
+ if (!g) return false;
1358
+ const proc: unknown =
1359
+ typeof g.get === "function" ? g.get() : g.value;
1360
+ if (typeof proc !== "object" || proc === null) {
1361
+ return false;
1362
+ }
1363
+ return (
1364
+ "versions" in proc &&
1365
+ typeof (proc as { versions?: unknown }).versions === "object"
1366
+ );
1367
+ } catch {
1368
+ return false;
1369
+ }
1379
1370
  }
1380
1371
 
1381
1372
  /**
@@ -1450,62 +1441,6 @@ export class Client {
1450
1441
  await this.negotiateOTK();
1451
1442
  }
1452
1443
 
1453
- /**
1454
- * Tears down the current WebSocket and opens a new one, keeping the same
1455
- * session (user + device in storage). Restarts the post-auth mail loop.
1456
- * Use for long-running processes or e2e where a fresh socket matches a
1457
- * newly-registered second device.
1458
- */
1459
- public async reconnectWebsocket(): Promise<void> {
1460
- this.postAuthVersion++;
1461
- if (this.pingInterval) {
1462
- clearInterval(this.pingInterval);
1463
- this.pingInterval = null;
1464
- }
1465
- this.socket.close();
1466
- try {
1467
- await new Promise<void>((resolve, reject) => {
1468
- const t = setTimeout(() => {
1469
- this.off("connected", onC);
1470
- reject(
1471
- new Error(
1472
- "reconnectWebsocket: timed out waiting for authorized",
1473
- ),
1474
- );
1475
- }, 15_000);
1476
- const onC = () => {
1477
- clearTimeout(t);
1478
- this.off("connected", onC);
1479
- resolve();
1480
- };
1481
- this.on("connected", onC);
1482
- try {
1483
- this.initSocket();
1484
- } catch (err: unknown) {
1485
- clearTimeout(t);
1486
- this.off("connected", onC);
1487
- const e =
1488
- err instanceof Error
1489
- ? err
1490
- : new Error(String(err), { cause: err });
1491
- reject(e);
1492
- }
1493
- });
1494
- } catch (e: unknown) {
1495
- throw e instanceof Error ? e : new Error(String(e), { cause: e });
1496
- }
1497
- await new Promise((r) => setTimeout(r, 0));
1498
- await this.negotiateOTK();
1499
- }
1500
-
1501
- /**
1502
- * Triggers an immediate inbox sync by fetching `/mail` once.
1503
- * Useful on mobile foreground resume where background work may pause.
1504
- */
1505
- public async syncInboxNow(): Promise<void> {
1506
- await this.getMail();
1507
- }
1508
-
1509
1444
  /**
1510
1445
  * Delete all local data — message history, encryption sessions, and prekeys.
1511
1446
  * Closes the client afterward. Credentials (keychain) must be cleared by the consumer.
@@ -1684,6 +1619,54 @@ export class Client {
1684
1619
  return this;
1685
1620
  }
1686
1621
 
1622
+ /**
1623
+ * Tears down the current WebSocket and opens a new one, keeping the same
1624
+ * session (user + device in storage). Restarts the post-auth mail loop.
1625
+ * Use for long-running processes or e2e where a fresh socket matches a
1626
+ * newly-registered second device.
1627
+ */
1628
+ public async reconnectWebsocket(): Promise<void> {
1629
+ this.postAuthVersion++;
1630
+ if (this.pingInterval) {
1631
+ clearInterval(this.pingInterval);
1632
+ this.pingInterval = null;
1633
+ }
1634
+ this.socket.close();
1635
+ try {
1636
+ await new Promise<void>((resolve, reject) => {
1637
+ const t = setTimeout(() => {
1638
+ this.off("connected", onC);
1639
+ reject(
1640
+ new Error(
1641
+ "reconnectWebsocket: timed out waiting for authorized",
1642
+ ),
1643
+ );
1644
+ }, 15_000);
1645
+ const onC = () => {
1646
+ clearTimeout(t);
1647
+ this.off("connected", onC);
1648
+ resolve();
1649
+ };
1650
+ this.on("connected", onC);
1651
+ try {
1652
+ this.initSocket();
1653
+ } catch (err: unknown) {
1654
+ clearTimeout(t);
1655
+ this.off("connected", onC);
1656
+ const e =
1657
+ err instanceof Error
1658
+ ? err
1659
+ : new Error(String(err), { cause: err });
1660
+ reject(e);
1661
+ }
1662
+ });
1663
+ } catch (e: unknown) {
1664
+ throw e instanceof Error ? e : new Error(String(e), { cause: e });
1665
+ }
1666
+ await new Promise((r) => setTimeout(r, 0));
1667
+ await this.negotiateOTK();
1668
+ }
1669
+
1687
1670
  /**
1688
1671
  * Registers a new account on the server.
1689
1672
  *
@@ -1757,6 +1740,14 @@ export class Client {
1757
1740
  return this;
1758
1741
  }
1759
1742
 
1743
+ /**
1744
+ * Triggers an immediate inbox sync by fetching `/mail` once.
1745
+ * Useful on mobile foreground resume where background work may pause.
1746
+ */
1747
+ public async syncInboxNow(): Promise<void> {
1748
+ await this.getMail();
1749
+ }
1750
+
1760
1751
  /**
1761
1752
  * Returns a compact `<username><deviceID>` debug label.
1762
1753
  */
@@ -1790,6 +1781,36 @@ export class Client {
1790
1781
  return whoami;
1791
1782
  }
1792
1783
 
1784
+ private async approveDeviceRequest(requestID: string): Promise<Device> {
1785
+ const req = await this.getDeviceRegistrationRequest(requestID);
1786
+ if (!req) {
1787
+ throw new Error("Device approval request not found.");
1788
+ }
1789
+ if (req.status !== "pending") {
1790
+ throw new Error(
1791
+ "Device approval request is not pending: " + req.status,
1792
+ );
1793
+ }
1794
+ const signed = XUtils.encodeHex(
1795
+ await xSignAsync(
1796
+ XUtils.decodeUTF8(requestID),
1797
+ this.signKeys.secretKey,
1798
+ ),
1799
+ );
1800
+ const response = await this.http.post(
1801
+ this.prefixes.HTTP +
1802
+ this.host +
1803
+ "/user/" +
1804
+ this.getUser().userID +
1805
+ "/devices/requests/" +
1806
+ requestID +
1807
+ "/approve",
1808
+ msgpack.encode({ signed }),
1809
+ { headers: { "Content-Type": "application/msgpack" } },
1810
+ );
1811
+ return decodeAxios(DeviceCodec, response.data);
1812
+ }
1813
+
1793
1814
  private censorPreKey(preKey: PreKeysSQL): PreKeysWS {
1794
1815
  if (!preKey.index) {
1795
1816
  throw new Error("Key index is required.");
@@ -1916,27 +1937,6 @@ export class Client {
1916
1937
  return decodeAxios(ServerCodec, res.data);
1917
1938
  }
1918
1939
 
1919
- /**
1920
- * `xDHAsync` and other helpers in `@vex-chat/crypto` use the process-wide
1921
- * active profile. When several {@link Client} instances use different
1922
- * `cryptoProfile` values, scope the global to this instance for the duration
1923
- * of that crypto work.
1924
- */
1925
- private async runWithThisCryptoProfile<T>(
1926
- fn: () => Promise<T>,
1927
- ): Promise<T> {
1928
- const prev = getCryptoProfile();
1929
- if (prev === this.cryptoProfile) {
1930
- return await fn();
1931
- }
1932
- setCryptoProfile(this.cryptoProfile);
1933
- try {
1934
- return await fn();
1935
- } finally {
1936
- setCryptoProfile(prev);
1937
- }
1938
- }
1939
-
1940
1940
  private async createSession(
1941
1941
  device: Device,
1942
1942
  user: User,
@@ -2144,36 +2144,6 @@ export class Client {
2144
2144
  await this.http.delete(this.getHost() + "/channel/" + channelID);
2145
2145
  }
2146
2146
 
2147
- private async approveDeviceRequest(requestID: string): Promise<Device> {
2148
- const req = await this.getDeviceRegistrationRequest(requestID);
2149
- if (!req) {
2150
- throw new Error("Device approval request not found.");
2151
- }
2152
- if (req.status !== "pending") {
2153
- throw new Error(
2154
- "Device approval request is not pending: " + req.status,
2155
- );
2156
- }
2157
- const signed = XUtils.encodeHex(
2158
- await xSignAsync(
2159
- XUtils.decodeUTF8(requestID),
2160
- this.signKeys.secretKey,
2161
- ),
2162
- );
2163
- const response = await this.http.post(
2164
- this.prefixes.HTTP +
2165
- this.host +
2166
- "/user/" +
2167
- this.getUser().userID +
2168
- "/devices/requests/" +
2169
- requestID +
2170
- "/approve",
2171
- msgpack.encode({ signed }),
2172
- { headers: { "Content-Type": "application/msgpack" } },
2173
- );
2174
- return decodeAxios(DeviceCodec, response.data);
2175
- }
2176
-
2177
2147
  private async deleteDevice(deviceID: string): Promise<void> {
2178
2148
  if (deviceID === this.getDevice().deviceID) {
2179
2149
  throw new Error("You can't delete the device you're logged in to.");
@@ -2188,52 +2158,6 @@ export class Client {
2188
2158
  );
2189
2159
  }
2190
2160
 
2191
- private async getDeviceRegistrationRequest(
2192
- requestID: string,
2193
- ): Promise<null | PendingDeviceRequest> {
2194
- try {
2195
- const response = await this.http.get(
2196
- this.prefixes.HTTP +
2197
- this.host +
2198
- "/user/" +
2199
- this.getUser().userID +
2200
- "/devices/requests/" +
2201
- requestID,
2202
- );
2203
- return decodeAxios(PendingDeviceRequestCodec, response.data);
2204
- } catch (err: unknown) {
2205
- if (isAxiosError(err) && err.response?.status === 404) {
2206
- return null;
2207
- }
2208
- throw err;
2209
- }
2210
- }
2211
-
2212
- private async listDeviceRegistrationRequests(): Promise<
2213
- PendingDeviceRequest[]
2214
- > {
2215
- const response = await this.http.get(
2216
- this.prefixes.HTTP +
2217
- this.host +
2218
- "/user/" +
2219
- this.getUser().userID +
2220
- "/devices/requests",
2221
- );
2222
- return decodeAxios(PendingDeviceRequestArrayCodec, response.data);
2223
- }
2224
-
2225
- private async rejectDeviceRequest(requestID: string): Promise<void> {
2226
- await this.http.post(
2227
- this.prefixes.HTTP +
2228
- this.host +
2229
- "/user/" +
2230
- this.getUser().userID +
2231
- "/devices/requests/" +
2232
- requestID +
2233
- "/reject",
2234
- );
2235
- }
2236
-
2237
2161
  private async deleteHistory(channelOrUserID: string): Promise<void> {
2238
2162
  await this.database.deleteHistory(channelOrUserID);
2239
2163
  }
@@ -2245,6 +2169,21 @@ export class Client {
2245
2169
  private async deleteServer(serverID: string): Promise<void> {
2246
2170
  await this.http.delete(this.getHost() + "/server/" + serverID);
2247
2171
  }
2172
+
2173
+ private deviceListFailureDetail(err: unknown): string {
2174
+ if (!isAxiosError(err)) {
2175
+ return "";
2176
+ }
2177
+ const st = err.response?.status;
2178
+ if (typeof st === "number") {
2179
+ return ` (HTTP ${String(st)})`;
2180
+ }
2181
+ if (err.code !== undefined) {
2182
+ return ` (${err.code})`;
2183
+ }
2184
+ return "";
2185
+ }
2186
+
2248
2187
  /**
2249
2188
  * Gets a list of permissions for a server.
2250
2189
  *
@@ -2294,6 +2233,56 @@ export class Client {
2294
2233
  }
2295
2234
  }
2296
2235
 
2236
+ private async fetchUserDeviceListOnce(userID: string): Promise<Device[]> {
2237
+ if (this.isManualCloseInFlight()) {
2238
+ return [];
2239
+ }
2240
+ const res = await this.http.get(
2241
+ this.getHost() + "/user/" + userID + "/devices",
2242
+ );
2243
+ const devices = decodeAxios(DeviceArrayCodec, res.data);
2244
+ for (const device of devices) {
2245
+ this.deviceRecords[device.deviceID] = device;
2246
+ }
2247
+ return devices;
2248
+ }
2249
+
2250
+ /**
2251
+ * DM / forward paths need the peer’s (or self) device rows under load: bounded
2252
+ * retries with exponential backoff (same shape as session pubkey hydration).
2253
+ */
2254
+ private async fetchUserDeviceListWithBackoff(
2255
+ userID: string,
2256
+ label: "own" | "peer",
2257
+ ): Promise<Device[]> {
2258
+ const base =
2259
+ label === "own"
2260
+ ? "Couldn't get own devices"
2261
+ : "Couldn't get device list";
2262
+ let lastErr: unknown;
2263
+ for (let attempt = 0; attempt < 5; attempt++) {
2264
+ if (this.isManualCloseInFlight()) {
2265
+ return [];
2266
+ }
2267
+ if (attempt > 0) {
2268
+ const delayMs = 100 * 2 ** (attempt - 1);
2269
+ // Chunk the delay so close() can finish before we retry HTTP.
2270
+ const chunkMs = 10;
2271
+ for (let elapsed = 0; elapsed < delayMs; elapsed += chunkMs) {
2272
+ if (this.isManualCloseInFlight()) {
2273
+ return [];
2274
+ }
2275
+ await sleep(Math.min(chunkMs, delayMs - elapsed));
2276
+ }
2277
+ }
2278
+ try {
2279
+ return await this.fetchUserDeviceListOnce(userID);
2280
+ } catch (err: unknown) {
2281
+ lastErr = err;
2282
+ }
2283
+ }
2284
+ throw new Error(`${base}${this.deviceListFailureDetail(lastErr)}`);
2285
+ }
2297
2286
  private async forward(message: Message) {
2298
2287
  if (this.isManualCloseInFlight()) {
2299
2288
  return;
@@ -2386,6 +2375,27 @@ export class Client {
2386
2375
  }
2387
2376
  }
2388
2377
 
2378
+ private async getDeviceRegistrationRequest(
2379
+ requestID: string,
2380
+ ): Promise<null | PendingDeviceRequest> {
2381
+ try {
2382
+ const response = await this.http.get(
2383
+ this.prefixes.HTTP +
2384
+ this.host +
2385
+ "/user/" +
2386
+ this.getUser().userID +
2387
+ "/devices/requests/" +
2388
+ requestID,
2389
+ );
2390
+ return decodeAxios(PendingDeviceRequestCodec, response.data);
2391
+ } catch (err: unknown) {
2392
+ if (isAxiosError(err) && err.response?.status === 404) {
2393
+ return null;
2394
+ }
2395
+ throw err;
2396
+ }
2397
+ }
2398
+
2389
2399
  /* Retrieves the current list of users you have sessions with. */
2390
2400
  private async getFamiliars(): Promise<User[]> {
2391
2401
  const sessions = await this.database.getAllSessions();
@@ -2448,8 +2458,8 @@ export class Client {
2448
2458
  }
2449
2459
  })();
2450
2460
  debugLibvexDm("getMail: inbox", {
2451
- deviceID: did,
2452
2461
  count: String(inbox.length),
2462
+ deviceID: did,
2453
2463
  });
2454
2464
  }
2455
2465
 
@@ -2459,8 +2469,8 @@ export class Client {
2459
2469
  if (libvexDebugDmEnabled()) {
2460
2470
  debugLibvexDm("getMail: readMail one", {
2461
2471
  mailID: mailBody.mailID,
2462
- type: String(mailBody.mailType),
2463
2472
  recipient: mailBody.recipient,
2473
+ type: String(mailBody.mailType),
2464
2474
  });
2465
2475
  }
2466
2476
  await this.readMail(mailHeader, mailBody, timestamp);
@@ -2598,20 +2608,6 @@ export class Client {
2598
2608
  return this.user;
2599
2609
  }
2600
2610
 
2601
- private deviceListFailureDetail(err: unknown): string {
2602
- if (!isAxiosError(err)) {
2603
- return "";
2604
- }
2605
- const st = err.response?.status;
2606
- if (typeof st === "number") {
2607
- return ` (HTTP ${String(st)})`;
2608
- }
2609
- if (err.code !== undefined) {
2610
- return ` (${err.code})`;
2611
- }
2612
- return "";
2613
- }
2614
-
2615
2611
  /**
2616
2612
  * Single GET for `/user/:id/devices`. On failure returns `null` (swallows errors)
2617
2613
  * — callers that need reliability should use `fetchUserDeviceListWithBackoff`.
@@ -2626,57 +2622,6 @@ export class Client {
2626
2622
  }
2627
2623
  }
2628
2624
 
2629
- private async fetchUserDeviceListOnce(userID: string): Promise<Device[]> {
2630
- if (this.isManualCloseInFlight()) {
2631
- return [];
2632
- }
2633
- const res = await this.http.get(
2634
- this.getHost() + "/user/" + userID + "/devices",
2635
- );
2636
- const devices = decodeAxios(DeviceArrayCodec, res.data);
2637
- for (const device of devices) {
2638
- this.deviceRecords[device.deviceID] = device;
2639
- }
2640
- return devices;
2641
- }
2642
-
2643
- /**
2644
- * DM / forward paths need the peer’s (or self) device rows under load: bounded
2645
- * retries with exponential backoff (same shape as session pubkey hydration).
2646
- */
2647
- private async fetchUserDeviceListWithBackoff(
2648
- userID: string,
2649
- label: "peer" | "own",
2650
- ): Promise<Device[]> {
2651
- const base =
2652
- label === "own"
2653
- ? "Couldn't get own devices"
2654
- : "Couldn't get device list";
2655
- let lastErr: unknown;
2656
- for (let attempt = 0; attempt < 5; attempt++) {
2657
- if (this.isManualCloseInFlight()) {
2658
- return [];
2659
- }
2660
- if (attempt > 0) {
2661
- const delayMs = 100 * 2 ** (attempt - 1);
2662
- // Chunk the delay so close() can finish before we retry HTTP.
2663
- const chunkMs = 10;
2664
- for (let elapsed = 0; elapsed < delayMs; elapsed += chunkMs) {
2665
- if (this.isManualCloseInFlight()) {
2666
- return [];
2667
- }
2668
- await sleep(Math.min(chunkMs, delayMs - elapsed));
2669
- }
2670
- }
2671
- try {
2672
- return await this.fetchUserDeviceListOnce(userID);
2673
- } catch (err: unknown) {
2674
- lastErr = err;
2675
- }
2676
- }
2677
- throw new Error(`${base}${this.deviceListFailureDetail(lastErr)}`);
2678
- }
2679
-
2680
2625
  private async getUserList(channelID: string): Promise<User[]> {
2681
2626
  const res = await this.http.post(
2682
2627
  this.getHost() + "/userList/" + channelID,
@@ -2686,10 +2631,6 @@ export class Client {
2686
2631
 
2687
2632
  private async handleNotify(msg: NotifyMsg) {
2688
2633
  switch (msg.event) {
2689
- case "mail":
2690
- await this.getMail();
2691
- this.fetchingMail = false;
2692
- break;
2693
2634
  case "deviceRequest": {
2694
2635
  const parsed = deviceRequestNotifyData.safeParse(msg.data);
2695
2636
  if (parsed.success) {
@@ -2697,6 +2638,10 @@ export class Client {
2697
2638
  }
2698
2639
  break;
2699
2640
  }
2641
+ case "mail":
2642
+ await this.getMail();
2643
+ this.fetchingMail = false;
2644
+ break;
2700
2645
  case "permission":
2701
2646
  this.emitter.emit(
2702
2647
  "permission",
@@ -2711,28 +2656,6 @@ export class Client {
2711
2656
  }
2712
2657
  }
2713
2658
 
2714
- /**
2715
- * Pipeline for decrypted messages — registered in `init`. After `close()` sets
2716
- * `manuallyClosing`, this becomes a no-op so fire-and-forget `forward` does not
2717
- * race HTTP teardown (we avoid `off()` here — it can interact badly with emit).
2718
- */
2719
- private readonly onInternalMessage = (message: Message): void => {
2720
- if (this.isManualCloseInFlight()) {
2721
- return;
2722
- }
2723
- if (message.direction === "outgoing" && !message.forward) {
2724
- void this.forward(message);
2725
- }
2726
-
2727
- if (
2728
- message.direction === "incoming" &&
2729
- message.recipient === message.sender
2730
- ) {
2731
- return;
2732
- }
2733
- void this.database.saveMessage(message);
2734
- };
2735
-
2736
2659
  /**
2737
2660
  * Initializes the keyring. This must be called before anything else.
2738
2661
  */
@@ -2834,6 +2757,15 @@ export class Client {
2834
2757
  }
2835
2758
  }
2836
2759
 
2760
+ /**
2761
+ * Fresh read of the `manuallyClosing` flag for async loops — direct property checks
2762
+ * after `await` are flagged as always-false by control-flow analysis even though
2763
+ * `close()` can run concurrently.
2764
+ */
2765
+ private isManualCloseInFlight(): boolean {
2766
+ return this.manuallyClosing;
2767
+ }
2768
+
2837
2769
  private async kickUser(userID: string, serverID: string): Promise<void> {
2838
2770
  const permissionList = await this.fetchPermissionList(serverID);
2839
2771
  for (const permission of permissionList) {
@@ -2854,6 +2786,19 @@ export class Client {
2854
2786
  }
2855
2787
  }
2856
2788
 
2789
+ private async listDeviceRegistrationRequests(): Promise<
2790
+ PendingDeviceRequest[]
2791
+ > {
2792
+ const response = await this.http.get(
2793
+ this.prefixes.HTTP +
2794
+ this.host +
2795
+ "/user/" +
2796
+ this.getUser().userID +
2797
+ "/devices/requests",
2798
+ );
2799
+ return decodeAxios(PendingDeviceRequestArrayCodec, response.data);
2800
+ }
2801
+
2857
2802
  private async markSessionVerified(sessionID: string) {
2858
2803
  return this.database.markSessionVerified(sessionID);
2859
2804
  }
@@ -2878,6 +2823,28 @@ export class Client {
2878
2823
  this.xKeyRing.ephemeralKeys = await xBoxKeyPairAsync();
2879
2824
  }
2880
2825
 
2826
+ /**
2827
+ * Pipeline for decrypted messages — registered in `init`. After `close()` sets
2828
+ * `manuallyClosing`, this becomes a no-op so fire-and-forget `forward` does not
2829
+ * race HTTP teardown (we avoid `off()` here — it can interact badly with emit).
2830
+ */
2831
+ private readonly onInternalMessage = (message: Message): void => {
2832
+ if (this.isManualCloseInFlight()) {
2833
+ return;
2834
+ }
2835
+ if (message.direction === "outgoing" && !message.forward) {
2836
+ void this.forward(message);
2837
+ }
2838
+
2839
+ if (
2840
+ message.direction === "incoming" &&
2841
+ message.recipient === message.sender
2842
+ ) {
2843
+ return;
2844
+ }
2845
+ void this.database.saveMessage(message);
2846
+ };
2847
+
2881
2848
  private ping() {
2882
2849
  if (!this.isAlive) {
2883
2850
  }
@@ -3022,9 +2989,7 @@ export class Client {
3022
2989
  void this.createSession(
3023
2990
  deviceEntry,
3024
2991
  user,
3025
- XUtils.decodeUTF8(
3026
- `��RETRY_REQUEST:${mail.mailID}��`,
3027
- ),
2992
+ new Uint8Array(),
3028
2993
  mail.group,
3029
2994
  uuid.v4(),
3030
2995
  false,
@@ -3064,10 +3029,10 @@ export class Client {
3064
3029
  "readMail initial: abort (otk index mismatch)",
3065
3030
  {
3066
3031
  mailID: mail.mailID,
3067
- preKeyIndex: String(preKeyIndex),
3068
3032
  otkIndex: String(
3069
3033
  otk?.index ?? "null",
3070
3034
  ),
3035
+ preKeyIndex: String(preKeyIndex),
3071
3036
  thisDevice:
3072
3037
  this.getDevice().deviceID,
3073
3038
  },
@@ -3104,8 +3069,8 @@ export class Client {
3104
3069
  debugLibvexDm(
3105
3070
  "readMail initial: abort (IK_A null, Ed→X25519?)",
3106
3071
  {
3107
- mailID: mail.mailID,
3108
3072
  fips: String(fipsRead),
3073
+ mailID: mail.mailID,
3109
3074
  thisDevice:
3110
3075
  this.getDevice().deviceID,
3111
3076
  },
@@ -3232,12 +3197,12 @@ export class Client {
3232
3197
  "readMail initial: ok (emit message)",
3233
3198
  {
3234
3199
  mailID: mail.mailID,
3235
- preKeyIndex: String(preKeyIndex),
3236
- thisDevice:
3237
- this.getDevice().deviceID,
3238
3200
  plaintextLen: String(
3239
3201
  plaintext.length,
3240
3202
  ),
3203
+ preKeyIndex: String(preKeyIndex),
3204
+ thisDevice:
3205
+ this.getDevice().deviceID,
3241
3206
  },
3242
3207
  );
3243
3208
  } catch {
@@ -3480,6 +3445,18 @@ export class Client {
3480
3445
  return decodeAxios(DeviceRegistrationResultCodec, res.data);
3481
3446
  }
3482
3447
 
3448
+ private async rejectDeviceRequest(requestID: string): Promise<void> {
3449
+ await this.http.post(
3450
+ this.prefixes.HTTP +
3451
+ this.host +
3452
+ "/user/" +
3453
+ this.getUser().userID +
3454
+ "/devices/requests/" +
3455
+ requestID +
3456
+ "/reject",
3457
+ );
3458
+ }
3459
+
3483
3460
  private async respond(msg: ChallMsg) {
3484
3461
  const response: RespMsg = {
3485
3462
  signed: await xSignAsync(
@@ -3602,6 +3579,27 @@ export class Client {
3602
3579
  return device;
3603
3580
  }
3604
3581
 
3582
+ /**
3583
+ * `xDHAsync` and other helpers in `@vex-chat/crypto` use the process-wide
3584
+ * active profile. When several {@link Client} instances use different
3585
+ * `cryptoProfile` values, scope the global to this instance for the duration
3586
+ * of that crypto work.
3587
+ */
3588
+ private async runWithThisCryptoProfile<T>(
3589
+ fn: () => Promise<T>,
3590
+ ): Promise<T> {
3591
+ const prev = getCryptoProfile();
3592
+ if (prev === this.cryptoProfile) {
3593
+ return await fn();
3594
+ }
3595
+ setCryptoProfile(this.cryptoProfile);
3596
+ try {
3597
+ return await fn();
3598
+ } finally {
3599
+ setCryptoProfile(prev);
3600
+ }
3601
+ }
3602
+
3605
3603
  /* header is 32 bytes and is either empty
3606
3604
  or contains an HMAC of the message with
3607
3605
  a derived SK */
@@ -3679,9 +3677,9 @@ export class Client {
3679
3677
  if (!session || retry) {
3680
3678
  if (libvexDebugDmEnabled()) {
3681
3679
  debugLibvexDm("sendMail: createSession path", {
3680
+ hasSession: String(!!session),
3682
3681
  peerDevice: device.deviceID,
3683
3682
  retry: String(retry),
3684
- hasSession: String(!!session),
3685
3683
  });
3686
3684
  }
3687
3685
  await this.createSession(
@@ -3830,11 +3828,11 @@ export class Client {
3830
3828
  debugLibvexDm(
3831
3829
  "sendMessage: peer device list (merged, sorted)",
3832
3830
  {
3833
- userID,
3834
3831
  nAfterBackoff: String(afterBackoff.length),
3835
3832
  nMerged: String(deviceListRaw.length),
3836
3833
  nSorted: String(deviceList.length),
3837
3834
  ourDevice: this.getDevice().deviceID,
3835
+ userID,
3838
3836
  },
3839
3837
  );
3840
3838
  for (const [i, d] of deviceList.entries()) {
@@ -3852,8 +3850,8 @@ export class Client {
3852
3850
  try {
3853
3851
  if (libvexDebugDmEnabled()) {
3854
3852
  debugLibvexDm("sendMessage: sendMail start", {
3855
- recipientDevice: device.deviceID,
3856
3853
  mailID: messageMailID,
3854
+ recipientDevice: device.deviceID,
3857
3855
  });
3858
3856
  }
3859
3857
  await this.sendMail(