@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/src/Client.ts CHANGED
@@ -42,7 +42,9 @@ import type { AxiosInstance } from "axios";
42
42
 
43
43
  import {
44
44
  type CryptoProfile,
45
+ enterCryptoProfileScope,
45
46
  getCryptoProfile,
47
+ leaveCryptoProfileScope,
46
48
  setCryptoProfile,
47
49
  xBoxKeyPairAsync,
48
50
  xBoxKeyPairFromSecretAsync,
@@ -78,6 +80,11 @@ import { EventEmitter } from "eventemitter3";
78
80
  import * as uuid from "uuid";
79
81
  import { z } from "zod/v4";
80
82
 
83
+ import {
84
+ clampLocalMessageRetentionDays,
85
+ formatVexRetentionEnvelope,
86
+ stripVexRetentionEnvelope,
87
+ } from "./retention.js";
81
88
  import {
82
89
  WebSocketAdapter,
83
90
  WebSocketNotOpenError,
@@ -363,6 +370,13 @@ export interface ClientOptions {
363
370
  host?: string;
364
371
  /** Use sqlite in-memory mode (`:memory:`) instead of a file. */
365
372
  inMemoryDb?: boolean;
373
+ /**
374
+ * Maximum age (in days) for messages kept in local storage. Values above
375
+ * 30 are clamped to 30 to match server retention. Peers may request a
376
+ * shorter window via an optional plaintext hint; this setting is still
377
+ * capped at 30 and is not enforceable against a modified client.
378
+ */
379
+ localMessageRetentionDays?: number;
366
380
  /** Whether local message history should be persisted by default storage. */
367
381
  saveHistory?: boolean;
368
382
  /** Use `http/ws` instead of `https/wss`. Intended for local/dev environments. */
@@ -375,6 +389,14 @@ export type DeviceRegistrationResult = Device | PendingDeviceRegistration;
375
389
  * @ignore
376
390
  */
377
391
  export interface Devices {
392
+ /**
393
+ * Deletes an unpublished enrollment before any owner notification
394
+ * (e.g. user picked "not my account").
395
+ */
396
+ abortPendingRegistration: (args: {
397
+ challenge: string;
398
+ requestID: string;
399
+ }) => Promise<void>;
378
400
  /** Approves a pending device registration request as the current device. */
379
401
  approveRequest: (requestID: string) => Promise<Device>;
380
402
  /** Deletes one of the account's devices (except the currently active one). */
@@ -402,6 +424,14 @@ export interface Devices {
402
424
  challenge: string;
403
425
  requestID: string;
404
426
  }) => Promise<null | PendingDeviceRequest>;
427
+ /**
428
+ * After the user confirms the pending enrollment is theirs, notifies
429
+ * their existing devices (same proof as poll).
430
+ */
431
+ publishPendingRegistration: (args: {
432
+ challenge: string;
433
+ requestID: string;
434
+ }) => Promise<void>;
405
435
  /** Registers the current key material as a new device. */
406
436
  register: () => Promise<DeviceRegistrationResult | null>;
407
437
  /** Rejects a pending device registration request as the current device. */
@@ -552,6 +582,12 @@ export interface Message {
552
582
  readerID: string;
553
583
  /** Recipient device ID. */
554
584
  recipient: string;
585
+ /**
586
+ * Optional peer hint (1–30): cooperative senders prefix plaintext; used
587
+ * with {@link ClientOptions.localMessageRetentionDays} to pick the
588
+ * shorter local retention window. Ignored when absent.
589
+ */
590
+ retentionHintDays?: number;
555
591
  /** Sender device ID. */
556
592
  sender: string;
557
593
  /** Time the message was created/received. */
@@ -740,7 +776,11 @@ export interface Messages {
740
776
  /** Deletes local history for a user/channel. */
741
777
  delete: (userOrChannelID: string) => Promise<void>;
742
778
  /** Sends an encrypted message to all members of a channel. */
743
- group: (channelID: string, message: string) => Promise<void>;
779
+ group: (
780
+ channelID: string,
781
+ message: string,
782
+ opts?: { retentionHintDays?: number },
783
+ ) => Promise<void>;
744
784
  /** Deletes all locally stored message history. */
745
785
  purge: () => Promise<void>;
746
786
  /** Returns local direct-message history with one user. */
@@ -748,7 +788,11 @@ export interface Messages {
748
788
  /** Returns local group-message history for one channel. */
749
789
  retrieveGroup: (channelID: string) => Promise<Message[]>;
750
790
  /** Sends an encrypted direct message to one user. */
751
- send: (userID: string, message: string) => Promise<void>;
791
+ send: (
792
+ userID: string,
793
+ message: string,
794
+ opts?: { retentionHintDays?: number },
795
+ ) => Promise<void>;
752
796
  }
753
797
 
754
798
  /**
@@ -977,12 +1021,16 @@ export class Client {
977
1021
  * Device management methods.
978
1022
  */
979
1023
  public devices: Devices = {
1024
+ abortPendingRegistration:
1025
+ this.abortPendingDeviceRegistration.bind(this),
980
1026
  approveRequest: this.approveDeviceRequest.bind(this),
981
1027
  delete: this.deleteDevice.bind(this),
982
1028
  getRequest: this.getDeviceRegistrationRequest.bind(this),
983
1029
  list: this.listDevices.bind(this),
984
1030
  listRequests: this.listDeviceRegistrationRequests.bind(this),
985
1031
  pollPendingRegistration: this.pollPendingDeviceRegistration.bind(this),
1032
+ publishPendingRegistration:
1033
+ this.publishPendingDeviceRegistration.bind(this),
986
1034
  register: this.registerDevice.bind(this),
987
1035
  rejectRequest: this.rejectDeviceRequest.bind(this),
988
1036
  retrieve: this.getDeviceByID.bind(this),
@@ -1072,7 +1120,11 @@ export class Client {
1072
1120
  * @param channelID - The channel to send a message to.
1073
1121
  * @param message - The message to send.
1074
1122
  */
1075
- group: this.sendGroupMessage.bind(this),
1123
+ group: (
1124
+ channelID: string,
1125
+ message: string,
1126
+ opts?: { retentionHintDays?: number },
1127
+ ) => this.sendGroupMessage(channelID, message, opts),
1076
1128
  purge: this.purgeHistory.bind(this),
1077
1129
  /**
1078
1130
  * Gets the message history with a specific userID.
@@ -1093,7 +1145,11 @@ export class Client {
1093
1145
  * @param userID - The user to send a message to.
1094
1146
  * @param message - The message to send.
1095
1147
  */
1096
- send: this.sendMessage.bind(this),
1148
+ send: (
1149
+ userID: string,
1150
+ message: string,
1151
+ opts?: { retentionHintDays?: number },
1152
+ ) => this.sendMessage(userID, message, opts),
1097
1153
  };
1098
1154
  /**
1099
1155
  * Server moderation helper methods.
@@ -1250,9 +1306,13 @@ export class Client {
1250
1306
  private readonly httpAbortController = new AbortController();
1251
1307
  private readonly idKeys: KeyPair | null;
1252
1308
  private isAlive: boolean = true;
1253
- private readonly mailInterval?: NodeJS.Timeout;
1309
+ private localMessageRetentionDays: number;
1254
1310
 
1311
+ private localRetentionPurgeTimer: null | ReturnType<typeof setInterval> =
1312
+ null;
1313
+ private readonly mailInterval?: NodeJS.Timeout;
1255
1314
  private manuallyClosing: boolean = false;
1315
+
1256
1316
  /**
1257
1317
  * Node-only: per-client HTTP(S) agents (see `init()` + `storage/node/http-agents`).
1258
1318
  * Dropped on `close()` so idle keep-alive sockets do not keep the process alive.
@@ -1266,20 +1326,21 @@ export class Client {
1266
1326
  and finally falls back to username. */
1267
1327
  /** Negative cache for user lookups that returned 404. TTL = 30 minutes. */
1268
1328
  private readonly notFoundUsers = new Map<string, number>();
1269
-
1270
1329
  private readonly options?: ClientOptions | undefined;
1271
1330
 
1272
1331
  private pingInterval: null | ReturnType<typeof setTimeout> = null;
1332
+
1273
1333
  /**
1274
1334
  * Bumped when the WebSocket is torn down and re-opened so the previous
1275
1335
  * `postAuth` loop exits instead of overlapping a new one.
1276
1336
  */
1277
1337
  private postAuthVersion = 0;
1278
-
1279
1338
  private readonly prefixes:
1280
1339
  | { HTTP: "http://"; WS: "ws://" }
1281
1340
  | { HTTP: "https://"; WS: "wss://" };
1341
+
1282
1342
  private reading: boolean = false;
1343
+ private retentionPurgeDebounce: null | ReturnType<typeof setTimeout> = null;
1283
1344
  private readonly seenMailIDs: Set<string> = new Set();
1284
1345
  private sessionRecords: Record<string, SessionCrypto> = {};
1285
1346
 
@@ -1303,6 +1364,9 @@ export class Client {
1303
1364
  storage?: Storage,
1304
1365
  ) {
1305
1366
  this.options = options;
1367
+ this.localMessageRetentionDays = clampLocalMessageRetentionDays(
1368
+ options?.localMessageRetentionDays,
1369
+ );
1306
1370
  this.cryptoProfile = material.cryptoProfile;
1307
1371
  this.signKeys = material.signKeys;
1308
1372
  this.idKeys = material.idKeys;
@@ -1600,6 +1664,14 @@ export class Client {
1600
1664
  if (this.mailInterval) {
1601
1665
  clearInterval(this.mailInterval);
1602
1666
  }
1667
+ if (this.localRetentionPurgeTimer) {
1668
+ clearInterval(this.localRetentionPurgeTimer);
1669
+ this.localRetentionPurgeTimer = null;
1670
+ }
1671
+ if (this.retentionPurgeDebounce) {
1672
+ clearTimeout(this.retentionPurgeDebounce);
1673
+ this.retentionPurgeDebounce = null;
1674
+ }
1603
1675
  delete this.xKeyRing;
1604
1676
 
1605
1677
  if (!muteEvent) {
@@ -1679,6 +1751,11 @@ export class Client {
1679
1751
  };
1680
1752
  }
1681
1753
 
1754
+ /** Current local retention cap in days (always 1–30). */
1755
+ public getLocalMessageRetentionDays(): number {
1756
+ return this.localMessageRetentionDays;
1757
+ }
1758
+
1682
1759
  /**
1683
1760
  * Authenticates with username/password and stores the Bearer auth token.
1684
1761
  *
@@ -2019,6 +2096,15 @@ export class Client {
2019
2096
  return this;
2020
2097
  }
2021
2098
 
2099
+ /**
2100
+ * Updates the local retention cap (1–30 days) and prunes immediately.
2101
+ * Does not affect server-side storage.
2102
+ */
2103
+ public setLocalMessageRetentionDays(days: number): void {
2104
+ this.localMessageRetentionDays = clampLocalMessageRetentionDays(days);
2105
+ void this.runLocalRetentionPurge();
2106
+ }
2107
+
2022
2108
  /**
2023
2109
  * Triggers an immediate inbox sync by fetching `/mail` once.
2024
2110
  * Useful on mobile foreground resume where background work may pause.
@@ -2060,6 +2146,23 @@ export class Client {
2060
2146
  return whoami;
2061
2147
  }
2062
2148
 
2149
+ private async abortPendingDeviceRegistration(args: {
2150
+ challenge: string;
2151
+ requestID: string;
2152
+ }): Promise<void> {
2153
+ const signed = await this.signPendingRegistrationChallenge(
2154
+ args.challenge,
2155
+ );
2156
+ await this.http.post(
2157
+ this.getHost() +
2158
+ "/user/devices/requests/" +
2159
+ args.requestID +
2160
+ "/abort",
2161
+ msgpack.encode({ signed }),
2162
+ { headers: { "Content-Type": "application/msgpack" } },
2163
+ );
2164
+ }
2165
+
2063
2166
  private acknowledgeInboundMail(mail: MailWS): void {
2064
2167
  this.seenMailIDs.add(mail.mailID);
2065
2168
  this.sendReceipt(new Uint8Array(mail.nonce));
@@ -2497,11 +2600,9 @@ export class Client {
2497
2600
  private async deletePermission(permissionID: string): Promise<void> {
2498
2601
  await this.http.delete(this.getHost() + "/permission/" + permissionID);
2499
2602
  }
2500
-
2501
2603
  private async deleteServer(serverID: string): Promise<void> {
2502
2604
  await this.http.delete(this.getHost() + "/server/" + serverID);
2503
2605
  }
2504
-
2505
2606
  private deviceListFailureDetail(err: unknown): string {
2506
2607
  if (!isAxiosError(err)) {
2507
2608
  return "";
@@ -2515,6 +2616,7 @@ export class Client {
2515
2616
  }
2516
2617
  return "";
2517
2618
  }
2619
+
2518
2620
  /**
2519
2621
  * Gets a list of permissions for a server.
2520
2622
  *
@@ -3015,6 +3117,8 @@ export class Client {
3015
3117
  return decodeAxios(UserArrayCodec, res.data);
3016
3118
  }
3017
3119
 
3120
+ // ── Passkeys ────────────────────────────────────────────────────────
3121
+
3018
3122
  private async handleNotify(msg: NotifyMsg) {
3019
3123
  switch (msg.event) {
3020
3124
  case "deviceRequest": {
@@ -3054,6 +3158,8 @@ export class Client {
3054
3158
  }
3055
3159
  }
3056
3160
 
3161
+ // ── Passkeys ────────────────────────────────────────────────────────
3162
+
3057
3163
  /**
3058
3164
  * Initializes the keyring. This must be called before anything else.
3059
3165
  */
@@ -3073,11 +3179,14 @@ export class Client {
3073
3179
 
3074
3180
  await this.populateKeyRing();
3075
3181
  this.emitter.on("message", this.onInternalMessage);
3182
+ void this.runLocalRetentionPurge();
3183
+ this.localRetentionPurgeTimer = setInterval(
3184
+ () => void this.runLocalRetentionPurge(),
3185
+ 6 * 60 * 60 * 1000,
3186
+ );
3076
3187
  this.emitter.emit("ready");
3077
3188
  }
3078
3189
 
3079
- // ── Passkeys ────────────────────────────────────────────────────────
3080
-
3081
3190
  private initSocket() {
3082
3191
  try {
3083
3192
  if (!this.token) {
@@ -3284,6 +3393,7 @@ export class Client {
3284
3393
  return;
3285
3394
  }
3286
3395
  void this.database.saveMessage(message);
3396
+ this.scheduleRetentionPurge();
3287
3397
  };
3288
3398
 
3289
3399
  private async passkeyApproveDeviceRequest(
@@ -3360,21 +3470,12 @@ export class Client {
3360
3470
  );
3361
3471
  }
3362
3472
 
3363
- /**
3364
- * Polls the public unauthenticated request status endpoint as the
3365
- * requesting device. Signs the server-issued challenge with the local
3366
- * private signing key so the server can verify ownership of the pending
3367
- * request without us needing a user token.
3368
- */
3369
3473
  private async pollPendingDeviceRegistration(args: {
3370
3474
  challenge: string;
3371
3475
  requestID: string;
3372
3476
  }): Promise<null | PendingDeviceRequest> {
3373
- const signed = XUtils.encodeHex(
3374
- await xSignAsync(
3375
- XUtils.decodeHex(args.challenge),
3376
- this.signKeys.secretKey,
3377
- ),
3477
+ const signed = await this.signPendingRegistrationChallenge(
3478
+ args.challenge,
3378
3479
  );
3379
3480
  try {
3380
3481
  const response = await this.http.post(
@@ -3484,6 +3585,23 @@ export class Client {
3484
3585
  }
3485
3586
  }
3486
3587
 
3588
+ private async publishPendingDeviceRegistration(args: {
3589
+ challenge: string;
3590
+ requestID: string;
3591
+ }): Promise<void> {
3592
+ const signed = await this.signPendingRegistrationChallenge(
3593
+ args.challenge,
3594
+ );
3595
+ await this.http.post(
3596
+ this.getHost() +
3597
+ "/user/devices/requests/" +
3598
+ args.requestID +
3599
+ "/publish",
3600
+ msgpack.encode({ signed }),
3601
+ { headers: { "Content-Type": "application/msgpack" } },
3602
+ );
3603
+ }
3604
+
3487
3605
  private async purgeHistory(): Promise<void> {
3488
3606
  await this.database.purgeHistory();
3489
3607
  }
@@ -3712,13 +3830,39 @@ export class Client {
3712
3830
  if (!mail.forward) {
3713
3831
  plaintext = XUtils.encodeUTF8(unsealed);
3714
3832
  }
3833
+ let incomingPlain = plaintext;
3834
+ let hintFromEnvelope: number | undefined;
3835
+ if (!mail.forward && plaintext.length > 0) {
3836
+ const stripped =
3837
+ stripVexRetentionEnvelope(plaintext);
3838
+ incomingPlain = stripped.body;
3839
+ hintFromEnvelope = stripped.retentionHintDays;
3840
+ }
3715
3841
 
3716
3842
  // emit the message
3717
3843
  const fwdMsg1 = mail.forward
3718
3844
  ? messageSchema.parse(msgpack.decode(unsealed))
3719
3845
  : null;
3720
3846
  const message: Message = fwdMsg1
3721
- ? { ...fwdMsg1, forward: true }
3847
+ ? (() => {
3848
+ const stripped =
3849
+ stripVexRetentionEnvelope(
3850
+ fwdMsg1.message,
3851
+ );
3852
+ const base: Message = {
3853
+ ...fwdMsg1,
3854
+ forward: true,
3855
+ message: stripped.body,
3856
+ };
3857
+ return stripped.retentionHintDays !==
3858
+ undefined
3859
+ ? {
3860
+ ...base,
3861
+ retentionHintDays:
3862
+ stripped.retentionHintDays,
3863
+ }
3864
+ : base;
3865
+ })()
3722
3866
  : {
3723
3867
  authorID: mail.authorID,
3724
3868
  decrypted: true,
@@ -3728,12 +3872,18 @@ export class Client {
3728
3872
  ? uuid.stringify(mail.group)
3729
3873
  : null,
3730
3874
  mailID: mail.mailID,
3731
- message: plaintext,
3875
+ message: incomingPlain,
3732
3876
  nonce: XUtils.encodeHex(
3733
3877
  new Uint8Array(mail.nonce),
3734
3878
  ),
3735
3879
  readerID: mail.readerID,
3736
3880
  recipient: mail.recipient,
3881
+ ...(hintFromEnvelope !== undefined
3882
+ ? {
3883
+ retentionHintDays:
3884
+ hintFromEnvelope,
3885
+ }
3886
+ : {}),
3737
3887
  sender: mail.sender,
3738
3888
  timestamp: timestamp,
3739
3889
  };
@@ -3900,11 +4050,35 @@ export class Client {
3900
4050
  const fwdMsg2 = mail.forward
3901
4051
  ? messageSchema.parse(msgpack.decode(decrypted))
3902
4052
  : null;
4053
+ const rawIncoming = XUtils.encodeUTF8(decrypted);
4054
+ let bodyIncoming = rawIncoming;
4055
+ let hintIncoming: number | undefined;
4056
+ if (!mail.forward) {
4057
+ const stripped =
4058
+ stripVexRetentionEnvelope(rawIncoming);
4059
+ bodyIncoming = stripped.body;
4060
+ hintIncoming = stripped.retentionHintDays;
4061
+ }
3903
4062
  const message: Message = fwdMsg2
3904
- ? {
3905
- ...fwdMsg2,
3906
- forward: true,
3907
- }
4063
+ ? (() => {
4064
+ const stripped =
4065
+ stripVexRetentionEnvelope(
4066
+ fwdMsg2.message,
4067
+ );
4068
+ const base: Message = {
4069
+ ...fwdMsg2,
4070
+ forward: true,
4071
+ message: stripped.body,
4072
+ };
4073
+ return stripped.retentionHintDays !==
4074
+ undefined
4075
+ ? {
4076
+ ...base,
4077
+ retentionHintDays:
4078
+ stripped.retentionHintDays,
4079
+ }
4080
+ : base;
4081
+ })()
3908
4082
  : {
3909
4083
  authorID: mail.authorID,
3910
4084
  decrypted: true,
@@ -3914,12 +4088,15 @@ export class Client {
3914
4088
  ? uuid.stringify(mail.group)
3915
4089
  : null,
3916
4090
  mailID: mail.mailID,
3917
- message: XUtils.encodeUTF8(decrypted),
4091
+ message: bodyIncoming,
3918
4092
  nonce: XUtils.encodeHex(
3919
4093
  new Uint8Array(mail.nonce),
3920
4094
  ),
3921
4095
  readerID: mail.readerID,
3922
4096
  recipient: mail.recipient,
4097
+ ...(hintIncoming !== undefined
4098
+ ? { retentionHintDays: hintIncoming }
4099
+ : {}),
3923
4100
  sender: mail.sender,
3924
4101
  timestamp: timestamp,
3925
4102
  };
@@ -4168,6 +4345,19 @@ export class Client {
4168
4345
  return device;
4169
4346
  }
4170
4347
 
4348
+ private async runLocalRetentionPurge(): Promise<void> {
4349
+ if (this.isManualCloseInFlight()) {
4350
+ return;
4351
+ }
4352
+ try {
4353
+ await this.database.pruneExpiredLocalMessages(
4354
+ this.localMessageRetentionDays,
4355
+ );
4356
+ } catch {
4357
+ /* best-effort */
4358
+ }
4359
+ }
4360
+
4171
4361
  /**
4172
4362
  * `xDHAsync` and other helpers in `@vex-chat/crypto` use the process-wide
4173
4363
  * active profile. When several {@link Client} instances use different
@@ -4177,18 +4367,24 @@ export class Client {
4177
4367
  private async runWithThisCryptoProfile<T>(
4178
4368
  fn: () => Promise<T>,
4179
4369
  ): Promise<T> {
4180
- const prev = getCryptoProfile();
4181
- if (prev === this.cryptoProfile) {
4182
- return await fn();
4183
- }
4184
- setCryptoProfile(this.cryptoProfile);
4370
+ enterCryptoProfileScope(this.cryptoProfile);
4185
4371
  try {
4186
4372
  return await fn();
4187
4373
  } finally {
4188
- setCryptoProfile(prev);
4374
+ leaveCryptoProfileScope();
4189
4375
  }
4190
4376
  }
4191
4377
 
4378
+ private scheduleRetentionPurge(): void {
4379
+ if (this.retentionPurgeDebounce) {
4380
+ clearTimeout(this.retentionPurgeDebounce);
4381
+ }
4382
+ this.retentionPurgeDebounce = setTimeout(() => {
4383
+ this.retentionPurgeDebounce = null;
4384
+ void this.runLocalRetentionPurge();
4385
+ }, 3000);
4386
+ }
4387
+
4192
4388
  /* header is 32 bytes and is either empty
4193
4389
  or contains an HMAC of the message with
4194
4390
  a derived SK */
@@ -4224,6 +4420,7 @@ export class Client {
4224
4420
  private async sendGroupMessage(
4225
4421
  channelID: string,
4226
4422
  message: string,
4423
+ opts?: { retentionHintDays?: number },
4227
4424
  ): Promise<void> {
4228
4425
  const userList = await this.getUserList(channelID);
4229
4426
  for (const user of userList) {
@@ -4231,7 +4428,11 @@ export class Client {
4231
4428
  }
4232
4429
 
4233
4430
  const mailID = uuid.v4();
4234
- const msgBytes = XUtils.decodeUTF8(message);
4431
+ const payload = formatVexRetentionEnvelope(
4432
+ message,
4433
+ opts?.retentionHintDays,
4434
+ );
4435
+ const msgBytes = XUtils.decodeUTF8(payload);
4235
4436
  const myUserID = this.getUser().userID;
4236
4437
  // Fan-out only to *other* server members. The current account's other
4237
4438
  // devices receive the same group mail via `forward()` on the outgoing
@@ -4403,8 +4604,25 @@ export class Client {
4403
4604
  const fwdOut = forward
4404
4605
  ? messageSchema.parse(msgpack.decode(msg))
4405
4606
  : null;
4607
+ const rawUtf8 = XUtils.encodeUTF8(msg);
4608
+ const strippedOut = stripVexRetentionEnvelope(rawUtf8);
4406
4609
  const outMsg: Message = fwdOut
4407
- ? { ...fwdOut, forward: true }
4610
+ ? (() => {
4611
+ const stripped = stripVexRetentionEnvelope(
4612
+ fwdOut.message,
4613
+ );
4614
+ const base: Message = {
4615
+ ...fwdOut,
4616
+ forward: true,
4617
+ message: stripped.body,
4618
+ };
4619
+ return stripped.retentionHintDays !== undefined
4620
+ ? {
4621
+ ...base,
4622
+ retentionHintDays: stripped.retentionHintDays,
4623
+ }
4624
+ : base;
4625
+ })()
4408
4626
  : {
4409
4627
  authorID: mail.authorID,
4410
4628
  decrypted: true,
@@ -4412,10 +4630,16 @@ export class Client {
4412
4630
  forward: mail.forward,
4413
4631
  group: mail.group ? uuid.stringify(mail.group) : null,
4414
4632
  mailID: mail.mailID,
4415
- message: XUtils.encodeUTF8(msg),
4633
+ message: strippedOut.body,
4416
4634
  nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
4417
4635
  readerID: mail.readerID,
4418
4636
  recipient: mail.recipient,
4637
+ ...(strippedOut.retentionHintDays !== undefined
4638
+ ? {
4639
+ retentionHintDays:
4640
+ strippedOut.retentionHintDays,
4641
+ }
4642
+ : {}),
4419
4643
  sender: mail.sender,
4420
4644
  timestamp: new Date().toISOString(),
4421
4645
  };
@@ -4480,7 +4704,15 @@ export class Client {
4480
4704
  }
4481
4705
  }
4482
4706
 
4483
- private async sendMessage(userID: string, message: string): Promise<void> {
4707
+ private async sendMessage(
4708
+ userID: string,
4709
+ message: string,
4710
+ opts?: { retentionHintDays?: number },
4711
+ ): Promise<void> {
4712
+ const payload = formatVexRetentionEnvelope(
4713
+ message,
4714
+ opts?.retentionHintDays,
4715
+ );
4484
4716
  try {
4485
4717
  const [userEntry, err] = await this.fetchUser(userID);
4486
4718
  if (err) {
@@ -4553,7 +4785,7 @@ export class Client {
4553
4785
  await this.sendMail(
4554
4786
  device,
4555
4787
  userEntry,
4556
- XUtils.decodeUTF8(message),
4788
+ XUtils.decodeUTF8(payload),
4557
4789
  null,
4558
4790
  messageMailID,
4559
4791
  false,
@@ -4616,6 +4848,26 @@ export class Client {
4616
4848
 
4617
4849
  private setUser(user: User): void {
4618
4850
  this.user = user;
4851
+ // Fresh identity / token: drop stale 404 negative-cache entries so a
4852
+ // prior transient miss (or wrong host) cannot block DM sends for 30m.
4853
+ this.notFoundUsers.clear();
4854
+ }
4855
+
4856
+ /**
4857
+ * Polls the public unauthenticated request status endpoint as the
4858
+ * requesting device. Signs the server-issued challenge with the local
4859
+ * private signing key so the server can verify ownership of the pending
4860
+ * request without us needing a user token.
4861
+ */
4862
+ private async signPendingRegistrationChallenge(
4863
+ challengeHex: string,
4864
+ ): Promise<string> {
4865
+ return XUtils.encodeHex(
4866
+ await xSignAsync(
4867
+ XUtils.decodeHex(challengeHex),
4868
+ this.signKeys.secretKey,
4869
+ ),
4870
+ );
4619
4871
  }
4620
4872
 
4621
4873
  private async submitOTK(amount: number) {
package/src/Storage.ts CHANGED
@@ -91,6 +91,14 @@ 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, effectiveMessageRetentionHintDays(row))`
97
+ * (invalid or non-positive stored hints are treated like 30).
98
+ */
99
+ pruneExpiredLocalMessages: (
100
+ clientMaxRetentionDays: number,
101
+ ) => Promise<void>;
94
102
  /** Deletes all message history. */
95
103
  purgeHistory: () => Promise<void>;
96
104
  /** Deletes all local key/session state. */