@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/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,
@@ -202,6 +202,7 @@ import {
202
202
  DeviceArrayCodec,
203
203
  DeviceChallengeCodec,
204
204
  DeviceCodec,
205
+ DeviceRegistrationResultCodec,
205
206
  EmojiArrayCodec,
206
207
  EmojiCodec,
207
208
  FileSQLCodec,
@@ -209,6 +210,8 @@ import {
209
210
  InviteCodec,
210
211
  KeyBundleCodec,
211
212
  OtkCountCodec,
213
+ PendingDeviceRequestArrayCodec,
214
+ PendingDeviceRequestCodec,
212
215
  PermissionArrayCodec,
213
216
  PermissionCodec,
214
217
  ServerArrayCodec,
@@ -276,6 +279,12 @@ export interface ClientOptions {
276
279
  cryptoProfile?: "fips" | "tweetnacl";
277
280
  /** Folder path where the sqlite file is created. */
278
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;
279
288
  /** Platform label for device registration (e.g. "ios", "macos", "linux"). */
280
289
  deviceName?: string;
281
290
  /** API host without protocol. Defaults to `api.vex.wtf`. */
@@ -286,46 +295,30 @@ export interface ClientOptions {
286
295
  saveHistory?: boolean;
287
296
  /** Use `http/ws` instead of `https/wss`. Intended for local/dev environments. */
288
297
  unsafeHttp?: boolean;
289
- /**
290
- * When set (non-empty), sent as `x-dev-api-key` on every HTTP request.
291
- * Spire omits in-process rate limits when this matches the server's `DEV_API_KEY`
292
- * (local / load-testing only — never use in production).
293
- */
294
- devApiKey?: string;
295
298
  }
296
299
 
300
+ export type DeviceRegistrationResult = Device | PendingDeviceRegistration;
301
+
297
302
  /**
298
303
  * @ignore
299
304
  */
300
305
  export interface Devices {
306
+ /** Approves a pending device registration request as the current device. */
307
+ approveRequest: (requestID: string) => Promise<Device>;
301
308
  /** Deletes one of the account's devices (except the currently active one). */
302
309
  delete: (deviceID: string) => Promise<void>;
310
+ /** Fetches one pending registration request by ID for the current user. */
311
+ getRequest: (requestID: string) => Promise<null | PendingDeviceRequest>;
312
+ /** Lists pending/processed registration requests for the current user. */
313
+ listRequests: () => Promise<PendingDeviceRequest[]>;
303
314
  /** Registers the current key material as a new device. */
304
- register: () => Promise<Device | null>;
315
+ register: () => Promise<DeviceRegistrationResult | null>;
316
+ /** Rejects a pending device registration request as the current device. */
317
+ rejectRequest: (requestID: string) => Promise<void>;
305
318
  /** Fetches one device by ID. */
306
319
  retrieve: (deviceIdentifier: string) => Promise<Device | null>;
307
320
  }
308
321
 
309
- /**
310
- * Channel is a chat channel on a server.
311
- *
312
- * Common fields:
313
- * - `channelID`
314
- * - `serverID`
315
- * - `name`
316
- */
317
- export type { Channel } from "@vex-chat/types";
318
-
319
- /**
320
- * Server is a single chat server.
321
- *
322
- * Common fields:
323
- * - `serverID`
324
- * - `name`
325
- * - `icon` (optional URL/data)
326
- */
327
- export type { Server } from "@vex-chat/types";
328
-
329
322
  /**
330
323
  * @ignore
331
324
  */
@@ -379,6 +372,26 @@ export interface FileProgress {
379
372
  */
380
373
  export type FileRes = FileResponse;
381
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
+
382
395
  /**
383
396
  * @ignore
384
397
  */
@@ -454,6 +467,31 @@ export interface Message {
454
467
  timestamp: string;
455
468
  }
456
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
+
457
495
  /** Zod schema matching the {@link Message} interface for forwarded-message decode. */
458
496
  const messageSchema: z.ZodType<Message> = z.object({
459
497
  authorID: z.string(),
@@ -476,6 +514,14 @@ const mailInboxEntry = z.tuple([
476
514
  MailWSSchema,
477
515
  z.string(),
478
516
  ]);
517
+ const deviceRequestNotifyData = z.object({
518
+ requestID: z.string(),
519
+ status: z.union([
520
+ z.literal("approved"),
521
+ z.literal("pending"),
522
+ z.literal("rejected"),
523
+ ]),
524
+ });
479
525
 
480
526
  /**
481
527
  * Event signatures emitted by {@link Client}.
@@ -490,6 +536,14 @@ export interface ClientEvents {
490
536
  connected: () => void;
491
537
  /** Mail decryption pass is in progress. */
492
538
  decryptingMail: () => void;
539
+ /** Device approval queue changed (pending/approved/rejected). */
540
+ deviceRequest: (update: {
541
+ requestID: string;
542
+ status: Extract<
543
+ PendingDeviceApprovalStatus,
544
+ "approved" | "pending" | "rejected"
545
+ >;
546
+ }) => void;
493
547
  /** WebSocket connection lost. */
494
548
  disconnect: () => void;
495
549
  /** Progress update for a file upload or download. */
@@ -748,8 +802,12 @@ export class Client {
748
802
  * Device management methods.
749
803
  */
750
804
  public devices: Devices = {
805
+ approveRequest: this.approveDeviceRequest.bind(this),
751
806
  delete: this.deleteDevice.bind(this),
807
+ getRequest: this.getDeviceRegistrationRequest.bind(this),
808
+ listRequests: this.listDeviceRegistrationRequests.bind(this),
752
809
  register: this.registerDevice.bind(this),
810
+ rejectRequest: this.rejectDeviceRequest.bind(this),
753
811
  retrieve: this.getDeviceByID.bind(this),
754
812
  };
755
813
 
@@ -967,6 +1025,8 @@ export class Client {
967
1025
  retrieve: this.fetchUser.bind(this),
968
1026
  };
969
1027
 
1028
+ private readonly cryptoProfile: CryptoProfile;
1029
+
970
1030
  private readonly database: Storage;
971
1031
 
972
1032
  private readonly dbPath: string;
@@ -977,34 +1037,28 @@ export class Client {
977
1037
 
978
1038
  // ── Event subscription (composition over inheritance) ───────────────
979
1039
  private readonly emitter = new EventEmitter<ClientEvents>();
980
-
981
1040
  private fetchingMail: boolean = false;
1041
+
982
1042
  private firstMailFetch = true;
983
1043
 
984
1044
  private readonly forwarded = new Set<string>();
985
-
986
1045
  private readonly host: string;
987
- /**
988
- * Node-only: per-client HTTP(S) agents (see `init()` + `storage/node/http-agents`).
989
- * Dropped on `close()` so idle keep-alive sockets do not keep the process alive.
990
- */
991
- private nodeHttpAgents?: {
992
- http: { destroy(): void };
993
- https: { destroy(): void };
994
- };
1046
+ private readonly http: AxiosInstance;
995
1047
  /** Cancels in-flight axios work on `close()` so `postAuth`/`getMail` cannot hang forever. */
996
1048
  private readonly httpAbortController = new AbortController();
997
- private readonly http: AxiosInstance;
998
1049
  private readonly idKeys: KeyPair | null;
999
1050
  private isAlive: boolean = true;
1000
1051
  private readonly mailInterval?: NodeJS.Timeout;
1001
1052
 
1002
1053
  private manuallyClosing: boolean = false;
1003
1054
  /**
1004
- * Bumped when the WebSocket is torn down and re-opened so the previous
1005
- * `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.
1006
1057
  */
1007
- private postAuthVersion = 0;
1058
+ private nodeHttpAgents?: {
1059
+ http: { destroy(): void };
1060
+ https: { destroy(): void };
1061
+ };
1008
1062
  /* Retrieves the userID with the user identifier.
1009
1063
  user identifier is checked for userID, then signkey,
1010
1064
  and finally falls back to username. */
@@ -1014,24 +1068,28 @@ export class Client {
1014
1068
  private readonly options?: ClientOptions | undefined;
1015
1069
 
1016
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
+
1017
1077
  private readonly prefixes:
1018
1078
  | { HTTP: "http://"; WS: "ws://" }
1019
1079
  | { HTTP: "https://"; WS: "wss://" };
1020
-
1021
1080
  private reading: boolean = false;
1022
1081
  private readonly seenMailIDs: Set<string> = new Set();
1023
1082
  private sessionRecords: Record<string, SessionCrypto> = {};
1083
+
1024
1084
  // these are created from one set of sign keys
1025
1085
  private readonly signKeys: KeyPair;
1026
-
1027
1086
  private socket: WebSocketLike;
1028
1087
  private token: null | string = null;
1088
+
1029
1089
  private user?: User;
1030
1090
 
1031
1091
  private userRecords: Record<string, User> = {};
1032
-
1033
1092
  private xKeyRing?: XKeyRing;
1034
- private readonly cryptoProfile: CryptoProfile;
1035
1093
 
1036
1094
  private constructor(
1037
1095
  material: {
@@ -1246,32 +1304,6 @@ export class Client {
1246
1304
  return xMnemonic(xKDF(XUtils.decodeHex(session.fingerprint)));
1247
1305
  }
1248
1306
 
1249
- /**
1250
- * True when running under Node (has `process.versions`).
1251
- * Uses indirect lookup so the bare `process` global never appears in
1252
- * source that the platform-guard plugin scans.
1253
- */
1254
- private static isNodeRuntime(): boolean {
1255
- try {
1256
- const g = Object.getOwnPropertyDescriptor(
1257
- globalThis,
1258
- "\u0070rocess",
1259
- );
1260
- if (!g) return false;
1261
- const proc: unknown =
1262
- typeof g.get === "function" ? g.get() : g.value;
1263
- if (typeof proc !== "object" || proc === null) {
1264
- return false;
1265
- }
1266
- return (
1267
- "versions" in proc &&
1268
- typeof (proc as { versions?: unknown }).versions === "object"
1269
- );
1270
- } catch {
1271
- return false;
1272
- }
1273
- }
1274
-
1275
1307
  /**
1276
1308
  * Browser-safe NODE_ENV accessor.
1277
1309
  * Uses indirect lookup so the bare `process` global never appears in
@@ -1312,12 +1344,29 @@ export class Client {
1312
1344
  }
1313
1345
 
1314
1346
  /**
1315
- * Fresh read of the `manuallyClosing` flag for async loops — direct property checks
1316
- * after `await` are flagged as always-false by control-flow analysis even though
1317
- * `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.
1318
1350
  */
1319
- private isManualCloseInFlight(): boolean {
1320
- 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
+ }
1321
1370
  }
1322
1371
 
1323
1372
  /**
@@ -1392,62 +1441,6 @@ export class Client {
1392
1441
  await this.negotiateOTK();
1393
1442
  }
1394
1443
 
1395
- /**
1396
- * Tears down the current WebSocket and opens a new one, keeping the same
1397
- * session (user + device in storage). Restarts the post-auth mail loop.
1398
- * Use for long-running processes or e2e where a fresh socket matches a
1399
- * newly-registered second device.
1400
- */
1401
- public async reconnectWebsocket(): Promise<void> {
1402
- this.postAuthVersion++;
1403
- if (this.pingInterval) {
1404
- clearInterval(this.pingInterval);
1405
- this.pingInterval = null;
1406
- }
1407
- this.socket.close();
1408
- try {
1409
- await new Promise<void>((resolve, reject) => {
1410
- const t = setTimeout(() => {
1411
- this.off("connected", onC);
1412
- reject(
1413
- new Error(
1414
- "reconnectWebsocket: timed out waiting for authorized",
1415
- ),
1416
- );
1417
- }, 15_000);
1418
- const onC = () => {
1419
- clearTimeout(t);
1420
- this.off("connected", onC);
1421
- resolve();
1422
- };
1423
- this.on("connected", onC);
1424
- try {
1425
- this.initSocket();
1426
- } catch (err: unknown) {
1427
- clearTimeout(t);
1428
- this.off("connected", onC);
1429
- const e =
1430
- err instanceof Error
1431
- ? err
1432
- : new Error(String(err), { cause: err });
1433
- reject(e);
1434
- }
1435
- });
1436
- } catch (e: unknown) {
1437
- throw e instanceof Error ? e : new Error(String(e), { cause: e });
1438
- }
1439
- await new Promise((r) => setTimeout(r, 0));
1440
- await this.negotiateOTK();
1441
- }
1442
-
1443
- /**
1444
- * Triggers an immediate inbox sync by fetching `/mail` once.
1445
- * Useful on mobile foreground resume where background work may pause.
1446
- */
1447
- public async syncInboxNow(): Promise<void> {
1448
- await this.getMail();
1449
- }
1450
-
1451
1444
  /**
1452
1445
  * Delete all local data — message history, encryption sessions, and prekeys.
1453
1446
  * Closes the client afterward. Credentials (keychain) must be cleared by the consumer.
@@ -1626,6 +1619,54 @@ export class Client {
1626
1619
  return this;
1627
1620
  }
1628
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
+
1629
1670
  /**
1630
1671
  * Registers a new account on the server.
1631
1672
  *
@@ -1699,6 +1740,14 @@ export class Client {
1699
1740
  return this;
1700
1741
  }
1701
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
+
1702
1751
  /**
1703
1752
  * Returns a compact `<username><deviceID>` debug label.
1704
1753
  */
@@ -1732,6 +1781,36 @@ export class Client {
1732
1781
  return whoami;
1733
1782
  }
1734
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
+
1735
1814
  private censorPreKey(preKey: PreKeysSQL): PreKeysWS {
1736
1815
  if (!preKey.index) {
1737
1816
  throw new Error("Key index is required.");
@@ -1852,31 +1931,10 @@ export class Client {
1852
1931
  }
1853
1932
 
1854
1933
  private async createServer(name: string): Promise<Server> {
1855
- const res = await this.http.post(
1856
- this.getHost() + "/server/" + globalThis.btoa(name),
1857
- );
1858
- return decodeAxios(ServerCodec, res.data);
1859
- }
1860
-
1861
- /**
1862
- * `xDHAsync` and other helpers in `@vex-chat/crypto` use the process-wide
1863
- * active profile. When several {@link Client} instances use different
1864
- * `cryptoProfile` values, scope the global to this instance for the duration
1865
- * of that crypto work.
1866
- */
1867
- private async runWithThisCryptoProfile<T>(
1868
- fn: () => Promise<T>,
1869
- ): Promise<T> {
1870
- const prev = getCryptoProfile();
1871
- if (prev === this.cryptoProfile) {
1872
- return await fn();
1873
- }
1874
- setCryptoProfile(this.cryptoProfile);
1875
- try {
1876
- return await fn();
1877
- } finally {
1878
- setCryptoProfile(prev);
1879
- }
1934
+ const res = await this.http.post(
1935
+ this.getHost() + "/server/" + globalThis.btoa(name),
1936
+ );
1937
+ return decodeAxios(ServerCodec, res.data);
1880
1938
  }
1881
1939
 
1882
1940
  private async createSession(
@@ -2111,6 +2169,21 @@ export class Client {
2111
2169
  private async deleteServer(serverID: string): Promise<void> {
2112
2170
  await this.http.delete(this.getHost() + "/server/" + serverID);
2113
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
+
2114
2187
  /**
2115
2188
  * Gets a list of permissions for a server.
2116
2189
  *
@@ -2160,6 +2233,56 @@ export class Client {
2160
2233
  }
2161
2234
  }
2162
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
+ }
2163
2286
  private async forward(message: Message) {
2164
2287
  if (this.isManualCloseInFlight()) {
2165
2288
  return;
@@ -2252,6 +2375,27 @@ export class Client {
2252
2375
  }
2253
2376
  }
2254
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
+
2255
2399
  /* Retrieves the current list of users you have sessions with. */
2256
2400
  private async getFamiliars(): Promise<User[]> {
2257
2401
  const sessions = await this.database.getAllSessions();
@@ -2314,8 +2458,8 @@ export class Client {
2314
2458
  }
2315
2459
  })();
2316
2460
  debugLibvexDm("getMail: inbox", {
2317
- deviceID: did,
2318
2461
  count: String(inbox.length),
2462
+ deviceID: did,
2319
2463
  });
2320
2464
  }
2321
2465
 
@@ -2325,8 +2469,8 @@ export class Client {
2325
2469
  if (libvexDebugDmEnabled()) {
2326
2470
  debugLibvexDm("getMail: readMail one", {
2327
2471
  mailID: mailBody.mailID,
2328
- type: String(mailBody.mailType),
2329
2472
  recipient: mailBody.recipient,
2473
+ type: String(mailBody.mailType),
2330
2474
  });
2331
2475
  }
2332
2476
  await this.readMail(mailHeader, mailBody, timestamp);
@@ -2464,20 +2608,6 @@ export class Client {
2464
2608
  return this.user;
2465
2609
  }
2466
2610
 
2467
- private deviceListFailureDetail(err: unknown): string {
2468
- if (!isAxiosError(err)) {
2469
- return "";
2470
- }
2471
- const st = err.response?.status;
2472
- if (typeof st === "number") {
2473
- return ` (HTTP ${String(st)})`;
2474
- }
2475
- if (err.code !== undefined) {
2476
- return ` (${err.code})`;
2477
- }
2478
- return "";
2479
- }
2480
-
2481
2611
  /**
2482
2612
  * Single GET for `/user/:id/devices`. On failure returns `null` (swallows errors)
2483
2613
  * — callers that need reliability should use `fetchUserDeviceListWithBackoff`.
@@ -2492,57 +2622,6 @@ export class Client {
2492
2622
  }
2493
2623
  }
2494
2624
 
2495
- private async fetchUserDeviceListOnce(userID: string): Promise<Device[]> {
2496
- if (this.isManualCloseInFlight()) {
2497
- return [];
2498
- }
2499
- const res = await this.http.get(
2500
- this.getHost() + "/user/" + userID + "/devices",
2501
- );
2502
- const devices = decodeAxios(DeviceArrayCodec, res.data);
2503
- for (const device of devices) {
2504
- this.deviceRecords[device.deviceID] = device;
2505
- }
2506
- return devices;
2507
- }
2508
-
2509
- /**
2510
- * DM / forward paths need the peer’s (or self) device rows under load: bounded
2511
- * retries with exponential backoff (same shape as session pubkey hydration).
2512
- */
2513
- private async fetchUserDeviceListWithBackoff(
2514
- userID: string,
2515
- label: "peer" | "own",
2516
- ): Promise<Device[]> {
2517
- const base =
2518
- label === "own"
2519
- ? "Couldn't get own devices"
2520
- : "Couldn't get device list";
2521
- let lastErr: unknown;
2522
- for (let attempt = 0; attempt < 5; attempt++) {
2523
- if (this.isManualCloseInFlight()) {
2524
- return [];
2525
- }
2526
- if (attempt > 0) {
2527
- const delayMs = 100 * 2 ** (attempt - 1);
2528
- // Chunk the delay so close() can finish before we retry HTTP.
2529
- const chunkMs = 10;
2530
- for (let elapsed = 0; elapsed < delayMs; elapsed += chunkMs) {
2531
- if (this.isManualCloseInFlight()) {
2532
- return [];
2533
- }
2534
- await sleep(Math.min(chunkMs, delayMs - elapsed));
2535
- }
2536
- }
2537
- try {
2538
- return await this.fetchUserDeviceListOnce(userID);
2539
- } catch (err: unknown) {
2540
- lastErr = err;
2541
- }
2542
- }
2543
- throw new Error(`${base}${this.deviceListFailureDetail(lastErr)}`);
2544
- }
2545
-
2546
2625
  private async getUserList(channelID: string): Promise<User[]> {
2547
2626
  const res = await this.http.post(
2548
2627
  this.getHost() + "/userList/" + channelID,
@@ -2552,6 +2631,13 @@ export class Client {
2552
2631
 
2553
2632
  private async handleNotify(msg: NotifyMsg) {
2554
2633
  switch (msg.event) {
2634
+ case "deviceRequest": {
2635
+ const parsed = deviceRequestNotifyData.safeParse(msg.data);
2636
+ if (parsed.success) {
2637
+ this.emitter.emit("deviceRequest", parsed.data);
2638
+ }
2639
+ break;
2640
+ }
2555
2641
  case "mail":
2556
2642
  await this.getMail();
2557
2643
  this.fetchingMail = false;
@@ -2570,28 +2656,6 @@ export class Client {
2570
2656
  }
2571
2657
  }
2572
2658
 
2573
- /**
2574
- * Pipeline for decrypted messages — registered in `init`. After `close()` sets
2575
- * `manuallyClosing`, this becomes a no-op so fire-and-forget `forward` does not
2576
- * race HTTP teardown (we avoid `off()` here — it can interact badly with emit).
2577
- */
2578
- private readonly onInternalMessage = (message: Message): void => {
2579
- if (this.isManualCloseInFlight()) {
2580
- return;
2581
- }
2582
- if (message.direction === "outgoing" && !message.forward) {
2583
- void this.forward(message);
2584
- }
2585
-
2586
- if (
2587
- message.direction === "incoming" &&
2588
- message.recipient === message.sender
2589
- ) {
2590
- return;
2591
- }
2592
- void this.database.saveMessage(message);
2593
- };
2594
-
2595
2659
  /**
2596
2660
  * Initializes the keyring. This must be called before anything else.
2597
2661
  */
@@ -2693,6 +2757,15 @@ export class Client {
2693
2757
  }
2694
2758
  }
2695
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
+
2696
2769
  private async kickUser(userID: string, serverID: string): Promise<void> {
2697
2770
  const permissionList = await this.fetchPermissionList(serverID);
2698
2771
  for (const permission of permissionList) {
@@ -2713,6 +2786,19 @@ export class Client {
2713
2786
  }
2714
2787
  }
2715
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
+
2716
2802
  private async markSessionVerified(sessionID: string) {
2717
2803
  return this.database.markSessionVerified(sessionID);
2718
2804
  }
@@ -2737,6 +2823,28 @@ export class Client {
2737
2823
  this.xKeyRing.ephemeralKeys = await xBoxKeyPairAsync();
2738
2824
  }
2739
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
+
2740
2848
  private ping() {
2741
2849
  if (!this.isAlive) {
2742
2850
  }
@@ -2881,9 +2989,7 @@ export class Client {
2881
2989
  void this.createSession(
2882
2990
  deviceEntry,
2883
2991
  user,
2884
- XUtils.decodeUTF8(
2885
- `��RETRY_REQUEST:${mail.mailID}��`,
2886
- ),
2992
+ new Uint8Array(),
2887
2993
  mail.group,
2888
2994
  uuid.v4(),
2889
2995
  false,
@@ -2923,10 +3029,10 @@ export class Client {
2923
3029
  "readMail initial: abort (otk index mismatch)",
2924
3030
  {
2925
3031
  mailID: mail.mailID,
2926
- preKeyIndex: String(preKeyIndex),
2927
3032
  otkIndex: String(
2928
3033
  otk?.index ?? "null",
2929
3034
  ),
3035
+ preKeyIndex: String(preKeyIndex),
2930
3036
  thisDevice:
2931
3037
  this.getDevice().deviceID,
2932
3038
  },
@@ -2963,8 +3069,8 @@ export class Client {
2963
3069
  debugLibvexDm(
2964
3070
  "readMail initial: abort (IK_A null, Ed→X25519?)",
2965
3071
  {
2966
- mailID: mail.mailID,
2967
3072
  fips: String(fipsRead),
3073
+ mailID: mail.mailID,
2968
3074
  thisDevice:
2969
3075
  this.getDevice().deviceID,
2970
3076
  },
@@ -3091,12 +3197,12 @@ export class Client {
3091
3197
  "readMail initial: ok (emit message)",
3092
3198
  {
3093
3199
  mailID: mail.mailID,
3094
- preKeyIndex: String(preKeyIndex),
3095
- thisDevice:
3096
- this.getDevice().deviceID,
3097
3200
  plaintextLen: String(
3098
3201
  plaintext.length,
3099
3202
  ),
3203
+ preKeyIndex: String(preKeyIndex),
3204
+ thisDevice:
3205
+ this.getDevice().deviceID,
3100
3206
  },
3101
3207
  );
3102
3208
  } catch {
@@ -3283,7 +3389,7 @@ export class Client {
3283
3389
  return decodeAxios(PermissionCodec, res.data);
3284
3390
  }
3285
3391
 
3286
- private async registerDevice(): Promise<Device | null> {
3392
+ private async registerDevice(): Promise<DeviceRegistrationResult | null> {
3287
3393
  while (!this.xKeyRing) {
3288
3394
  await sleep(100);
3289
3395
  }
@@ -3336,7 +3442,19 @@ export class Client {
3336
3442
  msgpack.encode(devMsg),
3337
3443
  { headers: { "Content-Type": "application/msgpack" } },
3338
3444
  );
3339
- return decodeAxios(DeviceCodec, res.data);
3445
+ return decodeAxios(DeviceRegistrationResultCodec, res.data);
3446
+ }
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
+ );
3340
3458
  }
3341
3459
 
3342
3460
  private async respond(msg: ChallMsg) {
@@ -3444,8 +3562,13 @@ export class Client {
3444
3562
  await this.populateKeyRing();
3445
3563
 
3446
3564
  const newDevice = await this.registerDevice();
3447
- if (newDevice) {
3565
+ if (newDevice && "deviceID" in newDevice) {
3448
3566
  device = newDevice;
3567
+ } else if (newDevice && "status" in newDevice) {
3568
+ throw new Error(
3569
+ "Device registration requires approval from an existing device. requestID=" +
3570
+ newDevice.requestID,
3571
+ );
3449
3572
  } else {
3450
3573
  throw new Error("Error registering device.");
3451
3574
  }
@@ -3456,6 +3579,27 @@ export class Client {
3456
3579
  return device;
3457
3580
  }
3458
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
+
3459
3603
  /* header is 32 bytes and is either empty
3460
3604
  or contains an HMAC of the message with
3461
3605
  a derived SK */
@@ -3533,9 +3677,9 @@ export class Client {
3533
3677
  if (!session || retry) {
3534
3678
  if (libvexDebugDmEnabled()) {
3535
3679
  debugLibvexDm("sendMail: createSession path", {
3680
+ hasSession: String(!!session),
3536
3681
  peerDevice: device.deviceID,
3537
3682
  retry: String(retry),
3538
- hasSession: String(!!session),
3539
3683
  });
3540
3684
  }
3541
3685
  await this.createSession(
@@ -3684,11 +3828,11 @@ export class Client {
3684
3828
  debugLibvexDm(
3685
3829
  "sendMessage: peer device list (merged, sorted)",
3686
3830
  {
3687
- userID,
3688
3831
  nAfterBackoff: String(afterBackoff.length),
3689
3832
  nMerged: String(deviceListRaw.length),
3690
3833
  nSorted: String(deviceList.length),
3691
3834
  ourDevice: this.getDevice().deviceID,
3835
+ userID,
3692
3836
  },
3693
3837
  );
3694
3838
  for (const [i, d] of deviceList.entries()) {
@@ -3699,13 +3843,15 @@ export class Client {
3699
3843
  }
3700
3844
  let lastErr: unknown;
3701
3845
  let failCount = 0;
3846
+ // One logical DM fan-outs to multiple recipient devices. Reuse a
3847
+ // single mailID so local/UI dedupe treats it as one message.
3848
+ const messageMailID = uuid.v4();
3702
3849
  for (const device of deviceList) {
3703
- const mailID = uuid.v4();
3704
3850
  try {
3705
3851
  if (libvexDebugDmEnabled()) {
3706
3852
  debugLibvexDm("sendMessage: sendMail start", {
3853
+ mailID: messageMailID,
3707
3854
  recipientDevice: device.deviceID,
3708
- mailID,
3709
3855
  });
3710
3856
  }
3711
3857
  await this.sendMail(
@@ -3713,7 +3859,7 @@ export class Client {
3713
3859
  userEntry,
3714
3860
  XUtils.decodeUTF8(message),
3715
3861
  null,
3716
- mailID,
3862
+ messageMailID,
3717
3863
  false,
3718
3864
  );
3719
3865
  if (libvexDebugDmEnabled()) {