@vex-chat/libvex 6.2.3 → 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/src/Client.ts CHANGED
@@ -78,6 +78,11 @@ import { EventEmitter } from "eventemitter3";
78
78
  import * as uuid from "uuid";
79
79
  import { z } from "zod/v4";
80
80
 
81
+ import {
82
+ clampLocalMessageRetentionDays,
83
+ formatVexRetentionEnvelope,
84
+ stripVexRetentionEnvelope,
85
+ } from "./retention.js";
81
86
  import {
82
87
  WebSocketAdapter,
83
88
  WebSocketNotOpenError,
@@ -363,6 +368,13 @@ export interface ClientOptions {
363
368
  host?: string;
364
369
  /** Use sqlite in-memory mode (`:memory:`) instead of a file. */
365
370
  inMemoryDb?: boolean;
371
+ /**
372
+ * Maximum age (in days) for messages kept in local storage. Values above
373
+ * 30 are clamped to 30 to match server retention. Peers may request a
374
+ * shorter window via an optional plaintext hint; this setting is still
375
+ * capped at 30 and is not enforceable against a modified client.
376
+ */
377
+ localMessageRetentionDays?: number;
366
378
  /** Whether local message history should be persisted by default storage. */
367
379
  saveHistory?: boolean;
368
380
  /** Use `http/ws` instead of `https/wss`. Intended for local/dev environments. */
@@ -552,6 +564,12 @@ export interface Message {
552
564
  readerID: string;
553
565
  /** Recipient device ID. */
554
566
  recipient: string;
567
+ /**
568
+ * Optional peer hint (1–30): cooperative senders prefix plaintext; used
569
+ * with {@link ClientOptions.localMessageRetentionDays} to pick the
570
+ * shorter local retention window. Ignored when absent.
571
+ */
572
+ retentionHintDays?: number;
555
573
  /** Sender device ID. */
556
574
  sender: string;
557
575
  /** Time the message was created/received. */
@@ -740,7 +758,11 @@ export interface Messages {
740
758
  /** Deletes local history for a user/channel. */
741
759
  delete: (userOrChannelID: string) => Promise<void>;
742
760
  /** Sends an encrypted message to all members of a channel. */
743
- group: (channelID: string, message: string) => Promise<void>;
761
+ group: (
762
+ channelID: string,
763
+ message: string,
764
+ opts?: { retentionHintDays?: number },
765
+ ) => Promise<void>;
744
766
  /** Deletes all locally stored message history. */
745
767
  purge: () => Promise<void>;
746
768
  /** Returns local direct-message history with one user. */
@@ -748,7 +770,11 @@ export interface Messages {
748
770
  /** Returns local group-message history for one channel. */
749
771
  retrieveGroup: (channelID: string) => Promise<Message[]>;
750
772
  /** Sends an encrypted direct message to one user. */
751
- send: (userID: string, message: string) => Promise<void>;
773
+ send: (
774
+ userID: string,
775
+ message: string,
776
+ opts?: { retentionHintDays?: number },
777
+ ) => Promise<void>;
752
778
  }
753
779
 
754
780
  /**
@@ -1072,7 +1098,11 @@ export class Client {
1072
1098
  * @param channelID - The channel to send a message to.
1073
1099
  * @param message - The message to send.
1074
1100
  */
1075
- group: this.sendGroupMessage.bind(this),
1101
+ group: (
1102
+ channelID: string,
1103
+ message: string,
1104
+ opts?: { retentionHintDays?: number },
1105
+ ) => this.sendGroupMessage(channelID, message, opts),
1076
1106
  purge: this.purgeHistory.bind(this),
1077
1107
  /**
1078
1108
  * Gets the message history with a specific userID.
@@ -1093,7 +1123,11 @@ export class Client {
1093
1123
  * @param userID - The user to send a message to.
1094
1124
  * @param message - The message to send.
1095
1125
  */
1096
- send: this.sendMessage.bind(this),
1126
+ send: (
1127
+ userID: string,
1128
+ message: string,
1129
+ opts?: { retentionHintDays?: number },
1130
+ ) => this.sendMessage(userID, message, opts),
1097
1131
  };
1098
1132
  /**
1099
1133
  * Server moderation helper methods.
@@ -1250,9 +1284,13 @@ export class Client {
1250
1284
  private readonly httpAbortController = new AbortController();
1251
1285
  private readonly idKeys: KeyPair | null;
1252
1286
  private isAlive: boolean = true;
1253
- private readonly mailInterval?: NodeJS.Timeout;
1287
+ private localMessageRetentionDays: number;
1254
1288
 
1289
+ private localRetentionPurgeTimer: null | ReturnType<typeof setInterval> =
1290
+ null;
1291
+ private readonly mailInterval?: NodeJS.Timeout;
1255
1292
  private manuallyClosing: boolean = false;
1293
+
1256
1294
  /**
1257
1295
  * Node-only: per-client HTTP(S) agents (see `init()` + `storage/node/http-agents`).
1258
1296
  * Dropped on `close()` so idle keep-alive sockets do not keep the process alive.
@@ -1266,20 +1304,21 @@ export class Client {
1266
1304
  and finally falls back to username. */
1267
1305
  /** Negative cache for user lookups that returned 404. TTL = 30 minutes. */
1268
1306
  private readonly notFoundUsers = new Map<string, number>();
1269
-
1270
1307
  private readonly options?: ClientOptions | undefined;
1271
1308
 
1272
1309
  private pingInterval: null | ReturnType<typeof setTimeout> = null;
1310
+
1273
1311
  /**
1274
1312
  * Bumped when the WebSocket is torn down and re-opened so the previous
1275
1313
  * `postAuth` loop exits instead of overlapping a new one.
1276
1314
  */
1277
1315
  private postAuthVersion = 0;
1278
-
1279
1316
  private readonly prefixes:
1280
1317
  | { HTTP: "http://"; WS: "ws://" }
1281
1318
  | { HTTP: "https://"; WS: "wss://" };
1319
+
1282
1320
  private reading: boolean = false;
1321
+ private retentionPurgeDebounce: null | ReturnType<typeof setTimeout> = null;
1283
1322
  private readonly seenMailIDs: Set<string> = new Set();
1284
1323
  private sessionRecords: Record<string, SessionCrypto> = {};
1285
1324
 
@@ -1303,6 +1342,9 @@ export class Client {
1303
1342
  storage?: Storage,
1304
1343
  ) {
1305
1344
  this.options = options;
1345
+ this.localMessageRetentionDays = clampLocalMessageRetentionDays(
1346
+ options?.localMessageRetentionDays,
1347
+ );
1306
1348
  this.cryptoProfile = material.cryptoProfile;
1307
1349
  this.signKeys = material.signKeys;
1308
1350
  this.idKeys = material.idKeys;
@@ -1600,6 +1642,14 @@ export class Client {
1600
1642
  if (this.mailInterval) {
1601
1643
  clearInterval(this.mailInterval);
1602
1644
  }
1645
+ if (this.localRetentionPurgeTimer) {
1646
+ clearInterval(this.localRetentionPurgeTimer);
1647
+ this.localRetentionPurgeTimer = null;
1648
+ }
1649
+ if (this.retentionPurgeDebounce) {
1650
+ clearTimeout(this.retentionPurgeDebounce);
1651
+ this.retentionPurgeDebounce = null;
1652
+ }
1603
1653
  delete this.xKeyRing;
1604
1654
 
1605
1655
  if (!muteEvent) {
@@ -1679,6 +1729,11 @@ export class Client {
1679
1729
  };
1680
1730
  }
1681
1731
 
1732
+ /** Current local retention cap in days (always 1–30). */
1733
+ public getLocalMessageRetentionDays(): number {
1734
+ return this.localMessageRetentionDays;
1735
+ }
1736
+
1682
1737
  /**
1683
1738
  * Authenticates with username/password and stores the Bearer auth token.
1684
1739
  *
@@ -2019,6 +2074,15 @@ export class Client {
2019
2074
  return this;
2020
2075
  }
2021
2076
 
2077
+ /**
2078
+ * Updates the local retention cap (1–30 days) and prunes immediately.
2079
+ * Does not affect server-side storage.
2080
+ */
2081
+ public setLocalMessageRetentionDays(days: number): void {
2082
+ this.localMessageRetentionDays = clampLocalMessageRetentionDays(days);
2083
+ void this.runLocalRetentionPurge();
2084
+ }
2085
+
2022
2086
  /**
2023
2087
  * Triggers an immediate inbox sync by fetching `/mail` once.
2024
2088
  * Useful on mobile foreground resume where background work may pause.
@@ -2501,7 +2565,6 @@ export class Client {
2501
2565
  private async deleteServer(serverID: string): Promise<void> {
2502
2566
  await this.http.delete(this.getHost() + "/server/" + serverID);
2503
2567
  }
2504
-
2505
2568
  private deviceListFailureDetail(err: unknown): string {
2506
2569
  if (!isAxiosError(err)) {
2507
2570
  return "";
@@ -3054,6 +3117,8 @@ export class Client {
3054
3117
  }
3055
3118
  }
3056
3119
 
3120
+ // ── Passkeys ────────────────────────────────────────────────────────
3121
+
3057
3122
  /**
3058
3123
  * Initializes the keyring. This must be called before anything else.
3059
3124
  */
@@ -3073,6 +3138,11 @@ export class Client {
3073
3138
 
3074
3139
  await this.populateKeyRing();
3075
3140
  this.emitter.on("message", this.onInternalMessage);
3141
+ void this.runLocalRetentionPurge();
3142
+ this.localRetentionPurgeTimer = setInterval(
3143
+ () => void this.runLocalRetentionPurge(),
3144
+ 6 * 60 * 60 * 1000,
3145
+ );
3076
3146
  this.emitter.emit("ready");
3077
3147
  }
3078
3148
 
@@ -3284,6 +3354,7 @@ export class Client {
3284
3354
  return;
3285
3355
  }
3286
3356
  void this.database.saveMessage(message);
3357
+ this.scheduleRetentionPurge();
3287
3358
  };
3288
3359
 
3289
3360
  private async passkeyApproveDeviceRequest(
@@ -3712,13 +3783,39 @@ export class Client {
3712
3783
  if (!mail.forward) {
3713
3784
  plaintext = XUtils.encodeUTF8(unsealed);
3714
3785
  }
3786
+ let incomingPlain = plaintext;
3787
+ let hintFromEnvelope: number | undefined;
3788
+ if (!mail.forward && plaintext.length > 0) {
3789
+ const stripped =
3790
+ stripVexRetentionEnvelope(plaintext);
3791
+ incomingPlain = stripped.body;
3792
+ hintFromEnvelope = stripped.retentionHintDays;
3793
+ }
3715
3794
 
3716
3795
  // emit the message
3717
3796
  const fwdMsg1 = mail.forward
3718
3797
  ? messageSchema.parse(msgpack.decode(unsealed))
3719
3798
  : null;
3720
3799
  const message: Message = fwdMsg1
3721
- ? { ...fwdMsg1, forward: true }
3800
+ ? (() => {
3801
+ const stripped =
3802
+ stripVexRetentionEnvelope(
3803
+ fwdMsg1.message,
3804
+ );
3805
+ const base: Message = {
3806
+ ...fwdMsg1,
3807
+ forward: true,
3808
+ message: stripped.body,
3809
+ };
3810
+ return stripped.retentionHintDays !==
3811
+ undefined
3812
+ ? {
3813
+ ...base,
3814
+ retentionHintDays:
3815
+ stripped.retentionHintDays,
3816
+ }
3817
+ : base;
3818
+ })()
3722
3819
  : {
3723
3820
  authorID: mail.authorID,
3724
3821
  decrypted: true,
@@ -3728,12 +3825,18 @@ export class Client {
3728
3825
  ? uuid.stringify(mail.group)
3729
3826
  : null,
3730
3827
  mailID: mail.mailID,
3731
- message: plaintext,
3828
+ message: incomingPlain,
3732
3829
  nonce: XUtils.encodeHex(
3733
3830
  new Uint8Array(mail.nonce),
3734
3831
  ),
3735
3832
  readerID: mail.readerID,
3736
3833
  recipient: mail.recipient,
3834
+ ...(hintFromEnvelope !== undefined
3835
+ ? {
3836
+ retentionHintDays:
3837
+ hintFromEnvelope,
3838
+ }
3839
+ : {}),
3737
3840
  sender: mail.sender,
3738
3841
  timestamp: timestamp,
3739
3842
  };
@@ -3900,11 +4003,35 @@ export class Client {
3900
4003
  const fwdMsg2 = mail.forward
3901
4004
  ? messageSchema.parse(msgpack.decode(decrypted))
3902
4005
  : null;
4006
+ const rawIncoming = XUtils.encodeUTF8(decrypted);
4007
+ let bodyIncoming = rawIncoming;
4008
+ let hintIncoming: number | undefined;
4009
+ if (!mail.forward) {
4010
+ const stripped =
4011
+ stripVexRetentionEnvelope(rawIncoming);
4012
+ bodyIncoming = stripped.body;
4013
+ hintIncoming = stripped.retentionHintDays;
4014
+ }
3903
4015
  const message: Message = fwdMsg2
3904
- ? {
3905
- ...fwdMsg2,
3906
- forward: true,
3907
- }
4016
+ ? (() => {
4017
+ const stripped =
4018
+ stripVexRetentionEnvelope(
4019
+ fwdMsg2.message,
4020
+ );
4021
+ const base: Message = {
4022
+ ...fwdMsg2,
4023
+ forward: true,
4024
+ message: stripped.body,
4025
+ };
4026
+ return stripped.retentionHintDays !==
4027
+ undefined
4028
+ ? {
4029
+ ...base,
4030
+ retentionHintDays:
4031
+ stripped.retentionHintDays,
4032
+ }
4033
+ : base;
4034
+ })()
3908
4035
  : {
3909
4036
  authorID: mail.authorID,
3910
4037
  decrypted: true,
@@ -3914,12 +4041,15 @@ export class Client {
3914
4041
  ? uuid.stringify(mail.group)
3915
4042
  : null,
3916
4043
  mailID: mail.mailID,
3917
- message: XUtils.encodeUTF8(decrypted),
4044
+ message: bodyIncoming,
3918
4045
  nonce: XUtils.encodeHex(
3919
4046
  new Uint8Array(mail.nonce),
3920
4047
  ),
3921
4048
  readerID: mail.readerID,
3922
4049
  recipient: mail.recipient,
4050
+ ...(hintIncoming !== undefined
4051
+ ? { retentionHintDays: hintIncoming }
4052
+ : {}),
3923
4053
  sender: mail.sender,
3924
4054
  timestamp: timestamp,
3925
4055
  };
@@ -4168,6 +4298,19 @@ export class Client {
4168
4298
  return device;
4169
4299
  }
4170
4300
 
4301
+ private async runLocalRetentionPurge(): Promise<void> {
4302
+ if (this.isManualCloseInFlight()) {
4303
+ return;
4304
+ }
4305
+ try {
4306
+ await this.database.pruneExpiredLocalMessages(
4307
+ this.localMessageRetentionDays,
4308
+ );
4309
+ } catch {
4310
+ /* best-effort */
4311
+ }
4312
+ }
4313
+
4171
4314
  /**
4172
4315
  * `xDHAsync` and other helpers in `@vex-chat/crypto` use the process-wide
4173
4316
  * active profile. When several {@link Client} instances use different
@@ -4189,6 +4332,16 @@ export class Client {
4189
4332
  }
4190
4333
  }
4191
4334
 
4335
+ private scheduleRetentionPurge(): void {
4336
+ if (this.retentionPurgeDebounce) {
4337
+ clearTimeout(this.retentionPurgeDebounce);
4338
+ }
4339
+ this.retentionPurgeDebounce = setTimeout(() => {
4340
+ this.retentionPurgeDebounce = null;
4341
+ void this.runLocalRetentionPurge();
4342
+ }, 3000);
4343
+ }
4344
+
4192
4345
  /* header is 32 bytes and is either empty
4193
4346
  or contains an HMAC of the message with
4194
4347
  a derived SK */
@@ -4224,6 +4377,7 @@ export class Client {
4224
4377
  private async sendGroupMessage(
4225
4378
  channelID: string,
4226
4379
  message: string,
4380
+ opts?: { retentionHintDays?: number },
4227
4381
  ): Promise<void> {
4228
4382
  const userList = await this.getUserList(channelID);
4229
4383
  for (const user of userList) {
@@ -4231,7 +4385,11 @@ export class Client {
4231
4385
  }
4232
4386
 
4233
4387
  const mailID = uuid.v4();
4234
- const msgBytes = XUtils.decodeUTF8(message);
4388
+ const payload = formatVexRetentionEnvelope(
4389
+ message,
4390
+ opts?.retentionHintDays,
4391
+ );
4392
+ const msgBytes = XUtils.decodeUTF8(payload);
4235
4393
  const myUserID = this.getUser().userID;
4236
4394
  // Fan-out only to *other* server members. The current account's other
4237
4395
  // devices receive the same group mail via `forward()` on the outgoing
@@ -4403,8 +4561,25 @@ export class Client {
4403
4561
  const fwdOut = forward
4404
4562
  ? messageSchema.parse(msgpack.decode(msg))
4405
4563
  : null;
4564
+ const rawUtf8 = XUtils.encodeUTF8(msg);
4565
+ const strippedOut = stripVexRetentionEnvelope(rawUtf8);
4406
4566
  const outMsg: Message = fwdOut
4407
- ? { ...fwdOut, forward: true }
4567
+ ? (() => {
4568
+ const stripped = stripVexRetentionEnvelope(
4569
+ fwdOut.message,
4570
+ );
4571
+ const base: Message = {
4572
+ ...fwdOut,
4573
+ forward: true,
4574
+ message: stripped.body,
4575
+ };
4576
+ return stripped.retentionHintDays !== undefined
4577
+ ? {
4578
+ ...base,
4579
+ retentionHintDays: stripped.retentionHintDays,
4580
+ }
4581
+ : base;
4582
+ })()
4408
4583
  : {
4409
4584
  authorID: mail.authorID,
4410
4585
  decrypted: true,
@@ -4412,10 +4587,16 @@ export class Client {
4412
4587
  forward: mail.forward,
4413
4588
  group: mail.group ? uuid.stringify(mail.group) : null,
4414
4589
  mailID: mail.mailID,
4415
- message: XUtils.encodeUTF8(msg),
4590
+ message: strippedOut.body,
4416
4591
  nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
4417
4592
  readerID: mail.readerID,
4418
4593
  recipient: mail.recipient,
4594
+ ...(strippedOut.retentionHintDays !== undefined
4595
+ ? {
4596
+ retentionHintDays:
4597
+ strippedOut.retentionHintDays,
4598
+ }
4599
+ : {}),
4419
4600
  sender: mail.sender,
4420
4601
  timestamp: new Date().toISOString(),
4421
4602
  };
@@ -4480,7 +4661,15 @@ export class Client {
4480
4661
  }
4481
4662
  }
4482
4663
 
4483
- private async sendMessage(userID: string, message: string): Promise<void> {
4664
+ private async sendMessage(
4665
+ userID: string,
4666
+ message: string,
4667
+ opts?: { retentionHintDays?: number },
4668
+ ): Promise<void> {
4669
+ const payload = formatVexRetentionEnvelope(
4670
+ message,
4671
+ opts?.retentionHintDays,
4672
+ );
4484
4673
  try {
4485
4674
  const [userEntry, err] = await this.fetchUser(userID);
4486
4675
  if (err) {
@@ -4553,7 +4742,7 @@ export class Client {
4553
4742
  await this.sendMail(
4554
4743
  device,
4555
4744
  userEntry,
4556
- XUtils.decodeUTF8(message),
4745
+ XUtils.decodeUTF8(payload),
4557
4746
  null,
4558
4747
  messageMailID,
4559
4748
  false,
package/src/Storage.ts CHANGED
@@ -91,6 +91,13 @@ 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, retentionHintDays ?? 30)`.
97
+ */
98
+ pruneExpiredLocalMessages: (
99
+ clientMaxRetentionDays: number,
100
+ ) => Promise<void>;
94
101
  /** Deletes all message history. */
95
102
  purgeHistory: () => Promise<void>;
96
103
  /** Deletes all local key/session state. */
@@ -170,6 +170,25 @@ export class MemoryStorage extends EventEmitter implements Storage {
170
170
  return Promise.resolve();
171
171
  }
172
172
 
173
+ pruneExpiredLocalMessages(clientMaxRetentionDays: number): Promise<void> {
174
+ const cap = Math.min(
175
+ 30,
176
+ Math.max(1, Math.round(clientMaxRetentionDays)),
177
+ );
178
+ const now = Date.now();
179
+ const msPerDay = 86_400_000;
180
+ this.messages = this.messages.filter((m) => {
181
+ const hintDays = m.retentionHintDays ?? 30;
182
+ const maxDays = Math.min(30, cap, hintDays);
183
+ const ts = new Date(m.timestamp).getTime();
184
+ if (!Number.isFinite(ts)) {
185
+ return true;
186
+ }
187
+ return now - ts <= maxDays * msPerDay;
188
+ });
189
+ return Promise.resolve();
190
+ }
191
+
173
192
  purgeHistory(): Promise<void> {
174
193
  this.messages = [];
175
194
  return Promise.resolve();
@@ -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
  }