@vex-chat/libvex 6.3.0 → 6.3.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/dist/Client.d.ts +29 -6
- package/dist/Client.d.ts.map +1 -1
- package/dist/Client.js +99 -27
- package/dist/Client.js.map +1 -1
- package/dist/Storage.d.ts +2 -1
- package/dist/Storage.d.ts.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 +3 -2
- 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/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/retention.d.ts +6 -0
- package/dist/retention.d.ts.map +1 -1
- package/dist/retention.js +11 -0
- package/dist/retention.js.map +1 -1
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +19 -1
- package/dist/storage/sqlite.js.map +1 -1
- package/package.json +3 -3
- package/src/Client.ts +182 -42
- package/src/Storage.ts +2 -1
- package/src/__tests__/harness/memory-storage.ts +7 -2
- package/src/__tests__/retention.test.ts +11 -0
- package/src/index.ts +1 -0
- package/src/retention.ts +17 -0
- package/src/storage/sqlite.ts +24 -1
package/src/Client.ts
CHANGED
|
@@ -42,7 +42,9 @@ import type { AxiosInstance } from "axios";
|
|
|
42
42
|
|
|
43
43
|
import {
|
|
44
44
|
type CryptoProfile,
|
|
45
|
+
enterCryptoProfileScope,
|
|
45
46
|
getCryptoProfile,
|
|
47
|
+
leaveCryptoProfileScope,
|
|
46
48
|
setCryptoProfile,
|
|
47
49
|
xBoxKeyPairAsync,
|
|
48
50
|
xBoxKeyPairFromSecretAsync,
|
|
@@ -387,6 +389,14 @@ export type DeviceRegistrationResult = Device | PendingDeviceRegistration;
|
|
|
387
389
|
* @ignore
|
|
388
390
|
*/
|
|
389
391
|
export interface Devices {
|
|
392
|
+
/**
|
|
393
|
+
* Deletes an unpublished enrollment before any owner notification
|
|
394
|
+
* (e.g. user picked "not my account").
|
|
395
|
+
*/
|
|
396
|
+
abortPendingRegistration: (args: {
|
|
397
|
+
challenge: string;
|
|
398
|
+
requestID: string;
|
|
399
|
+
}) => Promise<void>;
|
|
390
400
|
/** Approves a pending device registration request as the current device. */
|
|
391
401
|
approveRequest: (requestID: string) => Promise<Device>;
|
|
392
402
|
/** Deletes one of the account's devices (except the currently active one). */
|
|
@@ -414,6 +424,14 @@ export interface Devices {
|
|
|
414
424
|
challenge: string;
|
|
415
425
|
requestID: string;
|
|
416
426
|
}) => Promise<null | PendingDeviceRequest>;
|
|
427
|
+
/**
|
|
428
|
+
* After the user confirms the pending enrollment is theirs, notifies
|
|
429
|
+
* their existing devices (same proof as poll).
|
|
430
|
+
*/
|
|
431
|
+
publishPendingRegistration: (args: {
|
|
432
|
+
challenge: string;
|
|
433
|
+
requestID: string;
|
|
434
|
+
}) => Promise<void>;
|
|
417
435
|
/** Registers the current key material as a new device. */
|
|
418
436
|
register: () => Promise<DeviceRegistrationResult | null>;
|
|
419
437
|
/** Rejects a pending device registration request as the current device. */
|
|
@@ -1003,12 +1021,16 @@ export class Client {
|
|
|
1003
1021
|
* Device management methods.
|
|
1004
1022
|
*/
|
|
1005
1023
|
public devices: Devices = {
|
|
1024
|
+
abortPendingRegistration:
|
|
1025
|
+
this.abortPendingDeviceRegistration.bind(this),
|
|
1006
1026
|
approveRequest: this.approveDeviceRequest.bind(this),
|
|
1007
1027
|
delete: this.deleteDevice.bind(this),
|
|
1008
1028
|
getRequest: this.getDeviceRegistrationRequest.bind(this),
|
|
1009
1029
|
list: this.listDevices.bind(this),
|
|
1010
1030
|
listRequests: this.listDeviceRegistrationRequests.bind(this),
|
|
1011
1031
|
pollPendingRegistration: this.pollPendingDeviceRegistration.bind(this),
|
|
1032
|
+
publishPendingRegistration:
|
|
1033
|
+
this.publishPendingDeviceRegistration.bind(this),
|
|
1012
1034
|
register: this.registerDevice.bind(this),
|
|
1013
1035
|
rejectRequest: this.rejectDeviceRequest.bind(this),
|
|
1014
1036
|
retrieve: this.getDeviceByID.bind(this),
|
|
@@ -1320,6 +1342,8 @@ export class Client {
|
|
|
1320
1342
|
private reading: boolean = false;
|
|
1321
1343
|
private retentionPurgeDebounce: null | ReturnType<typeof setTimeout> = null;
|
|
1322
1344
|
private readonly seenMailIDs: Set<string> = new Set();
|
|
1345
|
+
private readonly sessionHealBackoffUntil = new Map<string, number>();
|
|
1346
|
+
private readonly sessionHealInFlight = new Set<string>();
|
|
1323
1347
|
private sessionRecords: Record<string, SessionCrypto> = {};
|
|
1324
1348
|
|
|
1325
1349
|
// these are created from one set of sign keys
|
|
@@ -2124,6 +2148,23 @@ export class Client {
|
|
|
2124
2148
|
return whoami;
|
|
2125
2149
|
}
|
|
2126
2150
|
|
|
2151
|
+
private async abortPendingDeviceRegistration(args: {
|
|
2152
|
+
challenge: string;
|
|
2153
|
+
requestID: string;
|
|
2154
|
+
}): Promise<void> {
|
|
2155
|
+
const signed = await this.signPendingRegistrationChallenge(
|
|
2156
|
+
args.challenge,
|
|
2157
|
+
);
|
|
2158
|
+
await this.http.post(
|
|
2159
|
+
this.getHost() +
|
|
2160
|
+
"/user/devices/requests/" +
|
|
2161
|
+
args.requestID +
|
|
2162
|
+
"/abort",
|
|
2163
|
+
msgpack.encode({ signed }),
|
|
2164
|
+
{ headers: { "Content-Type": "application/msgpack" } },
|
|
2165
|
+
);
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2127
2168
|
private acknowledgeInboundMail(mail: MailWS): void {
|
|
2128
2169
|
this.seenMailIDs.add(mail.mailID);
|
|
2129
2170
|
this.sendReceipt(new Uint8Array(mail.nonce));
|
|
@@ -2561,7 +2602,6 @@ export class Client {
|
|
|
2561
2602
|
private async deletePermission(permissionID: string): Promise<void> {
|
|
2562
2603
|
await this.http.delete(this.getHost() + "/permission/" + permissionID);
|
|
2563
2604
|
}
|
|
2564
|
-
|
|
2565
2605
|
private async deleteServer(serverID: string): Promise<void> {
|
|
2566
2606
|
await this.http.delete(this.getHost() + "/server/" + serverID);
|
|
2567
2607
|
}
|
|
@@ -2578,6 +2618,7 @@ export class Client {
|
|
|
2578
2618
|
}
|
|
2579
2619
|
return "";
|
|
2580
2620
|
}
|
|
2621
|
+
|
|
2581
2622
|
/**
|
|
2582
2623
|
* Gets a list of permissions for a server.
|
|
2583
2624
|
*
|
|
@@ -2760,7 +2801,7 @@ export class Client {
|
|
|
2760
2801
|
continue;
|
|
2761
2802
|
}
|
|
2762
2803
|
try {
|
|
2763
|
-
await this.
|
|
2804
|
+
await this.sendMailWithRecovery(
|
|
2764
2805
|
device,
|
|
2765
2806
|
this.getUser(),
|
|
2766
2807
|
msgBytes,
|
|
@@ -3078,6 +3119,8 @@ export class Client {
|
|
|
3078
3119
|
return decodeAxios(UserArrayCodec, res.data);
|
|
3079
3120
|
}
|
|
3080
3121
|
|
|
3122
|
+
// ── Passkeys ────────────────────────────────────────────────────────
|
|
3123
|
+
|
|
3081
3124
|
private async handleNotify(msg: NotifyMsg) {
|
|
3082
3125
|
switch (msg.event) {
|
|
3083
3126
|
case "deviceRequest": {
|
|
@@ -3146,8 +3189,6 @@ export class Client {
|
|
|
3146
3189
|
this.emitter.emit("ready");
|
|
3147
3190
|
}
|
|
3148
3191
|
|
|
3149
|
-
// ── Passkeys ────────────────────────────────────────────────────────
|
|
3150
|
-
|
|
3151
3192
|
private initSocket() {
|
|
3152
3193
|
try {
|
|
3153
3194
|
if (!this.token) {
|
|
@@ -3431,21 +3472,12 @@ export class Client {
|
|
|
3431
3472
|
);
|
|
3432
3473
|
}
|
|
3433
3474
|
|
|
3434
|
-
/**
|
|
3435
|
-
* Polls the public unauthenticated request status endpoint as the
|
|
3436
|
-
* requesting device. Signs the server-issued challenge with the local
|
|
3437
|
-
* private signing key so the server can verify ownership of the pending
|
|
3438
|
-
* request without us needing a user token.
|
|
3439
|
-
*/
|
|
3440
3475
|
private async pollPendingDeviceRegistration(args: {
|
|
3441
3476
|
challenge: string;
|
|
3442
3477
|
requestID: string;
|
|
3443
3478
|
}): Promise<null | PendingDeviceRequest> {
|
|
3444
|
-
const signed =
|
|
3445
|
-
|
|
3446
|
-
XUtils.decodeHex(args.challenge),
|
|
3447
|
-
this.signKeys.secretKey,
|
|
3448
|
-
),
|
|
3479
|
+
const signed = await this.signPendingRegistrationChallenge(
|
|
3480
|
+
args.challenge,
|
|
3449
3481
|
);
|
|
3450
3482
|
try {
|
|
3451
3483
|
const response = await this.http.post(
|
|
@@ -3555,6 +3587,23 @@ export class Client {
|
|
|
3555
3587
|
}
|
|
3556
3588
|
}
|
|
3557
3589
|
|
|
3590
|
+
private async publishPendingDeviceRegistration(args: {
|
|
3591
|
+
challenge: string;
|
|
3592
|
+
requestID: string;
|
|
3593
|
+
}): Promise<void> {
|
|
3594
|
+
const signed = await this.signPendingRegistrationChallenge(
|
|
3595
|
+
args.challenge,
|
|
3596
|
+
);
|
|
3597
|
+
await this.http.post(
|
|
3598
|
+
this.getHost() +
|
|
3599
|
+
"/user/devices/requests/" +
|
|
3600
|
+
args.requestID +
|
|
3601
|
+
"/publish",
|
|
3602
|
+
msgpack.encode({ signed }),
|
|
3603
|
+
{ headers: { "Content-Type": "application/msgpack" } },
|
|
3604
|
+
);
|
|
3605
|
+
}
|
|
3606
|
+
|
|
3558
3607
|
private async purgeHistory(): Promise<void> {
|
|
3559
3608
|
await this.database.purgeHistory();
|
|
3560
3609
|
}
|
|
@@ -3598,23 +3647,50 @@ export class Client {
|
|
|
3598
3647
|
|
|
3599
3648
|
try {
|
|
3600
3649
|
await this.runWithThisCryptoProfile(async () => {
|
|
3601
|
-
const healSession =
|
|
3650
|
+
const healSession = () => {
|
|
3602
3651
|
if (this.manuallyClosing || !this.xKeyRing) {
|
|
3603
3652
|
return;
|
|
3604
3653
|
}
|
|
3605
|
-
const
|
|
3606
|
-
const
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
|
|
3613
|
-
|
|
3614
|
-
false,
|
|
3615
|
-
true,
|
|
3616
|
-
);
|
|
3654
|
+
const senderDeviceID = mail.sender;
|
|
3655
|
+
const now = Date.now();
|
|
3656
|
+
const blockedUntil =
|
|
3657
|
+
this.sessionHealBackoffUntil.get(senderDeviceID) ?? 0;
|
|
3658
|
+
if (now < blockedUntil) {
|
|
3659
|
+
return;
|
|
3660
|
+
}
|
|
3661
|
+
if (this.sessionHealInFlight.has(senderDeviceID)) {
|
|
3662
|
+
return;
|
|
3617
3663
|
}
|
|
3664
|
+
this.sessionHealInFlight.add(senderDeviceID);
|
|
3665
|
+
void (async () => {
|
|
3666
|
+
try {
|
|
3667
|
+
const deviceEntry =
|
|
3668
|
+
await this.getDeviceByID(senderDeviceID);
|
|
3669
|
+
const [user, _err] = await this.fetchUser(
|
|
3670
|
+
mail.authorID,
|
|
3671
|
+
);
|
|
3672
|
+
if (deviceEntry && user) {
|
|
3673
|
+
await this.createSession(
|
|
3674
|
+
deviceEntry,
|
|
3675
|
+
user,
|
|
3676
|
+
new Uint8Array(),
|
|
3677
|
+
mail.group,
|
|
3678
|
+
uuid.v4(),
|
|
3679
|
+
false,
|
|
3680
|
+
true,
|
|
3681
|
+
);
|
|
3682
|
+
}
|
|
3683
|
+
} finally {
|
|
3684
|
+
// Avoid hammering /keyBundle when a bad/corrupt mail item
|
|
3685
|
+
// triggers repeated decrypt failures for the same sender.
|
|
3686
|
+
// Use a conservative backoff to cap load during auth churn.
|
|
3687
|
+
this.sessionHealBackoffUntil.set(
|
|
3688
|
+
senderDeviceID,
|
|
3689
|
+
Date.now() + 30_000,
|
|
3690
|
+
);
|
|
3691
|
+
this.sessionHealInFlight.delete(senderDeviceID);
|
|
3692
|
+
}
|
|
3693
|
+
})();
|
|
3618
3694
|
};
|
|
3619
3695
|
|
|
3620
3696
|
switch (mail.mailType) {
|
|
@@ -3955,7 +4031,7 @@ export class Client {
|
|
|
3955
4031
|
}
|
|
3956
4032
|
|
|
3957
4033
|
if (!session) {
|
|
3958
|
-
|
|
4034
|
+
healSession();
|
|
3959
4035
|
return;
|
|
3960
4036
|
}
|
|
3961
4037
|
|
|
@@ -3989,7 +4065,7 @@ export class Client {
|
|
|
3989
4065
|
const HMAC = xHMAC(mail, messageKey);
|
|
3990
4066
|
|
|
3991
4067
|
if (!XUtils.bytesEqual(HMAC, header)) {
|
|
3992
|
-
|
|
4068
|
+
healSession();
|
|
3993
4069
|
return;
|
|
3994
4070
|
}
|
|
3995
4071
|
|
|
@@ -4085,7 +4161,7 @@ export class Client {
|
|
|
4085
4161
|
] = session;
|
|
4086
4162
|
this.acknowledgeInboundMail(mail);
|
|
4087
4163
|
} else {
|
|
4088
|
-
|
|
4164
|
+
healSession();
|
|
4089
4165
|
this.emitter.emit("retryRequest", {
|
|
4090
4166
|
mailID: mail.mailID,
|
|
4091
4167
|
source: "decrypt_failure",
|
|
@@ -4320,15 +4396,11 @@ export class Client {
|
|
|
4320
4396
|
private async runWithThisCryptoProfile<T>(
|
|
4321
4397
|
fn: () => Promise<T>,
|
|
4322
4398
|
): Promise<T> {
|
|
4323
|
-
|
|
4324
|
-
if (prev === this.cryptoProfile) {
|
|
4325
|
-
return await fn();
|
|
4326
|
-
}
|
|
4327
|
-
setCryptoProfile(this.cryptoProfile);
|
|
4399
|
+
enterCryptoProfileScope(this.cryptoProfile);
|
|
4328
4400
|
try {
|
|
4329
4401
|
return await fn();
|
|
4330
4402
|
} finally {
|
|
4331
|
-
|
|
4403
|
+
leaveCryptoProfileScope();
|
|
4332
4404
|
}
|
|
4333
4405
|
}
|
|
4334
4406
|
|
|
@@ -4350,10 +4422,14 @@ export class Client {
|
|
|
4350
4422
|
let elapsed = 0;
|
|
4351
4423
|
let backoff = 50;
|
|
4352
4424
|
while (this.socket.readyState !== 1) {
|
|
4425
|
+
if (this.isManualCloseInFlight()) {
|
|
4426
|
+
throw new WebSocketNotOpenError(this.socket.readyState);
|
|
4427
|
+
}
|
|
4428
|
+
if (this.socket.readyState === 2 || this.socket.readyState === 3) {
|
|
4429
|
+
throw new WebSocketNotOpenError(this.socket.readyState);
|
|
4430
|
+
}
|
|
4353
4431
|
if (elapsed >= maxWaitMs) {
|
|
4354
|
-
throw new
|
|
4355
|
-
"WebSocket did not reach OPEN state within 30 seconds.",
|
|
4356
|
-
);
|
|
4432
|
+
throw new WebSocketNotOpenError(this.socket.readyState);
|
|
4357
4433
|
}
|
|
4358
4434
|
await sleep(backoff);
|
|
4359
4435
|
elapsed += backoff;
|
|
@@ -4441,7 +4517,7 @@ export class Client {
|
|
|
4441
4517
|
continue;
|
|
4442
4518
|
}
|
|
4443
4519
|
try {
|
|
4444
|
-
await this.
|
|
4520
|
+
await this.sendMailWithRecovery(
|
|
4445
4521
|
device,
|
|
4446
4522
|
ownerRecord,
|
|
4447
4523
|
msgBytes,
|
|
@@ -4661,6 +4737,32 @@ export class Client {
|
|
|
4661
4737
|
}
|
|
4662
4738
|
}
|
|
4663
4739
|
|
|
4740
|
+
private async sendMailWithRecovery(
|
|
4741
|
+
device: Device,
|
|
4742
|
+
user: User,
|
|
4743
|
+
msg: Uint8Array,
|
|
4744
|
+
group: null | Uint8Array,
|
|
4745
|
+
mailID: null | string,
|
|
4746
|
+
forward: boolean,
|
|
4747
|
+
): Promise<void> {
|
|
4748
|
+
try {
|
|
4749
|
+
await this.sendMail(device, user, msg, group, mailID, forward);
|
|
4750
|
+
} catch (err: unknown) {
|
|
4751
|
+
if (!this.shouldRetryDeliveryWithFreshSession(err)) {
|
|
4752
|
+
throw err;
|
|
4753
|
+
}
|
|
4754
|
+
await this.sendMail(
|
|
4755
|
+
device,
|
|
4756
|
+
user,
|
|
4757
|
+
msg,
|
|
4758
|
+
group,
|
|
4759
|
+
mailID,
|
|
4760
|
+
forward,
|
|
4761
|
+
true,
|
|
4762
|
+
);
|
|
4763
|
+
}
|
|
4764
|
+
}
|
|
4765
|
+
|
|
4664
4766
|
private async sendMessage(
|
|
4665
4767
|
userID: string,
|
|
4666
4768
|
message: string,
|
|
@@ -4739,7 +4841,7 @@ export class Client {
|
|
|
4739
4841
|
recipientDevice: device.deviceID,
|
|
4740
4842
|
});
|
|
4741
4843
|
}
|
|
4742
|
-
await this.
|
|
4844
|
+
await this.sendMailWithRecovery(
|
|
4743
4845
|
device,
|
|
4744
4846
|
userEntry,
|
|
4745
4847
|
XUtils.decodeUTF8(payload),
|
|
@@ -4805,6 +4907,44 @@ export class Client {
|
|
|
4805
4907
|
|
|
4806
4908
|
private setUser(user: User): void {
|
|
4807
4909
|
this.user = user;
|
|
4910
|
+
// Fresh identity / token: drop stale 404 negative-cache entries so a
|
|
4911
|
+
// prior transient miss (or wrong host) cannot block DM sends for 30m.
|
|
4912
|
+
this.notFoundUsers.clear();
|
|
4913
|
+
}
|
|
4914
|
+
|
|
4915
|
+
private shouldRetryDeliveryWithFreshSession(err: unknown): boolean {
|
|
4916
|
+
if (err instanceof WebSocketNotOpenError) {
|
|
4917
|
+
return true;
|
|
4918
|
+
}
|
|
4919
|
+
const message =
|
|
4920
|
+
err instanceof Error
|
|
4921
|
+
? err.message.toLowerCase()
|
|
4922
|
+
: String(err).toLowerCase();
|
|
4923
|
+
return (
|
|
4924
|
+
message.includes("mail delivery failed") ||
|
|
4925
|
+
message.includes("not authenticated") ||
|
|
4926
|
+
message.includes("unauthorized") ||
|
|
4927
|
+
message.includes("websocket") ||
|
|
4928
|
+
message.includes("network") ||
|
|
4929
|
+
message.includes("timed out")
|
|
4930
|
+
);
|
|
4931
|
+
}
|
|
4932
|
+
|
|
4933
|
+
/**
|
|
4934
|
+
* Polls the public unauthenticated request status endpoint as the
|
|
4935
|
+
* requesting device. Signs the server-issued challenge with the local
|
|
4936
|
+
* private signing key so the server can verify ownership of the pending
|
|
4937
|
+
* request without us needing a user token.
|
|
4938
|
+
*/
|
|
4939
|
+
private async signPendingRegistrationChallenge(
|
|
4940
|
+
challengeHex: string,
|
|
4941
|
+
): Promise<string> {
|
|
4942
|
+
return XUtils.encodeHex(
|
|
4943
|
+
await xSignAsync(
|
|
4944
|
+
XUtils.decodeHex(challengeHex),
|
|
4945
|
+
this.signKeys.secretKey,
|
|
4946
|
+
),
|
|
4947
|
+
);
|
|
4808
4948
|
}
|
|
4809
4949
|
|
|
4810
4950
|
private async submitOTK(amount: number) {
|
package/src/Storage.ts
CHANGED
|
@@ -93,7 +93,8 @@ export interface Storage extends EventEmitter {
|
|
|
93
93
|
on(event: "error", callback: (error: Error) => void): this;
|
|
94
94
|
/**
|
|
95
95
|
* Deletes local messages older than the effective per-row retention:
|
|
96
|
-
* `min(30 days, clientMaxRetentionDays,
|
|
96
|
+
* `min(30 days, clientMaxRetentionDays, effectiveMessageRetentionHintDays(row))`
|
|
97
|
+
* (invalid or non-positive stored hints are treated like 30).
|
|
97
98
|
*/
|
|
98
99
|
pruneExpiredLocalMessages: (
|
|
99
100
|
clientMaxRetentionDays: number,
|
|
@@ -24,13 +24,16 @@ import {
|
|
|
24
24
|
XUtils,
|
|
25
25
|
} from "@vex-chat/crypto";
|
|
26
26
|
|
|
27
|
+
import { EventEmitter } from "eventemitter3";
|
|
28
|
+
|
|
29
|
+
import { effectiveMessageRetentionHintDays } from "../../retention.js";
|
|
30
|
+
|
|
27
31
|
/**
|
|
28
32
|
* Minimal in-memory Storage for browser/RN platform tests.
|
|
29
33
|
*
|
|
30
34
|
* Uses eventemitter3 (browser-safe) instead of Node's events module.
|
|
31
35
|
* No persistence — just enough for the register/login/connect/DM test flow.
|
|
32
36
|
*/
|
|
33
|
-
import { EventEmitter } from "eventemitter3";
|
|
34
37
|
|
|
35
38
|
export class MemoryStorage extends EventEmitter implements Storage {
|
|
36
39
|
public ready = false;
|
|
@@ -178,7 +181,9 @@ export class MemoryStorage extends EventEmitter implements Storage {
|
|
|
178
181
|
const now = Date.now();
|
|
179
182
|
const msPerDay = 86_400_000;
|
|
180
183
|
this.messages = this.messages.filter((m) => {
|
|
181
|
-
const hintDays =
|
|
184
|
+
const hintDays = effectiveMessageRetentionHintDays(
|
|
185
|
+
m.retentionHintDays,
|
|
186
|
+
);
|
|
182
187
|
const maxDays = Math.min(30, cap, hintDays);
|
|
183
188
|
const ts = new Date(m.timestamp).getTime();
|
|
184
189
|
if (!Number.isFinite(ts)) {
|
|
@@ -7,6 +7,7 @@ import { describe, expect, it } from "vitest";
|
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
9
|
clampLocalMessageRetentionDays,
|
|
10
|
+
effectiveMessageRetentionHintDays,
|
|
10
11
|
formatVexRetentionEnvelope,
|
|
11
12
|
MAX_LOCAL_MESSAGE_RETENTION_DAYS,
|
|
12
13
|
stripVexRetentionEnvelope,
|
|
@@ -36,4 +37,14 @@ describe("retention", () => {
|
|
|
36
37
|
it("MAX matches server contract constant name in docs", () => {
|
|
37
38
|
expect(MAX_LOCAL_MESSAGE_RETENTION_DAYS).toBe(30);
|
|
38
39
|
});
|
|
40
|
+
|
|
41
|
+
it("effectiveMessageRetentionHintDays treats 0 and invalid as 30-day default", () => {
|
|
42
|
+
expect(effectiveMessageRetentionHintDays(undefined)).toBe(30);
|
|
43
|
+
expect(effectiveMessageRetentionHintDays(null)).toBe(30);
|
|
44
|
+
expect(effectiveMessageRetentionHintDays(0)).toBe(30);
|
|
45
|
+
expect(effectiveMessageRetentionHintDays(-3)).toBe(30);
|
|
46
|
+
expect(effectiveMessageRetentionHintDays(Number.NaN)).toBe(30);
|
|
47
|
+
expect(effectiveMessageRetentionHintDays(7)).toBe(7);
|
|
48
|
+
expect(effectiveMessageRetentionHintDays(45)).toBe(30);
|
|
49
|
+
});
|
|
39
50
|
});
|
package/src/index.ts
CHANGED
|
@@ -40,6 +40,7 @@ export type {
|
|
|
40
40
|
export { createCodec, msgpack } from "./codec.js";
|
|
41
41
|
export {
|
|
42
42
|
clampLocalMessageRetentionDays,
|
|
43
|
+
effectiveMessageRetentionHintDays,
|
|
43
44
|
MAX_LOCAL_MESSAGE_RETENTION_DAYS,
|
|
44
45
|
} from "./retention.js";
|
|
45
46
|
export type { Storage } from "./Storage.js";
|
package/src/retention.ts
CHANGED
|
@@ -24,6 +24,23 @@ export function clampLocalMessageRetentionDays(
|
|
|
24
24
|
return Math.min(MAX_LOCAL_MESSAGE_RETENTION_DAYS, Math.max(1, n));
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Normalizes a per-message `retentionHintDays` value read from storage.
|
|
29
|
+
* Non-finite, missing, or non-positive values behave like "no hint" (30 days)
|
|
30
|
+
* so a corrupt `0` row cannot wipe the entire local history on prune.
|
|
31
|
+
*/
|
|
32
|
+
export function effectiveMessageRetentionHintDays(
|
|
33
|
+
stored: null | number | undefined,
|
|
34
|
+
): number {
|
|
35
|
+
if (stored == null || !Number.isFinite(stored) || stored < 1) {
|
|
36
|
+
return MAX_LOCAL_MESSAGE_RETENTION_DAYS;
|
|
37
|
+
}
|
|
38
|
+
return Math.min(
|
|
39
|
+
MAX_LOCAL_MESSAGE_RETENTION_DAYS,
|
|
40
|
+
Math.max(1, Math.round(stored)),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
27
44
|
const RETENTION_PREFIX = /^vex-retention:([1-9]|[12]\d|30)\n/;
|
|
28
45
|
|
|
29
46
|
/**
|
package/src/storage/sqlite.ts
CHANGED
|
@@ -28,6 +28,13 @@ import type { Device, PreKeysSQL, SessionSQL } from "@vex-chat/types";
|
|
|
28
28
|
*
|
|
29
29
|
* This replaces three separate storage classes (Storage.ts, TauriStorage,
|
|
30
30
|
* ExpoStorage) with a single implementation.
|
|
31
|
+
*
|
|
32
|
+
* **One database file today** holds both `sessions` (Double Ratchet /
|
|
33
|
+
* X3DH state — required to decrypt *new* traffic) and `messages` (history).
|
|
34
|
+
* If the file is lost or corrupted you lose both; restoring from backup
|
|
35
|
+
* re-seeds device keys but cannot reconstruct dropped ratchet chains from
|
|
36
|
+
* the server alone. A future split could park `sessions` + OTKs in a
|
|
37
|
+
* smaller “crypto state” store separate from bulk message history.
|
|
31
38
|
*/
|
|
32
39
|
import {
|
|
33
40
|
getCryptoProfile,
|
|
@@ -44,6 +51,7 @@ import {
|
|
|
44
51
|
import { EventEmitter } from "eventemitter3";
|
|
45
52
|
import { type Kysely, sql } from "kysely";
|
|
46
53
|
|
|
54
|
+
import { effectiveMessageRetentionHintDays } from "../retention.js";
|
|
47
55
|
import { parseSkippedKeysStrict } from "../utils/ratchet.js";
|
|
48
56
|
|
|
49
57
|
export class SqliteStorage extends EventEmitter implements Storage {
|
|
@@ -436,7 +444,9 @@ export class SqliteStorage extends EventEmitter implements Storage {
|
|
|
436
444
|
const msPerDay = 86_400_000;
|
|
437
445
|
const toDelete: string[] = [];
|
|
438
446
|
for (const r of rows) {
|
|
439
|
-
const hintDays =
|
|
447
|
+
const hintDays = effectiveMessageRetentionHintDays(
|
|
448
|
+
r.retentionHintDays,
|
|
449
|
+
);
|
|
440
450
|
const maxDays = Math.min(30, cap, hintDays);
|
|
441
451
|
const ts = new Date(r.timestamp).getTime();
|
|
442
452
|
if (!Number.isFinite(ts)) {
|
|
@@ -668,6 +678,14 @@ export class SqliteStorage extends EventEmitter implements Storage {
|
|
|
668
678
|
): Promise<Message[]> {
|
|
669
679
|
const fips = getCryptoProfile() === "fips";
|
|
670
680
|
const out: Message[] = [];
|
|
681
|
+
let processed = 0;
|
|
682
|
+
/** Yield so RN / web UIs can paint between at-rest decrypt blocks. */
|
|
683
|
+
const yieldToHost = (): Promise<void> =>
|
|
684
|
+
new Promise((resolve) => {
|
|
685
|
+
setTimeout(resolve, 0);
|
|
686
|
+
});
|
|
687
|
+
const yieldEvery = 28;
|
|
688
|
+
|
|
671
689
|
for (const msg of messages) {
|
|
672
690
|
const decryptedFlag = msg.decrypted !== 0;
|
|
673
691
|
let plaintext = msg.message;
|
|
@@ -707,6 +725,11 @@ export class SqliteStorage extends EventEmitter implements Storage {
|
|
|
707
725
|
rowMessage.retentionHintDays = msg.retentionHintDays;
|
|
708
726
|
}
|
|
709
727
|
out.push(rowMessage);
|
|
728
|
+
|
|
729
|
+
processed += 1;
|
|
730
|
+
if (processed % yieldEvery === 0) {
|
|
731
|
+
await yieldToHost();
|
|
732
|
+
}
|
|
710
733
|
}
|
|
711
734
|
return out;
|
|
712
735
|
}
|