@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
|
@@ -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;
|
|
@@ -170,6 +173,27 @@ export class MemoryStorage extends EventEmitter implements Storage {
|
|
|
170
173
|
return Promise.resolve();
|
|
171
174
|
}
|
|
172
175
|
|
|
176
|
+
pruneExpiredLocalMessages(clientMaxRetentionDays: number): Promise<void> {
|
|
177
|
+
const cap = Math.min(
|
|
178
|
+
30,
|
|
179
|
+
Math.max(1, Math.round(clientMaxRetentionDays)),
|
|
180
|
+
);
|
|
181
|
+
const now = Date.now();
|
|
182
|
+
const msPerDay = 86_400_000;
|
|
183
|
+
this.messages = this.messages.filter((m) => {
|
|
184
|
+
const hintDays = effectiveMessageRetentionHintDays(
|
|
185
|
+
m.retentionHintDays,
|
|
186
|
+
);
|
|
187
|
+
const maxDays = Math.min(30, cap, hintDays);
|
|
188
|
+
const ts = new Date(m.timestamp).getTime();
|
|
189
|
+
if (!Number.isFinite(ts)) {
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
return now - ts <= maxDays * msPerDay;
|
|
193
|
+
});
|
|
194
|
+
return Promise.resolve();
|
|
195
|
+
}
|
|
196
|
+
|
|
173
197
|
purgeHistory(): Promise<void> {
|
|
174
198
|
this.messages = [];
|
|
175
199
|
return Promise.resolve();
|
|
@@ -0,0 +1,50 @@
|
|
|
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
|
+
effectiveMessageRetentionHintDays,
|
|
11
|
+
formatVexRetentionEnvelope,
|
|
12
|
+
MAX_LOCAL_MESSAGE_RETENTION_DAYS,
|
|
13
|
+
stripVexRetentionEnvelope,
|
|
14
|
+
} from "../retention.js";
|
|
15
|
+
|
|
16
|
+
describe("retention", () => {
|
|
17
|
+
it("clamps local retention to 1–30", () => {
|
|
18
|
+
expect(clampLocalMessageRetentionDays(undefined)).toBe(30);
|
|
19
|
+
expect(clampLocalMessageRetentionDays(0)).toBe(1);
|
|
20
|
+
expect(clampLocalMessageRetentionDays(45)).toBe(30);
|
|
21
|
+
expect(clampLocalMessageRetentionDays(7)).toBe(7);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("round-trips envelope prefix", () => {
|
|
25
|
+
const wrapped = formatVexRetentionEnvelope("hello", 7);
|
|
26
|
+
expect(wrapped).toBe("vex-retention:7\nhello");
|
|
27
|
+
expect(stripVexRetentionEnvelope(wrapped)).toEqual({
|
|
28
|
+
body: "hello",
|
|
29
|
+
retentionHintDays: 7,
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("format without hint leaves body unchanged", () => {
|
|
34
|
+
expect(formatVexRetentionEnvelope("plain", undefined)).toBe("plain");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("MAX matches server contract constant name in docs", () => {
|
|
38
|
+
expect(MAX_LOCAL_MESSAGE_RETENTION_DAYS).toBe(30);
|
|
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
|
+
});
|
|
50
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -38,6 +38,11 @@ export type {
|
|
|
38
38
|
VexFile,
|
|
39
39
|
} from "./Client.js";
|
|
40
40
|
export { createCodec, msgpack } from "./codec.js";
|
|
41
|
+
export {
|
|
42
|
+
clampLocalMessageRetentionDays,
|
|
43
|
+
effectiveMessageRetentionHintDays,
|
|
44
|
+
MAX_LOCAL_MESSAGE_RETENTION_DAYS,
|
|
45
|
+
} from "./retention.js";
|
|
41
46
|
export type { Storage } from "./Storage.js";
|
|
42
47
|
export type {
|
|
43
48
|
KeyPair,
|
package/src/retention.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
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
|
+
/**
|
|
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
|
+
|
|
44
|
+
const RETENTION_PREFIX = /^vex-retention:([1-9]|[12]\d|30)\n/;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Prefixes plaintext with a machine-readable retention hint for other clients.
|
|
48
|
+
* When `retentionHintDays` is omitted, returns `body` unchanged.
|
|
49
|
+
*/
|
|
50
|
+
export function formatVexRetentionEnvelope(
|
|
51
|
+
body: string,
|
|
52
|
+
retentionHintDays?: null | number,
|
|
53
|
+
): string {
|
|
54
|
+
if (
|
|
55
|
+
retentionHintDays === null ||
|
|
56
|
+
retentionHintDays === undefined ||
|
|
57
|
+
!Number.isFinite(retentionHintDays)
|
|
58
|
+
) {
|
|
59
|
+
return body;
|
|
60
|
+
}
|
|
61
|
+
const d = clampLocalMessageRetentionDays(retentionHintDays);
|
|
62
|
+
return `vex-retention:${String(d)}\n${body}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Strips an optional first-line retention hint placed by cooperative clients.
|
|
67
|
+
* Malicious peers can omit or forge this; local expiry still cannot exceed 30 days.
|
|
68
|
+
*/
|
|
69
|
+
export function stripVexRetentionEnvelope(plaintext: string): {
|
|
70
|
+
body: string;
|
|
71
|
+
retentionHintDays?: number;
|
|
72
|
+
} {
|
|
73
|
+
const m = RETENTION_PREFIX.exec(plaintext);
|
|
74
|
+
if (!m) {
|
|
75
|
+
return { body: plaintext };
|
|
76
|
+
}
|
|
77
|
+
const hint = Math.min(
|
|
78
|
+
MAX_LOCAL_MESSAGE_RETENTION_DAYS,
|
|
79
|
+
Math.max(1, Number(m[1])),
|
|
80
|
+
);
|
|
81
|
+
return {
|
|
82
|
+
body: plaintext.slice(m[0].length),
|
|
83
|
+
retentionHintDays: hint,
|
|
84
|
+
};
|
|
85
|
+
}
|
package/src/storage/schema.ts
CHANGED
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 {
|
|
@@ -349,6 +357,7 @@ export class SqliteStorage extends EventEmitter implements Storage {
|
|
|
349
357
|
)
|
|
350
358
|
.execute();
|
|
351
359
|
await this.ensureSessionRatchetColumns();
|
|
360
|
+
await this.ensureRetentionHintColumn();
|
|
352
361
|
|
|
353
362
|
await this.db.schema
|
|
354
363
|
.createTable("preKeys")
|
|
@@ -416,6 +425,48 @@ export class SqliteStorage extends EventEmitter implements Storage {
|
|
|
416
425
|
|
|
417
426
|
// ── PreKeys / OneTimeKeys ────────────────────────────────────────────────
|
|
418
427
|
|
|
428
|
+
async pruneExpiredLocalMessages(
|
|
429
|
+
clientMaxRetentionDays: number,
|
|
430
|
+
): Promise<void> {
|
|
431
|
+
await this.untilReady();
|
|
432
|
+
if (this.closing) {
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
const cap = Math.min(
|
|
436
|
+
30,
|
|
437
|
+
Math.max(1, Math.round(clientMaxRetentionDays)),
|
|
438
|
+
);
|
|
439
|
+
const rows = await this.db
|
|
440
|
+
.selectFrom("messages")
|
|
441
|
+
.select(["mailID", "timestamp", "retentionHintDays"])
|
|
442
|
+
.execute();
|
|
443
|
+
const now = Date.now();
|
|
444
|
+
const msPerDay = 86_400_000;
|
|
445
|
+
const toDelete: string[] = [];
|
|
446
|
+
for (const r of rows) {
|
|
447
|
+
const hintDays = effectiveMessageRetentionHintDays(
|
|
448
|
+
r.retentionHintDays,
|
|
449
|
+
);
|
|
450
|
+
const maxDays = Math.min(30, cap, hintDays);
|
|
451
|
+
const ts = new Date(r.timestamp).getTime();
|
|
452
|
+
if (!Number.isFinite(ts)) {
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
if (now - ts > maxDays * msPerDay) {
|
|
456
|
+
toDelete.push(r.mailID);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
if (toDelete.length === 0) {
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
for (const mailID of toDelete) {
|
|
463
|
+
await this.db
|
|
464
|
+
.deleteFrom("messages")
|
|
465
|
+
.where("mailID", "=", mailID)
|
|
466
|
+
.execute();
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
419
470
|
async purgeHistory(): Promise<void> {
|
|
420
471
|
await this.db.deleteFrom("messages").execute();
|
|
421
472
|
}
|
|
@@ -489,6 +540,16 @@ export class SqliteStorage extends EventEmitter implements Storage {
|
|
|
489
540
|
nonce: message.nonce,
|
|
490
541
|
readerID: message.readerID,
|
|
491
542
|
recipient: message.recipient,
|
|
543
|
+
retentionHintDays:
|
|
544
|
+
message.retentionHintDays === undefined
|
|
545
|
+
? null
|
|
546
|
+
: Math.min(
|
|
547
|
+
30,
|
|
548
|
+
Math.max(
|
|
549
|
+
1,
|
|
550
|
+
Math.round(message.retentionHintDays),
|
|
551
|
+
),
|
|
552
|
+
),
|
|
492
553
|
sender: message.sender,
|
|
493
554
|
timestamp: message.timestamp,
|
|
494
555
|
})
|
|
@@ -617,6 +678,14 @@ export class SqliteStorage extends EventEmitter implements Storage {
|
|
|
617
678
|
): Promise<Message[]> {
|
|
618
679
|
const fips = getCryptoProfile() === "fips";
|
|
619
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
|
+
|
|
620
689
|
for (const msg of messages) {
|
|
621
690
|
const decryptedFlag = msg.decrypted !== 0;
|
|
622
691
|
let plaintext = msg.message;
|
|
@@ -638,7 +707,7 @@ export class SqliteStorage extends EventEmitter implements Storage {
|
|
|
638
707
|
}
|
|
639
708
|
const direction =
|
|
640
709
|
msg.direction === "incoming" ? "incoming" : "outgoing";
|
|
641
|
-
|
|
710
|
+
const rowMessage: Message = {
|
|
642
711
|
authorID: msg.authorID,
|
|
643
712
|
decrypted: decryptedFlag,
|
|
644
713
|
direction,
|
|
@@ -651,7 +720,16 @@ export class SqliteStorage extends EventEmitter implements Storage {
|
|
|
651
720
|
recipient: msg.recipient,
|
|
652
721
|
sender: msg.sender,
|
|
653
722
|
timestamp: msg.timestamp,
|
|
654
|
-
}
|
|
723
|
+
};
|
|
724
|
+
if (msg.retentionHintDays != null) {
|
|
725
|
+
rowMessage.retentionHintDays = msg.retentionHintDays;
|
|
726
|
+
}
|
|
727
|
+
out.push(rowMessage);
|
|
728
|
+
|
|
729
|
+
processed += 1;
|
|
730
|
+
if (processed % yieldEvery === 0) {
|
|
731
|
+
await yieldToHost();
|
|
732
|
+
}
|
|
655
733
|
}
|
|
656
734
|
return out;
|
|
657
735
|
}
|
|
@@ -669,6 +747,18 @@ export class SqliteStorage extends EventEmitter implements Storage {
|
|
|
669
747
|
};
|
|
670
748
|
}
|
|
671
749
|
|
|
750
|
+
private async ensureRetentionHintColumn(): Promise<void> {
|
|
751
|
+
try {
|
|
752
|
+
await sql
|
|
753
|
+
.raw(
|
|
754
|
+
"ALTER TABLE messages ADD COLUMN retentionHintDays integer",
|
|
755
|
+
)
|
|
756
|
+
.execute(this.db);
|
|
757
|
+
} catch {
|
|
758
|
+
// Existing databases may already have this column.
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
672
762
|
private async ensureSessionRatchetColumns(): Promise<void> {
|
|
673
763
|
const add = async (
|
|
674
764
|
column: string,
|