@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.
@@ -87,11 +87,40 @@ export function platformSuite(
87
87
  (m) => m.direction === "incoming" && m.decrypted,
88
88
  `[${platformName}] self-DM`,
89
89
  );
90
- void client.messages.send(me.userID, "platform-test");
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,
@@ -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
+ }
@@ -65,6 +65,8 @@ interface MessagesTable {
65
65
  nonce: string;
66
66
  readerID: string;
67
67
  recipient: string;
68
+ /** Optional peer hint (1–30 days); null when absent or legacy row. */
69
+ retentionHintDays: null | number;
68
70
  sender: string;
69
71
  timestamp: string;
70
72
  }
@@ -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
- out.push({
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,