@vex-chat/libvex 7.0.2 → 7.1.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/src/Client.ts CHANGED
@@ -429,8 +429,32 @@ export interface Devices {
429
429
  * approving device.
430
430
  */
431
431
  approveRequest: (requestID: string) => Promise<Device>;
432
+ /**
433
+ * Begin creating a passkey from a newly approved, still pending device
434
+ * enrollment. Proves possession of the requesting device key by signing
435
+ * the original pending-registration challenge.
436
+ */
437
+ beginPendingPasskeyRegistration: (args: {
438
+ challenge: string;
439
+ name: string;
440
+ requestID: string;
441
+ }) => Promise<{
442
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- WebAuthn options shape varies per simplewebauthn version
443
+ options: any;
444
+ requestID: string;
445
+ }>;
432
446
  /** Deletes one of the account's devices (except the currently active one). */
433
447
  delete: (deviceID: string) => Promise<void>;
448
+ /**
449
+ * Finish creating a passkey for a newly approved pending device
450
+ * enrollment.
451
+ */
452
+ finishPendingPasskeyRegistration: (args: {
453
+ challenge: string;
454
+ name: string;
455
+ requestID: string;
456
+ response: Record<string, unknown>;
457
+ }) => Promise<Passkey>;
434
458
  /** Fetches one pending registration request by ID for the current user. */
435
459
  getRequest: (requestID: string) => Promise<null | PendingDeviceRequest>;
436
460
  /** Lists every device belonging to the current account. */
@@ -1303,7 +1327,11 @@ export class Client {
1303
1327
  abortPendingRegistration:
1304
1328
  this.abortPendingDeviceRegistration.bind(this),
1305
1329
  approveRequest: this.approveDeviceRequest.bind(this),
1330
+ beginPendingPasskeyRegistration:
1331
+ this.beginPendingDevicePasskeyRegistration.bind(this),
1306
1332
  delete: this.deleteDevice.bind(this),
1333
+ finishPendingPasskeyRegistration:
1334
+ this.finishPendingDevicePasskeyRegistration.bind(this),
1307
1335
  getRequest: this.getDeviceRegistrationRequest.bind(this),
1308
1336
  list: this.listDevices.bind(this),
1309
1337
  listRequests: this.listDeviceRegistrationRequests.bind(this),
@@ -2529,6 +2557,29 @@ export class Client {
2529
2557
  return decodeHttpResponse(PasskeyOptionsCodec, response.data);
2530
2558
  }
2531
2559
 
2560
+ private async beginPendingDevicePasskeyRegistration(args: {
2561
+ challenge: string;
2562
+ name: string;
2563
+ requestID: string;
2564
+ }): Promise<{
2565
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- WebAuthn options shape varies per simplewebauthn version
2566
+ options: any;
2567
+ requestID: string;
2568
+ }> {
2569
+ const signed = await this.signPendingRegistrationChallenge(
2570
+ args.challenge,
2571
+ );
2572
+ const response = await this.http.post(
2573
+ this.getHost() +
2574
+ "/user/devices/requests/" +
2575
+ args.requestID +
2576
+ "/passkeys/register/begin",
2577
+ msgpack.encode({ name: args.name, signed }),
2578
+ { headers: { "Content-Type": "application/msgpack" } },
2579
+ );
2580
+ return decodeHttpResponse(PasskeyOptionsCodec, response.data);
2581
+ }
2582
+
2532
2583
  private censorPreKey(preKey: PreKeysSQL): PreKeysWS {
2533
2584
  if (!preKey.index) {
2534
2585
  throw new Error("Key index is required.");
@@ -2685,7 +2736,7 @@ export class Client {
2685
2736
  * errors should not reject the full read pipeline.
2686
2737
  */
2687
2738
  allowKeyBundleFailure = false,
2688
- ): Promise<void> {
2739
+ ): Promise<Message | null> {
2689
2740
  return this.runWithThisCryptoProfile(async () => {
2690
2741
  let keyBundle: KeyBundle;
2691
2742
 
@@ -2698,7 +2749,7 @@ export class Client {
2698
2749
  );
2699
2750
  } catch (e) {
2700
2751
  if (allowKeyBundleFailure) {
2701
- return;
2752
+ return null;
2702
2753
  }
2703
2754
  const wrap =
2704
2755
  e instanceof Error ? e : new Error(String(e), { cause: e });
@@ -2710,7 +2761,7 @@ export class Client {
2710
2761
 
2711
2762
  if (!this.xKeyRing) {
2712
2763
  if (this.manuallyClosing) {
2713
- return;
2764
+ return null;
2714
2765
  }
2715
2766
  throw new Error("Key ring not initialized.");
2716
2767
  }
@@ -2859,6 +2910,7 @@ export class Client {
2859
2910
  }
2860
2911
 
2861
2912
  await this.deliverMailResource(msg, hmac, mail);
2913
+ return shouldEmitHandshakeMessage ? emitMsg : null;
2862
2914
  });
2863
2915
  }
2864
2916
 
@@ -3119,6 +3171,31 @@ export class Client {
3119
3171
  return decodeHttpResponse(PasskeyCodec, response.data);
3120
3172
  }
3121
3173
 
3174
+ private async finishPendingDevicePasskeyRegistration(args: {
3175
+ challenge: string;
3176
+ name: string;
3177
+ requestID: string;
3178
+ response: Record<string, unknown>;
3179
+ }): Promise<Passkey> {
3180
+ const signed = await this.signPendingRegistrationChallenge(
3181
+ args.challenge,
3182
+ );
3183
+ const response = await this.http.post(
3184
+ this.getHost() +
3185
+ "/user/devices/requests/" +
3186
+ args.requestID +
3187
+ "/passkeys/register/finish",
3188
+ msgpack.encode({
3189
+ name: args.name,
3190
+ requestID: args.requestID,
3191
+ response: args.response,
3192
+ signed,
3193
+ }),
3194
+ { headers: { "Content-Type": "application/msgpack" } },
3195
+ );
3196
+ return decodeHttpResponse(PasskeyCodec, response.data);
3197
+ }
3198
+
3122
3199
  private async flushMailBatchOverSocket(
3123
3200
  batch: PendingMailBatchDelivery[],
3124
3201
  ): Promise<void> {
@@ -3223,6 +3300,8 @@ export class Client {
3223
3300
  const targetDevices = devices.filter(
3224
3301
  (device) => device.deviceID !== this.getDevice().deviceID,
3225
3302
  );
3303
+ let failCount = 0;
3304
+ let lastErr: unknown;
3226
3305
  for (
3227
3306
  let index = 0;
3228
3307
  index < targetDevices.length;
@@ -3243,12 +3322,27 @@ export class Client {
3243
3322
  copy.mailID,
3244
3323
  true,
3245
3324
  );
3246
- } catch {
3247
- /* best-effort per device */
3325
+ } catch (err: unknown) {
3326
+ failCount += 1;
3327
+ lastErr = err;
3248
3328
  }
3249
3329
  }),
3250
3330
  );
3251
3331
  }
3332
+ if (failCount === 0) {
3333
+ return;
3334
+ }
3335
+ const base =
3336
+ lastErr instanceof Error ? lastErr : new Error(String(lastErr));
3337
+ if (failCount === targetDevices.length) {
3338
+ throw base;
3339
+ }
3340
+ const partial = new Error(
3341
+ `Forwarded direct message failed to reach ${String(failCount)} of ` +
3342
+ `${String(targetDevices.length)} owned device(s).`,
3343
+ );
3344
+ partial.cause = base;
3345
+ throw partial;
3252
3346
  }
3253
3347
 
3254
3348
  private async getChannelByID(channelID: string): Promise<Channel | null> {
@@ -3826,21 +3920,14 @@ export class Client {
3826
3920
  }
3827
3921
 
3828
3922
  /**
3829
- * Pipeline for decrypted messages registered in `init`. After `close()` sets
3830
- * `manuallyClosing`, this becomes a no-op so fire-and-forget `forward` does not
3831
- * race HTTP teardown (we avoid `off()` here — it can interact badly with emit).
3923
+ * Pipeline for decrypted messages - registered in `init`. After `close()` sets
3924
+ * `manuallyClosing`, this becomes a no-op (we avoid `off()` here - it can
3925
+ * interact badly with emit).
3832
3926
  */
3833
3927
  private readonly onInternalMessage = (message: Message): void => {
3834
3928
  if (this.isManualCloseInFlight()) {
3835
3929
  return;
3836
3930
  }
3837
- if (
3838
- message.direction === "outgoing" &&
3839
- !message.forward &&
3840
- message.group === null
3841
- ) {
3842
- void this.forward(message);
3843
- }
3844
3931
 
3845
3932
  if (
3846
3933
  message.direction === "incoming" &&
@@ -5184,7 +5271,7 @@ export class Client {
5184
5271
  mailID: null | string,
5185
5272
  forward: boolean,
5186
5273
  retry = false,
5187
- ): Promise<void> {
5274
+ ): Promise<Message | null> {
5188
5275
  while (this.sending.has(device.deviceID)) {
5189
5276
  await sleep(100);
5190
5277
  }
@@ -5202,7 +5289,7 @@ export class Client {
5202
5289
  retry: String(retry),
5203
5290
  });
5204
5291
  }
5205
- await this.createSession(
5292
+ const createdMessage = await this.createSession(
5206
5293
  device,
5207
5294
  user,
5208
5295
  msg,
@@ -5216,7 +5303,7 @@ export class Client {
5216
5303
  peerDevice: device.deviceID,
5217
5304
  });
5218
5305
  }
5219
- return;
5306
+ return createdMessage;
5220
5307
  }
5221
5308
 
5222
5309
  if (libvexDebugDmEnabled()) {
@@ -5312,6 +5399,7 @@ export class Client {
5312
5399
  this.sessionRecords[XUtils.encodeHex(session.publicKey)] = session;
5313
5400
 
5314
5401
  await this.deliverMailResource(msgb, hmac, mail);
5402
+ return outMsg;
5315
5403
  } finally {
5316
5404
  this.sending.delete(device.deviceID);
5317
5405
  }
@@ -5324,14 +5412,21 @@ export class Client {
5324
5412
  group: null | Uint8Array,
5325
5413
  mailID: null | string,
5326
5414
  forward: boolean,
5327
- ): Promise<void> {
5415
+ ): Promise<Message | null> {
5328
5416
  try {
5329
- await this.sendMail(device, user, msg, group, mailID, forward);
5417
+ return await this.sendMail(
5418
+ device,
5419
+ user,
5420
+ msg,
5421
+ group,
5422
+ mailID,
5423
+ forward,
5424
+ );
5330
5425
  } catch (err: unknown) {
5331
5426
  if (!this.shouldRetryDeliveryWithFreshSession(err)) {
5332
5427
  throw err;
5333
5428
  }
5334
- await this.sendMail(
5429
+ return await this.sendMail(
5335
5430
  device,
5336
5431
  user,
5337
5432
  msg,
@@ -5411,6 +5506,9 @@ export class Client {
5411
5506
  // single mailID so local/UI dedupe treats it as one message.
5412
5507
  const messageMailID = uuid.v4();
5413
5508
  const msgBytes = XUtils.decodeUTF8(payload);
5509
+ const firstOutgoingMessage: { current: Message | null } = {
5510
+ current: null,
5511
+ };
5414
5512
  for (
5415
5513
  let index = 0;
5416
5514
  index < deviceList.length;
@@ -5429,7 +5527,7 @@ export class Client {
5429
5527
  recipientDevice: device.deviceID,
5430
5528
  });
5431
5529
  }
5432
- await this.sendMailWithRecovery(
5530
+ const sentMessage = await this.sendMailWithRecovery(
5433
5531
  device,
5434
5532
  userEntry,
5435
5533
  msgBytes,
@@ -5437,6 +5535,13 @@ export class Client {
5437
5535
  messageMailID,
5438
5536
  false,
5439
5537
  );
5538
+ if (
5539
+ firstOutgoingMessage.current === null &&
5540
+ sentMessage &&
5541
+ !sentMessage.forward
5542
+ ) {
5543
+ firstOutgoingMessage.current = sentMessage;
5544
+ }
5440
5545
  if (libvexDebugDmEnabled()) {
5441
5546
  debugLibvexDm("sendMessage: sendMail ok", {
5442
5547
  recipientDevice: device.deviceID,
@@ -5483,6 +5588,12 @@ export class Client {
5483
5588
  partial.cause = base;
5484
5589
  throw partial;
5485
5590
  }
5591
+ if (
5592
+ userID !== this.getUser().userID &&
5593
+ firstOutgoingMessage.current !== null
5594
+ ) {
5595
+ await this.forward(firstOutgoingMessage.current);
5596
+ }
5486
5597
  } catch (err: unknown) {
5487
5598
  throw err;
5488
5599
  }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Copyright (c) 2020-2026 Vex Heavy Industries LLC
3
+ * Licensed under AGPL-3.0. See LICENSE for details.
4
+ * Commercial licenses available at vex.wtf
5
+ */
6
+
7
+ import type { Device, User } from "@vex-chat/types";
8
+
9
+ import { describe, expect, it, vi } from "vitest";
10
+
11
+ import { msgpack } from "../codec.js";
12
+ import { Client, type Message } from "../index.js";
13
+
14
+ interface SendMailCall {
15
+ device: Device;
16
+ forward: boolean;
17
+ group: null | Uint8Array;
18
+ mailID: null | string;
19
+ msg: Uint8Array;
20
+ user: User;
21
+ }
22
+
23
+ type SendMessage = (
24
+ this: unknown,
25
+ userID: string,
26
+ message: string,
27
+ ) => Promise<void>;
28
+
29
+ const now = "2026-05-27T00:00:00.000Z";
30
+
31
+ function device(deviceID: string, owner: string): Device {
32
+ return {
33
+ deleted: false,
34
+ deviceID,
35
+ lastLogin: now,
36
+ name: deviceID,
37
+ owner,
38
+ signKey: `${deviceID}-sign-key`,
39
+ };
40
+ }
41
+
42
+ function user(userID: string, username: string): User {
43
+ return {
44
+ lastSeen: now,
45
+ userID,
46
+ username,
47
+ };
48
+ }
49
+
50
+ describe("direct message own-device forwarding", () => {
51
+ it("sends a forwarded copy to the sender's other devices", async () => {
52
+ const senderUser = user("user-a", "alice");
53
+ const peerUser = user("user-b", "bob");
54
+ const senderOriginalDevice = device("a-device-1", senderUser.userID);
55
+ const senderCurrentDevice = device("a-device-2", senderUser.userID);
56
+ const peerDevice = device("b-device-1", peerUser.userID);
57
+ const calls: SendMailCall[] = [];
58
+
59
+ const fakeClient = {
60
+ fetchUser: vi.fn((userID: string) =>
61
+ Promise.resolve([
62
+ userID === peerUser.userID ? peerUser : senderUser,
63
+ null,
64
+ ]),
65
+ ),
66
+ fetchUserDeviceListOnce: vi.fn((userID: string) =>
67
+ Promise.resolve(
68
+ userID === peerUser.userID
69
+ ? [peerDevice]
70
+ : [senderOriginalDevice, senderCurrentDevice],
71
+ ),
72
+ ),
73
+ fetchUserDeviceListWithBackoff: vi.fn((userID: string) =>
74
+ Promise.resolve(
75
+ userID === peerUser.userID
76
+ ? [peerDevice]
77
+ : [senderOriginalDevice, senderCurrentDevice],
78
+ ),
79
+ ),
80
+ forward: Reflect.get(Client.prototype, "forward") as (
81
+ message: Message,
82
+ ) => Promise<void>,
83
+ forwarded: new Set<string>(),
84
+ getDevice: () => senderCurrentDevice,
85
+ getUser: () => senderUser,
86
+ isManualCloseInFlight: () => false,
87
+ sendMailWithRecovery: vi.fn(
88
+ (
89
+ sentDevice: Device,
90
+ sentUser: User,
91
+ msg: Uint8Array,
92
+ group: null | Uint8Array,
93
+ mailID: null | string,
94
+ forward: boolean,
95
+ ): Promise<Message> => {
96
+ calls.push({
97
+ device: sentDevice,
98
+ forward,
99
+ group,
100
+ mailID,
101
+ msg,
102
+ user: sentUser,
103
+ });
104
+ return Promise.resolve({
105
+ authorID: senderUser.userID,
106
+ decrypted: true,
107
+ direction: "outgoing",
108
+ forward,
109
+ group: null,
110
+ mailID: mailID ?? "generated-mail-id",
111
+ message: forward
112
+ ? (msgpack.decode(msg) as Message).message
113
+ : "hello from second device",
114
+ nonce: `${sentDevice.deviceID}-nonce`,
115
+ readerID: sentUser.userID,
116
+ recipient: sentDevice.deviceID,
117
+ sender: senderCurrentDevice.deviceID,
118
+ timestamp: now,
119
+ });
120
+ },
121
+ ),
122
+ };
123
+
124
+ const sendMessage = Reflect.get(
125
+ Client.prototype,
126
+ "sendMessage",
127
+ ) as SendMessage;
128
+
129
+ await sendMessage.call(
130
+ fakeClient,
131
+ peerUser.userID,
132
+ "hello from second device",
133
+ );
134
+
135
+ expect(calls).toHaveLength(2);
136
+ expect(calls[0]).toMatchObject({
137
+ device: peerDevice,
138
+ forward: false,
139
+ user: peerUser,
140
+ });
141
+ expect(calls[1]).toMatchObject({
142
+ device: senderOriginalDevice,
143
+ forward: true,
144
+ user: senderUser,
145
+ });
146
+ const forwardedPayload = msgpack.decode(calls[1]!.msg) as Message;
147
+ expect(forwardedPayload).toMatchObject({
148
+ authorID: senderUser.userID,
149
+ direction: "outgoing",
150
+ forward: false,
151
+ message: "hello from second device",
152
+ readerID: peerUser.userID,
153
+ sender: senderCurrentDevice.deviceID,
154
+ });
155
+ expect(forwardedPayload.mailID).toBe(calls[0]!.mailID);
156
+ });
157
+ });