@massalabs/gossip-sdk 0.0.2-dev.20260128094509 → 0.0.2-dev.20260128160824

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