@vex-chat/libvex 6.2.3 → 6.3.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 +56 -8
- package/dist/Client.d.ts.map +1 -1
- package/dist/Client.js +167 -30
- package/dist/Client.js.map +1 -1
- package/dist/Storage.d.ts +6 -0
- package/dist/Storage.d.ts.map +1 -1
- package/dist/__tests__/harness/memory-storage.d.ts +2 -1
- package/dist/__tests__/harness/memory-storage.d.ts.map +1 -1
- package/dist/__tests__/harness/memory-storage.js +17 -1
- 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 +32 -0
- package/dist/retention.d.ts.map +1 -0
- package/dist/retention.js +62 -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 +72 -2
- package/dist/storage/sqlite.js.map +1 -1
- package/package.json +2 -2
- package/src/Client.ts +292 -40
- package/src/Storage.ts +8 -0
- package/src/__tests__/harness/memory-storage.ts +25 -1
- package/src/__tests__/retention.test.ts +50 -0
- package/src/index.ts +5 -0
- package/src/retention.ts +85 -0
- package/src/storage/schema.ts +2 -0
- package/src/storage/sqlite.ts +92 -2
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,
|
|
@@ -78,6 +80,11 @@ import { EventEmitter } from "eventemitter3";
|
|
|
78
80
|
import * as uuid from "uuid";
|
|
79
81
|
import { z } from "zod/v4";
|
|
80
82
|
|
|
83
|
+
import {
|
|
84
|
+
clampLocalMessageRetentionDays,
|
|
85
|
+
formatVexRetentionEnvelope,
|
|
86
|
+
stripVexRetentionEnvelope,
|
|
87
|
+
} from "./retention.js";
|
|
81
88
|
import {
|
|
82
89
|
WebSocketAdapter,
|
|
83
90
|
WebSocketNotOpenError,
|
|
@@ -363,6 +370,13 @@ export interface ClientOptions {
|
|
|
363
370
|
host?: string;
|
|
364
371
|
/** Use sqlite in-memory mode (`:memory:`) instead of a file. */
|
|
365
372
|
inMemoryDb?: boolean;
|
|
373
|
+
/**
|
|
374
|
+
* Maximum age (in days) for messages kept in local storage. Values above
|
|
375
|
+
* 30 are clamped to 30 to match server retention. Peers may request a
|
|
376
|
+
* shorter window via an optional plaintext hint; this setting is still
|
|
377
|
+
* capped at 30 and is not enforceable against a modified client.
|
|
378
|
+
*/
|
|
379
|
+
localMessageRetentionDays?: number;
|
|
366
380
|
/** Whether local message history should be persisted by default storage. */
|
|
367
381
|
saveHistory?: boolean;
|
|
368
382
|
/** Use `http/ws` instead of `https/wss`. Intended for local/dev environments. */
|
|
@@ -375,6 +389,14 @@ export type DeviceRegistrationResult = Device | PendingDeviceRegistration;
|
|
|
375
389
|
* @ignore
|
|
376
390
|
*/
|
|
377
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>;
|
|
378
400
|
/** Approves a pending device registration request as the current device. */
|
|
379
401
|
approveRequest: (requestID: string) => Promise<Device>;
|
|
380
402
|
/** Deletes one of the account's devices (except the currently active one). */
|
|
@@ -402,6 +424,14 @@ export interface Devices {
|
|
|
402
424
|
challenge: string;
|
|
403
425
|
requestID: string;
|
|
404
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>;
|
|
405
435
|
/** Registers the current key material as a new device. */
|
|
406
436
|
register: () => Promise<DeviceRegistrationResult | null>;
|
|
407
437
|
/** Rejects a pending device registration request as the current device. */
|
|
@@ -552,6 +582,12 @@ export interface Message {
|
|
|
552
582
|
readerID: string;
|
|
553
583
|
/** Recipient device ID. */
|
|
554
584
|
recipient: string;
|
|
585
|
+
/**
|
|
586
|
+
* Optional peer hint (1–30): cooperative senders prefix plaintext; used
|
|
587
|
+
* with {@link ClientOptions.localMessageRetentionDays} to pick the
|
|
588
|
+
* shorter local retention window. Ignored when absent.
|
|
589
|
+
*/
|
|
590
|
+
retentionHintDays?: number;
|
|
555
591
|
/** Sender device ID. */
|
|
556
592
|
sender: string;
|
|
557
593
|
/** Time the message was created/received. */
|
|
@@ -740,7 +776,11 @@ export interface Messages {
|
|
|
740
776
|
/** Deletes local history for a user/channel. */
|
|
741
777
|
delete: (userOrChannelID: string) => Promise<void>;
|
|
742
778
|
/** Sends an encrypted message to all members of a channel. */
|
|
743
|
-
group: (
|
|
779
|
+
group: (
|
|
780
|
+
channelID: string,
|
|
781
|
+
message: string,
|
|
782
|
+
opts?: { retentionHintDays?: number },
|
|
783
|
+
) => Promise<void>;
|
|
744
784
|
/** Deletes all locally stored message history. */
|
|
745
785
|
purge: () => Promise<void>;
|
|
746
786
|
/** Returns local direct-message history with one user. */
|
|
@@ -748,7 +788,11 @@ export interface Messages {
|
|
|
748
788
|
/** Returns local group-message history for one channel. */
|
|
749
789
|
retrieveGroup: (channelID: string) => Promise<Message[]>;
|
|
750
790
|
/** Sends an encrypted direct message to one user. */
|
|
751
|
-
send: (
|
|
791
|
+
send: (
|
|
792
|
+
userID: string,
|
|
793
|
+
message: string,
|
|
794
|
+
opts?: { retentionHintDays?: number },
|
|
795
|
+
) => Promise<void>;
|
|
752
796
|
}
|
|
753
797
|
|
|
754
798
|
/**
|
|
@@ -977,12 +1021,16 @@ export class Client {
|
|
|
977
1021
|
* Device management methods.
|
|
978
1022
|
*/
|
|
979
1023
|
public devices: Devices = {
|
|
1024
|
+
abortPendingRegistration:
|
|
1025
|
+
this.abortPendingDeviceRegistration.bind(this),
|
|
980
1026
|
approveRequest: this.approveDeviceRequest.bind(this),
|
|
981
1027
|
delete: this.deleteDevice.bind(this),
|
|
982
1028
|
getRequest: this.getDeviceRegistrationRequest.bind(this),
|
|
983
1029
|
list: this.listDevices.bind(this),
|
|
984
1030
|
listRequests: this.listDeviceRegistrationRequests.bind(this),
|
|
985
1031
|
pollPendingRegistration: this.pollPendingDeviceRegistration.bind(this),
|
|
1032
|
+
publishPendingRegistration:
|
|
1033
|
+
this.publishPendingDeviceRegistration.bind(this),
|
|
986
1034
|
register: this.registerDevice.bind(this),
|
|
987
1035
|
rejectRequest: this.rejectDeviceRequest.bind(this),
|
|
988
1036
|
retrieve: this.getDeviceByID.bind(this),
|
|
@@ -1072,7 +1120,11 @@ export class Client {
|
|
|
1072
1120
|
* @param channelID - The channel to send a message to.
|
|
1073
1121
|
* @param message - The message to send.
|
|
1074
1122
|
*/
|
|
1075
|
-
group:
|
|
1123
|
+
group: (
|
|
1124
|
+
channelID: string,
|
|
1125
|
+
message: string,
|
|
1126
|
+
opts?: { retentionHintDays?: number },
|
|
1127
|
+
) => this.sendGroupMessage(channelID, message, opts),
|
|
1076
1128
|
purge: this.purgeHistory.bind(this),
|
|
1077
1129
|
/**
|
|
1078
1130
|
* Gets the message history with a specific userID.
|
|
@@ -1093,7 +1145,11 @@ export class Client {
|
|
|
1093
1145
|
* @param userID - The user to send a message to.
|
|
1094
1146
|
* @param message - The message to send.
|
|
1095
1147
|
*/
|
|
1096
|
-
send:
|
|
1148
|
+
send: (
|
|
1149
|
+
userID: string,
|
|
1150
|
+
message: string,
|
|
1151
|
+
opts?: { retentionHintDays?: number },
|
|
1152
|
+
) => this.sendMessage(userID, message, opts),
|
|
1097
1153
|
};
|
|
1098
1154
|
/**
|
|
1099
1155
|
* Server moderation helper methods.
|
|
@@ -1250,9 +1306,13 @@ export class Client {
|
|
|
1250
1306
|
private readonly httpAbortController = new AbortController();
|
|
1251
1307
|
private readonly idKeys: KeyPair | null;
|
|
1252
1308
|
private isAlive: boolean = true;
|
|
1253
|
-
private
|
|
1309
|
+
private localMessageRetentionDays: number;
|
|
1254
1310
|
|
|
1311
|
+
private localRetentionPurgeTimer: null | ReturnType<typeof setInterval> =
|
|
1312
|
+
null;
|
|
1313
|
+
private readonly mailInterval?: NodeJS.Timeout;
|
|
1255
1314
|
private manuallyClosing: boolean = false;
|
|
1315
|
+
|
|
1256
1316
|
/**
|
|
1257
1317
|
* Node-only: per-client HTTP(S) agents (see `init()` + `storage/node/http-agents`).
|
|
1258
1318
|
* Dropped on `close()` so idle keep-alive sockets do not keep the process alive.
|
|
@@ -1266,20 +1326,21 @@ export class Client {
|
|
|
1266
1326
|
and finally falls back to username. */
|
|
1267
1327
|
/** Negative cache for user lookups that returned 404. TTL = 30 minutes. */
|
|
1268
1328
|
private readonly notFoundUsers = new Map<string, number>();
|
|
1269
|
-
|
|
1270
1329
|
private readonly options?: ClientOptions | undefined;
|
|
1271
1330
|
|
|
1272
1331
|
private pingInterval: null | ReturnType<typeof setTimeout> = null;
|
|
1332
|
+
|
|
1273
1333
|
/**
|
|
1274
1334
|
* Bumped when the WebSocket is torn down and re-opened so the previous
|
|
1275
1335
|
* `postAuth` loop exits instead of overlapping a new one.
|
|
1276
1336
|
*/
|
|
1277
1337
|
private postAuthVersion = 0;
|
|
1278
|
-
|
|
1279
1338
|
private readonly prefixes:
|
|
1280
1339
|
| { HTTP: "http://"; WS: "ws://" }
|
|
1281
1340
|
| { HTTP: "https://"; WS: "wss://" };
|
|
1341
|
+
|
|
1282
1342
|
private reading: boolean = false;
|
|
1343
|
+
private retentionPurgeDebounce: null | ReturnType<typeof setTimeout> = null;
|
|
1283
1344
|
private readonly seenMailIDs: Set<string> = new Set();
|
|
1284
1345
|
private sessionRecords: Record<string, SessionCrypto> = {};
|
|
1285
1346
|
|
|
@@ -1303,6 +1364,9 @@ export class Client {
|
|
|
1303
1364
|
storage?: Storage,
|
|
1304
1365
|
) {
|
|
1305
1366
|
this.options = options;
|
|
1367
|
+
this.localMessageRetentionDays = clampLocalMessageRetentionDays(
|
|
1368
|
+
options?.localMessageRetentionDays,
|
|
1369
|
+
);
|
|
1306
1370
|
this.cryptoProfile = material.cryptoProfile;
|
|
1307
1371
|
this.signKeys = material.signKeys;
|
|
1308
1372
|
this.idKeys = material.idKeys;
|
|
@@ -1600,6 +1664,14 @@ export class Client {
|
|
|
1600
1664
|
if (this.mailInterval) {
|
|
1601
1665
|
clearInterval(this.mailInterval);
|
|
1602
1666
|
}
|
|
1667
|
+
if (this.localRetentionPurgeTimer) {
|
|
1668
|
+
clearInterval(this.localRetentionPurgeTimer);
|
|
1669
|
+
this.localRetentionPurgeTimer = null;
|
|
1670
|
+
}
|
|
1671
|
+
if (this.retentionPurgeDebounce) {
|
|
1672
|
+
clearTimeout(this.retentionPurgeDebounce);
|
|
1673
|
+
this.retentionPurgeDebounce = null;
|
|
1674
|
+
}
|
|
1603
1675
|
delete this.xKeyRing;
|
|
1604
1676
|
|
|
1605
1677
|
if (!muteEvent) {
|
|
@@ -1679,6 +1751,11 @@ export class Client {
|
|
|
1679
1751
|
};
|
|
1680
1752
|
}
|
|
1681
1753
|
|
|
1754
|
+
/** Current local retention cap in days (always 1–30). */
|
|
1755
|
+
public getLocalMessageRetentionDays(): number {
|
|
1756
|
+
return this.localMessageRetentionDays;
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1682
1759
|
/**
|
|
1683
1760
|
* Authenticates with username/password and stores the Bearer auth token.
|
|
1684
1761
|
*
|
|
@@ -2019,6 +2096,15 @@ export class Client {
|
|
|
2019
2096
|
return this;
|
|
2020
2097
|
}
|
|
2021
2098
|
|
|
2099
|
+
/**
|
|
2100
|
+
* Updates the local retention cap (1–30 days) and prunes immediately.
|
|
2101
|
+
* Does not affect server-side storage.
|
|
2102
|
+
*/
|
|
2103
|
+
public setLocalMessageRetentionDays(days: number): void {
|
|
2104
|
+
this.localMessageRetentionDays = clampLocalMessageRetentionDays(days);
|
|
2105
|
+
void this.runLocalRetentionPurge();
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2022
2108
|
/**
|
|
2023
2109
|
* Triggers an immediate inbox sync by fetching `/mail` once.
|
|
2024
2110
|
* Useful on mobile foreground resume where background work may pause.
|
|
@@ -2060,6 +2146,23 @@ export class Client {
|
|
|
2060
2146
|
return whoami;
|
|
2061
2147
|
}
|
|
2062
2148
|
|
|
2149
|
+
private async abortPendingDeviceRegistration(args: {
|
|
2150
|
+
challenge: string;
|
|
2151
|
+
requestID: string;
|
|
2152
|
+
}): Promise<void> {
|
|
2153
|
+
const signed = await this.signPendingRegistrationChallenge(
|
|
2154
|
+
args.challenge,
|
|
2155
|
+
);
|
|
2156
|
+
await this.http.post(
|
|
2157
|
+
this.getHost() +
|
|
2158
|
+
"/user/devices/requests/" +
|
|
2159
|
+
args.requestID +
|
|
2160
|
+
"/abort",
|
|
2161
|
+
msgpack.encode({ signed }),
|
|
2162
|
+
{ headers: { "Content-Type": "application/msgpack" } },
|
|
2163
|
+
);
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2063
2166
|
private acknowledgeInboundMail(mail: MailWS): void {
|
|
2064
2167
|
this.seenMailIDs.add(mail.mailID);
|
|
2065
2168
|
this.sendReceipt(new Uint8Array(mail.nonce));
|
|
@@ -2497,11 +2600,9 @@ export class Client {
|
|
|
2497
2600
|
private async deletePermission(permissionID: string): Promise<void> {
|
|
2498
2601
|
await this.http.delete(this.getHost() + "/permission/" + permissionID);
|
|
2499
2602
|
}
|
|
2500
|
-
|
|
2501
2603
|
private async deleteServer(serverID: string): Promise<void> {
|
|
2502
2604
|
await this.http.delete(this.getHost() + "/server/" + serverID);
|
|
2503
2605
|
}
|
|
2504
|
-
|
|
2505
2606
|
private deviceListFailureDetail(err: unknown): string {
|
|
2506
2607
|
if (!isAxiosError(err)) {
|
|
2507
2608
|
return "";
|
|
@@ -2515,6 +2616,7 @@ export class Client {
|
|
|
2515
2616
|
}
|
|
2516
2617
|
return "";
|
|
2517
2618
|
}
|
|
2619
|
+
|
|
2518
2620
|
/**
|
|
2519
2621
|
* Gets a list of permissions for a server.
|
|
2520
2622
|
*
|
|
@@ -3015,6 +3117,8 @@ export class Client {
|
|
|
3015
3117
|
return decodeAxios(UserArrayCodec, res.data);
|
|
3016
3118
|
}
|
|
3017
3119
|
|
|
3120
|
+
// ── Passkeys ────────────────────────────────────────────────────────
|
|
3121
|
+
|
|
3018
3122
|
private async handleNotify(msg: NotifyMsg) {
|
|
3019
3123
|
switch (msg.event) {
|
|
3020
3124
|
case "deviceRequest": {
|
|
@@ -3054,6 +3158,8 @@ export class Client {
|
|
|
3054
3158
|
}
|
|
3055
3159
|
}
|
|
3056
3160
|
|
|
3161
|
+
// ── Passkeys ────────────────────────────────────────────────────────
|
|
3162
|
+
|
|
3057
3163
|
/**
|
|
3058
3164
|
* Initializes the keyring. This must be called before anything else.
|
|
3059
3165
|
*/
|
|
@@ -3073,11 +3179,14 @@ export class Client {
|
|
|
3073
3179
|
|
|
3074
3180
|
await this.populateKeyRing();
|
|
3075
3181
|
this.emitter.on("message", this.onInternalMessage);
|
|
3182
|
+
void this.runLocalRetentionPurge();
|
|
3183
|
+
this.localRetentionPurgeTimer = setInterval(
|
|
3184
|
+
() => void this.runLocalRetentionPurge(),
|
|
3185
|
+
6 * 60 * 60 * 1000,
|
|
3186
|
+
);
|
|
3076
3187
|
this.emitter.emit("ready");
|
|
3077
3188
|
}
|
|
3078
3189
|
|
|
3079
|
-
// ── Passkeys ────────────────────────────────────────────────────────
|
|
3080
|
-
|
|
3081
3190
|
private initSocket() {
|
|
3082
3191
|
try {
|
|
3083
3192
|
if (!this.token) {
|
|
@@ -3284,6 +3393,7 @@ export class Client {
|
|
|
3284
3393
|
return;
|
|
3285
3394
|
}
|
|
3286
3395
|
void this.database.saveMessage(message);
|
|
3396
|
+
this.scheduleRetentionPurge();
|
|
3287
3397
|
};
|
|
3288
3398
|
|
|
3289
3399
|
private async passkeyApproveDeviceRequest(
|
|
@@ -3360,21 +3470,12 @@ export class Client {
|
|
|
3360
3470
|
);
|
|
3361
3471
|
}
|
|
3362
3472
|
|
|
3363
|
-
/**
|
|
3364
|
-
* Polls the public unauthenticated request status endpoint as the
|
|
3365
|
-
* requesting device. Signs the server-issued challenge with the local
|
|
3366
|
-
* private signing key so the server can verify ownership of the pending
|
|
3367
|
-
* request without us needing a user token.
|
|
3368
|
-
*/
|
|
3369
3473
|
private async pollPendingDeviceRegistration(args: {
|
|
3370
3474
|
challenge: string;
|
|
3371
3475
|
requestID: string;
|
|
3372
3476
|
}): Promise<null | PendingDeviceRequest> {
|
|
3373
|
-
const signed =
|
|
3374
|
-
|
|
3375
|
-
XUtils.decodeHex(args.challenge),
|
|
3376
|
-
this.signKeys.secretKey,
|
|
3377
|
-
),
|
|
3477
|
+
const signed = await this.signPendingRegistrationChallenge(
|
|
3478
|
+
args.challenge,
|
|
3378
3479
|
);
|
|
3379
3480
|
try {
|
|
3380
3481
|
const response = await this.http.post(
|
|
@@ -3484,6 +3585,23 @@ export class Client {
|
|
|
3484
3585
|
}
|
|
3485
3586
|
}
|
|
3486
3587
|
|
|
3588
|
+
private async publishPendingDeviceRegistration(args: {
|
|
3589
|
+
challenge: string;
|
|
3590
|
+
requestID: string;
|
|
3591
|
+
}): Promise<void> {
|
|
3592
|
+
const signed = await this.signPendingRegistrationChallenge(
|
|
3593
|
+
args.challenge,
|
|
3594
|
+
);
|
|
3595
|
+
await this.http.post(
|
|
3596
|
+
this.getHost() +
|
|
3597
|
+
"/user/devices/requests/" +
|
|
3598
|
+
args.requestID +
|
|
3599
|
+
"/publish",
|
|
3600
|
+
msgpack.encode({ signed }),
|
|
3601
|
+
{ headers: { "Content-Type": "application/msgpack" } },
|
|
3602
|
+
);
|
|
3603
|
+
}
|
|
3604
|
+
|
|
3487
3605
|
private async purgeHistory(): Promise<void> {
|
|
3488
3606
|
await this.database.purgeHistory();
|
|
3489
3607
|
}
|
|
@@ -3712,13 +3830,39 @@ export class Client {
|
|
|
3712
3830
|
if (!mail.forward) {
|
|
3713
3831
|
plaintext = XUtils.encodeUTF8(unsealed);
|
|
3714
3832
|
}
|
|
3833
|
+
let incomingPlain = plaintext;
|
|
3834
|
+
let hintFromEnvelope: number | undefined;
|
|
3835
|
+
if (!mail.forward && plaintext.length > 0) {
|
|
3836
|
+
const stripped =
|
|
3837
|
+
stripVexRetentionEnvelope(plaintext);
|
|
3838
|
+
incomingPlain = stripped.body;
|
|
3839
|
+
hintFromEnvelope = stripped.retentionHintDays;
|
|
3840
|
+
}
|
|
3715
3841
|
|
|
3716
3842
|
// emit the message
|
|
3717
3843
|
const fwdMsg1 = mail.forward
|
|
3718
3844
|
? messageSchema.parse(msgpack.decode(unsealed))
|
|
3719
3845
|
: null;
|
|
3720
3846
|
const message: Message = fwdMsg1
|
|
3721
|
-
?
|
|
3847
|
+
? (() => {
|
|
3848
|
+
const stripped =
|
|
3849
|
+
stripVexRetentionEnvelope(
|
|
3850
|
+
fwdMsg1.message,
|
|
3851
|
+
);
|
|
3852
|
+
const base: Message = {
|
|
3853
|
+
...fwdMsg1,
|
|
3854
|
+
forward: true,
|
|
3855
|
+
message: stripped.body,
|
|
3856
|
+
};
|
|
3857
|
+
return stripped.retentionHintDays !==
|
|
3858
|
+
undefined
|
|
3859
|
+
? {
|
|
3860
|
+
...base,
|
|
3861
|
+
retentionHintDays:
|
|
3862
|
+
stripped.retentionHintDays,
|
|
3863
|
+
}
|
|
3864
|
+
: base;
|
|
3865
|
+
})()
|
|
3722
3866
|
: {
|
|
3723
3867
|
authorID: mail.authorID,
|
|
3724
3868
|
decrypted: true,
|
|
@@ -3728,12 +3872,18 @@ export class Client {
|
|
|
3728
3872
|
? uuid.stringify(mail.group)
|
|
3729
3873
|
: null,
|
|
3730
3874
|
mailID: mail.mailID,
|
|
3731
|
-
message:
|
|
3875
|
+
message: incomingPlain,
|
|
3732
3876
|
nonce: XUtils.encodeHex(
|
|
3733
3877
|
new Uint8Array(mail.nonce),
|
|
3734
3878
|
),
|
|
3735
3879
|
readerID: mail.readerID,
|
|
3736
3880
|
recipient: mail.recipient,
|
|
3881
|
+
...(hintFromEnvelope !== undefined
|
|
3882
|
+
? {
|
|
3883
|
+
retentionHintDays:
|
|
3884
|
+
hintFromEnvelope,
|
|
3885
|
+
}
|
|
3886
|
+
: {}),
|
|
3737
3887
|
sender: mail.sender,
|
|
3738
3888
|
timestamp: timestamp,
|
|
3739
3889
|
};
|
|
@@ -3900,11 +4050,35 @@ export class Client {
|
|
|
3900
4050
|
const fwdMsg2 = mail.forward
|
|
3901
4051
|
? messageSchema.parse(msgpack.decode(decrypted))
|
|
3902
4052
|
: null;
|
|
4053
|
+
const rawIncoming = XUtils.encodeUTF8(decrypted);
|
|
4054
|
+
let bodyIncoming = rawIncoming;
|
|
4055
|
+
let hintIncoming: number | undefined;
|
|
4056
|
+
if (!mail.forward) {
|
|
4057
|
+
const stripped =
|
|
4058
|
+
stripVexRetentionEnvelope(rawIncoming);
|
|
4059
|
+
bodyIncoming = stripped.body;
|
|
4060
|
+
hintIncoming = stripped.retentionHintDays;
|
|
4061
|
+
}
|
|
3903
4062
|
const message: Message = fwdMsg2
|
|
3904
|
-
? {
|
|
3905
|
-
|
|
3906
|
-
|
|
3907
|
-
|
|
4063
|
+
? (() => {
|
|
4064
|
+
const stripped =
|
|
4065
|
+
stripVexRetentionEnvelope(
|
|
4066
|
+
fwdMsg2.message,
|
|
4067
|
+
);
|
|
4068
|
+
const base: Message = {
|
|
4069
|
+
...fwdMsg2,
|
|
4070
|
+
forward: true,
|
|
4071
|
+
message: stripped.body,
|
|
4072
|
+
};
|
|
4073
|
+
return stripped.retentionHintDays !==
|
|
4074
|
+
undefined
|
|
4075
|
+
? {
|
|
4076
|
+
...base,
|
|
4077
|
+
retentionHintDays:
|
|
4078
|
+
stripped.retentionHintDays,
|
|
4079
|
+
}
|
|
4080
|
+
: base;
|
|
4081
|
+
})()
|
|
3908
4082
|
: {
|
|
3909
4083
|
authorID: mail.authorID,
|
|
3910
4084
|
decrypted: true,
|
|
@@ -3914,12 +4088,15 @@ export class Client {
|
|
|
3914
4088
|
? uuid.stringify(mail.group)
|
|
3915
4089
|
: null,
|
|
3916
4090
|
mailID: mail.mailID,
|
|
3917
|
-
message:
|
|
4091
|
+
message: bodyIncoming,
|
|
3918
4092
|
nonce: XUtils.encodeHex(
|
|
3919
4093
|
new Uint8Array(mail.nonce),
|
|
3920
4094
|
),
|
|
3921
4095
|
readerID: mail.readerID,
|
|
3922
4096
|
recipient: mail.recipient,
|
|
4097
|
+
...(hintIncoming !== undefined
|
|
4098
|
+
? { retentionHintDays: hintIncoming }
|
|
4099
|
+
: {}),
|
|
3923
4100
|
sender: mail.sender,
|
|
3924
4101
|
timestamp: timestamp,
|
|
3925
4102
|
};
|
|
@@ -4168,6 +4345,19 @@ export class Client {
|
|
|
4168
4345
|
return device;
|
|
4169
4346
|
}
|
|
4170
4347
|
|
|
4348
|
+
private async runLocalRetentionPurge(): Promise<void> {
|
|
4349
|
+
if (this.isManualCloseInFlight()) {
|
|
4350
|
+
return;
|
|
4351
|
+
}
|
|
4352
|
+
try {
|
|
4353
|
+
await this.database.pruneExpiredLocalMessages(
|
|
4354
|
+
this.localMessageRetentionDays,
|
|
4355
|
+
);
|
|
4356
|
+
} catch {
|
|
4357
|
+
/* best-effort */
|
|
4358
|
+
}
|
|
4359
|
+
}
|
|
4360
|
+
|
|
4171
4361
|
/**
|
|
4172
4362
|
* `xDHAsync` and other helpers in `@vex-chat/crypto` use the process-wide
|
|
4173
4363
|
* active profile. When several {@link Client} instances use different
|
|
@@ -4177,18 +4367,24 @@ export class Client {
|
|
|
4177
4367
|
private async runWithThisCryptoProfile<T>(
|
|
4178
4368
|
fn: () => Promise<T>,
|
|
4179
4369
|
): Promise<T> {
|
|
4180
|
-
|
|
4181
|
-
if (prev === this.cryptoProfile) {
|
|
4182
|
-
return await fn();
|
|
4183
|
-
}
|
|
4184
|
-
setCryptoProfile(this.cryptoProfile);
|
|
4370
|
+
enterCryptoProfileScope(this.cryptoProfile);
|
|
4185
4371
|
try {
|
|
4186
4372
|
return await fn();
|
|
4187
4373
|
} finally {
|
|
4188
|
-
|
|
4374
|
+
leaveCryptoProfileScope();
|
|
4189
4375
|
}
|
|
4190
4376
|
}
|
|
4191
4377
|
|
|
4378
|
+
private scheduleRetentionPurge(): void {
|
|
4379
|
+
if (this.retentionPurgeDebounce) {
|
|
4380
|
+
clearTimeout(this.retentionPurgeDebounce);
|
|
4381
|
+
}
|
|
4382
|
+
this.retentionPurgeDebounce = setTimeout(() => {
|
|
4383
|
+
this.retentionPurgeDebounce = null;
|
|
4384
|
+
void this.runLocalRetentionPurge();
|
|
4385
|
+
}, 3000);
|
|
4386
|
+
}
|
|
4387
|
+
|
|
4192
4388
|
/* header is 32 bytes and is either empty
|
|
4193
4389
|
or contains an HMAC of the message with
|
|
4194
4390
|
a derived SK */
|
|
@@ -4224,6 +4420,7 @@ export class Client {
|
|
|
4224
4420
|
private async sendGroupMessage(
|
|
4225
4421
|
channelID: string,
|
|
4226
4422
|
message: string,
|
|
4423
|
+
opts?: { retentionHintDays?: number },
|
|
4227
4424
|
): Promise<void> {
|
|
4228
4425
|
const userList = await this.getUserList(channelID);
|
|
4229
4426
|
for (const user of userList) {
|
|
@@ -4231,7 +4428,11 @@ export class Client {
|
|
|
4231
4428
|
}
|
|
4232
4429
|
|
|
4233
4430
|
const mailID = uuid.v4();
|
|
4234
|
-
const
|
|
4431
|
+
const payload = formatVexRetentionEnvelope(
|
|
4432
|
+
message,
|
|
4433
|
+
opts?.retentionHintDays,
|
|
4434
|
+
);
|
|
4435
|
+
const msgBytes = XUtils.decodeUTF8(payload);
|
|
4235
4436
|
const myUserID = this.getUser().userID;
|
|
4236
4437
|
// Fan-out only to *other* server members. The current account's other
|
|
4237
4438
|
// devices receive the same group mail via `forward()` on the outgoing
|
|
@@ -4403,8 +4604,25 @@ export class Client {
|
|
|
4403
4604
|
const fwdOut = forward
|
|
4404
4605
|
? messageSchema.parse(msgpack.decode(msg))
|
|
4405
4606
|
: null;
|
|
4607
|
+
const rawUtf8 = XUtils.encodeUTF8(msg);
|
|
4608
|
+
const strippedOut = stripVexRetentionEnvelope(rawUtf8);
|
|
4406
4609
|
const outMsg: Message = fwdOut
|
|
4407
|
-
?
|
|
4610
|
+
? (() => {
|
|
4611
|
+
const stripped = stripVexRetentionEnvelope(
|
|
4612
|
+
fwdOut.message,
|
|
4613
|
+
);
|
|
4614
|
+
const base: Message = {
|
|
4615
|
+
...fwdOut,
|
|
4616
|
+
forward: true,
|
|
4617
|
+
message: stripped.body,
|
|
4618
|
+
};
|
|
4619
|
+
return stripped.retentionHintDays !== undefined
|
|
4620
|
+
? {
|
|
4621
|
+
...base,
|
|
4622
|
+
retentionHintDays: stripped.retentionHintDays,
|
|
4623
|
+
}
|
|
4624
|
+
: base;
|
|
4625
|
+
})()
|
|
4408
4626
|
: {
|
|
4409
4627
|
authorID: mail.authorID,
|
|
4410
4628
|
decrypted: true,
|
|
@@ -4412,10 +4630,16 @@ export class Client {
|
|
|
4412
4630
|
forward: mail.forward,
|
|
4413
4631
|
group: mail.group ? uuid.stringify(mail.group) : null,
|
|
4414
4632
|
mailID: mail.mailID,
|
|
4415
|
-
message:
|
|
4633
|
+
message: strippedOut.body,
|
|
4416
4634
|
nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
|
|
4417
4635
|
readerID: mail.readerID,
|
|
4418
4636
|
recipient: mail.recipient,
|
|
4637
|
+
...(strippedOut.retentionHintDays !== undefined
|
|
4638
|
+
? {
|
|
4639
|
+
retentionHintDays:
|
|
4640
|
+
strippedOut.retentionHintDays,
|
|
4641
|
+
}
|
|
4642
|
+
: {}),
|
|
4419
4643
|
sender: mail.sender,
|
|
4420
4644
|
timestamp: new Date().toISOString(),
|
|
4421
4645
|
};
|
|
@@ -4480,7 +4704,15 @@ export class Client {
|
|
|
4480
4704
|
}
|
|
4481
4705
|
}
|
|
4482
4706
|
|
|
4483
|
-
private async sendMessage(
|
|
4707
|
+
private async sendMessage(
|
|
4708
|
+
userID: string,
|
|
4709
|
+
message: string,
|
|
4710
|
+
opts?: { retentionHintDays?: number },
|
|
4711
|
+
): Promise<void> {
|
|
4712
|
+
const payload = formatVexRetentionEnvelope(
|
|
4713
|
+
message,
|
|
4714
|
+
opts?.retentionHintDays,
|
|
4715
|
+
);
|
|
4484
4716
|
try {
|
|
4485
4717
|
const [userEntry, err] = await this.fetchUser(userID);
|
|
4486
4718
|
if (err) {
|
|
@@ -4553,7 +4785,7 @@ export class Client {
|
|
|
4553
4785
|
await this.sendMail(
|
|
4554
4786
|
device,
|
|
4555
4787
|
userEntry,
|
|
4556
|
-
XUtils.decodeUTF8(
|
|
4788
|
+
XUtils.decodeUTF8(payload),
|
|
4557
4789
|
null,
|
|
4558
4790
|
messageMailID,
|
|
4559
4791
|
false,
|
|
@@ -4616,6 +4848,26 @@ export class Client {
|
|
|
4616
4848
|
|
|
4617
4849
|
private setUser(user: User): void {
|
|
4618
4850
|
this.user = user;
|
|
4851
|
+
// Fresh identity / token: drop stale 404 negative-cache entries so a
|
|
4852
|
+
// prior transient miss (or wrong host) cannot block DM sends for 30m.
|
|
4853
|
+
this.notFoundUsers.clear();
|
|
4854
|
+
}
|
|
4855
|
+
|
|
4856
|
+
/**
|
|
4857
|
+
* Polls the public unauthenticated request status endpoint as the
|
|
4858
|
+
* requesting device. Signs the server-issued challenge with the local
|
|
4859
|
+
* private signing key so the server can verify ownership of the pending
|
|
4860
|
+
* request without us needing a user token.
|
|
4861
|
+
*/
|
|
4862
|
+
private async signPendingRegistrationChallenge(
|
|
4863
|
+
challengeHex: string,
|
|
4864
|
+
): Promise<string> {
|
|
4865
|
+
return XUtils.encodeHex(
|
|
4866
|
+
await xSignAsync(
|
|
4867
|
+
XUtils.decodeHex(challengeHex),
|
|
4868
|
+
this.signKeys.secretKey,
|
|
4869
|
+
),
|
|
4870
|
+
);
|
|
4619
4871
|
}
|
|
4620
4872
|
|
|
4621
4873
|
private async submitOTK(amount: number) {
|
package/src/Storage.ts
CHANGED
|
@@ -91,6 +91,14 @@ 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, effectiveMessageRetentionHintDays(row))`
|
|
97
|
+
* (invalid or non-positive stored hints are treated like 30).
|
|
98
|
+
*/
|
|
99
|
+
pruneExpiredLocalMessages: (
|
|
100
|
+
clientMaxRetentionDays: number,
|
|
101
|
+
) => Promise<void>;
|
|
94
102
|
/** Deletes all message history. */
|
|
95
103
|
purgeHistory: () => Promise<void>;
|
|
96
104
|
/** Deletes all local key/session state. */
|