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