@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/dist/Client.d.ts +28 -3
- package/dist/Client.d.ts.map +1 -1
- package/dist/Client.js +63 -17
- package/dist/Client.js.map +1 -1
- package/package.json +1 -1
- package/src/Client.ts +133 -22
- package/src/__tests__/dm-own-device-forward.test.ts +157 -0
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<
|
|
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
|
-
|
|
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
|
|
3830
|
-
* `manuallyClosing`, this becomes a no-op
|
|
3831
|
-
*
|
|
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<
|
|
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<
|
|
5415
|
+
): Promise<Message | null> {
|
|
5328
5416
|
try {
|
|
5329
|
-
await this.sendMail(
|
|
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
|
+
});
|