@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.
@@ -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,
@@ -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
+ }
@@ -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
  }
@@ -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
- out.push({
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,