@vex-chat/libvex 5.5.0 → 5.5.2
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 +114 -103
- package/dist/Client.d.ts.map +1 -1
- package/dist/Client.js +317 -314
- 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 +448 -437
- 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,41 @@ 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
|
+
/**
|
|
496
|
+
* Retry request emitted when message decryption fails and session healing starts.
|
|
497
|
+
*/
|
|
498
|
+
export interface RetryRequest {
|
|
499
|
+
/** Mail ID that should be retried after session healing. */
|
|
500
|
+
mailID: string;
|
|
501
|
+
/** Origin of the retry signal. */
|
|
502
|
+
source: "decrypt_failure" | "server_notify";
|
|
503
|
+
}
|
|
504
|
+
|
|
495
505
|
/** Zod schema matching the {@link Message} interface for forwarded-message decode. */
|
|
496
506
|
const messageSchema: z.ZodType<Message> = z.object({
|
|
497
507
|
authorID: z.string(),
|
|
@@ -522,6 +532,12 @@ const deviceRequestNotifyData = z.object({
|
|
|
522
532
|
z.literal("rejected"),
|
|
523
533
|
]),
|
|
524
534
|
});
|
|
535
|
+
const retryRequestNotifyData = z.union([
|
|
536
|
+
z.string(),
|
|
537
|
+
z.object({
|
|
538
|
+
mailID: z.string(),
|
|
539
|
+
}),
|
|
540
|
+
]);
|
|
525
541
|
|
|
526
542
|
/**
|
|
527
543
|
* Event signatures emitted by {@link Client}.
|
|
@@ -536,8 +552,6 @@ export interface ClientEvents {
|
|
|
536
552
|
connected: () => void;
|
|
537
553
|
/** Mail decryption pass is in progress. */
|
|
538
554
|
decryptingMail: () => void;
|
|
539
|
-
/** WebSocket connection lost. */
|
|
540
|
-
disconnect: () => void;
|
|
541
555
|
/** Device approval queue changed (pending/approved/rejected). */
|
|
542
556
|
deviceRequest: (update: {
|
|
543
557
|
requestID: string;
|
|
@@ -546,6 +560,8 @@ export interface ClientEvents {
|
|
|
546
560
|
"approved" | "pending" | "rejected"
|
|
547
561
|
>;
|
|
548
562
|
}) => void;
|
|
563
|
+
/** WebSocket connection lost. */
|
|
564
|
+
disconnect: () => void;
|
|
549
565
|
/** Progress update for a file upload or download. */
|
|
550
566
|
fileProgress: (progress: FileProgress) => void;
|
|
551
567
|
/** A direct or group message was sent or received. */
|
|
@@ -554,6 +570,8 @@ export interface ClientEvents {
|
|
|
554
570
|
permission: (permission: Permission) => void;
|
|
555
571
|
/** Post-auth setup complete — safe to call messaging/user APIs. */
|
|
556
572
|
ready: () => void;
|
|
573
|
+
/** Session healing requested a retry for a specific mail ID. */
|
|
574
|
+
retryRequest: (retry: RetryRequest) => void;
|
|
557
575
|
/** A new encryption session was established with a peer device. */
|
|
558
576
|
session: (session: Session, user: User) => void;
|
|
559
577
|
}
|
|
@@ -806,8 +824,8 @@ export class Client {
|
|
|
806
824
|
delete: this.deleteDevice.bind(this),
|
|
807
825
|
getRequest: this.getDeviceRegistrationRequest.bind(this),
|
|
808
826
|
listRequests: this.listDeviceRegistrationRequests.bind(this),
|
|
809
|
-
rejectRequest: this.rejectDeviceRequest.bind(this),
|
|
810
827
|
register: this.registerDevice.bind(this),
|
|
828
|
+
rejectRequest: this.rejectDeviceRequest.bind(this),
|
|
811
829
|
retrieve: this.getDeviceByID.bind(this),
|
|
812
830
|
};
|
|
813
831
|
|
|
@@ -1025,6 +1043,8 @@ export class Client {
|
|
|
1025
1043
|
retrieve: this.fetchUser.bind(this),
|
|
1026
1044
|
};
|
|
1027
1045
|
|
|
1046
|
+
private readonly cryptoProfile: CryptoProfile;
|
|
1047
|
+
|
|
1028
1048
|
private readonly database: Storage;
|
|
1029
1049
|
|
|
1030
1050
|
private readonly dbPath: string;
|
|
@@ -1035,34 +1055,28 @@ export class Client {
|
|
|
1035
1055
|
|
|
1036
1056
|
// ── Event subscription (composition over inheritance) ───────────────
|
|
1037
1057
|
private readonly emitter = new EventEmitter<ClientEvents>();
|
|
1038
|
-
|
|
1039
1058
|
private fetchingMail: boolean = false;
|
|
1059
|
+
|
|
1040
1060
|
private firstMailFetch = true;
|
|
1041
1061
|
|
|
1042
1062
|
private readonly forwarded = new Set<string>();
|
|
1043
|
-
|
|
1044
1063
|
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
|
-
};
|
|
1064
|
+
private readonly http: AxiosInstance;
|
|
1053
1065
|
/** Cancels in-flight axios work on `close()` so `postAuth`/`getMail` cannot hang forever. */
|
|
1054
1066
|
private readonly httpAbortController = new AbortController();
|
|
1055
|
-
private readonly http: AxiosInstance;
|
|
1056
1067
|
private readonly idKeys: KeyPair | null;
|
|
1057
1068
|
private isAlive: boolean = true;
|
|
1058
1069
|
private readonly mailInterval?: NodeJS.Timeout;
|
|
1059
1070
|
|
|
1060
1071
|
private manuallyClosing: boolean = false;
|
|
1061
1072
|
/**
|
|
1062
|
-
*
|
|
1063
|
-
* `
|
|
1073
|
+
* Node-only: per-client HTTP(S) agents (see `init()` + `storage/node/http-agents`).
|
|
1074
|
+
* Dropped on `close()` so idle keep-alive sockets do not keep the process alive.
|
|
1064
1075
|
*/
|
|
1065
|
-
private
|
|
1076
|
+
private nodeHttpAgents?: {
|
|
1077
|
+
http: { destroy(): void };
|
|
1078
|
+
https: { destroy(): void };
|
|
1079
|
+
};
|
|
1066
1080
|
/* Retrieves the userID with the user identifier.
|
|
1067
1081
|
user identifier is checked for userID, then signkey,
|
|
1068
1082
|
and finally falls back to username. */
|
|
@@ -1072,24 +1086,28 @@ export class Client {
|
|
|
1072
1086
|
private readonly options?: ClientOptions | undefined;
|
|
1073
1087
|
|
|
1074
1088
|
private pingInterval: null | ReturnType<typeof setTimeout> = null;
|
|
1089
|
+
/**
|
|
1090
|
+
* Bumped when the WebSocket is torn down and re-opened so the previous
|
|
1091
|
+
* `postAuth` loop exits instead of overlapping a new one.
|
|
1092
|
+
*/
|
|
1093
|
+
private postAuthVersion = 0;
|
|
1094
|
+
|
|
1075
1095
|
private readonly prefixes:
|
|
1076
1096
|
| { HTTP: "http://"; WS: "ws://" }
|
|
1077
1097
|
| { HTTP: "https://"; WS: "wss://" };
|
|
1078
|
-
|
|
1079
1098
|
private reading: boolean = false;
|
|
1080
1099
|
private readonly seenMailIDs: Set<string> = new Set();
|
|
1081
1100
|
private sessionRecords: Record<string, SessionCrypto> = {};
|
|
1101
|
+
|
|
1082
1102
|
// these are created from one set of sign keys
|
|
1083
1103
|
private readonly signKeys: KeyPair;
|
|
1084
|
-
|
|
1085
1104
|
private socket: WebSocketLike;
|
|
1086
1105
|
private token: null | string = null;
|
|
1106
|
+
|
|
1087
1107
|
private user?: User;
|
|
1088
1108
|
|
|
1089
1109
|
private userRecords: Record<string, User> = {};
|
|
1090
|
-
|
|
1091
1110
|
private xKeyRing?: XKeyRing;
|
|
1092
|
-
private readonly cryptoProfile: CryptoProfile;
|
|
1093
1111
|
|
|
1094
1112
|
private constructor(
|
|
1095
1113
|
material: {
|
|
@@ -1305,48 +1323,22 @@ export class Client {
|
|
|
1305
1323
|
}
|
|
1306
1324
|
|
|
1307
1325
|
/**
|
|
1308
|
-
*
|
|
1326
|
+
* Browser-safe NODE_ENV accessor.
|
|
1309
1327
|
* Uses indirect lookup so the bare `process` global never appears in
|
|
1310
1328
|
* source that the platform-guard plugin scans.
|
|
1311
1329
|
*/
|
|
1312
|
-
private static
|
|
1330
|
+
private static getNodeEnv(): string | undefined {
|
|
1313
1331
|
try {
|
|
1314
1332
|
const g = Object.getOwnPropertyDescriptor(
|
|
1315
1333
|
globalThis,
|
|
1316
1334
|
"\u0070rocess",
|
|
1317
1335
|
);
|
|
1318
|
-
if (!g) return
|
|
1336
|
+
if (!g) return undefined;
|
|
1337
|
+
// Node 24+ exposes `process` as an accessor (get/set), not a value.
|
|
1319
1338
|
const proc: unknown =
|
|
1320
1339
|
typeof g.get === "function" ? g.get() : g.value;
|
|
1321
1340
|
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;
|
|
1341
|
+
return undefined;
|
|
1350
1342
|
}
|
|
1351
1343
|
const envDesc = Object.getOwnPropertyDescriptor(proc, "env");
|
|
1352
1344
|
if (!envDesc) return undefined;
|
|
@@ -1370,12 +1362,29 @@ export class Client {
|
|
|
1370
1362
|
}
|
|
1371
1363
|
|
|
1372
1364
|
/**
|
|
1373
|
-
*
|
|
1374
|
-
*
|
|
1375
|
-
*
|
|
1365
|
+
* True when running under Node (has `process.versions`).
|
|
1366
|
+
* Uses indirect lookup so the bare `process` global never appears in
|
|
1367
|
+
* source that the platform-guard plugin scans.
|
|
1376
1368
|
*/
|
|
1377
|
-
private
|
|
1378
|
-
|
|
1369
|
+
private static isNodeRuntime(): boolean {
|
|
1370
|
+
try {
|
|
1371
|
+
const g = Object.getOwnPropertyDescriptor(
|
|
1372
|
+
globalThis,
|
|
1373
|
+
"\u0070rocess",
|
|
1374
|
+
);
|
|
1375
|
+
if (!g) return false;
|
|
1376
|
+
const proc: unknown =
|
|
1377
|
+
typeof g.get === "function" ? g.get() : g.value;
|
|
1378
|
+
if (typeof proc !== "object" || proc === null) {
|
|
1379
|
+
return false;
|
|
1380
|
+
}
|
|
1381
|
+
return (
|
|
1382
|
+
"versions" in proc &&
|
|
1383
|
+
typeof (proc as { versions?: unknown }).versions === "object"
|
|
1384
|
+
);
|
|
1385
|
+
} catch {
|
|
1386
|
+
return false;
|
|
1387
|
+
}
|
|
1379
1388
|
}
|
|
1380
1389
|
|
|
1381
1390
|
/**
|
|
@@ -1450,62 +1459,6 @@ export class Client {
|
|
|
1450
1459
|
await this.negotiateOTK();
|
|
1451
1460
|
}
|
|
1452
1461
|
|
|
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
1462
|
/**
|
|
1510
1463
|
* Delete all local data — message history, encryption sessions, and prekeys.
|
|
1511
1464
|
* Closes the client afterward. Credentials (keychain) must be cleared by the consumer.
|
|
@@ -1684,6 +1637,54 @@ export class Client {
|
|
|
1684
1637
|
return this;
|
|
1685
1638
|
}
|
|
1686
1639
|
|
|
1640
|
+
/**
|
|
1641
|
+
* Tears down the current WebSocket and opens a new one, keeping the same
|
|
1642
|
+
* session (user + device in storage). Restarts the post-auth mail loop.
|
|
1643
|
+
* Use for long-running processes or e2e where a fresh socket matches a
|
|
1644
|
+
* newly-registered second device.
|
|
1645
|
+
*/
|
|
1646
|
+
public async reconnectWebsocket(): Promise<void> {
|
|
1647
|
+
this.postAuthVersion++;
|
|
1648
|
+
if (this.pingInterval) {
|
|
1649
|
+
clearInterval(this.pingInterval);
|
|
1650
|
+
this.pingInterval = null;
|
|
1651
|
+
}
|
|
1652
|
+
this.socket.close();
|
|
1653
|
+
try {
|
|
1654
|
+
await new Promise<void>((resolve, reject) => {
|
|
1655
|
+
const t = setTimeout(() => {
|
|
1656
|
+
this.off("connected", onC);
|
|
1657
|
+
reject(
|
|
1658
|
+
new Error(
|
|
1659
|
+
"reconnectWebsocket: timed out waiting for authorized",
|
|
1660
|
+
),
|
|
1661
|
+
);
|
|
1662
|
+
}, 15_000);
|
|
1663
|
+
const onC = () => {
|
|
1664
|
+
clearTimeout(t);
|
|
1665
|
+
this.off("connected", onC);
|
|
1666
|
+
resolve();
|
|
1667
|
+
};
|
|
1668
|
+
this.on("connected", onC);
|
|
1669
|
+
try {
|
|
1670
|
+
this.initSocket();
|
|
1671
|
+
} catch (err: unknown) {
|
|
1672
|
+
clearTimeout(t);
|
|
1673
|
+
this.off("connected", onC);
|
|
1674
|
+
const e =
|
|
1675
|
+
err instanceof Error
|
|
1676
|
+
? err
|
|
1677
|
+
: new Error(String(err), { cause: err });
|
|
1678
|
+
reject(e);
|
|
1679
|
+
}
|
|
1680
|
+
});
|
|
1681
|
+
} catch (e: unknown) {
|
|
1682
|
+
throw e instanceof Error ? e : new Error(String(e), { cause: e });
|
|
1683
|
+
}
|
|
1684
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
1685
|
+
await this.negotiateOTK();
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1687
1688
|
/**
|
|
1688
1689
|
* Registers a new account on the server.
|
|
1689
1690
|
*
|
|
@@ -1757,6 +1758,14 @@ export class Client {
|
|
|
1757
1758
|
return this;
|
|
1758
1759
|
}
|
|
1759
1760
|
|
|
1761
|
+
/**
|
|
1762
|
+
* Triggers an immediate inbox sync by fetching `/mail` once.
|
|
1763
|
+
* Useful on mobile foreground resume where background work may pause.
|
|
1764
|
+
*/
|
|
1765
|
+
public async syncInboxNow(): Promise<void> {
|
|
1766
|
+
await this.getMail();
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1760
1769
|
/**
|
|
1761
1770
|
* Returns a compact `<username><deviceID>` debug label.
|
|
1762
1771
|
*/
|
|
@@ -1790,6 +1799,36 @@ export class Client {
|
|
|
1790
1799
|
return whoami;
|
|
1791
1800
|
}
|
|
1792
1801
|
|
|
1802
|
+
private async approveDeviceRequest(requestID: string): Promise<Device> {
|
|
1803
|
+
const req = await this.getDeviceRegistrationRequest(requestID);
|
|
1804
|
+
if (!req) {
|
|
1805
|
+
throw new Error("Device approval request not found.");
|
|
1806
|
+
}
|
|
1807
|
+
if (req.status !== "pending") {
|
|
1808
|
+
throw new Error(
|
|
1809
|
+
"Device approval request is not pending: " + req.status,
|
|
1810
|
+
);
|
|
1811
|
+
}
|
|
1812
|
+
const signed = XUtils.encodeHex(
|
|
1813
|
+
await xSignAsync(
|
|
1814
|
+
XUtils.decodeUTF8(requestID),
|
|
1815
|
+
this.signKeys.secretKey,
|
|
1816
|
+
),
|
|
1817
|
+
);
|
|
1818
|
+
const response = await this.http.post(
|
|
1819
|
+
this.prefixes.HTTP +
|
|
1820
|
+
this.host +
|
|
1821
|
+
"/user/" +
|
|
1822
|
+
this.getUser().userID +
|
|
1823
|
+
"/devices/requests/" +
|
|
1824
|
+
requestID +
|
|
1825
|
+
"/approve",
|
|
1826
|
+
msgpack.encode({ signed }),
|
|
1827
|
+
{ headers: { "Content-Type": "application/msgpack" } },
|
|
1828
|
+
);
|
|
1829
|
+
return decodeAxios(DeviceCodec, response.data);
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1793
1832
|
private censorPreKey(preKey: PreKeysSQL): PreKeysWS {
|
|
1794
1833
|
if (!preKey.index) {
|
|
1795
1834
|
throw new Error("Key index is required.");
|
|
@@ -1916,27 +1955,6 @@ export class Client {
|
|
|
1916
1955
|
return decodeAxios(ServerCodec, res.data);
|
|
1917
1956
|
}
|
|
1918
1957
|
|
|
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
1958
|
private async createSession(
|
|
1941
1959
|
device: Device,
|
|
1942
1960
|
user: User,
|
|
@@ -2144,36 +2162,6 @@ export class Client {
|
|
|
2144
2162
|
await this.http.delete(this.getHost() + "/channel/" + channelID);
|
|
2145
2163
|
}
|
|
2146
2164
|
|
|
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
2165
|
private async deleteDevice(deviceID: string): Promise<void> {
|
|
2178
2166
|
if (deviceID === this.getDevice().deviceID) {
|
|
2179
2167
|
throw new Error("You can't delete the device you're logged in to.");
|
|
@@ -2188,52 +2176,6 @@ export class Client {
|
|
|
2188
2176
|
);
|
|
2189
2177
|
}
|
|
2190
2178
|
|
|
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
2179
|
private async deleteHistory(channelOrUserID: string): Promise<void> {
|
|
2238
2180
|
await this.database.deleteHistory(channelOrUserID);
|
|
2239
2181
|
}
|
|
@@ -2245,6 +2187,21 @@ export class Client {
|
|
|
2245
2187
|
private async deleteServer(serverID: string): Promise<void> {
|
|
2246
2188
|
await this.http.delete(this.getHost() + "/server/" + serverID);
|
|
2247
2189
|
}
|
|
2190
|
+
|
|
2191
|
+
private deviceListFailureDetail(err: unknown): string {
|
|
2192
|
+
if (!isAxiosError(err)) {
|
|
2193
|
+
return "";
|
|
2194
|
+
}
|
|
2195
|
+
const st = err.response?.status;
|
|
2196
|
+
if (typeof st === "number") {
|
|
2197
|
+
return ` (HTTP ${String(st)})`;
|
|
2198
|
+
}
|
|
2199
|
+
if (err.code !== undefined) {
|
|
2200
|
+
return ` (${err.code})`;
|
|
2201
|
+
}
|
|
2202
|
+
return "";
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2248
2205
|
/**
|
|
2249
2206
|
* Gets a list of permissions for a server.
|
|
2250
2207
|
*
|
|
@@ -2289,11 +2246,61 @@ export class Client {
|
|
|
2289
2246
|
this.notFoundUsers.set(userIdentifier, Date.now());
|
|
2290
2247
|
return [null, err];
|
|
2291
2248
|
}
|
|
2292
|
-
// Transient (5xx, network error) — don't cache, caller can retry
|
|
2293
|
-
return [null, isAxiosError(err) ? err : null];
|
|
2249
|
+
// Transient (5xx, network error) — don't cache, caller can retry
|
|
2250
|
+
return [null, isAxiosError(err) ? err : null];
|
|
2251
|
+
}
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
private async fetchUserDeviceListOnce(userID: string): Promise<Device[]> {
|
|
2255
|
+
if (this.isManualCloseInFlight()) {
|
|
2256
|
+
return [];
|
|
2257
|
+
}
|
|
2258
|
+
const res = await this.http.get(
|
|
2259
|
+
this.getHost() + "/user/" + userID + "/devices",
|
|
2260
|
+
);
|
|
2261
|
+
const devices = decodeAxios(DeviceArrayCodec, res.data);
|
|
2262
|
+
for (const device of devices) {
|
|
2263
|
+
this.deviceRecords[device.deviceID] = device;
|
|
2264
|
+
}
|
|
2265
|
+
return devices;
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
/**
|
|
2269
|
+
* DM / forward paths need the peer’s (or self) device rows under load: bounded
|
|
2270
|
+
* retries with exponential backoff (same shape as session pubkey hydration).
|
|
2271
|
+
*/
|
|
2272
|
+
private async fetchUserDeviceListWithBackoff(
|
|
2273
|
+
userID: string,
|
|
2274
|
+
label: "own" | "peer",
|
|
2275
|
+
): Promise<Device[]> {
|
|
2276
|
+
const base =
|
|
2277
|
+
label === "own"
|
|
2278
|
+
? "Couldn't get own devices"
|
|
2279
|
+
: "Couldn't get device list";
|
|
2280
|
+
let lastErr: unknown;
|
|
2281
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
2282
|
+
if (this.isManualCloseInFlight()) {
|
|
2283
|
+
return [];
|
|
2284
|
+
}
|
|
2285
|
+
if (attempt > 0) {
|
|
2286
|
+
const delayMs = 100 * 2 ** (attempt - 1);
|
|
2287
|
+
// Chunk the delay so close() can finish before we retry HTTP.
|
|
2288
|
+
const chunkMs = 10;
|
|
2289
|
+
for (let elapsed = 0; elapsed < delayMs; elapsed += chunkMs) {
|
|
2290
|
+
if (this.isManualCloseInFlight()) {
|
|
2291
|
+
return [];
|
|
2292
|
+
}
|
|
2293
|
+
await sleep(Math.min(chunkMs, delayMs - elapsed));
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2296
|
+
try {
|
|
2297
|
+
return await this.fetchUserDeviceListOnce(userID);
|
|
2298
|
+
} catch (err: unknown) {
|
|
2299
|
+
lastErr = err;
|
|
2300
|
+
}
|
|
2294
2301
|
}
|
|
2302
|
+
throw new Error(`${base}${this.deviceListFailureDetail(lastErr)}`);
|
|
2295
2303
|
}
|
|
2296
|
-
|
|
2297
2304
|
private async forward(message: Message) {
|
|
2298
2305
|
if (this.isManualCloseInFlight()) {
|
|
2299
2306
|
return;
|
|
@@ -2386,6 +2393,27 @@ export class Client {
|
|
|
2386
2393
|
}
|
|
2387
2394
|
}
|
|
2388
2395
|
|
|
2396
|
+
private async getDeviceRegistrationRequest(
|
|
2397
|
+
requestID: string,
|
|
2398
|
+
): Promise<null | PendingDeviceRequest> {
|
|
2399
|
+
try {
|
|
2400
|
+
const response = await this.http.get(
|
|
2401
|
+
this.prefixes.HTTP +
|
|
2402
|
+
this.host +
|
|
2403
|
+
"/user/" +
|
|
2404
|
+
this.getUser().userID +
|
|
2405
|
+
"/devices/requests/" +
|
|
2406
|
+
requestID,
|
|
2407
|
+
);
|
|
2408
|
+
return decodeAxios(PendingDeviceRequestCodec, response.data);
|
|
2409
|
+
} catch (err: unknown) {
|
|
2410
|
+
if (isAxiosError(err) && err.response?.status === 404) {
|
|
2411
|
+
return null;
|
|
2412
|
+
}
|
|
2413
|
+
throw err;
|
|
2414
|
+
}
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2389
2417
|
/* Retrieves the current list of users you have sessions with. */
|
|
2390
2418
|
private async getFamiliars(): Promise<User[]> {
|
|
2391
2419
|
const sessions = await this.database.getAllSessions();
|
|
@@ -2448,8 +2476,8 @@ export class Client {
|
|
|
2448
2476
|
}
|
|
2449
2477
|
})();
|
|
2450
2478
|
debugLibvexDm("getMail: inbox", {
|
|
2451
|
-
deviceID: did,
|
|
2452
2479
|
count: String(inbox.length),
|
|
2480
|
+
deviceID: did,
|
|
2453
2481
|
});
|
|
2454
2482
|
}
|
|
2455
2483
|
|
|
@@ -2459,8 +2487,8 @@ export class Client {
|
|
|
2459
2487
|
if (libvexDebugDmEnabled()) {
|
|
2460
2488
|
debugLibvexDm("getMail: readMail one", {
|
|
2461
2489
|
mailID: mailBody.mailID,
|
|
2462
|
-
type: String(mailBody.mailType),
|
|
2463
2490
|
recipient: mailBody.recipient,
|
|
2491
|
+
type: String(mailBody.mailType),
|
|
2464
2492
|
});
|
|
2465
2493
|
}
|
|
2466
2494
|
await this.readMail(mailHeader, mailBody, timestamp);
|
|
@@ -2598,20 +2626,6 @@ export class Client {
|
|
|
2598
2626
|
return this.user;
|
|
2599
2627
|
}
|
|
2600
2628
|
|
|
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
2629
|
/**
|
|
2616
2630
|
* Single GET for `/user/:id/devices`. On failure returns `null` (swallows errors)
|
|
2617
2631
|
* — callers that need reliability should use `fetchUserDeviceListWithBackoff`.
|
|
@@ -2626,57 +2640,6 @@ export class Client {
|
|
|
2626
2640
|
}
|
|
2627
2641
|
}
|
|
2628
2642
|
|
|
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
2643
|
private async getUserList(channelID: string): Promise<User[]> {
|
|
2681
2644
|
const res = await this.http.post(
|
|
2682
2645
|
this.getHost() + "/userList/" + channelID,
|
|
@@ -2686,10 +2649,6 @@ export class Client {
|
|
|
2686
2649
|
|
|
2687
2650
|
private async handleNotify(msg: NotifyMsg) {
|
|
2688
2651
|
switch (msg.event) {
|
|
2689
|
-
case "mail":
|
|
2690
|
-
await this.getMail();
|
|
2691
|
-
this.fetchingMail = false;
|
|
2692
|
-
break;
|
|
2693
2652
|
case "deviceRequest": {
|
|
2694
2653
|
const parsed = deviceRequestNotifyData.safeParse(msg.data);
|
|
2695
2654
|
if (parsed.success) {
|
|
@@ -2697,6 +2656,10 @@ export class Client {
|
|
|
2697
2656
|
}
|
|
2698
2657
|
break;
|
|
2699
2658
|
}
|
|
2659
|
+
case "mail":
|
|
2660
|
+
await this.getMail();
|
|
2661
|
+
this.fetchingMail = false;
|
|
2662
|
+
break;
|
|
2700
2663
|
case "permission":
|
|
2701
2664
|
this.emitter.emit(
|
|
2702
2665
|
"permission",
|
|
@@ -2704,35 +2667,25 @@ export class Client {
|
|
|
2704
2667
|
);
|
|
2705
2668
|
break;
|
|
2706
2669
|
case "retryRequest":
|
|
2707
|
-
|
|
2670
|
+
{
|
|
2671
|
+
const parsed = retryRequestNotifyData.safeParse(msg.data);
|
|
2672
|
+
if (parsed.success) {
|
|
2673
|
+
const mailID =
|
|
2674
|
+
typeof parsed.data === "string"
|
|
2675
|
+
? parsed.data
|
|
2676
|
+
: parsed.data.mailID;
|
|
2677
|
+
this.emitter.emit("retryRequest", {
|
|
2678
|
+
mailID,
|
|
2679
|
+
source: "server_notify",
|
|
2680
|
+
});
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2708
2683
|
break;
|
|
2709
2684
|
default:
|
|
2710
2685
|
break;
|
|
2711
2686
|
}
|
|
2712
2687
|
}
|
|
2713
2688
|
|
|
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
2689
|
/**
|
|
2737
2690
|
* Initializes the keyring. This must be called before anything else.
|
|
2738
2691
|
*/
|
|
@@ -2834,6 +2787,15 @@ export class Client {
|
|
|
2834
2787
|
}
|
|
2835
2788
|
}
|
|
2836
2789
|
|
|
2790
|
+
/**
|
|
2791
|
+
* Fresh read of the `manuallyClosing` flag for async loops — direct property checks
|
|
2792
|
+
* after `await` are flagged as always-false by control-flow analysis even though
|
|
2793
|
+
* `close()` can run concurrently.
|
|
2794
|
+
*/
|
|
2795
|
+
private isManualCloseInFlight(): boolean {
|
|
2796
|
+
return this.manuallyClosing;
|
|
2797
|
+
}
|
|
2798
|
+
|
|
2837
2799
|
private async kickUser(userID: string, serverID: string): Promise<void> {
|
|
2838
2800
|
const permissionList = await this.fetchPermissionList(serverID);
|
|
2839
2801
|
for (const permission of permissionList) {
|
|
@@ -2854,6 +2816,19 @@ export class Client {
|
|
|
2854
2816
|
}
|
|
2855
2817
|
}
|
|
2856
2818
|
|
|
2819
|
+
private async listDeviceRegistrationRequests(): Promise<
|
|
2820
|
+
PendingDeviceRequest[]
|
|
2821
|
+
> {
|
|
2822
|
+
const response = await this.http.get(
|
|
2823
|
+
this.prefixes.HTTP +
|
|
2824
|
+
this.host +
|
|
2825
|
+
"/user/" +
|
|
2826
|
+
this.getUser().userID +
|
|
2827
|
+
"/devices/requests",
|
|
2828
|
+
);
|
|
2829
|
+
return decodeAxios(PendingDeviceRequestArrayCodec, response.data);
|
|
2830
|
+
}
|
|
2831
|
+
|
|
2857
2832
|
private async markSessionVerified(sessionID: string) {
|
|
2858
2833
|
return this.database.markSessionVerified(sessionID);
|
|
2859
2834
|
}
|
|
@@ -2878,6 +2853,28 @@ export class Client {
|
|
|
2878
2853
|
this.xKeyRing.ephemeralKeys = await xBoxKeyPairAsync();
|
|
2879
2854
|
}
|
|
2880
2855
|
|
|
2856
|
+
/**
|
|
2857
|
+
* Pipeline for decrypted messages — registered in `init`. After `close()` sets
|
|
2858
|
+
* `manuallyClosing`, this becomes a no-op so fire-and-forget `forward` does not
|
|
2859
|
+
* race HTTP teardown (we avoid `off()` here — it can interact badly with emit).
|
|
2860
|
+
*/
|
|
2861
|
+
private readonly onInternalMessage = (message: Message): void => {
|
|
2862
|
+
if (this.isManualCloseInFlight()) {
|
|
2863
|
+
return;
|
|
2864
|
+
}
|
|
2865
|
+
if (message.direction === "outgoing" && !message.forward) {
|
|
2866
|
+
void this.forward(message);
|
|
2867
|
+
}
|
|
2868
|
+
|
|
2869
|
+
if (
|
|
2870
|
+
message.direction === "incoming" &&
|
|
2871
|
+
message.recipient === message.sender
|
|
2872
|
+
) {
|
|
2873
|
+
return;
|
|
2874
|
+
}
|
|
2875
|
+
void this.database.saveMessage(message);
|
|
2876
|
+
};
|
|
2877
|
+
|
|
2881
2878
|
private ping() {
|
|
2882
2879
|
if (!this.isAlive) {
|
|
2883
2880
|
}
|
|
@@ -3022,9 +3019,7 @@ export class Client {
|
|
|
3022
3019
|
void this.createSession(
|
|
3023
3020
|
deviceEntry,
|
|
3024
3021
|
user,
|
|
3025
|
-
|
|
3026
|
-
`��RETRY_REQUEST:${mail.mailID}��`,
|
|
3027
|
-
),
|
|
3022
|
+
new Uint8Array(),
|
|
3028
3023
|
mail.group,
|
|
3029
3024
|
uuid.v4(),
|
|
3030
3025
|
false,
|
|
@@ -3064,10 +3059,10 @@ export class Client {
|
|
|
3064
3059
|
"readMail initial: abort (otk index mismatch)",
|
|
3065
3060
|
{
|
|
3066
3061
|
mailID: mail.mailID,
|
|
3067
|
-
preKeyIndex: String(preKeyIndex),
|
|
3068
3062
|
otkIndex: String(
|
|
3069
3063
|
otk?.index ?? "null",
|
|
3070
3064
|
),
|
|
3065
|
+
preKeyIndex: String(preKeyIndex),
|
|
3071
3066
|
thisDevice:
|
|
3072
3067
|
this.getDevice().deviceID,
|
|
3073
3068
|
},
|
|
@@ -3104,8 +3099,8 @@ export class Client {
|
|
|
3104
3099
|
debugLibvexDm(
|
|
3105
3100
|
"readMail initial: abort (IK_A null, Ed→X25519?)",
|
|
3106
3101
|
{
|
|
3107
|
-
mailID: mail.mailID,
|
|
3108
3102
|
fips: String(fipsRead),
|
|
3103
|
+
mailID: mail.mailID,
|
|
3109
3104
|
thisDevice:
|
|
3110
3105
|
this.getDevice().deviceID,
|
|
3111
3106
|
},
|
|
@@ -3232,12 +3227,12 @@ export class Client {
|
|
|
3232
3227
|
"readMail initial: ok (emit message)",
|
|
3233
3228
|
{
|
|
3234
3229
|
mailID: mail.mailID,
|
|
3235
|
-
preKeyIndex: String(preKeyIndex),
|
|
3236
|
-
thisDevice:
|
|
3237
|
-
this.getDevice().deviceID,
|
|
3238
3230
|
plaintextLen: String(
|
|
3239
3231
|
plaintext.length,
|
|
3240
3232
|
),
|
|
3233
|
+
preKeyIndex: String(preKeyIndex),
|
|
3234
|
+
thisDevice:
|
|
3235
|
+
this.getDevice().deviceID,
|
|
3241
3236
|
},
|
|
3242
3237
|
);
|
|
3243
3238
|
} catch {
|
|
@@ -3384,27 +3379,10 @@ export class Client {
|
|
|
3384
3379
|
);
|
|
3385
3380
|
} else {
|
|
3386
3381
|
void healSession();
|
|
3387
|
-
|
|
3388
|
-
// emit the message
|
|
3389
|
-
const message: Message = {
|
|
3390
|
-
authorID: mail.authorID,
|
|
3391
|
-
decrypted: false,
|
|
3392
|
-
direction: "incoming",
|
|
3393
|
-
forward: mail.forward,
|
|
3394
|
-
group: mail.group
|
|
3395
|
-
? uuid.stringify(mail.group)
|
|
3396
|
-
: null,
|
|
3382
|
+
this.emitter.emit("retryRequest", {
|
|
3397
3383
|
mailID: mail.mailID,
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
new Uint8Array(mail.nonce),
|
|
3401
|
-
),
|
|
3402
|
-
readerID: mail.readerID,
|
|
3403
|
-
recipient: mail.recipient,
|
|
3404
|
-
sender: mail.sender,
|
|
3405
|
-
timestamp: timestamp,
|
|
3406
|
-
};
|
|
3407
|
-
this.emitter.emit("message", message);
|
|
3384
|
+
source: "decrypt_failure",
|
|
3385
|
+
});
|
|
3408
3386
|
}
|
|
3409
3387
|
break;
|
|
3410
3388
|
}
|
|
@@ -3480,6 +3458,18 @@ export class Client {
|
|
|
3480
3458
|
return decodeAxios(DeviceRegistrationResultCodec, res.data);
|
|
3481
3459
|
}
|
|
3482
3460
|
|
|
3461
|
+
private async rejectDeviceRequest(requestID: string): Promise<void> {
|
|
3462
|
+
await this.http.post(
|
|
3463
|
+
this.prefixes.HTTP +
|
|
3464
|
+
this.host +
|
|
3465
|
+
"/user/" +
|
|
3466
|
+
this.getUser().userID +
|
|
3467
|
+
"/devices/requests/" +
|
|
3468
|
+
requestID +
|
|
3469
|
+
"/reject",
|
|
3470
|
+
);
|
|
3471
|
+
}
|
|
3472
|
+
|
|
3483
3473
|
private async respond(msg: ChallMsg) {
|
|
3484
3474
|
const response: RespMsg = {
|
|
3485
3475
|
signed: await xSignAsync(
|
|
@@ -3602,6 +3592,27 @@ export class Client {
|
|
|
3602
3592
|
return device;
|
|
3603
3593
|
}
|
|
3604
3594
|
|
|
3595
|
+
/**
|
|
3596
|
+
* `xDHAsync` and other helpers in `@vex-chat/crypto` use the process-wide
|
|
3597
|
+
* active profile. When several {@link Client} instances use different
|
|
3598
|
+
* `cryptoProfile` values, scope the global to this instance for the duration
|
|
3599
|
+
* of that crypto work.
|
|
3600
|
+
*/
|
|
3601
|
+
private async runWithThisCryptoProfile<T>(
|
|
3602
|
+
fn: () => Promise<T>,
|
|
3603
|
+
): Promise<T> {
|
|
3604
|
+
const prev = getCryptoProfile();
|
|
3605
|
+
if (prev === this.cryptoProfile) {
|
|
3606
|
+
return await fn();
|
|
3607
|
+
}
|
|
3608
|
+
setCryptoProfile(this.cryptoProfile);
|
|
3609
|
+
try {
|
|
3610
|
+
return await fn();
|
|
3611
|
+
} finally {
|
|
3612
|
+
setCryptoProfile(prev);
|
|
3613
|
+
}
|
|
3614
|
+
}
|
|
3615
|
+
|
|
3605
3616
|
/* header is 32 bytes and is either empty
|
|
3606
3617
|
or contains an HMAC of the message with
|
|
3607
3618
|
a derived SK */
|
|
@@ -3679,9 +3690,9 @@ export class Client {
|
|
|
3679
3690
|
if (!session || retry) {
|
|
3680
3691
|
if (libvexDebugDmEnabled()) {
|
|
3681
3692
|
debugLibvexDm("sendMail: createSession path", {
|
|
3693
|
+
hasSession: String(!!session),
|
|
3682
3694
|
peerDevice: device.deviceID,
|
|
3683
3695
|
retry: String(retry),
|
|
3684
|
-
hasSession: String(!!session),
|
|
3685
3696
|
});
|
|
3686
3697
|
}
|
|
3687
3698
|
await this.createSession(
|
|
@@ -3830,11 +3841,11 @@ export class Client {
|
|
|
3830
3841
|
debugLibvexDm(
|
|
3831
3842
|
"sendMessage: peer device list (merged, sorted)",
|
|
3832
3843
|
{
|
|
3833
|
-
userID,
|
|
3834
3844
|
nAfterBackoff: String(afterBackoff.length),
|
|
3835
3845
|
nMerged: String(deviceListRaw.length),
|
|
3836
3846
|
nSorted: String(deviceList.length),
|
|
3837
3847
|
ourDevice: this.getDevice().deviceID,
|
|
3848
|
+
userID,
|
|
3838
3849
|
},
|
|
3839
3850
|
);
|
|
3840
3851
|
for (const [i, d] of deviceList.entries()) {
|
|
@@ -3852,8 +3863,8 @@ export class Client {
|
|
|
3852
3863
|
try {
|
|
3853
3864
|
if (libvexDebugDmEnabled()) {
|
|
3854
3865
|
debugLibvexDm("sendMessage: sendMail start", {
|
|
3855
|
-
recipientDevice: device.deviceID,
|
|
3856
3866
|
mailID: messageMailID,
|
|
3867
|
+
recipientDevice: device.deviceID,
|
|
3857
3868
|
});
|
|
3858
3869
|
}
|
|
3859
3870
|
await this.sendMail(
|