@vex-chat/libvex 6.2.2 → 6.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Client.d.ts +32 -2
- package/dist/Client.d.ts.map +1 -1
- package/dist/Client.js +192 -23
- package/dist/Client.js.map +1 -1
- package/dist/Storage.d.ts +5 -0
- package/dist/Storage.d.ts.map +1 -1
- package/dist/__tests__/harness/memory-storage.d.ts +1 -0
- package/dist/__tests__/harness/memory-storage.d.ts.map +1 -1
- package/dist/__tests__/harness/memory-storage.js +15 -0
- package/dist/__tests__/harness/memory-storage.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/retention.d.ts +26 -0
- package/dist/retention.d.ts.map +1 -0
- package/dist/retention.js +51 -0
- package/dist/retention.js.map +1 -0
- package/dist/storage/schema.d.ts +2 -0
- package/dist/storage/schema.d.ts.map +1 -1
- package/dist/storage/sqlite.d.ts +2 -0
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +54 -2
- package/dist/storage/sqlite.js.map +1 -1
- package/package.json +3 -3
- package/src/Client.ts +281 -30
- package/src/Storage.ts +7 -0
- package/src/__tests__/harness/memory-storage.ts +19 -0
- package/src/__tests__/harness/shared-suite.ts +30 -21
- package/src/__tests__/retention.test.ts +39 -0
- package/src/index.ts +4 -0
- package/src/retention.ts +68 -0
- package/src/storage/schema.ts +2 -0
- package/src/storage/sqlite.ts +69 -2
|
@@ -87,11 +87,40 @@ export function platformSuite(
|
|
|
87
87
|
(m) => m.direction === "incoming" && m.decrypted,
|
|
88
88
|
`[${platformName}] self-DM`,
|
|
89
89
|
);
|
|
90
|
-
|
|
90
|
+
await client.messages.send(me.userID, "platform-test");
|
|
91
91
|
const msg = await msgPromise;
|
|
92
92
|
expect(msg.message).toBe("platform-test");
|
|
93
93
|
});
|
|
94
94
|
|
|
95
|
+
test("message history retrieve + delete", async () => {
|
|
96
|
+
const me = client.me.user();
|
|
97
|
+
const body = "history-test";
|
|
98
|
+
|
|
99
|
+
// Runs early (right after first self-DM) so ratchet/session state is
|
|
100
|
+
// fresh — the same assertion was flaky late in the suite when many
|
|
101
|
+
// prior tests had exercised the client against Spire.
|
|
102
|
+
const msgPromise = waitForMessage(
|
|
103
|
+
client,
|
|
104
|
+
(m) =>
|
|
105
|
+
m.direction === "incoming" &&
|
|
106
|
+
m.decrypted &&
|
|
107
|
+
m.message === body,
|
|
108
|
+
"history DM",
|
|
109
|
+
25_000,
|
|
110
|
+
);
|
|
111
|
+
await client.messages.send(me.userID, body);
|
|
112
|
+
await client.syncInboxNow();
|
|
113
|
+
const inbound = await msgPromise;
|
|
114
|
+
expect(inbound.authorID).toBe(me.userID);
|
|
115
|
+
|
|
116
|
+
const history = await client.messages.retrieve(me.userID);
|
|
117
|
+
expect(history.length).toBeGreaterThan(0);
|
|
118
|
+
|
|
119
|
+
await client.messages.delete(me.userID);
|
|
120
|
+
const afterDelete = await client.messages.retrieve(me.userID);
|
|
121
|
+
expect(afterDelete.length).toBe(0);
|
|
122
|
+
}, 35_000);
|
|
123
|
+
|
|
95
124
|
test("two-user DM", async () => {
|
|
96
125
|
const SK2 = await e2eGenerateSecretKey();
|
|
97
126
|
const opts2: ClientOptions = e2eClientOptionsBase();
|
|
@@ -356,26 +385,6 @@ export function platformSuite(
|
|
|
356
385
|
}
|
|
357
386
|
});
|
|
358
387
|
|
|
359
|
-
test("message history retrieve + delete", async () => {
|
|
360
|
-
const me = client.me.user();
|
|
361
|
-
|
|
362
|
-
// Send a message and wait for it
|
|
363
|
-
const msgPromise = waitForMessage(
|
|
364
|
-
client,
|
|
365
|
-
(m) => m.direction === "incoming" && m.decrypted,
|
|
366
|
-
"history DM",
|
|
367
|
-
);
|
|
368
|
-
void client.messages.send(me.userID, "history-test");
|
|
369
|
-
await msgPromise;
|
|
370
|
-
|
|
371
|
-
const history = await client.messages.retrieve(me.userID);
|
|
372
|
-
expect(history.length).toBeGreaterThan(0);
|
|
373
|
-
|
|
374
|
-
await client.messages.delete(me.userID);
|
|
375
|
-
const afterDelete = await client.messages.retrieve(me.userID);
|
|
376
|
-
expect(afterDelete.length).toBe(0);
|
|
377
|
-
});
|
|
378
|
-
|
|
379
388
|
test("file upload + download", async () => {
|
|
380
389
|
const [details, key] = await client.files.create(testFile);
|
|
381
390
|
expect(details.fileID).toBeTruthy();
|
|
@@ -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
package/src/storage/sqlite.ts
CHANGED
|
@@ -349,6 +349,7 @@ export class SqliteStorage extends EventEmitter implements Storage {
|
|
|
349
349
|
)
|
|
350
350
|
.execute();
|
|
351
351
|
await this.ensureSessionRatchetColumns();
|
|
352
|
+
await this.ensureRetentionHintColumn();
|
|
352
353
|
|
|
353
354
|
await this.db.schema
|
|
354
355
|
.createTable("preKeys")
|
|
@@ -416,6 +417,46 @@ export class SqliteStorage extends EventEmitter implements Storage {
|
|
|
416
417
|
|
|
417
418
|
// ── PreKeys / OneTimeKeys ────────────────────────────────────────────────
|
|
418
419
|
|
|
420
|
+
async pruneExpiredLocalMessages(
|
|
421
|
+
clientMaxRetentionDays: number,
|
|
422
|
+
): Promise<void> {
|
|
423
|
+
await this.untilReady();
|
|
424
|
+
if (this.closing) {
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
const cap = Math.min(
|
|
428
|
+
30,
|
|
429
|
+
Math.max(1, Math.round(clientMaxRetentionDays)),
|
|
430
|
+
);
|
|
431
|
+
const rows = await this.db
|
|
432
|
+
.selectFrom("messages")
|
|
433
|
+
.select(["mailID", "timestamp", "retentionHintDays"])
|
|
434
|
+
.execute();
|
|
435
|
+
const now = Date.now();
|
|
436
|
+
const msPerDay = 86_400_000;
|
|
437
|
+
const toDelete: string[] = [];
|
|
438
|
+
for (const r of rows) {
|
|
439
|
+
const hintDays = r.retentionHintDays ?? 30;
|
|
440
|
+
const maxDays = Math.min(30, cap, hintDays);
|
|
441
|
+
const ts = new Date(r.timestamp).getTime();
|
|
442
|
+
if (!Number.isFinite(ts)) {
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
if (now - ts > maxDays * msPerDay) {
|
|
446
|
+
toDelete.push(r.mailID);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
if (toDelete.length === 0) {
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
for (const mailID of toDelete) {
|
|
453
|
+
await this.db
|
|
454
|
+
.deleteFrom("messages")
|
|
455
|
+
.where("mailID", "=", mailID)
|
|
456
|
+
.execute();
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
419
460
|
async purgeHistory(): Promise<void> {
|
|
420
461
|
await this.db.deleteFrom("messages").execute();
|
|
421
462
|
}
|
|
@@ -489,6 +530,16 @@ export class SqliteStorage extends EventEmitter implements Storage {
|
|
|
489
530
|
nonce: message.nonce,
|
|
490
531
|
readerID: message.readerID,
|
|
491
532
|
recipient: message.recipient,
|
|
533
|
+
retentionHintDays:
|
|
534
|
+
message.retentionHintDays === undefined
|
|
535
|
+
? null
|
|
536
|
+
: Math.min(
|
|
537
|
+
30,
|
|
538
|
+
Math.max(
|
|
539
|
+
1,
|
|
540
|
+
Math.round(message.retentionHintDays),
|
|
541
|
+
),
|
|
542
|
+
),
|
|
492
543
|
sender: message.sender,
|
|
493
544
|
timestamp: message.timestamp,
|
|
494
545
|
})
|
|
@@ -638,7 +689,7 @@ export class SqliteStorage extends EventEmitter implements Storage {
|
|
|
638
689
|
}
|
|
639
690
|
const direction =
|
|
640
691
|
msg.direction === "incoming" ? "incoming" : "outgoing";
|
|
641
|
-
|
|
692
|
+
const rowMessage: Message = {
|
|
642
693
|
authorID: msg.authorID,
|
|
643
694
|
decrypted: decryptedFlag,
|
|
644
695
|
direction,
|
|
@@ -651,7 +702,11 @@ export class SqliteStorage extends EventEmitter implements Storage {
|
|
|
651
702
|
recipient: msg.recipient,
|
|
652
703
|
sender: msg.sender,
|
|
653
704
|
timestamp: msg.timestamp,
|
|
654
|
-
}
|
|
705
|
+
};
|
|
706
|
+
if (msg.retentionHintDays != null) {
|
|
707
|
+
rowMessage.retentionHintDays = msg.retentionHintDays;
|
|
708
|
+
}
|
|
709
|
+
out.push(rowMessage);
|
|
655
710
|
}
|
|
656
711
|
return out;
|
|
657
712
|
}
|
|
@@ -669,6 +724,18 @@ export class SqliteStorage extends EventEmitter implements Storage {
|
|
|
669
724
|
};
|
|
670
725
|
}
|
|
671
726
|
|
|
727
|
+
private async ensureRetentionHintColumn(): Promise<void> {
|
|
728
|
+
try {
|
|
729
|
+
await sql
|
|
730
|
+
.raw(
|
|
731
|
+
"ALTER TABLE messages ADD COLUMN retentionHintDays integer",
|
|
732
|
+
)
|
|
733
|
+
.execute(this.db);
|
|
734
|
+
} catch {
|
|
735
|
+
// Existing databases may already have this column.
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
672
739
|
private async ensureSessionRatchetColumns(): Promise<void> {
|
|
673
740
|
const add = async (
|
|
674
741
|
column: string,
|