@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.
- package/dist/Client.d.ts +111 -10
- package/dist/Client.d.ts.map +1 -1
- package/dist/Client.js +165 -8
- package/dist/Client.js.map +1 -1
- package/dist/codecs.d.ts +60 -0
- package/dist/codecs.d.ts.map +1 -1
- package/dist/codecs.js +22 -1
- package/dist/codecs.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/transport/websocket.d.ts +31 -0
- package/dist/transport/websocket.d.ts.map +1 -1
- package/dist/transport/websocket.js +56 -1
- package/dist/transport/websocket.js.map +1 -1
- package/package.json +3 -3
- package/src/Client.ts +322 -19
- package/src/codecs.ts +30 -0
- package/src/index.ts +2 -1
- package/src/transport/websocket.ts +59 -1
|
@@ -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;
|
|
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.
|
|
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
|
|
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/
|
|
97
|
-
"@vex-chat/
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|