@massalabs/gossip-sdk 0.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.
Files changed (69) hide show
  1. package/README.md +484 -0
  2. package/package.json +41 -0
  3. package/src/api/messageProtocol/index.ts +53 -0
  4. package/src/api/messageProtocol/mock.ts +13 -0
  5. package/src/api/messageProtocol/rest.ts +209 -0
  6. package/src/api/messageProtocol/types.ts +70 -0
  7. package/src/config/protocol.ts +97 -0
  8. package/src/config/sdk.ts +131 -0
  9. package/src/contacts.ts +210 -0
  10. package/src/core/SdkEventEmitter.ts +91 -0
  11. package/src/core/SdkPolling.ts +134 -0
  12. package/src/core/index.ts +9 -0
  13. package/src/crypto/bip39.ts +84 -0
  14. package/src/crypto/encryption.ts +77 -0
  15. package/src/db.ts +465 -0
  16. package/src/gossipSdk.ts +994 -0
  17. package/src/index.ts +211 -0
  18. package/src/services/announcement.ts +653 -0
  19. package/src/services/auth.ts +95 -0
  20. package/src/services/discussion.ts +380 -0
  21. package/src/services/message.ts +1055 -0
  22. package/src/services/refresh.ts +234 -0
  23. package/src/sw.ts +17 -0
  24. package/src/types/events.ts +108 -0
  25. package/src/types.ts +70 -0
  26. package/src/utils/base64.ts +39 -0
  27. package/src/utils/contacts.ts +161 -0
  28. package/src/utils/discussions.ts +55 -0
  29. package/src/utils/logs.ts +86 -0
  30. package/src/utils/messageSerialization.ts +257 -0
  31. package/src/utils/queue.ts +106 -0
  32. package/src/utils/type.ts +7 -0
  33. package/src/utils/userId.ts +114 -0
  34. package/src/utils/validation.ts +144 -0
  35. package/src/utils.ts +47 -0
  36. package/src/wasm/encryption.ts +108 -0
  37. package/src/wasm/index.ts +20 -0
  38. package/src/wasm/loader.ts +123 -0
  39. package/src/wasm/session.ts +276 -0
  40. package/src/wasm/userKeys.ts +31 -0
  41. package/test/config/protocol.spec.ts +31 -0
  42. package/test/config/sdk.spec.ts +163 -0
  43. package/test/db/helpers.spec.ts +142 -0
  44. package/test/db/operations.spec.ts +128 -0
  45. package/test/db/states.spec.ts +535 -0
  46. package/test/integration/discussion-flow.spec.ts +422 -0
  47. package/test/integration/messaging-flow.spec.ts +708 -0
  48. package/test/integration/sdk-lifecycle.spec.ts +325 -0
  49. package/test/mocks/index.ts +9 -0
  50. package/test/mocks/mockMessageProtocol.ts +100 -0
  51. package/test/services/auth.spec.ts +311 -0
  52. package/test/services/discussion.spec.ts +279 -0
  53. package/test/services/message-deduplication.spec.ts +299 -0
  54. package/test/services/message-startup.spec.ts +331 -0
  55. package/test/services/message.spec.ts +817 -0
  56. package/test/services/refresh.spec.ts +199 -0
  57. package/test/services/session-status.spec.ts +349 -0
  58. package/test/session/wasm.spec.ts +227 -0
  59. package/test/setup.ts +52 -0
  60. package/test/utils/contacts.spec.ts +156 -0
  61. package/test/utils/discussions.spec.ts +66 -0
  62. package/test/utils/queue.spec.ts +52 -0
  63. package/test/utils/serialization.spec.ts +120 -0
  64. package/test/utils/userId.spec.ts +120 -0
  65. package/test/utils/validation.spec.ts +223 -0
  66. package/test/utils.ts +212 -0
  67. package/tsconfig.json +26 -0
  68. package/tsconfig.tsbuildinfo +1 -0
  69. package/vitest.config.ts +28 -0
@@ -0,0 +1,1055 @@
1
+ /**
2
+ * Message Reception Service
3
+ *
4
+ * Handles fetching encrypted messages from the protocol and decrypting them.
5
+ * This service works both in host app contexts and SDK/automation context.
6
+ */
7
+
8
+ import {
9
+ DiscussionStatus,
10
+ type Message,
11
+ type GossipDatabase,
12
+ MessageDirection,
13
+ MessageStatus,
14
+ MessageType,
15
+ } from '../db';
16
+ import { decodeUserId, encodeUserId } from '../utils/userId';
17
+ import { IMessageProtocol, EncryptedMessage } from '../api/messageProtocol';
18
+ import {
19
+ SessionStatus,
20
+ SendMessageOutput,
21
+ } from '../assets/generated/wasm/gossip_wasm';
22
+ import { SessionModule } from '../wasm';
23
+ import {
24
+ serializeRegularMessage,
25
+ serializeReplyMessage,
26
+ serializeForwardMessage,
27
+ serializeKeepAliveMessage,
28
+ deserializeMessage,
29
+ } from '../utils/messageSerialization';
30
+ import { encodeToBase64 } from '../utils/base64';
31
+ import { Result } from '../utils/type';
32
+ import { DiscussionService } from './discussion';
33
+ import { sessionStatusToString } from '../wasm/session';
34
+ import { Logger } from '../utils/logs';
35
+ import { GossipSdkEvents } from '../types/events';
36
+ import { SdkConfig, defaultSdkConfig } from '../config/sdk';
37
+
38
+ export interface MessageResult {
39
+ success: boolean;
40
+ newMessagesCount: number;
41
+ error?: string;
42
+ }
43
+
44
+ export interface SendMessageResult {
45
+ success: boolean;
46
+ message?: Message;
47
+ error?: string;
48
+ }
49
+
50
+ interface Decrypted {
51
+ content: string;
52
+ sentAt: Date;
53
+ senderId: string;
54
+ seeker: Uint8Array;
55
+ replyTo?: {
56
+ originalContent: string;
57
+ originalSeeker: Uint8Array;
58
+ };
59
+ forwardOf?: {
60
+ originalContent: string;
61
+ originalSeeker: Uint8Array;
62
+ };
63
+ encryptedMessage: Uint8Array;
64
+ type: MessageType;
65
+ }
66
+
67
+ const sleep = (ms: number) => new Promise(res => setTimeout(res, ms));
68
+
69
+ const logger = new Logger('MessageService');
70
+
71
+ export class MessageService {
72
+ private db: GossipDatabase;
73
+ private messageProtocol: IMessageProtocol;
74
+ private session: SessionModule;
75
+ private discussionService: DiscussionService;
76
+ private events: GossipSdkEvents;
77
+ private config: SdkConfig;
78
+
79
+ constructor(
80
+ db: GossipDatabase,
81
+ messageProtocol: IMessageProtocol,
82
+ session: SessionModule,
83
+ discussionService: DiscussionService,
84
+ events: GossipSdkEvents = {},
85
+ config: SdkConfig = defaultSdkConfig
86
+ ) {
87
+ this.db = db;
88
+ this.messageProtocol = messageProtocol;
89
+ this.session = session;
90
+ this.discussionService = discussionService;
91
+ this.events = events;
92
+ this.config = config;
93
+ }
94
+
95
+ async fetchMessages(): Promise<MessageResult> {
96
+ const log = logger.forMethod('fetchMessages');
97
+
98
+ try {
99
+ if (!this.session) throw new Error('Session module not initialized');
100
+
101
+ let previousSeekers = new Set<string>();
102
+ let iterations = 0;
103
+ let newMessagesCount = 0;
104
+ let seekers: Uint8Array[] = [];
105
+
106
+ while (true) {
107
+ seekers = this.session.getMessageBoardReadKeys();
108
+ const currentSeekers = new Set(seekers.map(s => encodeToBase64(s)));
109
+
110
+ const allSame =
111
+ seekers.length === previousSeekers.size &&
112
+ [...currentSeekers].every(s => previousSeekers.has(s));
113
+
114
+ const maxIterations = this.config.messages.maxFetchIterations;
115
+ if (allSame || iterations >= maxIterations) {
116
+ if (iterations >= maxIterations) {
117
+ log.warn('fetch loop stopped due to max iterations', {
118
+ iterations,
119
+ maxIterations,
120
+ });
121
+ }
122
+ break;
123
+ }
124
+
125
+ const encryptedMessages =
126
+ await this.messageProtocol.fetchMessages(seekers);
127
+ previousSeekers = currentSeekers;
128
+
129
+ if (encryptedMessages.length === 0) {
130
+ iterations++;
131
+ await sleep(this.config.messages.fetchDelayMs);
132
+ continue;
133
+ }
134
+
135
+ const { decrypted: decryptedMessages, acknowledgedSeekers } =
136
+ await this.decryptMessages(encryptedMessages);
137
+
138
+ if (decryptedMessages.length > 0) {
139
+ const storedIds = await this.storeDecryptedMessages(
140
+ decryptedMessages,
141
+ this.session.userIdEncoded
142
+ );
143
+ newMessagesCount += storedIds.length;
144
+ }
145
+
146
+ if (acknowledgedSeekers.size > 0) {
147
+ log.info('processing acknowledged seekers', {
148
+ count: acknowledgedSeekers.size,
149
+ });
150
+ await this.acknowledgeMessages(
151
+ acknowledgedSeekers,
152
+ this.session.userIdEncoded
153
+ );
154
+ }
155
+
156
+ iterations++;
157
+ await sleep(this.config.messages.fetchDelayMs);
158
+ }
159
+
160
+ try {
161
+ await this.db.setActiveSeekers(seekers);
162
+ } catch (error) {
163
+ log.error('failed to update active seekers', error);
164
+ }
165
+
166
+ if (newMessagesCount > 0) {
167
+ log.info(`fetch completed — ${newMessagesCount} new messages received`);
168
+ }
169
+
170
+ return { success: true, newMessagesCount };
171
+ } catch (err) {
172
+ log.error('fetch failed', err);
173
+ return {
174
+ success: false,
175
+ newMessagesCount: 0,
176
+ error: err instanceof Error ? err.message : 'Unknown error',
177
+ };
178
+ }
179
+ }
180
+
181
+ private async decryptMessages(encrypted: EncryptedMessage[]): Promise<{
182
+ decrypted: Decrypted[];
183
+ acknowledgedSeekers: Set<string>;
184
+ }> {
185
+ const log = logger.forMethod('decryptMessages');
186
+
187
+ const decrypted: Decrypted[] = [];
188
+ const acknowledgedSeekers: Set<string> = new Set();
189
+
190
+ for (const msg of encrypted) {
191
+ try {
192
+ const out = await this.session.feedIncomingMessageBoardRead(
193
+ msg.seeker,
194
+ msg.ciphertext
195
+ );
196
+ if (!out) continue;
197
+
198
+ try {
199
+ const deserialized = deserializeMessage(out.message);
200
+
201
+ out.acknowledged_seekers.forEach((seeker: Uint8Array) =>
202
+ acknowledgedSeekers.add(encodeToBase64(seeker))
203
+ );
204
+
205
+ // keep-alive messages are just useful to keep the session alive, we don't need to store them
206
+ if (deserialized.type === MessageType.KEEP_ALIVE) {
207
+ continue;
208
+ }
209
+
210
+ decrypted.push({
211
+ content: deserialized.content,
212
+ sentAt: new Date(Number(out.timestamp)),
213
+ senderId: encodeUserId(out.user_id),
214
+ seeker: msg.seeker,
215
+ encryptedMessage: msg.ciphertext,
216
+ type: deserialized.type,
217
+ replyTo: deserialized.replyTo
218
+ ? {
219
+ originalContent: deserialized.replyTo.originalContent,
220
+ originalSeeker: deserialized.replyTo.originalSeeker,
221
+ }
222
+ : undefined,
223
+ forwardOf: deserialized.forwardOf
224
+ ? {
225
+ originalContent: deserialized.forwardOf.originalContent,
226
+ originalSeeker: deserialized.forwardOf.originalSeeker,
227
+ }
228
+ : undefined,
229
+ });
230
+ } catch (deserializationError) {
231
+ log.error('deserialization failed', {
232
+ error:
233
+ deserializationError instanceof Error
234
+ ? deserializationError.message
235
+ : 'Unknown error',
236
+ seeker: encodeToBase64(msg.seeker),
237
+ });
238
+ }
239
+ } catch (e) {
240
+ log.error('decryption failed', {
241
+ error: e instanceof Error ? e.message : 'Unknown error',
242
+ seeker: encodeToBase64(msg.seeker),
243
+ });
244
+ }
245
+ }
246
+
247
+ return { decrypted, acknowledgedSeekers };
248
+ }
249
+
250
+ private async storeDecryptedMessages(
251
+ decrypted: Decrypted[],
252
+ ownerUserId: string
253
+ ): Promise<number[]> {
254
+ const log = logger.forMethod('storeDecryptedMessages');
255
+
256
+ const storedIds: number[] = [];
257
+
258
+ for (const message of decrypted) {
259
+ const discussion = await this.db.getDiscussionByOwnerAndContact(
260
+ ownerUserId,
261
+ message.senderId
262
+ );
263
+
264
+ if (!discussion) {
265
+ log.error('no discussion for incoming message', {
266
+ senderId: message.senderId,
267
+ preview: message.content.slice(0, 50),
268
+ });
269
+ continue;
270
+ }
271
+
272
+ // Check for duplicate message (same content + similar timestamp from same sender)
273
+ // This handles edge case: app crashes after network send but before DB update,
274
+ // message gets re-sent on restart, peer receives duplicate
275
+ const isDuplicate = await this.isDuplicateMessage(
276
+ ownerUserId,
277
+ message.senderId,
278
+ message.content,
279
+ message.sentAt
280
+ );
281
+
282
+ if (isDuplicate) {
283
+ log.info('skipping duplicate message', {
284
+ senderId: message.senderId,
285
+ preview: message.content.slice(0, 30),
286
+ timestamp: message.sentAt.toISOString(),
287
+ });
288
+ continue;
289
+ }
290
+
291
+ let replyToMessageId: number | undefined;
292
+ if (message.replyTo?.originalSeeker) {
293
+ const original = await this.findMessageBySeeker(
294
+ message.replyTo.originalSeeker,
295
+ ownerUserId
296
+ );
297
+ if (!original) {
298
+ log.warn('reply target not found', {
299
+ originalSeeker: encodeToBase64(message.replyTo.originalSeeker),
300
+ });
301
+ }
302
+ replyToMessageId = original?.id;
303
+ }
304
+
305
+ const id = await this.db.transaction(
306
+ 'rw',
307
+ this.db.messages,
308
+ this.db.discussions,
309
+ async () => {
310
+ const id = await this.db.messages.add({
311
+ ownerUserId,
312
+ contactUserId: discussion.contactUserId,
313
+ content: message.content,
314
+ type: message.type,
315
+ direction: MessageDirection.INCOMING,
316
+ status: MessageStatus.DELIVERED,
317
+ timestamp: message.sentAt,
318
+ metadata: {},
319
+ seeker: message.seeker, // Store the seeker of the incoming message
320
+ encryptedMessage: message.encryptedMessage, // Store the ciphertext of the incoming message
321
+ replyTo: message.replyTo
322
+ ? {
323
+ // Store the original content as a fallback only if we couldn't find
324
+ // the original message in the database (replyToMessageId is undefined).
325
+ // If the original message exists, we don't need to store the content
326
+ // since we can fetch it using the originalSeeker.
327
+ originalContent: replyToMessageId
328
+ ? undefined
329
+ : message.replyTo.originalContent,
330
+ // Store the seeker (used to find the original message)
331
+ originalSeeker: message.replyTo.originalSeeker,
332
+ }
333
+ : undefined,
334
+
335
+ forwardOf: message.forwardOf
336
+ ? {
337
+ originalContent: message.forwardOf.originalContent,
338
+ originalSeeker: message.forwardOf.originalSeeker,
339
+ }
340
+ : undefined,
341
+ });
342
+ const now = new Date();
343
+ await this.db.discussions.update(discussion.id, {
344
+ lastMessageId: id,
345
+ lastMessageContent: message.content,
346
+ lastMessageTimestamp: message.sentAt,
347
+ updatedAt: now,
348
+ lastSyncTimestamp: now,
349
+ unreadCount: discussion.unreadCount + 1,
350
+ });
351
+ return id;
352
+ }
353
+ );
354
+ storedIds.push(id);
355
+
356
+ // Emit event for new message
357
+ if (this.events.onMessageReceived) {
358
+ const storedMessage = await this.db.messages.get(id);
359
+ if (storedMessage) {
360
+ this.events.onMessageReceived(storedMessage);
361
+ }
362
+ }
363
+ }
364
+
365
+ return storedIds;
366
+ }
367
+
368
+ async findMessageBySeeker(
369
+ seeker: Uint8Array,
370
+ ownerUserId: string
371
+ ): Promise<Message | undefined> {
372
+ return await this.db.messages
373
+ .where('[ownerUserId+seeker]')
374
+ .equals([ownerUserId, seeker])
375
+ .first();
376
+ }
377
+
378
+ /**
379
+ * Check if a message is a duplicate based on content and timestamp.
380
+ *
381
+ * A message is considered duplicate if:
382
+ * - Same sender (contactUserId)
383
+ * - Same content
384
+ * - Incoming direction
385
+ * - Timestamp within deduplication window (default 30 seconds)
386
+ *
387
+ * This handles the edge case where:
388
+ * 1. Sender sends message successfully to network
389
+ * 2. Sender app crashes before updating DB status to SENT
390
+ * 3. On restart, message is reset to WAITING_SESSION and re-sent
391
+ * 4. Receiver gets the same message twice with different seekers
392
+ *
393
+ * @param ownerUserId - The owner's user ID
394
+ * @param contactUserId - The sender's user ID
395
+ * @param content - The message content
396
+ * @param timestamp - The message timestamp
397
+ * @returns true if a duplicate exists
398
+ */
399
+ private async isDuplicateMessage(
400
+ ownerUserId: string,
401
+ contactUserId: string,
402
+ content: string,
403
+ timestamp: Date
404
+ ): Promise<boolean> {
405
+ const windowMs = this.config.messages.deduplicationWindowMs;
406
+ const windowStart = new Date(timestamp.getTime() - windowMs);
407
+ const windowEnd = new Date(timestamp.getTime() + windowMs);
408
+
409
+ // Query for messages from same sender with same content within time window
410
+ const existing = await this.db.messages
411
+ .where('[ownerUserId+contactUserId]')
412
+ .equals([ownerUserId, contactUserId])
413
+ .and(
414
+ msg =>
415
+ msg.direction === MessageDirection.INCOMING &&
416
+ msg.content === content &&
417
+ msg.timestamp >= windowStart &&
418
+ msg.timestamp <= windowEnd
419
+ )
420
+ .first();
421
+
422
+ return existing !== undefined;
423
+ }
424
+
425
+ private async acknowledgeMessages(
426
+ seekers: Set<string>,
427
+ userId: string
428
+ ): Promise<void> {
429
+ if (seekers.size === 0) return;
430
+
431
+ const updatedCount = await this.db.messages
432
+ .where('[ownerUserId+direction+status]')
433
+ .equals([userId, MessageDirection.OUTGOING, MessageStatus.SENT])
434
+ .filter(
435
+ message =>
436
+ message.seeker !== undefined &&
437
+ seekers.has(encodeToBase64(message.seeker))
438
+ )
439
+ .modify({ status: MessageStatus.DELIVERED });
440
+
441
+ // After marking messages as DELIVERED, clean up DELIVERED keep-alive messages
442
+ await this.db.messages
443
+ .where({
444
+ ownerUserId: userId,
445
+ status: MessageStatus.DELIVERED,
446
+ type: MessageType.KEEP_ALIVE,
447
+ })
448
+ .delete();
449
+
450
+ if (updatedCount > 0) {
451
+ logger
452
+ .forMethod('acknowledgeMessages')
453
+ .info(`acknowledged ${updatedCount} messages`);
454
+ }
455
+ }
456
+
457
+ async sendMessage(message: Message): Promise<SendMessageResult> {
458
+ const log = logger.forMethod('sendMessage');
459
+ log.info('sending message', {
460
+ messageContent: message.content,
461
+ messageType: message.type,
462
+ messageReplyTo: message.replyTo,
463
+ messageForwardOf: message.forwardOf,
464
+ });
465
+
466
+ const peerId = decodeUserId(message.contactUserId);
467
+ if (peerId.length !== 32) {
468
+ return {
469
+ success: false,
470
+ error: 'Invalid contact userId (must be 32 bytes)',
471
+ };
472
+ }
473
+
474
+ const discussion = await this.db.getDiscussionByOwnerAndContact(
475
+ message.ownerUserId,
476
+ message.contactUserId
477
+ );
478
+ if (!discussion) {
479
+ return { success: false, error: 'Discussion not found' };
480
+ }
481
+
482
+ const sessionStatus = this.session.peerSessionStatus(peerId);
483
+
484
+ // Check for session states that require renewal (session is truly lost)
485
+ // Per spec: when session is lost, queue message as WAITING_SESSION and trigger auto-renewal
486
+ const needsRenewalStatuses = [
487
+ SessionStatus.UnknownPeer,
488
+ SessionStatus.NoSession,
489
+ SessionStatus.Killed,
490
+ // Note: PeerRequested is NOT included - it means peer sent us an announcement
491
+ // and we should accept it, not trigger renewal (which would create a race condition)
492
+ ];
493
+
494
+ if (needsRenewalStatuses.includes(sessionStatus)) {
495
+ // Add message as WAITING_SESSION - it will be sent when session becomes Active
496
+ const messageId = await this.db.addMessage({
497
+ ...message,
498
+ status: MessageStatus.WAITING_SESSION,
499
+ });
500
+
501
+ log.info('session lost, queuing message as WAITING_SESSION', {
502
+ sessionStatus: sessionStatusToString(sessionStatus),
503
+ messageId,
504
+ });
505
+
506
+ // Trigger auto-renewal (per spec: call create_session when session is lost)
507
+ this.events.onSessionRenewalNeeded?.(message.contactUserId);
508
+
509
+ const queuedMessage = {
510
+ ...message,
511
+ id: messageId,
512
+ status: MessageStatus.WAITING_SESSION,
513
+ };
514
+
515
+ // Return success=true because the message is queued and will be sent later
516
+ // This matches the spec where messages in WAITING_SESSION are valid queue items
517
+ return {
518
+ success: true,
519
+ message: queuedMessage,
520
+ };
521
+ }
522
+
523
+ // PeerRequested: peer sent us an announcement, we need to accept/respond
524
+ // Queue the message but trigger accept flow, not renewal
525
+ if (sessionStatus === SessionStatus.PeerRequested) {
526
+ const messageId = await this.db.addMessage({
527
+ ...message,
528
+ status: MessageStatus.WAITING_SESSION,
529
+ });
530
+
531
+ log.info('peer requested session, queuing message - need to accept', {
532
+ sessionStatus: sessionStatusToString(sessionStatus),
533
+ messageId,
534
+ });
535
+
536
+ // Trigger accept flow (different from renewal - we respond to their announcement)
537
+ this.events.onSessionAcceptNeeded?.(message.contactUserId);
538
+
539
+ return {
540
+ success: true,
541
+ message: {
542
+ ...message,
543
+ id: messageId,
544
+ status: MessageStatus.WAITING_SESSION,
545
+ },
546
+ };
547
+ }
548
+
549
+ // Serialize message content (handle replies)
550
+ const serializeMessageResult = await this.serializeMessage(message);
551
+ if (!serializeMessageResult.success) {
552
+ return {
553
+ success: false,
554
+ error: serializeMessageResult.error,
555
+ };
556
+ }
557
+ log.info('message serialized', {
558
+ serializedContent: serializeMessageResult.data,
559
+ });
560
+ message.serializedContent = serializeMessageResult.data;
561
+
562
+ // Check if we can send messages on this discussion
563
+ const isUnstable = !(await this.discussionService.isStableState(
564
+ message.ownerUserId,
565
+ message.contactUserId
566
+ ));
567
+ const isSelfRequested = sessionStatus === SessionStatus.SelfRequested;
568
+
569
+ // Per spec: if session is SelfRequested or discussion unstable, queue as WAITING_SESSION
570
+ if (isUnstable || isSelfRequested) {
571
+ const messageId = await this.db.addMessage({
572
+ ...message,
573
+ status: MessageStatus.WAITING_SESSION,
574
+ });
575
+
576
+ // Clear console log for debugging
577
+ console.warn(
578
+ `[SendMessage] WAITING_SESSION - isUnstable=${isUnstable}, isSelfRequested=${isSelfRequested}, sessionStatus=${sessionStatusToString(sessionStatus)}`
579
+ );
580
+
581
+ log.info('discussion/session not ready, queuing as WAITING_SESSION', {
582
+ isUnstable,
583
+ isSelfRequested,
584
+ sessionStatus: sessionStatusToString(sessionStatus),
585
+ });
586
+
587
+ return {
588
+ success: true,
589
+ message: {
590
+ ...message,
591
+ id: messageId,
592
+ status: MessageStatus.WAITING_SESSION,
593
+ },
594
+ };
595
+ }
596
+
597
+ const messageId = await this.db.addMessage({
598
+ ...message,
599
+ status: MessageStatus.SENDING,
600
+ });
601
+
602
+ let sendOutput: SendMessageOutput | undefined;
603
+ try {
604
+ if (sessionStatus !== SessionStatus.Active) {
605
+ throw new Error(
606
+ `Session not active: ${sessionStatusToString(sessionStatus)}`
607
+ );
608
+ }
609
+
610
+ // CRITICAL: await session.sendMessage to ensure session state is persisted
611
+ // before the encrypted message is sent to the network
612
+ sendOutput = await this.session.sendMessage(
613
+ peerId,
614
+ message.serializedContent!
615
+ );
616
+ if (!sendOutput) throw new Error('sendMessage returned null');
617
+ } catch (error) {
618
+ await this.db.transaction(
619
+ 'rw',
620
+ this.db.messages,
621
+ this.db.discussions,
622
+ async () => {
623
+ await this.db.messages.update(messageId, {
624
+ status: MessageStatus.FAILED,
625
+ });
626
+ await this.db.discussions.update(discussion.id, {
627
+ status: DiscussionStatus.BROKEN,
628
+ });
629
+ }
630
+ );
631
+
632
+ log.error('encryption failed → discussion marked broken', error);
633
+ const failedMessage = {
634
+ ...message,
635
+ id: messageId,
636
+ status: MessageStatus.FAILED,
637
+ };
638
+ this.events.onMessageFailed?.(
639
+ failedMessage,
640
+ error instanceof Error ? error : new Error('Session error')
641
+ );
642
+
643
+ return {
644
+ success: false,
645
+ error: 'Session error',
646
+ message: failedMessage,
647
+ };
648
+ }
649
+
650
+ try {
651
+ await this.messageProtocol.sendMessage({
652
+ seeker: sendOutput.seeker,
653
+ ciphertext: sendOutput.data,
654
+ });
655
+
656
+ await this.db.messages.update(messageId, {
657
+ status: MessageStatus.SENT,
658
+ seeker: sendOutput.seeker,
659
+ encryptedMessage: sendOutput.data,
660
+ });
661
+
662
+ const sentMessage = {
663
+ ...message,
664
+ id: messageId,
665
+ status: MessageStatus.SENT,
666
+ };
667
+ this.events.onMessageSent?.(sentMessage);
668
+
669
+ return {
670
+ success: true,
671
+ message: sentMessage,
672
+ };
673
+ } catch (error) {
674
+ await this.db.messages.update(messageId, {
675
+ status: MessageStatus.FAILED,
676
+ seeker: sendOutput.seeker,
677
+ encryptedMessage: sendOutput.data,
678
+ });
679
+
680
+ log.error('network send failed → will retry later', error);
681
+ const failedMessage = {
682
+ ...message,
683
+ id: messageId,
684
+ status: MessageStatus.FAILED,
685
+ };
686
+ this.events.onMessageFailed?.(
687
+ failedMessage,
688
+ error instanceof Error ? error : new Error('Network send failed')
689
+ );
690
+
691
+ return {
692
+ success: false,
693
+ error: 'Network send failed',
694
+ message: failedMessage,
695
+ };
696
+ }
697
+ }
698
+
699
+ private async serializeMessage(
700
+ message: Message
701
+ ): Promise<Result<Uint8Array, string>> {
702
+ const log = logger.forMethod('serializeMessage');
703
+ if (message.replyTo?.originalSeeker) {
704
+ const originalMessage = await this.findMessageBySeeker(
705
+ message.replyTo.originalSeeker,
706
+ message.ownerUserId
707
+ );
708
+
709
+ if (!originalMessage) {
710
+ return {
711
+ success: false,
712
+ error: 'Original message not found for reply',
713
+ };
714
+ }
715
+
716
+ return {
717
+ success: true,
718
+ data: serializeReplyMessage(
719
+ message.content,
720
+ originalMessage.content,
721
+ message.replyTo.originalSeeker
722
+ ),
723
+ };
724
+ } else if (message.type === MessageType.KEEP_ALIVE) {
725
+ return {
726
+ success: true,
727
+ data: serializeKeepAliveMessage(),
728
+ };
729
+ } else if (
730
+ message.forwardOf?.originalContent &&
731
+ message.forwardOf.originalSeeker
732
+ ) {
733
+ try {
734
+ return {
735
+ success: true,
736
+ data: serializeForwardMessage(
737
+ message.forwardOf.originalContent,
738
+ message.content,
739
+ message.forwardOf.originalSeeker
740
+ ),
741
+ };
742
+ } catch (error) {
743
+ log.error('failed to serialize forward message', error);
744
+ return {
745
+ success: false,
746
+ error: 'Failed to serialize forward message',
747
+ };
748
+ }
749
+ } else {
750
+ // Regular message with type tag
751
+ return {
752
+ success: true,
753
+ data: serializeRegularMessage(message.content),
754
+ };
755
+ }
756
+ }
757
+
758
+ async resendMessages(messages: Map<string, Message[]>) {
759
+ const log = logger.forMethod('resendMessages');
760
+
761
+ const successfullySent: number[] = [];
762
+ let totalProcessed = 0;
763
+
764
+ for (const [contactId, retryMessages] of messages.entries()) {
765
+ const peerId = decodeUserId(contactId);
766
+ totalProcessed += retryMessages.length;
767
+
768
+ for (const msg of retryMessages) {
769
+ /* If the message has already been encrypted by sessionManager, resend it */
770
+ if (msg.encryptedMessage && msg.seeker) {
771
+ log.info(
772
+ 'message has already been encrypted by sessionManager with seeker',
773
+ {
774
+ messageContent: msg.content,
775
+ seeker: encodeToBase64(msg.seeker),
776
+ }
777
+ );
778
+ try {
779
+ await this.messageProtocol.sendMessage({
780
+ seeker: msg.seeker,
781
+ ciphertext: msg.encryptedMessage,
782
+ });
783
+ successfullySent.push(msg.id!);
784
+ log.info('message has been resent successfully on the network', {
785
+ messageContent: msg.content,
786
+ });
787
+ } catch (error) {
788
+ log.error('failed to resend message', {
789
+ error: error,
790
+ messageId: msg.id,
791
+ messageContent: msg.content,
792
+ });
793
+ }
794
+
795
+ /* If the message has not been encrypted by sessionManager, encrypt it and resend it */
796
+ } else {
797
+ log.info('message has not been encrypted by sessionManager', {
798
+ messageContent: msg.content,
799
+ });
800
+ const status = this.session.peerSessionStatus(peerId);
801
+ log.info('session status for peer', {
802
+ peerId: encodeUserId(peerId),
803
+ sessionStatus: sessionStatusToString(status),
804
+ });
805
+ /* If the session is waiting for peer acceptance, don't attempt to resend messages in this discussion
806
+ because we don't have the peer's next seeker yet*/
807
+ if (status === SessionStatus.SelfRequested) {
808
+ log.info('skipping resend — waiting for peer acceptance', {
809
+ contactId,
810
+ });
811
+ break;
812
+ }
813
+
814
+ /*
815
+ If session manager encryption fails for a message N, we can't send next N+1, N+2, ... messages in the discussion.
816
+ If the message N+1 is passed with success in session.sendMessage() before passing the message N,
817
+ message N would be considered as posterior to message N+1, which is not correct.
818
+ So if a message can't be encrypted in session.sendMessage() because of error session status,
819
+ we should break the loop and trigger auto-renewal.
820
+ */
821
+ const needsRenewalStatuses = [
822
+ SessionStatus.Killed,
823
+ SessionStatus.Saturated,
824
+ SessionStatus.NoSession,
825
+ SessionStatus.UnknownPeer,
826
+ // Note: PeerRequested is NOT included - it means peer sent us an announcement
827
+ // and we should accept it, not trigger renewal
828
+ ];
829
+
830
+ if (needsRenewalStatuses.includes(status)) {
831
+ // Per spec: trigger auto-renewal instead of marking as BROKEN
832
+ // Messages stay in WAITING_SESSION/FAILED and will be processed when session is Active
833
+ log.info('session lost during resend, triggering renewal', {
834
+ sessionStatus: sessionStatusToString(status),
835
+ contactId,
836
+ });
837
+ this.events.onSessionRenewalNeeded?.(contactId);
838
+ break;
839
+ }
840
+
841
+ // PeerRequested: peer sent us an announcement, need to accept
842
+ if (status === SessionStatus.PeerRequested) {
843
+ log.info(
844
+ 'peer requested session during resend, triggering accept',
845
+ {
846
+ sessionStatus: sessionStatusToString(status),
847
+ contactId,
848
+ }
849
+ );
850
+ this.events.onSessionAcceptNeeded?.(contactId);
851
+ break;
852
+ }
853
+
854
+ if (status !== SessionStatus.Active) {
855
+ log.warn('session not active — stopping resend', {
856
+ sessionStatus: sessionStatusToString(status),
857
+ contactId,
858
+ });
859
+ break;
860
+ }
861
+
862
+ // if the message has not been serialized, serialize it
863
+ let serializedContent = msg.serializedContent;
864
+ if (!serializedContent) {
865
+ log.info('message not serialized yet — serializing it', {
866
+ messageContent: msg.content,
867
+ });
868
+ const serializeResult = await this.serializeMessage(msg);
869
+ if (!serializeResult.success) {
870
+ log.error('serialization failed during resend', {
871
+ error: serializeResult.error,
872
+ });
873
+ break;
874
+ }
875
+ serializedContent = serializeResult.data;
876
+ log.info('message serialized', {
877
+ messageContent: msg.content,
878
+ serializedContent: serializedContent,
879
+ });
880
+ }
881
+
882
+ const sendOutput = await this.session.sendMessage(
883
+ peerId,
884
+ serializedContent
885
+ );
886
+ if (!sendOutput) {
887
+ log.error('session manager failed to send message', {
888
+ messageId: msg.id,
889
+ messageContent: msg.content,
890
+ });
891
+ break;
892
+ }
893
+
894
+ await this.db.messages.update(msg.id, {
895
+ seeker: sendOutput.seeker,
896
+ encryptedMessage: sendOutput.data,
897
+ });
898
+
899
+ try {
900
+ await this.messageProtocol.sendMessage({
901
+ seeker: sendOutput.seeker,
902
+ ciphertext: sendOutput.data,
903
+ });
904
+ successfullySent.push(msg.id!);
905
+ } catch (error) {
906
+ log.error('network send failed during resend', error);
907
+ }
908
+ }
909
+ }
910
+ }
911
+
912
+ if (successfullySent.length > 0) {
913
+ await this.db.transaction('rw', this.db.messages, async () => {
914
+ await Promise.all(
915
+ successfullySent.map(id =>
916
+ this.db.messages.update(id, { status: MessageStatus.SENT })
917
+ )
918
+ );
919
+ });
920
+ }
921
+
922
+ log.info('resend completed', {
923
+ contacts: messages.size,
924
+ messagesProcessed: totalProcessed,
925
+ successfullySent: successfullySent.length,
926
+ });
927
+ }
928
+
929
+ /**
930
+ * Process messages that are waiting for an active session.
931
+ * Called when a session becomes Active to send queued messages.
932
+ * Per spec: when session becomes Active, encrypt and send WAITING_SESSION messages.
933
+ *
934
+ * @param contactUserId - The contact whose session became active
935
+ * @returns Number of messages successfully sent
936
+ */
937
+ async processWaitingMessages(contactUserId: string): Promise<number> {
938
+ const log = logger.forMethod('processWaitingMessages');
939
+ const ownerUserId = this.session.userIdEncoded;
940
+ const peerId = decodeUserId(contactUserId);
941
+
942
+ // Check session is actually active
943
+ const sessionStatus = this.session.peerSessionStatus(peerId);
944
+ if (sessionStatus !== SessionStatus.Active) {
945
+ log.warn('cannot process waiting messages - session not active', {
946
+ sessionStatus: sessionStatusToString(sessionStatus),
947
+ contactUserId,
948
+ });
949
+ return 0;
950
+ }
951
+
952
+ // Get all WAITING_SESSION messages for this contact, ordered by timestamp
953
+ const waitingMessages = await this.db.messages
954
+ .where('[ownerUserId+contactUserId+status]')
955
+ .equals([ownerUserId, contactUserId, MessageStatus.WAITING_SESSION])
956
+ .sortBy('timestamp');
957
+
958
+ if (waitingMessages.length === 0) {
959
+ return 0;
960
+ }
961
+
962
+ log.info('processing waiting messages', {
963
+ count: waitingMessages.length,
964
+ contactUserId,
965
+ });
966
+
967
+ let successCount = 0;
968
+
969
+ for (const msg of waitingMessages) {
970
+ // Serialize if not already done
971
+ let serializedContent = msg.serializedContent;
972
+ if (!serializedContent) {
973
+ const serializeResult = await this.serializeMessage(msg);
974
+ if (!serializeResult.success) {
975
+ log.error('failed to serialize waiting message', {
976
+ messageId: msg.id,
977
+ error: serializeResult.error,
978
+ });
979
+ // Mark as FAILED since we can't serialize
980
+ await this.db.messages.update(msg.id!, {
981
+ status: MessageStatus.FAILED,
982
+ });
983
+ continue;
984
+ }
985
+ serializedContent = serializeResult.data;
986
+ }
987
+
988
+ // Encrypt with session manager (await to ensure persistence before network send)
989
+ const sendOutput = await this.session.sendMessage(
990
+ peerId,
991
+ serializedContent
992
+ );
993
+ if (!sendOutput) {
994
+ log.error('session manager failed to encrypt waiting message', {
995
+ messageId: msg.id,
996
+ });
997
+ // Don't mark as FAILED - session might have changed, retry later
998
+ break;
999
+ }
1000
+
1001
+ // Update message with encrypted data
1002
+ await this.db.messages.update(msg.id!, {
1003
+ status: MessageStatus.SENDING,
1004
+ seeker: sendOutput.seeker,
1005
+ encryptedMessage: sendOutput.data,
1006
+ serializedContent,
1007
+ });
1008
+
1009
+ // Send over network
1010
+ try {
1011
+ await this.messageProtocol.sendMessage({
1012
+ seeker: sendOutput.seeker,
1013
+ ciphertext: sendOutput.data,
1014
+ });
1015
+
1016
+ await this.db.messages.update(msg.id!, {
1017
+ status: MessageStatus.SENT,
1018
+ });
1019
+
1020
+ successCount++;
1021
+ this.events.onMessageSent?.({
1022
+ ...msg,
1023
+ status: MessageStatus.SENT,
1024
+ });
1025
+ } catch (error) {
1026
+ log.error('network send failed for waiting message', {
1027
+ messageId: msg.id,
1028
+ error,
1029
+ });
1030
+ // Keep as SENDING - will be retried by resendMessages
1031
+ await this.db.messages.update(msg.id!, {
1032
+ status: MessageStatus.FAILED,
1033
+ });
1034
+ }
1035
+ }
1036
+
1037
+ log.info('processed waiting messages', {
1038
+ total: waitingMessages.length,
1039
+ sent: successCount,
1040
+ });
1041
+
1042
+ return successCount;
1043
+ }
1044
+
1045
+ /**
1046
+ * Get count of messages waiting for session with a specific contact.
1047
+ */
1048
+ async getWaitingMessageCount(contactUserId: string): Promise<number> {
1049
+ const ownerUserId = this.session.userIdEncoded;
1050
+ return await this.db.messages
1051
+ .where('[ownerUserId+contactUserId+status]')
1052
+ .equals([ownerUserId, contactUserId, MessageStatus.WAITING_SESSION])
1053
+ .count();
1054
+ }
1055
+ }