@vex-chat/libvex 6.1.9 → 6.2.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.
@@ -25,7 +25,38 @@ export declare class WebSocketAdapter implements WebSocketLike {
25
25
  on(event: "close" | "open", listener: () => void): void;
26
26
  on(event: "error", listener: (error: Error) => void): void;
27
27
  on(event: "message", listener: (data: Uint8Array) => void): void;
28
+ /**
29
+ * Forward `data` to the underlying socket if and only if it's
30
+ * OPEN. Throws `WebSocketNotOpenError` (a typed, named error)
31
+ * otherwise, so callers can distinguish a teardown race from a
32
+ * protocol error and either retry on the next socket or drop
33
+ * the frame.
34
+ *
35
+ * Without this guard a transient teardown surfaces as
36
+ * `DOMException("INVALID_STATE_ERR")` from the platform WebSocket
37
+ * — opaque, hard to catch by name, and surfaced by React Native
38
+ * as an unhandled promise rejection (red box / "frozen UI") any
39
+ * time a `void this.send(...)` callsite (ping / pong / queued
40
+ * notify reply) is in flight when the close event lands.
41
+ */
28
42
  send(data: Uint8Array): void;
29
43
  terminate(): void;
30
44
  }
45
+ /**
46
+ * Thrown when `send()` is called on a socket that's not in the OPEN
47
+ * state. Surfaced as a named, recognisable type so callers can
48
+ * distinguish "transient teardown race" from a real protocol error
49
+ * and either drop the frame (pings) or wait for reconnect (real
50
+ * payloads).
51
+ *
52
+ * Replaces the bare `DOMException("INVALID_STATE_ERR")` that the
53
+ * underlying WebSocket throws — that one is opaque, gets reported
54
+ * by RN's dev console as a red unhandled rejection, and freezes the
55
+ * passkey/foreground/network-swap recovery flow because every code
56
+ * path that voids the resulting promise leaks the rejection.
57
+ */
58
+ export declare class WebSocketNotOpenError extends Error {
59
+ readonly readyState: number;
60
+ constructor(readyState: number);
61
+ }
31
62
  //# sourceMappingURL=websocket.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"websocket.d.ts","sourceRoot":"","sources":["../../src/transport/websocket.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH;;;;;GAKG;AACH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhD,qBAAa,gBAAiB,YAAW,aAAa;IAClD,OAAO,EAAE,CAAC,CAAC,GAAG,EAAE,KAAK,GAAG,KAAK,KAAK,IAAI,CAAC,GAAG,IAAI,CAAQ;IACtD,IAAI,UAAU,kBAEb;IACD,OAAO,CAAC,QAAQ,CAAC,cAAc,CAG3B;IACJ,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAAwC;IAC3E,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAG7B;IAEJ,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAY;gBAEnB,GAAG,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM;IAM1C,KAAK;IAIL,GAAG,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,EAAE,QAAQ,EAAE,MAAM,IAAI,GAAG,IAAI;IACxD,GAAG,CAAC,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,GAAG,IAAI;IAC3D,GAAG,CAAC,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,CAAC,IAAI,EAAE,UAAU,KAAK,IAAI,GAAG,IAAI;IA0BjE,EAAE,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,EAAE,QAAQ,EAAE,MAAM,IAAI,GAAG,IAAI;IACvD,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,GAAG,IAAI;IAC1D,EAAE,CAAC,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,CAAC,IAAI,EAAE,UAAU,KAAK,IAAI,GAAG,IAAI;IAiChE,IAAI,CAAC,IAAI,EAAE,UAAU;IAIrB,SAAS;CAGZ"}
1
+ {"version":3,"file":"websocket.d.ts","sourceRoot":"","sources":["../../src/transport/websocket.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH;;;;;GAKG;AACH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhD,qBAAa,gBAAiB,YAAW,aAAa;IAClD,OAAO,EAAE,CAAC,CAAC,GAAG,EAAE,KAAK,GAAG,KAAK,KAAK,IAAI,CAAC,GAAG,IAAI,CAAQ;IACtD,IAAI,UAAU,kBAEb;IACD,OAAO,CAAC,QAAQ,CAAC,cAAc,CAG3B;IACJ,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAAwC;IAC3E,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAG7B;IAEJ,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAY;gBAEnB,GAAG,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM;IAM1C,KAAK;IAIL,GAAG,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,EAAE,QAAQ,EAAE,MAAM,IAAI,GAAG,IAAI;IACxD,GAAG,CAAC,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,GAAG,IAAI;IAC3D,GAAG,CAAC,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,CAAC,IAAI,EAAE,UAAU,KAAK,IAAI,GAAG,IAAI;IA0BjE,EAAE,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,EAAE,QAAQ,EAAE,MAAM,IAAI,GAAG,IAAI;IACvD,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,GAAG,IAAI;IAC1D,EAAE,CAAC,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,CAAC,IAAI,EAAE,UAAU,KAAK,IAAI,GAAG,IAAI;IAiChE;;;;;;;;;;;;;OAaG;IACH,IAAI,CAAC,IAAI,EAAE,UAAU;IAyBrB,SAAS;CAGZ;AAED;;;;;;;;;;;;GAYG;AACH,qBAAa,qBAAsB,SAAQ,KAAK;IAC5C,SAAgB,UAAU,EAAE,MAAM,CAAC;gBAEvB,UAAU,EAAE,MAAM;CAKjC"}
@@ -78,11 +78,66 @@ export class WebSocketAdapter {
78
78
  this.ws.addEventListener(event, wrapped);
79
79
  }
80
80
  }
81
+ /**
82
+ * Forward `data` to the underlying socket if and only if it's
83
+ * OPEN. Throws `WebSocketNotOpenError` (a typed, named error)
84
+ * otherwise, so callers can distinguish a teardown race from a
85
+ * protocol error and either retry on the next socket or drop
86
+ * the frame.
87
+ *
88
+ * Without this guard a transient teardown surfaces as
89
+ * `DOMException("INVALID_STATE_ERR")` from the platform WebSocket
90
+ * — opaque, hard to catch by name, and surfaced by React Native
91
+ * as an unhandled promise rejection (red box / "frozen UI") any
92
+ * time a `void this.send(...)` callsite (ping / pong / queued
93
+ * notify reply) is in flight when the close event lands.
94
+ */
81
95
  send(data) {
82
- this.ws.send(new Uint8Array(data));
96
+ if (this.ws.readyState !== 1) {
97
+ throw new WebSocketNotOpenError(this.ws.readyState);
98
+ }
99
+ try {
100
+ this.ws.send(new Uint8Array(data));
101
+ }
102
+ catch (err) {
103
+ // Handles the TOCTOU between the readyState check above
104
+ // and the actual `ws.send` call. React Native's bridge
105
+ // can dispatch a `websocketMessage` and a
106
+ // `websocketClosed` back-to-back in the same JS turn:
107
+ // our message listener observes readyState=1 because
108
+ // the close event hasn't been processed yet, but the
109
+ // underlying WebSocket has already transitioned native-
110
+ // side and `send` throws INVALID_STATE_ERR.
111
+ if (err instanceof Error &&
112
+ /invalid_state|INVALID_STATE_ERR/i.test(err.message)) {
113
+ throw new WebSocketNotOpenError(this.ws.readyState);
114
+ }
115
+ throw err;
116
+ }
83
117
  }
84
118
  terminate() {
85
119
  this.ws.close();
86
120
  }
87
121
  }
122
+ /**
123
+ * Thrown when `send()` is called on a socket that's not in the OPEN
124
+ * state. Surfaced as a named, recognisable type so callers can
125
+ * distinguish "transient teardown race" from a real protocol error
126
+ * and either drop the frame (pings) or wait for reconnect (real
127
+ * payloads).
128
+ *
129
+ * Replaces the bare `DOMException("INVALID_STATE_ERR")` that the
130
+ * underlying WebSocket throws — that one is opaque, gets reported
131
+ * by RN's dev console as a red unhandled rejection, and freezes the
132
+ * passkey/foreground/network-swap recovery flow because every code
133
+ * path that voids the resulting promise leaks the rejection.
134
+ */
135
+ export class WebSocketNotOpenError extends Error {
136
+ readyState;
137
+ constructor(readyState) {
138
+ super(`WebSocket is not open (readyState=${readyState.toString()})`);
139
+ this.name = "WebSocketNotOpenError";
140
+ this.readyState = readyState;
141
+ }
142
+ }
88
143
  //# sourceMappingURL=websocket.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"websocket.js","sourceRoot":"","sources":["../../src/transport/websocket.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"websocket.js","sourceRoot":"","sources":["../../src/transport/websocket.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vex-chat/libvex",
3
- "version": "6.1.9",
3
+ "version": "6.2.1",
4
4
  "description": "Library for communicating with xchat server.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -93,8 +93,8 @@
93
93
  "msgpackr": "^1.11.9",
94
94
  "uuid": "^14.0.0",
95
95
  "zod": "^4.3.6",
96
- "@vex-chat/types": "^3.1.0",
97
- "@vex-chat/crypto": "^4.0.1"
96
+ "@vex-chat/crypto": "^5.0.0",
97
+ "@vex-chat/types": "^3.2.0"
98
98
  },
99
99
  "peerDependencies": {
100
100
  "better-sqlite3": ">=11.0.0"
package/src/Client.ts CHANGED
@@ -26,6 +26,7 @@ import type {
26
26
  KeyBundle,
27
27
  MailWS,
28
28
  NotifyMsg,
29
+ Passkey,
29
30
  Permission,
30
31
  PreKeysSQL,
31
32
  PreKeysWS,
@@ -77,7 +78,10 @@ import { EventEmitter } from "eventemitter3";
77
78
  import * as uuid from "uuid";
78
79
  import { z } from "zod/v4";
79
80
 
80
- import { WebSocketAdapter } from "./transport/websocket.js";
81
+ import {
82
+ WebSocketAdapter,
83
+ WebSocketNotOpenError,
84
+ } from "./transport/websocket.js";
81
85
  import {
82
86
  decodeFipsInitialExtraV1,
83
87
  encodeFipsInitialExtraV1,
@@ -149,6 +153,13 @@ function debugLibvexDm(
149
153
  console.error(`[libvex:debug-dm] ${payload}`);
150
154
  }
151
155
 
156
+ function ignoreSocketTeardown(err: unknown): void {
157
+ if (err instanceof WebSocketNotOpenError) return;
158
+ // Re-throw anything else as a real unhandled rejection so it
159
+ // shows up in dev tools and Sentry-style reporters.
160
+ throw err;
161
+ }
162
+
152
163
  function isRecord(x: unknown): x is Record<string, unknown> {
153
164
  return typeof x === "object" && x !== null;
154
165
  }
@@ -258,6 +269,10 @@ import {
258
269
  InviteCodec,
259
270
  KeyBundleCodec,
260
271
  OtkCountCodec,
272
+ PasskeyArrayCodec,
273
+ PasskeyAuthFinishResponseCodec,
274
+ PasskeyCodec,
275
+ PasskeyOptionsCodec,
261
276
  PendingDeviceRequestArrayCodec,
262
277
  PendingDeviceRequestCodec,
263
278
  PermissionArrayCodec,
@@ -316,6 +331,14 @@ export interface Channels {
316
331
  */
317
332
  export type { Device } from "@vex-chat/types";
318
333
 
334
+ /**
335
+ * Public passkey record returned by `client.passkeys.list()` and
336
+ * `client.passkeys.finishRegistration()`. Server-private fields
337
+ * (credential ID, public key, COSE algorithm, signature counter) are
338
+ * never exposed.
339
+ */
340
+ export type { Passkey } from "@vex-chat/types";
341
+
319
342
  /**
320
343
  * ClientOptions are the options you can pass into the client.
321
344
  */
@@ -358,6 +381,8 @@ export interface Devices {
358
381
  delete: (deviceID: string) => Promise<void>;
359
382
  /** Fetches one pending registration request by ID for the current user. */
360
383
  getRequest: (requestID: string) => Promise<null | PendingDeviceRequest>;
384
+ /** Lists every device belonging to the current account. */
385
+ list: () => Promise<Device[]>;
361
386
  /** Lists pending/processed registration requests for the current user. */
362
387
  listRequests: () => Promise<PendingDeviceRequest[]>;
363
388
  /**
@@ -438,6 +463,16 @@ export interface FileProgress {
438
463
  */
439
464
  export type FileRes = FileResponse;
440
465
 
466
+ /**
467
+ * @ignore
468
+ */
469
+ export interface Files {
470
+ /** Uploads and encrypts a file. */
471
+ create: (file: Uint8Array) => Promise<[FileSQL, string]>;
472
+ /** Downloads and decrypts a file using a file ID and key. */
473
+ retrieve: (fileID: string, key: string) => Promise<FileResponse | null>;
474
+ }
475
+
441
476
  /**
442
477
  * Channel is a chat channel on a server.
443
478
  *
@@ -458,16 +493,6 @@ export type { Channel } from "@vex-chat/types";
458
493
  */
459
494
  export type { Server } from "@vex-chat/types";
460
495
 
461
- /**
462
- * @ignore
463
- */
464
- export interface Files {
465
- /** Uploads and encrypts a file. */
466
- create: (file: Uint8Array) => Promise<[FileSQL, string]>;
467
- /** Downloads and decrypts a file using a file ID and key. */
468
- retrieve: (fileID: string, key: string) => Promise<FileResponse | null>;
469
- }
470
-
471
496
  /**
472
497
  * @ignore
473
498
  */
@@ -533,6 +558,65 @@ export interface Message {
533
558
  timestamp: string;
534
559
  }
535
560
 
561
+ /**
562
+ * Begin/finish handshakes for a passkey (WebAuthn) ceremony plus the
563
+ * passkey-only admin/recovery surface. The host application (a
564
+ * browser, Tauri webview, etc.) is responsible for invoking
565
+ * `navigator.credentials.create()` / `.get()` itself (e.g. via
566
+ * `@simplewebauthn/browser`) using the `options` returned from
567
+ * `begin*`, and then handing the resulting `RegistrationResponseJSON`
568
+ * / `AuthenticationResponseJSON` to `finish*`.
569
+ *
570
+ * @public
571
+ */
572
+ export interface Passkeys {
573
+ /** Approves a pending device-enrollment request using the passkey session. */
574
+ approveDeviceRequest: (requestID: string) => Promise<Device>;
575
+ /** Begin a public passkey authentication ceremony for `username`. */
576
+ beginAuthentication: (username: string) => Promise<{
577
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- WebAuthn options shape varies per simplewebauthn version
578
+ options: any;
579
+ requestID: string;
580
+ }>;
581
+ /** Begin adding a new passkey to the currently authenticated account. */
582
+ beginRegistration: (name: string) => Promise<{
583
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- WebAuthn options shape varies per simplewebauthn version
584
+ options: any;
585
+ requestID: string;
586
+ }>;
587
+ /** Remove a passkey from the account. */
588
+ delete: (passkeyID: string) => Promise<void>;
589
+ /** Delete one of the account's devices using the passkey session. */
590
+ deleteDevice: (deviceID: string) => Promise<void>;
591
+ /**
592
+ * Finish the public passkey authentication ceremony with the
593
+ * assertion produced by the host. On success the client is
594
+ * placed in passkey-only mode: the bearer is the passkey JWT,
595
+ * device-only flows (mail, etc.) will not work, and the
596
+ * `client.passkeys.*` admin methods become available.
597
+ */
598
+ finishAuthentication: (args: {
599
+ requestID: string;
600
+ response: Record<string, unknown>;
601
+ }) => Promise<{
602
+ passkeyID: string;
603
+ token: string;
604
+ user: User;
605
+ }>;
606
+ /** Finish adding a passkey to the currently authenticated account. */
607
+ finishRegistration: (args: {
608
+ name: string;
609
+ requestID: string;
610
+ response: Record<string, unknown>;
611
+ }) => Promise<Passkey>;
612
+ /** List the account's passkeys (public shape only — no key material). */
613
+ list: () => Promise<Passkey[]>;
614
+ /** List all of the account's devices using the passkey session. */
615
+ listDevices: () => Promise<Device[]>;
616
+ /** Reject a pending device-enrollment request using the passkey session. */
617
+ rejectDeviceRequest: (requestID: string) => Promise<void>;
618
+ }
619
+
536
620
  export type PendingDeviceApprovalStatus =
537
621
  | "approved"
538
622
  | "expired"
@@ -896,6 +980,7 @@ export class Client {
896
980
  approveRequest: this.approveDeviceRequest.bind(this),
897
981
  delete: this.deleteDevice.bind(this),
898
982
  getRequest: this.getDeviceRegistrationRequest.bind(this),
983
+ list: this.listDevices.bind(this),
899
984
  listRequests: this.listDeviceRegistrationRequests.bind(this),
900
985
  pollPendingRegistration: this.pollPendingDeviceRegistration.bind(this),
901
986
  register: this.registerDevice.bind(this),
@@ -969,6 +1054,7 @@ export class Client {
969
1054
  */
970
1055
  user: this.getUser.bind(this),
971
1056
  };
1057
+
972
1058
  /**
973
1059
  * Message operations (direct and group).
974
1060
  *
@@ -1009,7 +1095,6 @@ export class Client {
1009
1095
  */
1010
1096
  send: this.sendMessage.bind(this),
1011
1097
  };
1012
-
1013
1098
  /**
1014
1099
  * Server moderation helper methods.
1015
1100
  */
@@ -1018,6 +1103,31 @@ export class Client {
1018
1103
  kick: this.kickUser.bind(this),
1019
1104
  };
1020
1105
 
1106
+ /**
1107
+ * Passkey ("recovery credential") methods.
1108
+ *
1109
+ * Passkeys are an account-bound second-class credential that can
1110
+ * authenticate the owning user, list devices, delete devices, and
1111
+ * approve/reject pending device-enrollment requests — i.e.
1112
+ * provisioning + recovery. They cannot send/decrypt mail.
1113
+ *
1114
+ * The host app drives the WebAuthn ceremony (e.g. via
1115
+ * `@simplewebauthn/browser`) and hands the JSON response to
1116
+ * `finish*`.
1117
+ */
1118
+ public passkeys: Passkeys = {
1119
+ approveDeviceRequest: this.passkeyApproveDeviceRequest.bind(this),
1120
+ beginAuthentication: this.beginPasskeyAuthentication.bind(this),
1121
+ beginRegistration: this.beginPasskeyRegistration.bind(this),
1122
+ delete: this.deletePasskey.bind(this),
1123
+ deleteDevice: this.passkeyDeleteDevice.bind(this),
1124
+ finishAuthentication: this.finishPasskeyAuthentication.bind(this),
1125
+ finishRegistration: this.finishPasskeyRegistration.bind(this),
1126
+ list: this.listPasskeys.bind(this),
1127
+ listDevices: this.passkeyListDevices.bind(this),
1128
+ rejectDeviceRequest: this.passkeyRejectDeviceRequest.bind(this),
1129
+ };
1130
+
1021
1131
  /**
1022
1132
  * Permission-management methods for the current user.
1023
1133
  */
@@ -1981,6 +2091,33 @@ export class Client {
1981
2091
  return decodeAxios(DeviceCodec, response.data);
1982
2092
  }
1983
2093
 
2094
+ private async beginPasskeyAuthentication(username: string): Promise<{
2095
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- WebAuthn options shape varies per simplewebauthn version
2096
+ options: any;
2097
+ requestID: string;
2098
+ }> {
2099
+ const response = await this.http.post(
2100
+ this.getHost() + "/auth/passkey/begin",
2101
+ msgpack.encode({ username }),
2102
+ { headers: { "Content-Type": "application/msgpack" } },
2103
+ );
2104
+ return decodeAxios(PasskeyOptionsCodec, response.data);
2105
+ }
2106
+
2107
+ private async beginPasskeyRegistration(name: string): Promise<{
2108
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- WebAuthn options shape varies per simplewebauthn version
2109
+ options: any;
2110
+ requestID: string;
2111
+ }> {
2112
+ const userID = this.getUser().userID;
2113
+ const response = await this.http.post(
2114
+ this.getHost() + "/user/" + userID + "/passkeys/register/begin",
2115
+ msgpack.encode({ name }),
2116
+ { headers: { "Content-Type": "application/msgpack" } },
2117
+ );
2118
+ return decodeAxios(PasskeyOptionsCodec, response.data);
2119
+ }
2120
+
1984
2121
  private censorPreKey(preKey: PreKeysSQL): PreKeysWS {
1985
2122
  if (!preKey.index) {
1986
2123
  throw new Error("Key index is required.");
@@ -2310,7 +2447,15 @@ export class Client {
2310
2447
  }
2311
2448
  };
2312
2449
  this.socket.on("message", callback);
2313
- void this.send(msg, hmac);
2450
+ // Forward send failures to the outer promise instead
2451
+ // of leaking them as an unhandled rejection: the
2452
+ // listener above can never resolve if the send didn't
2453
+ // make it onto the wire, so without this the caller
2454
+ // would hang for the full 30s send-loop timeout.
2455
+ this.send(msg, hmac).catch((err: unknown) => {
2456
+ this.socket.off("message", callback);
2457
+ rej(err instanceof Error ? err : new Error(String(err)));
2458
+ });
2314
2459
  });
2315
2460
  });
2316
2461
  }
@@ -2337,6 +2482,13 @@ export class Client {
2337
2482
  await this.database.deleteHistory(channelOrUserID);
2338
2483
  }
2339
2484
 
2485
+ private async deletePasskey(passkeyID: string): Promise<void> {
2486
+ const userID = this.getUser().userID;
2487
+ await this.http.delete(
2488
+ this.getHost() + "/user/" + userID + "/passkeys/" + passkeyID,
2489
+ );
2490
+ }
2491
+
2340
2492
  private async deletePermission(permissionID: string): Promise<void> {
2341
2493
  await this.http.delete(this.getHost() + "/permission/" + permissionID);
2342
2494
  }
@@ -2374,7 +2526,6 @@ export class Client {
2374
2526
  );
2375
2527
  return decodeAxios(PermissionArrayCodec, res.data);
2376
2528
  }
2377
-
2378
2529
  private async fetchUser(
2379
2530
  userIdentifier: string,
2380
2531
  ): Promise<[null | User, AxiosError | null]> {
@@ -2469,6 +2620,50 @@ export class Client {
2469
2620
  }
2470
2621
  throw new Error(`${base}${this.deviceListFailureDetail(lastErr)}`);
2471
2622
  }
2623
+
2624
+ /**
2625
+ * Finish a passkey login and adopt the resulting JWT as the
2626
+ * client's bearer token. After this call, `client.passkeys.*`
2627
+ * admin methods are usable; messaging routes will continue to
2628
+ * require a real device token.
2629
+ */
2630
+ private async finishPasskeyAuthentication(args: {
2631
+ requestID: string;
2632
+ response: Record<string, unknown>;
2633
+ }): Promise<{
2634
+ passkeyID: string;
2635
+ token: string;
2636
+ user: User;
2637
+ }> {
2638
+ const response = await this.http.post(
2639
+ this.getHost() + "/auth/passkey/finish",
2640
+ msgpack.encode(args),
2641
+ { headers: { "Content-Type": "application/msgpack" } },
2642
+ );
2643
+ const decoded = decodeAxios(
2644
+ PasskeyAuthFinishResponseCodec,
2645
+ response.data,
2646
+ );
2647
+ this.setUser(decoded.user);
2648
+ this.token = decoded.token;
2649
+ this.http.defaults.headers.common.Authorization = `Bearer ${decoded.token}`;
2650
+ return decoded;
2651
+ }
2652
+
2653
+ private async finishPasskeyRegistration(args: {
2654
+ name: string;
2655
+ requestID: string;
2656
+ response: Record<string, unknown>;
2657
+ }): Promise<Passkey> {
2658
+ const userID = this.getUser().userID;
2659
+ const response = await this.http.post(
2660
+ this.getHost() + "/user/" + userID + "/passkeys/register/finish",
2661
+ msgpack.encode(args),
2662
+ { headers: { "Content-Type": "application/msgpack" } },
2663
+ );
2664
+ return decodeAxios(PasskeyCodec, response.data);
2665
+ }
2666
+
2472
2667
  private async forward(message: Message) {
2473
2668
  if (this.isManualCloseInFlight()) {
2474
2669
  return;
@@ -2959,6 +3154,8 @@ export class Client {
2959
3154
  }
2960
3155
  }
2961
3156
 
3157
+ // ── Passkeys ────────────────────────────────────────────────────────
3158
+
2962
3159
  /**
2963
3160
  * Fresh read of the `manuallyClosing` flag for async loops — direct property checks
2964
3161
  * after `await` are flagged as always-false by control-flow analysis even though
@@ -3001,6 +3198,28 @@ export class Client {
3001
3198
  return decodeAxios(PendingDeviceRequestArrayCodec, response.data);
3002
3199
  }
3003
3200
 
3201
+ /**
3202
+ * Lists every device the current account owns.
3203
+ *
3204
+ * Uses the device-authenticated `/user/:id/devices` route. For
3205
+ * the passkey-recovery equivalent see `client.passkeys.listDevices`.
3206
+ */
3207
+ private async listDevices(): Promise<Device[]> {
3208
+ const userID = this.getUser().userID;
3209
+ const res = await this.http.get(
3210
+ this.getHost() + "/user/" + userID + "/devices",
3211
+ );
3212
+ return decodeAxios(DeviceArrayCodec, res.data);
3213
+ }
3214
+
3215
+ private async listPasskeys(): Promise<Passkey[]> {
3216
+ const userID = this.getUser().userID;
3217
+ const response = await this.http.get(
3218
+ this.getHost() + "/user/" + userID + "/passkeys",
3219
+ );
3220
+ return decodeAxios(PasskeyArrayCodec, response.data);
3221
+ }
3222
+
3004
3223
  private async markSessionVerified(sessionID: string) {
3005
3224
  return this.database.markSessionVerified(sessionID);
3006
3225
  }
@@ -3047,6 +3266,48 @@ export class Client {
3047
3266
  void this.database.saveMessage(message);
3048
3267
  };
3049
3268
 
3269
+ private async passkeyApproveDeviceRequest(
3270
+ requestID: string,
3271
+ ): Promise<Device> {
3272
+ const userID = this.getUser().userID;
3273
+ const response = await this.http.post(
3274
+ this.getHost() +
3275
+ "/user/" +
3276
+ userID +
3277
+ "/passkey/devices/requests/" +
3278
+ requestID +
3279
+ "/approve",
3280
+ );
3281
+ return decodeAxios(DeviceCodec, response.data);
3282
+ }
3283
+
3284
+ private async passkeyDeleteDevice(deviceID: string): Promise<void> {
3285
+ const userID = this.getUser().userID;
3286
+ await this.http.delete(
3287
+ this.getHost() + "/user/" + userID + "/passkey/devices/" + deviceID,
3288
+ );
3289
+ }
3290
+
3291
+ private async passkeyListDevices(): Promise<Device[]> {
3292
+ const userID = this.getUser().userID;
3293
+ const response = await this.http.get(
3294
+ this.getHost() + "/user/" + userID + "/passkey/devices",
3295
+ );
3296
+ return decodeAxios(DeviceArrayCodec, response.data);
3297
+ }
3298
+
3299
+ private async passkeyRejectDeviceRequest(requestID: string): Promise<void> {
3300
+ const userID = this.getUser().userID;
3301
+ await this.http.post(
3302
+ this.getHost() +
3303
+ "/user/" +
3304
+ userID +
3305
+ "/passkey/devices/requests/" +
3306
+ requestID +
3307
+ "/reject",
3308
+ );
3309
+ }
3310
+
3050
3311
  private ping() {
3051
3312
  if (!this.isAlive) {
3052
3313
  // Previous ping went unanswered — the WebSocket is half-open
@@ -3069,7 +3330,14 @@ export class Client {
3069
3330
  return;
3070
3331
  }
3071
3332
  this.setAlive(false);
3072
- void this.send({ transmissionID: uuid.v4(), type: "ping" });
3333
+ // Swallow a teardown-race rejection: if the socket transitions
3334
+ // to CLOSING between our readyState check and the platform
3335
+ // `send`, the next ping interval will see `isAlive=false` and
3336
+ // close cleanly above. Real failures still bubble up because
3337
+ // we re-throw anything that isn't `WebSocketNotOpenError`.
3338
+ this.send({ transmissionID: uuid.v4(), type: "ping" }).catch(
3339
+ ignoreSocketTeardown,
3340
+ );
3073
3341
  }
3074
3342
 
3075
3343
  /**
@@ -3107,7 +3375,15 @@ export class Client {
3107
3375
  }
3108
3376
 
3109
3377
  private pong(transmissionID: string) {
3110
- void this.send({ transmissionID, type: "pong" });
3378
+ // Drop the pong if the socket is already tearing down — the
3379
+ // server will simply mark us absent and our own ping watchdog
3380
+ // will trigger a reconnect on the next interval. The
3381
+ // alternative (an unhandled rejection from `void this.send`)
3382
+ // shows up as a red INVALID_STATE_ERR every time native
3383
+ // delivers a `message` and a `close` in the same JS turn,
3384
+ // which is the exact race that fires during the Android
3385
+ // biometric prompt and any background → foreground swap.
3386
+ this.send({ transmissionID, type: "pong" }).catch(ignoreSocketTeardown);
3111
3387
  }
3112
3388
 
3113
3389
  private async populateKeyRing() {
@@ -3753,7 +4029,13 @@ export class Client {
3753
4029
  transmissionID: msg.transmissionID,
3754
4030
  type: "response",
3755
4031
  };
3756
- void this.send(response);
4032
+ // If the socket tore down between receiving the challenge
4033
+ // and signing the response, dropping the response is safe:
4034
+ // the server will time out the auth handshake and close,
4035
+ // our reconnect loop will redo the challenge on the next
4036
+ // socket. Logging it as an unhandled rejection only adds
4037
+ // noise during foreground/background swaps.
4038
+ this.send(response).catch(ignoreSocketTeardown);
3757
4039
  }
3758
4040
 
3759
4041
  private async retrieveEmojiByID(emojiID: string): Promise<Emoji | null> {
@@ -3905,6 +4187,17 @@ export class Client {
3905
4187
  backoff = Math.min(backoff * 2, 4_000);
3906
4188
  }
3907
4189
 
4190
+ // The adapter re-checks `readyState` and converts the
4191
+ // platform's opaque `DOMException("INVALID_STATE_ERR")` into a
4192
+ // typed `WebSocketNotOpenError`. That handles the TOCTOU
4193
+ // window between the loop above exiting on readyState=1 and
4194
+ // the synchronous `send` below: React Native's bridge can
4195
+ // dispatch a `websocketClosed` between the two, in which case
4196
+ // the socket has transitioned native-side even though our JS
4197
+ // close handler hasn't run yet. With the typed error,
4198
+ // discarded callers (`pong`, `ping`) can `.catch(ignore)` the
4199
+ // teardown without an unhandled rejection, and real callers
4200
+ // can choose to retry on the next reconnect.
3908
4201
  this.socket.send(XUtils.packMessage(msg, header));
3909
4202
  }
3910
4203
 
@@ -4096,7 +4389,14 @@ export class Client {
4096
4389
  }
4097
4390
  };
4098
4391
  this.socket.on("message", callback);
4099
- void this.send(msgb, hmac);
4392
+ // See the matching block above (sendMail handshake):
4393
+ // forward send failures to the outer promise so the
4394
+ // caller doesn't hang waiting for a response we never
4395
+ // sent.
4396
+ this.send(msgb, hmac).catch((err: unknown) => {
4397
+ this.socket.off("message", callback);
4398
+ rej(err instanceof Error ? err : new Error(String(err)));
4399
+ });
4100
4400
  });
4101
4401
  } finally {
4102
4402
  this.sending.delete(device.deviceID);
@@ -4227,7 +4527,10 @@ export class Client {
4227
4527
  transmissionID: uuid.v4(),
4228
4528
  type: "receipt",
4229
4529
  };
4230
- void this.send(receipt);
4530
+ // Receipts are best-effort acknowledgements; a missed one
4531
+ // just means the server resends the mail on the next sync.
4532
+ // Don't surface a teardown race as an unhandled rejection.
4533
+ this.send(receipt).catch(ignoreSocketTeardown);
4231
4534
  }
4232
4535
 
4233
4536
  private setAlive(status: boolean) {