@vex-chat/libvex 6.8.0 → 7.0.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/Storage.ts CHANGED
@@ -24,6 +24,11 @@ import type { EventEmitter } from "eventemitter3";
24
24
  * - Managing prekeys / one-time keys used for session setup
25
25
  * - Emitting lifecycle events (`ready`, `error`)
26
26
  */
27
+ export interface MessageUpdatePatch {
28
+ extra?: null | string | undefined;
29
+ message?: string | undefined;
30
+ }
31
+
27
32
  export interface Storage extends EventEmitter {
28
33
  /** Closes storage resources (connections, handles, transactions, etc.). */
29
34
  close: () => Promise<void>;
@@ -131,6 +136,12 @@ export interface Storage extends EventEmitter {
131
136
  preKeys: UnsavedPreKey[],
132
137
  oneTime: boolean,
133
138
  ) => Promise<PreKeysSQL[]>;
139
+
134
140
  /** Persists an encryption session. */
135
141
  saveSession: (session: Session) => Promise<void>;
142
+ /** Updates locally persisted message plaintext and/or encrypted extra by `mailID`. */
143
+ updateMessage: (
144
+ mailID: string,
145
+ patch: MessageUpdatePatch,
146
+ ) => Promise<boolean>;
136
147
  }
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { Message } from "../../index.js";
8
- import type { Storage } from "../../Storage.js";
8
+ import type { MessageUpdatePatch, Storage } from "../../Storage.js";
9
9
  import type {
10
10
  PreKeysCrypto,
11
11
  SessionCrypto,
@@ -276,6 +276,47 @@ export class MemoryStorage extends EventEmitter implements Storage {
276
276
  return Promise.resolve();
277
277
  }
278
278
 
279
+ async updateMessage(
280
+ mailID: string,
281
+ patch: MessageUpdatePatch,
282
+ ): Promise<boolean> {
283
+ const idx = this.messages.findIndex((m) => m.mailID === mailID);
284
+ if (idx < 0) {
285
+ return false;
286
+ }
287
+ if (
288
+ patch.message === undefined &&
289
+ !Object.prototype.hasOwnProperty.call(patch, "extra")
290
+ ) {
291
+ return false;
292
+ }
293
+ const current = this.messages[idx];
294
+ if (!current) {
295
+ return false;
296
+ }
297
+ const next = { ...current };
298
+ if (Object.prototype.hasOwnProperty.call(patch, "extra")) {
299
+ next.extra = patch.extra;
300
+ }
301
+ if (patch.message !== undefined) {
302
+ const fips = getCryptoProfile() === "fips";
303
+ const ct = fips
304
+ ? await xSecretboxAsync(
305
+ XUtils.decodeUTF8(patch.message),
306
+ XUtils.decodeHex(current.nonce),
307
+ this.atRestAesKey,
308
+ )
309
+ : xSecretbox(
310
+ XUtils.decodeUTF8(patch.message),
311
+ XUtils.decodeHex(current.nonce),
312
+ this.atRestAesKey,
313
+ );
314
+ next.message = XUtils.encodeHex(ct);
315
+ }
316
+ this.messages[idx] = next;
317
+ return true;
318
+ }
319
+
279
320
  private async decryptMessage(msg: Message): Promise<Message> {
280
321
  const copy = { ...msg };
281
322
  if (copy.decrypted) {
@@ -6,7 +6,10 @@
6
6
  import { describe, expect, it } from "vitest";
7
7
 
8
8
  import {
9
+ createMessageDeleteBatchEventExtra,
10
+ createMessageDeleteEventExtra,
9
11
  createMessageEmbedExtra,
12
+ createMessageUpdateEventExtra,
10
13
  type MessageEmbed,
11
14
  parseMessageExtra,
12
15
  serializeMessageExtra,
@@ -36,6 +39,7 @@ describe("message extra", () => {
36
39
  },
37
40
  ],
38
41
  display: "decorate",
42
+ iconAttachment: imageAttachment,
39
43
  kind: "git.workflow",
40
44
  title: "CI finished",
41
45
  version: 1,
@@ -79,6 +83,8 @@ describe("message extra", () => {
79
83
  const parsed = parseMessageExtra(
80
84
  JSON.stringify({
81
85
  embed: { display: "replace", kind: 123 },
86
+ messageDeleteEvent: { action: "delete" },
87
+ messageUpdateEvent: { action: "update", targetMailID: "m1" },
82
88
  reactionEvent: { action: "toggle", targetMailID: "m1" },
83
89
  vendor: { ok: true },
84
90
  version: 999,
@@ -91,6 +97,38 @@ describe("message extra", () => {
91
97
  });
92
98
  });
93
99
 
100
+ it("serializes and parses message update and delete events", () => {
101
+ const updateExtra = createMessageUpdateEventExtra(
102
+ "m-target",
103
+ "edited text",
104
+ );
105
+ const deleteExtra = createMessageDeleteEventExtra("m-target");
106
+
107
+ expect(parseMessageExtra(updateExtra).messageUpdateEvent).toEqual({
108
+ action: "update",
109
+ message: "edited text",
110
+ targetMailID: "m-target",
111
+ });
112
+ expect(parseMessageExtra(deleteExtra).messageDeleteEvent).toEqual({
113
+ action: "delete",
114
+ targetMailID: "m-target",
115
+ });
116
+ });
117
+
118
+ it("serializes and parses batched message delete events", () => {
119
+ const extra = createMessageDeleteBatchEventExtra([
120
+ "m-first",
121
+ "m-second",
122
+ "m-first",
123
+ "",
124
+ ]);
125
+
126
+ expect(parseMessageExtra(extra).messageDeleteEvent).toEqual({
127
+ action: "delete",
128
+ targetMailIDs: ["m-first", "m-second"],
129
+ });
130
+ });
131
+
94
132
  it("returns null for an empty serialized extra", () => {
95
133
  expect(serializeMessageExtra({ version: 1 })).toBeNull();
96
134
  });
package/src/index.ts CHANGED
@@ -49,13 +49,17 @@ export type {
49
49
  HttpResponse,
50
50
  } from "./http.js";
51
51
  export {
52
+ createMessageDeleteBatchEventExtra,
53
+ createMessageDeleteEventExtra,
52
54
  createMessageEmbedExtra,
55
+ createMessageUpdateEventExtra,
53
56
  MESSAGE_EXTRA_VERSION,
54
57
  parseMessageExtra,
55
58
  serializeMessageExtra,
56
59
  } from "./messageExtra.js";
57
60
  export type {
58
61
  EncryptedFileAttachmentReference,
62
+ MessageDeleteEvent,
59
63
  MessageEmbed,
60
64
  MessageEmbedAction,
61
65
  MessageEmbedBlock,
@@ -75,13 +79,14 @@ export type {
75
79
  MessageExtra,
76
80
  MessageReaction,
77
81
  MessageReactionEvent,
82
+ MessageUpdateEvent,
78
83
  } from "./messageExtra.js";
79
84
  export {
80
85
  clampLocalMessageRetentionDays,
81
86
  effectiveMessageRetentionHintDays,
82
87
  MAX_LOCAL_MESSAGE_RETENTION_DAYS,
83
88
  } from "./retention.js";
84
- export type { Storage } from "./Storage.js";
89
+ export type { MessageUpdatePatch, Storage } from "./Storage.js";
85
90
  export type {
86
91
  KeyPair,
87
92
  KeyStore,
@@ -14,12 +14,20 @@ export interface EncryptedFileAttachmentReference {
14
14
  key: string;
15
15
  }
16
16
 
17
+ export interface MessageDeleteEvent {
18
+ action: "delete";
19
+ deletedAt?: string | undefined;
20
+ targetMailID?: string | undefined;
21
+ targetMailIDs?: string[] | undefined;
22
+ }
23
+
17
24
  export interface MessageEmbed {
18
25
  actions?: MessageEmbedAction[] | undefined;
19
26
  blocks?: MessageEmbedBlock[] | undefined;
20
27
  display: MessageEmbedDisplay;
21
28
  fields?: MessageEmbedField[] | undefined;
22
29
  icon?: string | undefined;
30
+ iconAttachment?: EncryptedFileAttachmentReference | undefined;
23
31
  kind: string;
24
32
  source?: MessageEmbedSource | undefined;
25
33
  subtitle?: string | undefined;
@@ -132,6 +140,8 @@ export type MessageEmoji =
132
140
  export interface MessageExtra {
133
141
  [key: string]: unknown;
134
142
  embed?: MessageEmbed | undefined;
143
+ messageDeleteEvent?: MessageDeleteEvent | undefined;
144
+ messageUpdateEvent?: MessageUpdateEvent | undefined;
135
145
  reactionEvent?: MessageReactionEvent | undefined;
136
146
  reactions?: MessageReaction[] | undefined;
137
147
  version: typeof MESSAGE_EXTRA_VERSION;
@@ -148,6 +158,41 @@ export interface MessageReactionEvent {
148
158
  targetMailID: string;
149
159
  }
150
160
 
161
+ export interface MessageUpdateEvent {
162
+ action: "update";
163
+ editedAt?: string | undefined;
164
+ message: string;
165
+ targetMailID: string;
166
+ }
167
+
168
+ export function createMessageDeleteBatchEventExtra(
169
+ targetMailIDs: string[],
170
+ currentExtra?: null | string,
171
+ ): null | string {
172
+ return serializeMessageExtra({
173
+ ...parseMessageExtra(currentExtra),
174
+ messageDeleteEvent: {
175
+ action: "delete",
176
+ targetMailIDs,
177
+ },
178
+ version: MESSAGE_EXTRA_VERSION,
179
+ });
180
+ }
181
+
182
+ export function createMessageDeleteEventExtra(
183
+ targetMailID: string,
184
+ currentExtra?: null | string,
185
+ ): null | string {
186
+ return serializeMessageExtra({
187
+ ...parseMessageExtra(currentExtra),
188
+ messageDeleteEvent: {
189
+ action: "delete",
190
+ targetMailID,
191
+ },
192
+ version: MESSAGE_EXTRA_VERSION,
193
+ });
194
+ }
195
+
151
196
  export function createMessageEmbedExtra(
152
197
  embed: MessageEmbed,
153
198
  currentExtra?: null | string,
@@ -159,6 +204,22 @@ export function createMessageEmbedExtra(
159
204
  });
160
205
  }
161
206
 
207
+ export function createMessageUpdateEventExtra(
208
+ targetMailID: string,
209
+ message: string,
210
+ currentExtra?: null | string,
211
+ ): null | string {
212
+ return serializeMessageExtra({
213
+ ...parseMessageExtra(currentExtra),
214
+ messageUpdateEvent: {
215
+ action: "update",
216
+ message,
217
+ targetMailID,
218
+ },
219
+ version: MESSAGE_EXTRA_VERSION,
220
+ });
221
+ }
222
+
162
223
  export function parseMessageExtra(
163
224
  extra: null | string | undefined,
164
225
  ): MessageExtra {
@@ -173,17 +234,27 @@ export function parseMessageExtra(
173
234
  }
174
235
  const rest: Record<string, unknown> = { ...raw };
175
236
  delete rest["embed"];
237
+ delete rest["messageDeleteEvent"];
238
+ delete rest["messageUpdateEvent"];
176
239
  delete rest["reactionEvent"];
177
240
  delete rest["reactions"];
178
241
  delete rest["version"];
179
242
 
180
243
  const embed = parseMessageEmbed(raw["embed"]);
244
+ const messageDeleteEvent = parseMessageDeleteEvent(
245
+ raw["messageDeleteEvent"],
246
+ );
247
+ const messageUpdateEvent = parseMessageUpdateEvent(
248
+ raw["messageUpdateEvent"],
249
+ );
181
250
  const reactionEvent = parseMessageReactionEvent(raw["reactionEvent"]);
182
251
  const reactions = parseMessageReactions(raw["reactions"]);
183
252
 
184
253
  return {
185
254
  ...rest,
186
255
  ...(embed ? { embed } : {}),
256
+ ...(messageDeleteEvent ? { messageDeleteEvent } : {}),
257
+ ...(messageUpdateEvent ? { messageUpdateEvent } : {}),
187
258
  ...(reactionEvent ? { reactionEvent } : {}),
188
259
  ...(reactions.length > 0 ? { reactions } : {}),
189
260
  version: MESSAGE_EXTRA_VERSION,
@@ -262,6 +333,12 @@ function isRecord(value: unknown): value is Record<string, unknown> {
262
333
 
263
334
  function normalizeMessageExtra(extra: MessageExtra): MessageExtra {
264
335
  const embed = parseMessageEmbed(extra.embed);
336
+ const messageDeleteEvent = parseMessageDeleteEvent(
337
+ extra.messageDeleteEvent,
338
+ );
339
+ const messageUpdateEvent = parseMessageUpdateEvent(
340
+ extra.messageUpdateEvent,
341
+ );
265
342
  const reactionEvent = parseMessageReactionEvent(extra.reactionEvent);
266
343
  const reactions = parseMessageReactions(extra.reactions);
267
344
  const normalized: MessageExtra = {
@@ -274,6 +351,16 @@ function normalizeMessageExtra(extra: MessageExtra): MessageExtra {
274
351
  } else {
275
352
  delete normalized.embed;
276
353
  }
354
+ if (messageDeleteEvent) {
355
+ normalized.messageDeleteEvent = messageDeleteEvent;
356
+ } else {
357
+ delete normalized.messageDeleteEvent;
358
+ }
359
+ if (messageUpdateEvent) {
360
+ normalized.messageUpdateEvent = messageUpdateEvent;
361
+ } else {
362
+ delete normalized.messageUpdateEvent;
363
+ }
277
364
  if (reactionEvent) {
278
365
  normalized.reactionEvent = reactionEvent;
279
366
  } else {
@@ -311,6 +398,44 @@ function parseAttachment(
311
398
  };
312
399
  }
313
400
 
401
+ function parseMessageDeleteEvent(value: unknown): MessageDeleteEvent | null {
402
+ if (!isRecord(value) || value["action"] !== "delete") {
403
+ return null;
404
+ }
405
+ const targetMailID =
406
+ typeof value["targetMailID"] === "string" &&
407
+ value["targetMailID"].length > 0
408
+ ? value["targetMailID"]
409
+ : undefined;
410
+ const targetMailIDs = parseMessageDeleteTargets(value["targetMailIDs"]);
411
+ if (!targetMailID && targetMailIDs.length === 0) {
412
+ return null;
413
+ }
414
+ const event: MessageDeleteEvent = {
415
+ action: "delete",
416
+ ...(targetMailID ? { targetMailID } : {}),
417
+ ...(targetMailIDs.length > 0 ? { targetMailIDs } : {}),
418
+ };
419
+ copyOptionalString(event, value, "deletedAt");
420
+ return event;
421
+ }
422
+
423
+ function parseMessageDeleteTargets(value: unknown): string[] {
424
+ if (!Array.isArray(value)) {
425
+ return [];
426
+ }
427
+ const targets: string[] = [];
428
+ const seen = new Set<string>();
429
+ for (const item of value) {
430
+ if (typeof item !== "string" || item.length === 0 || seen.has(item)) {
431
+ continue;
432
+ }
433
+ seen.add(item);
434
+ targets.push(item);
435
+ }
436
+ return targets;
437
+ }
438
+
314
439
  function parseMessageEmbed(value: unknown): MessageEmbed | null {
315
440
  if (!isRecord(value)) return null;
316
441
  const display = value["display"];
@@ -331,6 +456,8 @@ function parseMessageEmbed(value: unknown): MessageEmbed | null {
331
456
  version: MESSAGE_EXTRA_VERSION,
332
457
  };
333
458
  copyOptionalString(embed, value, "icon");
459
+ const iconAttachment = parseAttachment(value["iconAttachment"]);
460
+ if (iconAttachment) embed.iconAttachment = iconAttachment;
334
461
  copyOptionalString(embed, value, "subtitle");
335
462
  copyOptionalBoolean(embed, value, "suppressLinkPreview");
336
463
  copyOptionalString(embed, value, "timestamp");
@@ -583,3 +710,21 @@ function parseMessageReactions(value: unknown): MessageReaction[] {
583
710
  return reaction ? [reaction] : [];
584
711
  });
585
712
  }
713
+
714
+ function parseMessageUpdateEvent(value: unknown): MessageUpdateEvent | null {
715
+ if (
716
+ !isRecord(value) ||
717
+ value["action"] !== "update" ||
718
+ typeof value["message"] !== "string" ||
719
+ typeof value["targetMailID"] !== "string"
720
+ ) {
721
+ return null;
722
+ }
723
+ const event: MessageUpdateEvent = {
724
+ action: "update",
725
+ message: value["message"],
726
+ targetMailID: value["targetMailID"],
727
+ };
728
+ copyOptionalString(event, value, "editedAt");
729
+ return event;
730
+ }
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { Message } from "../index.js";
8
- import type { Storage } from "../Storage.js";
8
+ import type { MessageUpdatePatch, Storage } from "../Storage.js";
9
9
  import type {
10
10
  PreKeysCrypto,
11
11
  SessionCrypto,
@@ -597,8 +597,6 @@ export class SqliteStorage extends EventEmitter implements Storage {
597
597
  }
598
598
  }
599
599
 
600
- // ── Devices ──────────────────────────────────────────────────────────────
601
-
602
600
  async savePreKeys(
603
601
  preKeys: UnsavedPreKey[],
604
602
  oneTime: boolean,
@@ -637,6 +635,8 @@ export class SqliteStorage extends EventEmitter implements Storage {
637
635
  return saved;
638
636
  }
639
637
 
638
+ // ── Devices ──────────────────────────────────────────────────────────────
639
+
640
640
  async saveSession(session: SessionSQL): Promise<void> {
641
641
  if (this.closing) {
642
642
  return;
@@ -703,6 +703,68 @@ export class SqliteStorage extends EventEmitter implements Storage {
703
703
  }
704
704
  }
705
705
 
706
+ async updateMessage(
707
+ mailID: string,
708
+ patch: MessageUpdatePatch,
709
+ ): Promise<boolean> {
710
+ if (this.isClosingNow()) {
711
+ return false;
712
+ }
713
+ await this.untilReady();
714
+ if (
715
+ patch.message === undefined &&
716
+ !Object.prototype.hasOwnProperty.call(patch, "extra")
717
+ ) {
718
+ return false;
719
+ }
720
+
721
+ const row = await this.db
722
+ .selectFrom("messages")
723
+ .selectAll()
724
+ .where("mailID", "=", mailID)
725
+ .executeTakeFirst();
726
+ if (!row) {
727
+ return false;
728
+ }
729
+
730
+ const current = (await this.decryptMessagesAsync([row]))[0];
731
+ if (!current) {
732
+ return false;
733
+ }
734
+ const next: Message = {
735
+ ...current,
736
+ ...(patch.message !== undefined ? { message: patch.message } : {}),
737
+ ...(Object.prototype.hasOwnProperty.call(patch, "extra")
738
+ ? { extra: patch.extra }
739
+ : {}),
740
+ };
741
+ const storedPlaintext = encodeStoredMessagePlaintext(next);
742
+ const fips = getCryptoProfile() === "fips";
743
+ const ct = fips
744
+ ? await xSecretboxAsync(
745
+ XUtils.decodeUTF8(storedPlaintext),
746
+ XUtils.decodeHex(row.nonce),
747
+ this.atRestAesKey,
748
+ )
749
+ : xSecretbox(
750
+ XUtils.decodeUTF8(storedPlaintext),
751
+ XUtils.decodeHex(row.nonce),
752
+ this.atRestAesKey,
753
+ );
754
+ if (this.isClosingNow()) {
755
+ return false;
756
+ }
757
+ const result = await this.db
758
+ .updateTable("messages")
759
+ .set({
760
+ extra: null,
761
+ message: XUtils.encodeHex(ct),
762
+ })
763
+ .where("mailID", "=", mailID)
764
+ .executeTakeFirst();
765
+ return Number(result.numUpdatedRows) > 0;
766
+ }
767
+
706
768
  // ── Purge ────────────────────────────────────────────────────────────────
707
769
 
708
770
  private async decryptMessagesAsync(