@vex-chat/libvex 6.2.2 → 6.3.0
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 +32 -2
- package/dist/Client.d.ts.map +1 -1
- package/dist/Client.js +192 -23
- package/dist/Client.js.map +1 -1
- package/dist/Storage.d.ts +5 -0
- package/dist/Storage.d.ts.map +1 -1
- package/dist/__tests__/harness/memory-storage.d.ts +1 -0
- package/dist/__tests__/harness/memory-storage.d.ts.map +1 -1
- package/dist/__tests__/harness/memory-storage.js +15 -0
- package/dist/__tests__/harness/memory-storage.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/retention.d.ts +26 -0
- package/dist/retention.d.ts.map +1 -0
- package/dist/retention.js +51 -0
- package/dist/retention.js.map +1 -0
- package/dist/storage/schema.d.ts +2 -0
- package/dist/storage/schema.d.ts.map +1 -1
- package/dist/storage/sqlite.d.ts +2 -0
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +54 -2
- package/dist/storage/sqlite.js.map +1 -1
- package/package.json +3 -3
- package/src/Client.ts +281 -30
- package/src/Storage.ts +7 -0
- package/src/__tests__/harness/memory-storage.ts +19 -0
- package/src/__tests__/harness/shared-suite.ts +30 -21
- package/src/__tests__/retention.test.ts +39 -0
- package/src/index.ts +4 -0
- package/src/retention.ts +68 -0
- package/src/storage/schema.ts +2 -0
- package/src/storage/sqlite.ts +69 -2
package/src/Client.ts
CHANGED
|
@@ -78,6 +78,11 @@ import { EventEmitter } from "eventemitter3";
|
|
|
78
78
|
import * as uuid from "uuid";
|
|
79
79
|
import { z } from "zod/v4";
|
|
80
80
|
|
|
81
|
+
import {
|
|
82
|
+
clampLocalMessageRetentionDays,
|
|
83
|
+
formatVexRetentionEnvelope,
|
|
84
|
+
stripVexRetentionEnvelope,
|
|
85
|
+
} from "./retention.js";
|
|
81
86
|
import {
|
|
82
87
|
WebSocketAdapter,
|
|
83
88
|
WebSocketNotOpenError,
|
|
@@ -363,6 +368,13 @@ export interface ClientOptions {
|
|
|
363
368
|
host?: string;
|
|
364
369
|
/** Use sqlite in-memory mode (`:memory:`) instead of a file. */
|
|
365
370
|
inMemoryDb?: boolean;
|
|
371
|
+
/**
|
|
372
|
+
* Maximum age (in days) for messages kept in local storage. Values above
|
|
373
|
+
* 30 are clamped to 30 to match server retention. Peers may request a
|
|
374
|
+
* shorter window via an optional plaintext hint; this setting is still
|
|
375
|
+
* capped at 30 and is not enforceable against a modified client.
|
|
376
|
+
*/
|
|
377
|
+
localMessageRetentionDays?: number;
|
|
366
378
|
/** Whether local message history should be persisted by default storage. */
|
|
367
379
|
saveHistory?: boolean;
|
|
368
380
|
/** Use `http/ws` instead of `https/wss`. Intended for local/dev environments. */
|
|
@@ -552,6 +564,12 @@ export interface Message {
|
|
|
552
564
|
readerID: string;
|
|
553
565
|
/** Recipient device ID. */
|
|
554
566
|
recipient: string;
|
|
567
|
+
/**
|
|
568
|
+
* Optional peer hint (1–30): cooperative senders prefix plaintext; used
|
|
569
|
+
* with {@link ClientOptions.localMessageRetentionDays} to pick the
|
|
570
|
+
* shorter local retention window. Ignored when absent.
|
|
571
|
+
*/
|
|
572
|
+
retentionHintDays?: number;
|
|
555
573
|
/** Sender device ID. */
|
|
556
574
|
sender: string;
|
|
557
575
|
/** Time the message was created/received. */
|
|
@@ -740,7 +758,11 @@ export interface Messages {
|
|
|
740
758
|
/** Deletes local history for a user/channel. */
|
|
741
759
|
delete: (userOrChannelID: string) => Promise<void>;
|
|
742
760
|
/** Sends an encrypted message to all members of a channel. */
|
|
743
|
-
group: (
|
|
761
|
+
group: (
|
|
762
|
+
channelID: string,
|
|
763
|
+
message: string,
|
|
764
|
+
opts?: { retentionHintDays?: number },
|
|
765
|
+
) => Promise<void>;
|
|
744
766
|
/** Deletes all locally stored message history. */
|
|
745
767
|
purge: () => Promise<void>;
|
|
746
768
|
/** Returns local direct-message history with one user. */
|
|
@@ -748,7 +770,11 @@ export interface Messages {
|
|
|
748
770
|
/** Returns local group-message history for one channel. */
|
|
749
771
|
retrieveGroup: (channelID: string) => Promise<Message[]>;
|
|
750
772
|
/** Sends an encrypted direct message to one user. */
|
|
751
|
-
send: (
|
|
773
|
+
send: (
|
|
774
|
+
userID: string,
|
|
775
|
+
message: string,
|
|
776
|
+
opts?: { retentionHintDays?: number },
|
|
777
|
+
) => Promise<void>;
|
|
752
778
|
}
|
|
753
779
|
|
|
754
780
|
/**
|
|
@@ -1072,7 +1098,11 @@ export class Client {
|
|
|
1072
1098
|
* @param channelID - The channel to send a message to.
|
|
1073
1099
|
* @param message - The message to send.
|
|
1074
1100
|
*/
|
|
1075
|
-
group:
|
|
1101
|
+
group: (
|
|
1102
|
+
channelID: string,
|
|
1103
|
+
message: string,
|
|
1104
|
+
opts?: { retentionHintDays?: number },
|
|
1105
|
+
) => this.sendGroupMessage(channelID, message, opts),
|
|
1076
1106
|
purge: this.purgeHistory.bind(this),
|
|
1077
1107
|
/**
|
|
1078
1108
|
* Gets the message history with a specific userID.
|
|
@@ -1093,7 +1123,11 @@ export class Client {
|
|
|
1093
1123
|
* @param userID - The user to send a message to.
|
|
1094
1124
|
* @param message - The message to send.
|
|
1095
1125
|
*/
|
|
1096
|
-
send:
|
|
1126
|
+
send: (
|
|
1127
|
+
userID: string,
|
|
1128
|
+
message: string,
|
|
1129
|
+
opts?: { retentionHintDays?: number },
|
|
1130
|
+
) => this.sendMessage(userID, message, opts),
|
|
1097
1131
|
};
|
|
1098
1132
|
/**
|
|
1099
1133
|
* Server moderation helper methods.
|
|
@@ -1250,9 +1284,13 @@ export class Client {
|
|
|
1250
1284
|
private readonly httpAbortController = new AbortController();
|
|
1251
1285
|
private readonly idKeys: KeyPair | null;
|
|
1252
1286
|
private isAlive: boolean = true;
|
|
1253
|
-
private
|
|
1287
|
+
private localMessageRetentionDays: number;
|
|
1254
1288
|
|
|
1289
|
+
private localRetentionPurgeTimer: null | ReturnType<typeof setInterval> =
|
|
1290
|
+
null;
|
|
1291
|
+
private readonly mailInterval?: NodeJS.Timeout;
|
|
1255
1292
|
private manuallyClosing: boolean = false;
|
|
1293
|
+
|
|
1256
1294
|
/**
|
|
1257
1295
|
* Node-only: per-client HTTP(S) agents (see `init()` + `storage/node/http-agents`).
|
|
1258
1296
|
* Dropped on `close()` so idle keep-alive sockets do not keep the process alive.
|
|
@@ -1266,20 +1304,21 @@ export class Client {
|
|
|
1266
1304
|
and finally falls back to username. */
|
|
1267
1305
|
/** Negative cache for user lookups that returned 404. TTL = 30 minutes. */
|
|
1268
1306
|
private readonly notFoundUsers = new Map<string, number>();
|
|
1269
|
-
|
|
1270
1307
|
private readonly options?: ClientOptions | undefined;
|
|
1271
1308
|
|
|
1272
1309
|
private pingInterval: null | ReturnType<typeof setTimeout> = null;
|
|
1310
|
+
|
|
1273
1311
|
/**
|
|
1274
1312
|
* Bumped when the WebSocket is torn down and re-opened so the previous
|
|
1275
1313
|
* `postAuth` loop exits instead of overlapping a new one.
|
|
1276
1314
|
*/
|
|
1277
1315
|
private postAuthVersion = 0;
|
|
1278
|
-
|
|
1279
1316
|
private readonly prefixes:
|
|
1280
1317
|
| { HTTP: "http://"; WS: "ws://" }
|
|
1281
1318
|
| { HTTP: "https://"; WS: "wss://" };
|
|
1319
|
+
|
|
1282
1320
|
private reading: boolean = false;
|
|
1321
|
+
private retentionPurgeDebounce: null | ReturnType<typeof setTimeout> = null;
|
|
1283
1322
|
private readonly seenMailIDs: Set<string> = new Set();
|
|
1284
1323
|
private sessionRecords: Record<string, SessionCrypto> = {};
|
|
1285
1324
|
|
|
@@ -1303,6 +1342,9 @@ export class Client {
|
|
|
1303
1342
|
storage?: Storage,
|
|
1304
1343
|
) {
|
|
1305
1344
|
this.options = options;
|
|
1345
|
+
this.localMessageRetentionDays = clampLocalMessageRetentionDays(
|
|
1346
|
+
options?.localMessageRetentionDays,
|
|
1347
|
+
);
|
|
1306
1348
|
this.cryptoProfile = material.cryptoProfile;
|
|
1307
1349
|
this.signKeys = material.signKeys;
|
|
1308
1350
|
this.idKeys = material.idKeys;
|
|
@@ -1600,6 +1642,14 @@ export class Client {
|
|
|
1600
1642
|
if (this.mailInterval) {
|
|
1601
1643
|
clearInterval(this.mailInterval);
|
|
1602
1644
|
}
|
|
1645
|
+
if (this.localRetentionPurgeTimer) {
|
|
1646
|
+
clearInterval(this.localRetentionPurgeTimer);
|
|
1647
|
+
this.localRetentionPurgeTimer = null;
|
|
1648
|
+
}
|
|
1649
|
+
if (this.retentionPurgeDebounce) {
|
|
1650
|
+
clearTimeout(this.retentionPurgeDebounce);
|
|
1651
|
+
this.retentionPurgeDebounce = null;
|
|
1652
|
+
}
|
|
1603
1653
|
delete this.xKeyRing;
|
|
1604
1654
|
|
|
1605
1655
|
if (!muteEvent) {
|
|
@@ -1679,6 +1729,11 @@ export class Client {
|
|
|
1679
1729
|
};
|
|
1680
1730
|
}
|
|
1681
1731
|
|
|
1732
|
+
/** Current local retention cap in days (always 1–30). */
|
|
1733
|
+
public getLocalMessageRetentionDays(): number {
|
|
1734
|
+
return this.localMessageRetentionDays;
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1682
1737
|
/**
|
|
1683
1738
|
* Authenticates with username/password and stores the Bearer auth token.
|
|
1684
1739
|
*
|
|
@@ -2019,6 +2074,15 @@ export class Client {
|
|
|
2019
2074
|
return this;
|
|
2020
2075
|
}
|
|
2021
2076
|
|
|
2077
|
+
/**
|
|
2078
|
+
* Updates the local retention cap (1–30 days) and prunes immediately.
|
|
2079
|
+
* Does not affect server-side storage.
|
|
2080
|
+
*/
|
|
2081
|
+
public setLocalMessageRetentionDays(days: number): void {
|
|
2082
|
+
this.localMessageRetentionDays = clampLocalMessageRetentionDays(days);
|
|
2083
|
+
void this.runLocalRetentionPurge();
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2022
2086
|
/**
|
|
2023
2087
|
* Triggers an immediate inbox sync by fetching `/mail` once.
|
|
2024
2088
|
* Useful on mobile foreground resume where background work may pause.
|
|
@@ -2060,6 +2124,11 @@ export class Client {
|
|
|
2060
2124
|
return whoami;
|
|
2061
2125
|
}
|
|
2062
2126
|
|
|
2127
|
+
private acknowledgeInboundMail(mail: MailWS): void {
|
|
2128
|
+
this.seenMailIDs.add(mail.mailID);
|
|
2129
|
+
this.sendReceipt(new Uint8Array(mail.nonce));
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2063
2132
|
private async approveDeviceRequest(requestID: string): Promise<Device> {
|
|
2064
2133
|
const req = await this.getDeviceRegistrationRequest(requestID);
|
|
2065
2134
|
if (!req) {
|
|
@@ -2496,7 +2565,6 @@ export class Client {
|
|
|
2496
2565
|
private async deleteServer(serverID: string): Promise<void> {
|
|
2497
2566
|
await this.http.delete(this.getHost() + "/server/" + serverID);
|
|
2498
2567
|
}
|
|
2499
|
-
|
|
2500
2568
|
private deviceListFailureDetail(err: unknown): string {
|
|
2501
2569
|
if (!isAxiosError(err)) {
|
|
2502
2570
|
return "";
|
|
@@ -2510,7 +2578,6 @@ export class Client {
|
|
|
2510
2578
|
}
|
|
2511
2579
|
return "";
|
|
2512
2580
|
}
|
|
2513
|
-
|
|
2514
2581
|
/**
|
|
2515
2582
|
* Gets a list of permissions for a server.
|
|
2516
2583
|
*
|
|
@@ -2526,6 +2593,7 @@ export class Client {
|
|
|
2526
2593
|
);
|
|
2527
2594
|
return decodeAxios(PermissionArrayCodec, res.data);
|
|
2528
2595
|
}
|
|
2596
|
+
|
|
2529
2597
|
private async fetchUser(
|
|
2530
2598
|
userIdentifier: string,
|
|
2531
2599
|
): Promise<[null | User, AxiosError | null]> {
|
|
@@ -3049,6 +3117,8 @@ export class Client {
|
|
|
3049
3117
|
}
|
|
3050
3118
|
}
|
|
3051
3119
|
|
|
3120
|
+
// ── Passkeys ────────────────────────────────────────────────────────
|
|
3121
|
+
|
|
3052
3122
|
/**
|
|
3053
3123
|
* Initializes the keyring. This must be called before anything else.
|
|
3054
3124
|
*/
|
|
@@ -3068,9 +3138,16 @@ export class Client {
|
|
|
3068
3138
|
|
|
3069
3139
|
await this.populateKeyRing();
|
|
3070
3140
|
this.emitter.on("message", this.onInternalMessage);
|
|
3141
|
+
void this.runLocalRetentionPurge();
|
|
3142
|
+
this.localRetentionPurgeTimer = setInterval(
|
|
3143
|
+
() => void this.runLocalRetentionPurge(),
|
|
3144
|
+
6 * 60 * 60 * 1000,
|
|
3145
|
+
);
|
|
3071
3146
|
this.emitter.emit("ready");
|
|
3072
3147
|
}
|
|
3073
3148
|
|
|
3149
|
+
// ── Passkeys ────────────────────────────────────────────────────────
|
|
3150
|
+
|
|
3074
3151
|
private initSocket() {
|
|
3075
3152
|
try {
|
|
3076
3153
|
if (!this.token) {
|
|
@@ -3169,8 +3246,6 @@ export class Client {
|
|
|
3169
3246
|
}
|
|
3170
3247
|
}
|
|
3171
3248
|
|
|
3172
|
-
// ── Passkeys ────────────────────────────────────────────────────────
|
|
3173
|
-
|
|
3174
3249
|
/**
|
|
3175
3250
|
* Fresh read of the `manuallyClosing` flag for async loops — direct property checks
|
|
3176
3251
|
* after `await` are flagged as always-false by control-flow analysis even though
|
|
@@ -3279,6 +3354,7 @@ export class Client {
|
|
|
3279
3354
|
return;
|
|
3280
3355
|
}
|
|
3281
3356
|
void this.database.saveMessage(message);
|
|
3357
|
+
this.scheduleRetentionPurge();
|
|
3282
3358
|
};
|
|
3283
3359
|
|
|
3284
3360
|
private async passkeyApproveDeviceRequest(
|
|
@@ -3503,7 +3579,6 @@ export class Client {
|
|
|
3503
3579
|
}
|
|
3504
3580
|
return;
|
|
3505
3581
|
}
|
|
3506
|
-
this.seenMailIDs.add(mail.mailID);
|
|
3507
3582
|
|
|
3508
3583
|
if (this.manuallyClosing) {
|
|
3509
3584
|
if (libvexDebugDmEnabled()) {
|
|
@@ -3514,7 +3589,6 @@ export class Client {
|
|
|
3514
3589
|
return;
|
|
3515
3590
|
}
|
|
3516
3591
|
|
|
3517
|
-
this.sendReceipt(new Uint8Array(mail.nonce));
|
|
3518
3592
|
let timeout = 1;
|
|
3519
3593
|
while (this.reading) {
|
|
3520
3594
|
await sleep(timeout);
|
|
@@ -3709,13 +3783,39 @@ export class Client {
|
|
|
3709
3783
|
if (!mail.forward) {
|
|
3710
3784
|
plaintext = XUtils.encodeUTF8(unsealed);
|
|
3711
3785
|
}
|
|
3786
|
+
let incomingPlain = plaintext;
|
|
3787
|
+
let hintFromEnvelope: number | undefined;
|
|
3788
|
+
if (!mail.forward && plaintext.length > 0) {
|
|
3789
|
+
const stripped =
|
|
3790
|
+
stripVexRetentionEnvelope(plaintext);
|
|
3791
|
+
incomingPlain = stripped.body;
|
|
3792
|
+
hintFromEnvelope = stripped.retentionHintDays;
|
|
3793
|
+
}
|
|
3712
3794
|
|
|
3713
3795
|
// emit the message
|
|
3714
3796
|
const fwdMsg1 = mail.forward
|
|
3715
3797
|
? messageSchema.parse(msgpack.decode(unsealed))
|
|
3716
3798
|
: null;
|
|
3717
3799
|
const message: Message = fwdMsg1
|
|
3718
|
-
?
|
|
3800
|
+
? (() => {
|
|
3801
|
+
const stripped =
|
|
3802
|
+
stripVexRetentionEnvelope(
|
|
3803
|
+
fwdMsg1.message,
|
|
3804
|
+
);
|
|
3805
|
+
const base: Message = {
|
|
3806
|
+
...fwdMsg1,
|
|
3807
|
+
forward: true,
|
|
3808
|
+
message: stripped.body,
|
|
3809
|
+
};
|
|
3810
|
+
return stripped.retentionHintDays !==
|
|
3811
|
+
undefined
|
|
3812
|
+
? {
|
|
3813
|
+
...base,
|
|
3814
|
+
retentionHintDays:
|
|
3815
|
+
stripped.retentionHintDays,
|
|
3816
|
+
}
|
|
3817
|
+
: base;
|
|
3818
|
+
})()
|
|
3719
3819
|
: {
|
|
3720
3820
|
authorID: mail.authorID,
|
|
3721
3821
|
decrypted: true,
|
|
@@ -3725,12 +3825,18 @@ export class Client {
|
|
|
3725
3825
|
? uuid.stringify(mail.group)
|
|
3726
3826
|
: null,
|
|
3727
3827
|
mailID: mail.mailID,
|
|
3728
|
-
message:
|
|
3828
|
+
message: incomingPlain,
|
|
3729
3829
|
nonce: XUtils.encodeHex(
|
|
3730
3830
|
new Uint8Array(mail.nonce),
|
|
3731
3831
|
),
|
|
3732
3832
|
readerID: mail.readerID,
|
|
3733
3833
|
recipient: mail.recipient,
|
|
3834
|
+
...(hintFromEnvelope !== undefined
|
|
3835
|
+
? {
|
|
3836
|
+
retentionHintDays:
|
|
3837
|
+
hintFromEnvelope,
|
|
3838
|
+
}
|
|
3839
|
+
: {}),
|
|
3734
3840
|
sender: mail.sender,
|
|
3735
3841
|
timestamp: timestamp,
|
|
3736
3842
|
};
|
|
@@ -3816,6 +3922,7 @@ export class Client {
|
|
|
3816
3922
|
this.emitter.emit("session", newSession, user);
|
|
3817
3923
|
} else {
|
|
3818
3924
|
}
|
|
3925
|
+
this.acknowledgeInboundMail(mail);
|
|
3819
3926
|
} else {
|
|
3820
3927
|
if (libvexDebugDmEnabled()) {
|
|
3821
3928
|
debugLibvexDm(
|
|
@@ -3896,11 +4003,35 @@ export class Client {
|
|
|
3896
4003
|
const fwdMsg2 = mail.forward
|
|
3897
4004
|
? messageSchema.parse(msgpack.decode(decrypted))
|
|
3898
4005
|
: null;
|
|
4006
|
+
const rawIncoming = XUtils.encodeUTF8(decrypted);
|
|
4007
|
+
let bodyIncoming = rawIncoming;
|
|
4008
|
+
let hintIncoming: number | undefined;
|
|
4009
|
+
if (!mail.forward) {
|
|
4010
|
+
const stripped =
|
|
4011
|
+
stripVexRetentionEnvelope(rawIncoming);
|
|
4012
|
+
bodyIncoming = stripped.body;
|
|
4013
|
+
hintIncoming = stripped.retentionHintDays;
|
|
4014
|
+
}
|
|
3899
4015
|
const message: Message = fwdMsg2
|
|
3900
|
-
? {
|
|
3901
|
-
|
|
3902
|
-
|
|
3903
|
-
|
|
4016
|
+
? (() => {
|
|
4017
|
+
const stripped =
|
|
4018
|
+
stripVexRetentionEnvelope(
|
|
4019
|
+
fwdMsg2.message,
|
|
4020
|
+
);
|
|
4021
|
+
const base: Message = {
|
|
4022
|
+
...fwdMsg2,
|
|
4023
|
+
forward: true,
|
|
4024
|
+
message: stripped.body,
|
|
4025
|
+
};
|
|
4026
|
+
return stripped.retentionHintDays !==
|
|
4027
|
+
undefined
|
|
4028
|
+
? {
|
|
4029
|
+
...base,
|
|
4030
|
+
retentionHintDays:
|
|
4031
|
+
stripped.retentionHintDays,
|
|
4032
|
+
}
|
|
4033
|
+
: base;
|
|
4034
|
+
})()
|
|
3904
4035
|
: {
|
|
3905
4036
|
authorID: mail.authorID,
|
|
3906
4037
|
decrypted: true,
|
|
@@ -3910,12 +4041,15 @@ export class Client {
|
|
|
3910
4041
|
? uuid.stringify(mail.group)
|
|
3911
4042
|
: null,
|
|
3912
4043
|
mailID: mail.mailID,
|
|
3913
|
-
message:
|
|
4044
|
+
message: bodyIncoming,
|
|
3914
4045
|
nonce: XUtils.encodeHex(
|
|
3915
4046
|
new Uint8Array(mail.nonce),
|
|
3916
4047
|
),
|
|
3917
4048
|
readerID: mail.readerID,
|
|
3918
4049
|
recipient: mail.recipient,
|
|
4050
|
+
...(hintIncoming !== undefined
|
|
4051
|
+
? { retentionHintDays: hintIncoming }
|
|
4052
|
+
: {}),
|
|
3919
4053
|
sender: mail.sender,
|
|
3920
4054
|
timestamp: timestamp,
|
|
3921
4055
|
};
|
|
@@ -3949,6 +4083,7 @@ export class Client {
|
|
|
3949
4083
|
this.sessionRecords[
|
|
3950
4084
|
XUtils.encodeHex(session.publicKey)
|
|
3951
4085
|
] = session;
|
|
4086
|
+
this.acknowledgeInboundMail(mail);
|
|
3952
4087
|
} else {
|
|
3953
4088
|
void healSession();
|
|
3954
4089
|
this.emitter.emit("retryRequest", {
|
|
@@ -4163,6 +4298,19 @@ export class Client {
|
|
|
4163
4298
|
return device;
|
|
4164
4299
|
}
|
|
4165
4300
|
|
|
4301
|
+
private async runLocalRetentionPurge(): Promise<void> {
|
|
4302
|
+
if (this.isManualCloseInFlight()) {
|
|
4303
|
+
return;
|
|
4304
|
+
}
|
|
4305
|
+
try {
|
|
4306
|
+
await this.database.pruneExpiredLocalMessages(
|
|
4307
|
+
this.localMessageRetentionDays,
|
|
4308
|
+
);
|
|
4309
|
+
} catch {
|
|
4310
|
+
/* best-effort */
|
|
4311
|
+
}
|
|
4312
|
+
}
|
|
4313
|
+
|
|
4166
4314
|
/**
|
|
4167
4315
|
* `xDHAsync` and other helpers in `@vex-chat/crypto` use the process-wide
|
|
4168
4316
|
* active profile. When several {@link Client} instances use different
|
|
@@ -4184,6 +4332,16 @@ export class Client {
|
|
|
4184
4332
|
}
|
|
4185
4333
|
}
|
|
4186
4334
|
|
|
4335
|
+
private scheduleRetentionPurge(): void {
|
|
4336
|
+
if (this.retentionPurgeDebounce) {
|
|
4337
|
+
clearTimeout(this.retentionPurgeDebounce);
|
|
4338
|
+
}
|
|
4339
|
+
this.retentionPurgeDebounce = setTimeout(() => {
|
|
4340
|
+
this.retentionPurgeDebounce = null;
|
|
4341
|
+
void this.runLocalRetentionPurge();
|
|
4342
|
+
}, 3000);
|
|
4343
|
+
}
|
|
4344
|
+
|
|
4187
4345
|
/* header is 32 bytes and is either empty
|
|
4188
4346
|
or contains an HMAC of the message with
|
|
4189
4347
|
a derived SK */
|
|
@@ -4219,6 +4377,7 @@ export class Client {
|
|
|
4219
4377
|
private async sendGroupMessage(
|
|
4220
4378
|
channelID: string,
|
|
4221
4379
|
message: string,
|
|
4380
|
+
opts?: { retentionHintDays?: number },
|
|
4222
4381
|
): Promise<void> {
|
|
4223
4382
|
const userList = await this.getUserList(channelID);
|
|
4224
4383
|
for (const user of userList) {
|
|
@@ -4226,28 +4385,89 @@ export class Client {
|
|
|
4226
4385
|
}
|
|
4227
4386
|
|
|
4228
4387
|
const mailID = uuid.v4();
|
|
4388
|
+
const payload = formatVexRetentionEnvelope(
|
|
4389
|
+
message,
|
|
4390
|
+
opts?.retentionHintDays,
|
|
4391
|
+
);
|
|
4392
|
+
const msgBytes = XUtils.decodeUTF8(payload);
|
|
4393
|
+
const myUserID = this.getUser().userID;
|
|
4394
|
+
// Fan-out only to *other* server members. The current account's other
|
|
4395
|
+
// devices receive the same group mail via `forward()` on the outgoing
|
|
4396
|
+
// `message` event (see `onInternalMessage`). Including our own devices
|
|
4397
|
+
// here races X3DH/ephemeral state and often fails silently — which
|
|
4398
|
+
// matched reports of flaky early group messages and missing delivery
|
|
4399
|
+
// while DMs (which never self-target) behaved better.
|
|
4400
|
+
const peerUserIDs = [...new Set(userList.map((u) => u.userID))].filter(
|
|
4401
|
+
(id) => id !== myUserID,
|
|
4402
|
+
);
|
|
4229
4403
|
|
|
4230
|
-
|
|
4231
|
-
|
|
4404
|
+
if (peerUserIDs.length === 0) {
|
|
4405
|
+
const dev = this.getDevice();
|
|
4406
|
+
const nonce = xMakeNonce();
|
|
4407
|
+
this.emitter.emit("message", {
|
|
4408
|
+
authorID: myUserID,
|
|
4409
|
+
decrypted: true,
|
|
4410
|
+
direction: "outgoing",
|
|
4411
|
+
forward: false,
|
|
4412
|
+
group: channelID,
|
|
4413
|
+
mailID,
|
|
4414
|
+
message,
|
|
4415
|
+
nonce: XUtils.encodeHex(nonce),
|
|
4416
|
+
readerID: myUserID,
|
|
4417
|
+
recipient: dev.deviceID,
|
|
4418
|
+
sender: dev.deviceID,
|
|
4419
|
+
timestamp: new Date().toISOString(),
|
|
4420
|
+
});
|
|
4421
|
+
return;
|
|
4422
|
+
}
|
|
4232
4423
|
|
|
4233
|
-
|
|
4424
|
+
const devices = await this.getMultiUserDeviceList(peerUserIDs);
|
|
4425
|
+
if (devices.length === 0) {
|
|
4426
|
+
throw new Error(
|
|
4427
|
+
"No devices registered for other channel members — cannot send group message.",
|
|
4428
|
+
);
|
|
4429
|
+
}
|
|
4430
|
+
|
|
4431
|
+
const stableDevices = [...devices].sort((a, b) =>
|
|
4432
|
+
a.deviceID.localeCompare(b.deviceID, "en"),
|
|
4433
|
+
);
|
|
4434
|
+
|
|
4435
|
+
let failCount = 0;
|
|
4436
|
+
let lastErr: unknown;
|
|
4437
|
+
for (const device of stableDevices) {
|
|
4234
4438
|
const ownerRecord = this.userRecords[device.owner];
|
|
4235
4439
|
if (!ownerRecord) {
|
|
4440
|
+
failCount += 1;
|
|
4236
4441
|
continue;
|
|
4237
4442
|
}
|
|
4238
4443
|
try {
|
|
4239
4444
|
await this.sendMail(
|
|
4240
4445
|
device,
|
|
4241
4446
|
ownerRecord,
|
|
4242
|
-
|
|
4447
|
+
msgBytes,
|
|
4243
4448
|
uuidToUint8(channelID),
|
|
4244
4449
|
mailID,
|
|
4245
4450
|
false,
|
|
4246
4451
|
);
|
|
4247
|
-
} catch {
|
|
4248
|
-
|
|
4452
|
+
} catch (e) {
|
|
4453
|
+
lastErr = e;
|
|
4454
|
+
failCount += 1;
|
|
4249
4455
|
}
|
|
4250
4456
|
}
|
|
4457
|
+
|
|
4458
|
+
if (failCount === stableDevices.length) {
|
|
4459
|
+
throw lastErr instanceof Error
|
|
4460
|
+
? lastErr
|
|
4461
|
+
: new Error(String(lastErr));
|
|
4462
|
+
}
|
|
4463
|
+
if (failCount > 0) {
|
|
4464
|
+
const partial = new Error(
|
|
4465
|
+
`Group message failed to reach ${String(failCount)} of ` +
|
|
4466
|
+
`${String(stableDevices.length)} peer device(s).`,
|
|
4467
|
+
);
|
|
4468
|
+
partial.cause = lastErr;
|
|
4469
|
+
throw partial;
|
|
4470
|
+
}
|
|
4251
4471
|
}
|
|
4252
4472
|
|
|
4253
4473
|
/* Sends encrypted mail to a user. */
|
|
@@ -4341,8 +4561,25 @@ export class Client {
|
|
|
4341
4561
|
const fwdOut = forward
|
|
4342
4562
|
? messageSchema.parse(msgpack.decode(msg))
|
|
4343
4563
|
: null;
|
|
4564
|
+
const rawUtf8 = XUtils.encodeUTF8(msg);
|
|
4565
|
+
const strippedOut = stripVexRetentionEnvelope(rawUtf8);
|
|
4344
4566
|
const outMsg: Message = fwdOut
|
|
4345
|
-
?
|
|
4567
|
+
? (() => {
|
|
4568
|
+
const stripped = stripVexRetentionEnvelope(
|
|
4569
|
+
fwdOut.message,
|
|
4570
|
+
);
|
|
4571
|
+
const base: Message = {
|
|
4572
|
+
...fwdOut,
|
|
4573
|
+
forward: true,
|
|
4574
|
+
message: stripped.body,
|
|
4575
|
+
};
|
|
4576
|
+
return stripped.retentionHintDays !== undefined
|
|
4577
|
+
? {
|
|
4578
|
+
...base,
|
|
4579
|
+
retentionHintDays: stripped.retentionHintDays,
|
|
4580
|
+
}
|
|
4581
|
+
: base;
|
|
4582
|
+
})()
|
|
4346
4583
|
: {
|
|
4347
4584
|
authorID: mail.authorID,
|
|
4348
4585
|
decrypted: true,
|
|
@@ -4350,10 +4587,16 @@ export class Client {
|
|
|
4350
4587
|
forward: mail.forward,
|
|
4351
4588
|
group: mail.group ? uuid.stringify(mail.group) : null,
|
|
4352
4589
|
mailID: mail.mailID,
|
|
4353
|
-
message:
|
|
4590
|
+
message: strippedOut.body,
|
|
4354
4591
|
nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
|
|
4355
4592
|
readerID: mail.readerID,
|
|
4356
4593
|
recipient: mail.recipient,
|
|
4594
|
+
...(strippedOut.retentionHintDays !== undefined
|
|
4595
|
+
? {
|
|
4596
|
+
retentionHintDays:
|
|
4597
|
+
strippedOut.retentionHintDays,
|
|
4598
|
+
}
|
|
4599
|
+
: {}),
|
|
4357
4600
|
sender: mail.sender,
|
|
4358
4601
|
timestamp: new Date().toISOString(),
|
|
4359
4602
|
};
|
|
@@ -4418,7 +4661,15 @@ export class Client {
|
|
|
4418
4661
|
}
|
|
4419
4662
|
}
|
|
4420
4663
|
|
|
4421
|
-
private async sendMessage(
|
|
4664
|
+
private async sendMessage(
|
|
4665
|
+
userID: string,
|
|
4666
|
+
message: string,
|
|
4667
|
+
opts?: { retentionHintDays?: number },
|
|
4668
|
+
): Promise<void> {
|
|
4669
|
+
const payload = formatVexRetentionEnvelope(
|
|
4670
|
+
message,
|
|
4671
|
+
opts?.retentionHintDays,
|
|
4672
|
+
);
|
|
4422
4673
|
try {
|
|
4423
4674
|
const [userEntry, err] = await this.fetchUser(userID);
|
|
4424
4675
|
if (err) {
|
|
@@ -4491,7 +4742,7 @@ export class Client {
|
|
|
4491
4742
|
await this.sendMail(
|
|
4492
4743
|
device,
|
|
4493
4744
|
userEntry,
|
|
4494
|
-
XUtils.decodeUTF8(
|
|
4745
|
+
XUtils.decodeUTF8(payload),
|
|
4495
4746
|
null,
|
|
4496
4747
|
messageMailID,
|
|
4497
4748
|
false,
|
package/src/Storage.ts
CHANGED
|
@@ -91,6 +91,13 @@ export interface Storage extends EventEmitter {
|
|
|
91
91
|
* @event
|
|
92
92
|
*/
|
|
93
93
|
on(event: "error", callback: (error: Error) => void): this;
|
|
94
|
+
/**
|
|
95
|
+
* Deletes local messages older than the effective per-row retention:
|
|
96
|
+
* `min(30 days, clientMaxRetentionDays, retentionHintDays ?? 30)`.
|
|
97
|
+
*/
|
|
98
|
+
pruneExpiredLocalMessages: (
|
|
99
|
+
clientMaxRetentionDays: number,
|
|
100
|
+
) => Promise<void>;
|
|
94
101
|
/** Deletes all message history. */
|
|
95
102
|
purgeHistory: () => Promise<void>;
|
|
96
103
|
/** Deletes all local key/session state. */
|
|
@@ -170,6 +170,25 @@ export class MemoryStorage extends EventEmitter implements Storage {
|
|
|
170
170
|
return Promise.resolve();
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
+
pruneExpiredLocalMessages(clientMaxRetentionDays: number): Promise<void> {
|
|
174
|
+
const cap = Math.min(
|
|
175
|
+
30,
|
|
176
|
+
Math.max(1, Math.round(clientMaxRetentionDays)),
|
|
177
|
+
);
|
|
178
|
+
const now = Date.now();
|
|
179
|
+
const msPerDay = 86_400_000;
|
|
180
|
+
this.messages = this.messages.filter((m) => {
|
|
181
|
+
const hintDays = m.retentionHintDays ?? 30;
|
|
182
|
+
const maxDays = Math.min(30, cap, hintDays);
|
|
183
|
+
const ts = new Date(m.timestamp).getTime();
|
|
184
|
+
if (!Number.isFinite(ts)) {
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
return now - ts <= maxDays * msPerDay;
|
|
188
|
+
});
|
|
189
|
+
return Promise.resolve();
|
|
190
|
+
}
|
|
191
|
+
|
|
173
192
|
purgeHistory(): Promise<void> {
|
|
174
193
|
this.messages = [];
|
|
175
194
|
return Promise.resolve();
|