@vex-chat/libvex 6.2.3 → 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 +31 -2
- package/dist/Client.d.ts.map +1 -1
- package/dist/Client.js +134 -15
- 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 +209 -20
- package/src/Storage.ts +7 -0
- package/src/__tests__/harness/memory-storage.ts +19 -0
- 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.
|
|
@@ -2501,7 +2565,6 @@ export class Client {
|
|
|
2501
2565
|
private async deleteServer(serverID: string): Promise<void> {
|
|
2502
2566
|
await this.http.delete(this.getHost() + "/server/" + serverID);
|
|
2503
2567
|
}
|
|
2504
|
-
|
|
2505
2568
|
private deviceListFailureDetail(err: unknown): string {
|
|
2506
2569
|
if (!isAxiosError(err)) {
|
|
2507
2570
|
return "";
|
|
@@ -3054,6 +3117,8 @@ export class Client {
|
|
|
3054
3117
|
}
|
|
3055
3118
|
}
|
|
3056
3119
|
|
|
3120
|
+
// ── Passkeys ────────────────────────────────────────────────────────
|
|
3121
|
+
|
|
3057
3122
|
/**
|
|
3058
3123
|
* Initializes the keyring. This must be called before anything else.
|
|
3059
3124
|
*/
|
|
@@ -3073,6 +3138,11 @@ export class Client {
|
|
|
3073
3138
|
|
|
3074
3139
|
await this.populateKeyRing();
|
|
3075
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
|
+
);
|
|
3076
3146
|
this.emitter.emit("ready");
|
|
3077
3147
|
}
|
|
3078
3148
|
|
|
@@ -3284,6 +3354,7 @@ export class Client {
|
|
|
3284
3354
|
return;
|
|
3285
3355
|
}
|
|
3286
3356
|
void this.database.saveMessage(message);
|
|
3357
|
+
this.scheduleRetentionPurge();
|
|
3287
3358
|
};
|
|
3288
3359
|
|
|
3289
3360
|
private async passkeyApproveDeviceRequest(
|
|
@@ -3712,13 +3783,39 @@ export class Client {
|
|
|
3712
3783
|
if (!mail.forward) {
|
|
3713
3784
|
plaintext = XUtils.encodeUTF8(unsealed);
|
|
3714
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
|
+
}
|
|
3715
3794
|
|
|
3716
3795
|
// emit the message
|
|
3717
3796
|
const fwdMsg1 = mail.forward
|
|
3718
3797
|
? messageSchema.parse(msgpack.decode(unsealed))
|
|
3719
3798
|
: null;
|
|
3720
3799
|
const message: Message = fwdMsg1
|
|
3721
|
-
?
|
|
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
|
+
})()
|
|
3722
3819
|
: {
|
|
3723
3820
|
authorID: mail.authorID,
|
|
3724
3821
|
decrypted: true,
|
|
@@ -3728,12 +3825,18 @@ export class Client {
|
|
|
3728
3825
|
? uuid.stringify(mail.group)
|
|
3729
3826
|
: null,
|
|
3730
3827
|
mailID: mail.mailID,
|
|
3731
|
-
message:
|
|
3828
|
+
message: incomingPlain,
|
|
3732
3829
|
nonce: XUtils.encodeHex(
|
|
3733
3830
|
new Uint8Array(mail.nonce),
|
|
3734
3831
|
),
|
|
3735
3832
|
readerID: mail.readerID,
|
|
3736
3833
|
recipient: mail.recipient,
|
|
3834
|
+
...(hintFromEnvelope !== undefined
|
|
3835
|
+
? {
|
|
3836
|
+
retentionHintDays:
|
|
3837
|
+
hintFromEnvelope,
|
|
3838
|
+
}
|
|
3839
|
+
: {}),
|
|
3737
3840
|
sender: mail.sender,
|
|
3738
3841
|
timestamp: timestamp,
|
|
3739
3842
|
};
|
|
@@ -3900,11 +4003,35 @@ export class Client {
|
|
|
3900
4003
|
const fwdMsg2 = mail.forward
|
|
3901
4004
|
? messageSchema.parse(msgpack.decode(decrypted))
|
|
3902
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
|
+
}
|
|
3903
4015
|
const message: Message = fwdMsg2
|
|
3904
|
-
? {
|
|
3905
|
-
|
|
3906
|
-
|
|
3907
|
-
|
|
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
|
+
})()
|
|
3908
4035
|
: {
|
|
3909
4036
|
authorID: mail.authorID,
|
|
3910
4037
|
decrypted: true,
|
|
@@ -3914,12 +4041,15 @@ export class Client {
|
|
|
3914
4041
|
? uuid.stringify(mail.group)
|
|
3915
4042
|
: null,
|
|
3916
4043
|
mailID: mail.mailID,
|
|
3917
|
-
message:
|
|
4044
|
+
message: bodyIncoming,
|
|
3918
4045
|
nonce: XUtils.encodeHex(
|
|
3919
4046
|
new Uint8Array(mail.nonce),
|
|
3920
4047
|
),
|
|
3921
4048
|
readerID: mail.readerID,
|
|
3922
4049
|
recipient: mail.recipient,
|
|
4050
|
+
...(hintIncoming !== undefined
|
|
4051
|
+
? { retentionHintDays: hintIncoming }
|
|
4052
|
+
: {}),
|
|
3923
4053
|
sender: mail.sender,
|
|
3924
4054
|
timestamp: timestamp,
|
|
3925
4055
|
};
|
|
@@ -4168,6 +4298,19 @@ export class Client {
|
|
|
4168
4298
|
return device;
|
|
4169
4299
|
}
|
|
4170
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
|
+
|
|
4171
4314
|
/**
|
|
4172
4315
|
* `xDHAsync` and other helpers in `@vex-chat/crypto` use the process-wide
|
|
4173
4316
|
* active profile. When several {@link Client} instances use different
|
|
@@ -4189,6 +4332,16 @@ export class Client {
|
|
|
4189
4332
|
}
|
|
4190
4333
|
}
|
|
4191
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
|
+
|
|
4192
4345
|
/* header is 32 bytes and is either empty
|
|
4193
4346
|
or contains an HMAC of the message with
|
|
4194
4347
|
a derived SK */
|
|
@@ -4224,6 +4377,7 @@ export class Client {
|
|
|
4224
4377
|
private async sendGroupMessage(
|
|
4225
4378
|
channelID: string,
|
|
4226
4379
|
message: string,
|
|
4380
|
+
opts?: { retentionHintDays?: number },
|
|
4227
4381
|
): Promise<void> {
|
|
4228
4382
|
const userList = await this.getUserList(channelID);
|
|
4229
4383
|
for (const user of userList) {
|
|
@@ -4231,7 +4385,11 @@ export class Client {
|
|
|
4231
4385
|
}
|
|
4232
4386
|
|
|
4233
4387
|
const mailID = uuid.v4();
|
|
4234
|
-
const
|
|
4388
|
+
const payload = formatVexRetentionEnvelope(
|
|
4389
|
+
message,
|
|
4390
|
+
opts?.retentionHintDays,
|
|
4391
|
+
);
|
|
4392
|
+
const msgBytes = XUtils.decodeUTF8(payload);
|
|
4235
4393
|
const myUserID = this.getUser().userID;
|
|
4236
4394
|
// Fan-out only to *other* server members. The current account's other
|
|
4237
4395
|
// devices receive the same group mail via `forward()` on the outgoing
|
|
@@ -4403,8 +4561,25 @@ export class Client {
|
|
|
4403
4561
|
const fwdOut = forward
|
|
4404
4562
|
? messageSchema.parse(msgpack.decode(msg))
|
|
4405
4563
|
: null;
|
|
4564
|
+
const rawUtf8 = XUtils.encodeUTF8(msg);
|
|
4565
|
+
const strippedOut = stripVexRetentionEnvelope(rawUtf8);
|
|
4406
4566
|
const outMsg: Message = fwdOut
|
|
4407
|
-
?
|
|
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
|
+
})()
|
|
4408
4583
|
: {
|
|
4409
4584
|
authorID: mail.authorID,
|
|
4410
4585
|
decrypted: true,
|
|
@@ -4412,10 +4587,16 @@ export class Client {
|
|
|
4412
4587
|
forward: mail.forward,
|
|
4413
4588
|
group: mail.group ? uuid.stringify(mail.group) : null,
|
|
4414
4589
|
mailID: mail.mailID,
|
|
4415
|
-
message:
|
|
4590
|
+
message: strippedOut.body,
|
|
4416
4591
|
nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
|
|
4417
4592
|
readerID: mail.readerID,
|
|
4418
4593
|
recipient: mail.recipient,
|
|
4594
|
+
...(strippedOut.retentionHintDays !== undefined
|
|
4595
|
+
? {
|
|
4596
|
+
retentionHintDays:
|
|
4597
|
+
strippedOut.retentionHintDays,
|
|
4598
|
+
}
|
|
4599
|
+
: {}),
|
|
4419
4600
|
sender: mail.sender,
|
|
4420
4601
|
timestamp: new Date().toISOString(),
|
|
4421
4602
|
};
|
|
@@ -4480,7 +4661,15 @@ export class Client {
|
|
|
4480
4661
|
}
|
|
4481
4662
|
}
|
|
4482
4663
|
|
|
4483
|
-
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
|
+
);
|
|
4484
4673
|
try {
|
|
4485
4674
|
const [userEntry, err] = await this.fetchUser(userID);
|
|
4486
4675
|
if (err) {
|
|
@@ -4553,7 +4742,7 @@ export class Client {
|
|
|
4553
4742
|
await this.sendMail(
|
|
4554
4743
|
device,
|
|
4555
4744
|
userEntry,
|
|
4556
|
-
XUtils.decodeUTF8(
|
|
4745
|
+
XUtils.decodeUTF8(payload),
|
|
4557
4746
|
null,
|
|
4558
4747
|
messageMailID,
|
|
4559
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();
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2020-2026 Vex Heavy Industries LLC
|
|
3
|
+
* Licensed under AGPL-3.0. See LICENSE for details.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
clampLocalMessageRetentionDays,
|
|
10
|
+
formatVexRetentionEnvelope,
|
|
11
|
+
MAX_LOCAL_MESSAGE_RETENTION_DAYS,
|
|
12
|
+
stripVexRetentionEnvelope,
|
|
13
|
+
} from "../retention.js";
|
|
14
|
+
|
|
15
|
+
describe("retention", () => {
|
|
16
|
+
it("clamps local retention to 1–30", () => {
|
|
17
|
+
expect(clampLocalMessageRetentionDays(undefined)).toBe(30);
|
|
18
|
+
expect(clampLocalMessageRetentionDays(0)).toBe(1);
|
|
19
|
+
expect(clampLocalMessageRetentionDays(45)).toBe(30);
|
|
20
|
+
expect(clampLocalMessageRetentionDays(7)).toBe(7);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("round-trips envelope prefix", () => {
|
|
24
|
+
const wrapped = formatVexRetentionEnvelope("hello", 7);
|
|
25
|
+
expect(wrapped).toBe("vex-retention:7\nhello");
|
|
26
|
+
expect(stripVexRetentionEnvelope(wrapped)).toEqual({
|
|
27
|
+
body: "hello",
|
|
28
|
+
retentionHintDays: 7,
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("format without hint leaves body unchanged", () => {
|
|
33
|
+
expect(formatVexRetentionEnvelope("plain", undefined)).toBe("plain");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("MAX matches server contract constant name in docs", () => {
|
|
37
|
+
expect(MAX_LOCAL_MESSAGE_RETENTION_DAYS).toBe(30);
|
|
38
|
+
});
|
|
39
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -38,6 +38,10 @@ export type {
|
|
|
38
38
|
VexFile,
|
|
39
39
|
} from "./Client.js";
|
|
40
40
|
export { createCodec, msgpack } from "./codec.js";
|
|
41
|
+
export {
|
|
42
|
+
clampLocalMessageRetentionDays,
|
|
43
|
+
MAX_LOCAL_MESSAGE_RETENTION_DAYS,
|
|
44
|
+
} from "./retention.js";
|
|
41
45
|
export type { Storage } from "./Storage.js";
|
|
42
46
|
export type {
|
|
43
47
|
KeyPair,
|
package/src/retention.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
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
|
+
/** Matches the server-side minimum; clients cannot retain longer locally. */
|
|
8
|
+
export const MAX_LOCAL_MESSAGE_RETENTION_DAYS = 30;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Clamps a user preference to 1…{@link MAX_LOCAL_MESSAGE_RETENTION_DAYS}.
|
|
12
|
+
* Non-finite or missing values default to the maximum (keep up to the server cap).
|
|
13
|
+
*/
|
|
14
|
+
export function clampLocalMessageRetentionDays(
|
|
15
|
+
days: null | number | undefined,
|
|
16
|
+
): number {
|
|
17
|
+
if (days === null || days === undefined) {
|
|
18
|
+
return MAX_LOCAL_MESSAGE_RETENTION_DAYS;
|
|
19
|
+
}
|
|
20
|
+
const n = Math.round(days);
|
|
21
|
+
if (!Number.isFinite(n)) {
|
|
22
|
+
return MAX_LOCAL_MESSAGE_RETENTION_DAYS;
|
|
23
|
+
}
|
|
24
|
+
return Math.min(MAX_LOCAL_MESSAGE_RETENTION_DAYS, Math.max(1, n));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const RETENTION_PREFIX = /^vex-retention:([1-9]|[12]\d|30)\n/;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Prefixes plaintext with a machine-readable retention hint for other clients.
|
|
31
|
+
* When `retentionHintDays` is omitted, returns `body` unchanged.
|
|
32
|
+
*/
|
|
33
|
+
export function formatVexRetentionEnvelope(
|
|
34
|
+
body: string,
|
|
35
|
+
retentionHintDays?: null | number,
|
|
36
|
+
): string {
|
|
37
|
+
if (
|
|
38
|
+
retentionHintDays === null ||
|
|
39
|
+
retentionHintDays === undefined ||
|
|
40
|
+
!Number.isFinite(retentionHintDays)
|
|
41
|
+
) {
|
|
42
|
+
return body;
|
|
43
|
+
}
|
|
44
|
+
const d = clampLocalMessageRetentionDays(retentionHintDays);
|
|
45
|
+
return `vex-retention:${String(d)}\n${body}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Strips an optional first-line retention hint placed by cooperative clients.
|
|
50
|
+
* Malicious peers can omit or forge this; local expiry still cannot exceed 30 days.
|
|
51
|
+
*/
|
|
52
|
+
export function stripVexRetentionEnvelope(plaintext: string): {
|
|
53
|
+
body: string;
|
|
54
|
+
retentionHintDays?: number;
|
|
55
|
+
} {
|
|
56
|
+
const m = RETENTION_PREFIX.exec(plaintext);
|
|
57
|
+
if (!m) {
|
|
58
|
+
return { body: plaintext };
|
|
59
|
+
}
|
|
60
|
+
const hint = Math.min(
|
|
61
|
+
MAX_LOCAL_MESSAGE_RETENTION_DAYS,
|
|
62
|
+
Math.max(1, Number(m[1])),
|
|
63
|
+
);
|
|
64
|
+
return {
|
|
65
|
+
body: plaintext.slice(m[0].length),
|
|
66
|
+
retentionHintDays: hint,
|
|
67
|
+
};
|
|
68
|
+
}
|
package/src/storage/schema.ts
CHANGED