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

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 (142) 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 +498 -0
  11. package/dist/assets/generated/wasm/gossip_wasm.js +1399 -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 +68 -0
  14. package/dist/assets/generated/wasm/package.json +15 -0
  15. package/dist/config/protocol.d.ts +36 -0
  16. package/dist/config/protocol.js +77 -0
  17. package/dist/config/sdk.d.ts +82 -0
  18. package/dist/config/sdk.js +55 -0
  19. package/{src/contacts.ts → dist/contacts.d.ts} +10 -94
  20. package/dist/contacts.js +166 -0
  21. package/dist/core/SdkEventEmitter.d.ts +36 -0
  22. package/dist/core/SdkEventEmitter.js +59 -0
  23. package/dist/core/SdkPolling.d.ts +35 -0
  24. package/dist/core/SdkPolling.js +100 -0
  25. package/{src/core/index.ts → dist/core/index.d.ts} +0 -2
  26. package/dist/core/index.js +5 -0
  27. package/dist/crypto/bip39.d.ts +34 -0
  28. package/dist/crypto/bip39.js +62 -0
  29. package/dist/crypto/encryption.d.ts +37 -0
  30. package/dist/crypto/encryption.js +46 -0
  31. package/dist/db.d.ts +190 -0
  32. package/dist/db.js +311 -0
  33. package/dist/gossipSdk.d.ts +274 -0
  34. package/dist/gossipSdk.js +690 -0
  35. package/dist/index.d.ts +73 -0
  36. package/dist/index.js +77 -0
  37. package/dist/services/announcement.d.ts +43 -0
  38. package/dist/services/announcement.js +491 -0
  39. package/dist/services/auth.d.ts +37 -0
  40. package/dist/services/auth.js +76 -0
  41. package/dist/services/discussion.d.ts +63 -0
  42. package/dist/services/discussion.js +297 -0
  43. package/dist/services/message.d.ts +74 -0
  44. package/dist/services/message.js +826 -0
  45. package/dist/services/refresh.d.ts +41 -0
  46. package/dist/services/refresh.js +205 -0
  47. package/{src/sw.ts → dist/sw.d.ts} +1 -8
  48. package/dist/sw.js +10 -0
  49. package/dist/types/events.d.ts +80 -0
  50. package/dist/types/events.js +7 -0
  51. package/dist/types.d.ts +32 -0
  52. package/dist/types.js +7 -0
  53. package/dist/utils/base64.d.ts +10 -0
  54. package/dist/utils/base64.js +30 -0
  55. package/dist/utils/contacts.d.ts +42 -0
  56. package/dist/utils/contacts.js +113 -0
  57. package/dist/utils/discussions.d.ts +24 -0
  58. package/dist/utils/discussions.js +38 -0
  59. package/dist/utils/logs.d.ts +19 -0
  60. package/dist/utils/logs.js +89 -0
  61. package/dist/utils/messageSerialization.d.ts +64 -0
  62. package/dist/utils/messageSerialization.js +184 -0
  63. package/dist/utils/queue.d.ts +50 -0
  64. package/dist/utils/queue.js +110 -0
  65. package/dist/utils/type.d.ts +10 -0
  66. package/dist/utils/type.js +4 -0
  67. package/dist/utils/userId.d.ts +40 -0
  68. package/dist/utils/userId.js +90 -0
  69. package/dist/utils/validation.d.ts +50 -0
  70. package/dist/utils/validation.js +112 -0
  71. package/dist/utils.d.ts +30 -0
  72. package/{src/utils.ts → dist/utils.js} +9 -19
  73. package/dist/wasm/encryption.d.ts +56 -0
  74. package/{src/wasm/encryption.ts → dist/wasm/encryption.js} +22 -51
  75. package/dist/wasm/index.d.ts +10 -0
  76. package/{src/wasm/index.ts → dist/wasm/index.js} +1 -8
  77. package/dist/wasm/loader.d.ts +21 -0
  78. package/dist/wasm/loader.js +103 -0
  79. package/dist/wasm/session.d.ts +85 -0
  80. package/dist/wasm/session.js +226 -0
  81. package/dist/wasm/userKeys.d.ts +17 -0
  82. package/{src/wasm/userKeys.ts → dist/wasm/userKeys.js} +6 -13
  83. package/package.json +5 -1
  84. package/src/api/messageProtocol/index.ts +0 -53
  85. package/src/api/messageProtocol/rest.ts +0 -209
  86. package/src/api/messageProtocol/types.ts +0 -70
  87. package/src/config/protocol.ts +0 -97
  88. package/src/config/sdk.ts +0 -131
  89. package/src/core/SdkEventEmitter.ts +0 -91
  90. package/src/core/SdkPolling.ts +0 -134
  91. package/src/crypto/bip39.ts +0 -84
  92. package/src/crypto/encryption.ts +0 -77
  93. package/src/db.ts +0 -465
  94. package/src/gossipSdk.ts +0 -994
  95. package/src/index.ts +0 -211
  96. package/src/services/announcement.ts +0 -653
  97. package/src/services/auth.ts +0 -95
  98. package/src/services/discussion.ts +0 -380
  99. package/src/services/message.ts +0 -1055
  100. package/src/services/refresh.ts +0 -234
  101. package/src/types/events.ts +0 -108
  102. package/src/types.ts +0 -70
  103. package/src/utils/base64.ts +0 -39
  104. package/src/utils/contacts.ts +0 -161
  105. package/src/utils/discussions.ts +0 -55
  106. package/src/utils/logs.ts +0 -86
  107. package/src/utils/messageSerialization.ts +0 -257
  108. package/src/utils/queue.ts +0 -106
  109. package/src/utils/type.ts +0 -7
  110. package/src/utils/userId.ts +0 -114
  111. package/src/utils/validation.ts +0 -144
  112. package/src/wasm/loader.ts +0 -123
  113. package/src/wasm/session.ts +0 -276
  114. package/test/config/protocol.spec.ts +0 -31
  115. package/test/config/sdk.spec.ts +0 -163
  116. package/test/db/helpers.spec.ts +0 -142
  117. package/test/db/operations.spec.ts +0 -128
  118. package/test/db/states.spec.ts +0 -535
  119. package/test/integration/discussion-flow.spec.ts +0 -422
  120. package/test/integration/messaging-flow.spec.ts +0 -708
  121. package/test/integration/sdk-lifecycle.spec.ts +0 -325
  122. package/test/mocks/index.ts +0 -9
  123. package/test/mocks/mockMessageProtocol.ts +0 -100
  124. package/test/services/auth.spec.ts +0 -311
  125. package/test/services/discussion.spec.ts +0 -279
  126. package/test/services/message-deduplication.spec.ts +0 -299
  127. package/test/services/message-startup.spec.ts +0 -331
  128. package/test/services/message.spec.ts +0 -817
  129. package/test/services/refresh.spec.ts +0 -199
  130. package/test/services/session-status.spec.ts +0 -349
  131. package/test/session/wasm.spec.ts +0 -227
  132. package/test/setup.ts +0 -52
  133. package/test/utils/contacts.spec.ts +0 -156
  134. package/test/utils/discussions.spec.ts +0 -66
  135. package/test/utils/queue.spec.ts +0 -52
  136. package/test/utils/serialization.spec.ts +0 -120
  137. package/test/utils/userId.spec.ts +0 -120
  138. package/test/utils/validation.spec.ts +0 -223
  139. package/test/utils.ts +0 -212
  140. package/tsconfig.json +0 -26
  141. package/tsconfig.tsbuildinfo +0 -1
  142. package/vitest.config.ts +0 -28
@@ -0,0 +1,491 @@
1
+ /**
2
+ * Announcement Service
3
+ *
4
+ * Handles broadcasting and processing of session announcements.
5
+ */
6
+ import { DiscussionStatus, DiscussionDirection, } from '../db';
7
+ import { decodeUserId, encodeUserId } from '../utils/userId';
8
+ import { SessionStatus, } from '../assets/generated/wasm/gossip_wasm';
9
+ import { sessionStatusToString } from '../wasm/session';
10
+ import { Logger } from '../utils/logs';
11
+ import { defaultSdkConfig } from '../config/sdk';
12
+ const logger = new Logger('AnnouncementService');
13
+ export const EstablishSessionError = 'Session manager failed to establish outgoing session';
14
+ export class AnnouncementService {
15
+ constructor(db, messageProtocol, session, events = {}, config = defaultSdkConfig) {
16
+ Object.defineProperty(this, "db", {
17
+ enumerable: true,
18
+ configurable: true,
19
+ writable: true,
20
+ value: void 0
21
+ });
22
+ Object.defineProperty(this, "messageProtocol", {
23
+ enumerable: true,
24
+ configurable: true,
25
+ writable: true,
26
+ value: void 0
27
+ });
28
+ Object.defineProperty(this, "session", {
29
+ enumerable: true,
30
+ configurable: true,
31
+ writable: true,
32
+ value: void 0
33
+ });
34
+ Object.defineProperty(this, "isProcessingAnnouncements", {
35
+ enumerable: true,
36
+ configurable: true,
37
+ writable: true,
38
+ value: false
39
+ });
40
+ Object.defineProperty(this, "events", {
41
+ enumerable: true,
42
+ configurable: true,
43
+ writable: true,
44
+ value: void 0
45
+ });
46
+ Object.defineProperty(this, "config", {
47
+ enumerable: true,
48
+ configurable: true,
49
+ writable: true,
50
+ value: void 0
51
+ });
52
+ this.db = db;
53
+ this.messageProtocol = messageProtocol;
54
+ this.session = session;
55
+ this.events = events;
56
+ this.config = config;
57
+ }
58
+ setMessageProtocol(messageProtocol) {
59
+ this.messageProtocol = messageProtocol;
60
+ }
61
+ async sendAnnouncement(announcement) {
62
+ const log = logger.forMethod('sendAnnouncement');
63
+ try {
64
+ const counter = await this.messageProtocol.sendAnnouncement(announcement);
65
+ log.info('broadcast successful', { counter });
66
+ return { success: true, counter };
67
+ }
68
+ catch (error) {
69
+ log.error('broadcast failed', error);
70
+ return {
71
+ success: false,
72
+ error: error instanceof Error ? error.message : 'Unknown error',
73
+ };
74
+ }
75
+ }
76
+ async establishSession(contactPublicKeys, userData) {
77
+ const log = logger.forMethod('establishSession');
78
+ const contactUserId = encodeUserId(contactPublicKeys.derive_id());
79
+ // CRITICAL: await to ensure session state is persisted before sending
80
+ const announcement = await this.session.establishOutgoingSession(contactPublicKeys, userData);
81
+ if (announcement.length === 0) {
82
+ log.error('empty announcement returned', { contactUserId });
83
+ return {
84
+ success: false,
85
+ error: EstablishSessionError,
86
+ announcement,
87
+ };
88
+ }
89
+ const result = await this.sendAnnouncement(announcement);
90
+ if (!result.success) {
91
+ log.error('failed to broadcast announcement', {
92
+ contactUserId,
93
+ error: result.error,
94
+ });
95
+ return {
96
+ success: false,
97
+ error: result.error,
98
+ announcement,
99
+ };
100
+ }
101
+ log.info('announcement sent successfully', { contactUserId });
102
+ return { success: true, announcement };
103
+ }
104
+ async fetchAndProcessAnnouncements() {
105
+ const log = logger.forMethod('fetchAndProcessAnnouncements');
106
+ if (this.isProcessingAnnouncements) {
107
+ log.info('fetch already in progress, skipping');
108
+ return { success: true, newAnnouncementsCount: 0 };
109
+ }
110
+ const errors = [];
111
+ let announcements = [];
112
+ let fetchedCounters = [];
113
+ this.isProcessingAnnouncements = true;
114
+ try {
115
+ const pending = await this.db.pendingAnnouncements.toArray();
116
+ const successfullyProcessedPendingIds = [];
117
+ if (pending.length > 0) {
118
+ log.info(`processing ${pending.length} pending announcements from IndexedDB`);
119
+ // Process pending announcements one by one, tracking successes
120
+ let newAnnouncementsCount = 0;
121
+ for (const item of pending) {
122
+ try {
123
+ const result = await this._processIncomingAnnouncement(item.announcement);
124
+ // Mark as successfully processed (even if announcement was for unknown peer)
125
+ // Only keep if processing threw an error
126
+ if (item.id !== undefined) {
127
+ successfullyProcessedPendingIds.push(item.id);
128
+ }
129
+ if (result.success && result.contactUserId) {
130
+ newAnnouncementsCount++;
131
+ log.info(`processed pending announcement #${newAnnouncementsCount}`, {
132
+ contactUserId: result.contactUserId,
133
+ });
134
+ }
135
+ if (item.counter)
136
+ fetchedCounters.push(item.counter);
137
+ if (result.error)
138
+ errors.push(result.error);
139
+ }
140
+ catch (error) {
141
+ // Don't mark as processed - will be retried next time
142
+ log.error('failed to process pending announcement, will retry', {
143
+ id: item.id,
144
+ error,
145
+ });
146
+ errors.push(error instanceof Error ? error.message : 'Unknown error');
147
+ }
148
+ }
149
+ // Delete only successfully processed pending announcements
150
+ if (successfullyProcessedPendingIds.length > 0) {
151
+ await this.db.pendingAnnouncements.bulkDelete(successfullyProcessedPendingIds);
152
+ log.info(`deleted ${successfullyProcessedPendingIds.length} processed pending announcements`);
153
+ }
154
+ if (fetchedCounters.length > 0) {
155
+ const highestCounter = fetchedCounters.reduce((a, b) => Number(a) > Number(b) ? a : b);
156
+ await this.db.userProfile.update(this.session.userIdEncoded, {
157
+ lastBulletinCounter: highestCounter,
158
+ });
159
+ log.info('updated lastBulletinCounter', { highestCounter });
160
+ }
161
+ return {
162
+ success: errors.length === 0 || newAnnouncementsCount > 0,
163
+ newAnnouncementsCount,
164
+ error: errors.length > 0 ? errors.join(', ') : undefined,
165
+ };
166
+ }
167
+ // No pending - fetch from API
168
+ const cursor = (await this.db.userProfile.get(this.session.userIdEncoded))
169
+ ?.lastBulletinCounter;
170
+ const fetched = await this._fetchAnnouncements(cursor);
171
+ announcements = fetched.map(a => a.data);
172
+ fetchedCounters = fetched.map(a => a.counter);
173
+ let newAnnouncementsCount = 0;
174
+ for (const announcement of announcements) {
175
+ try {
176
+ const result = await this._processIncomingAnnouncement(announcement);
177
+ if (result.success && result.contactUserId) {
178
+ newAnnouncementsCount++;
179
+ log.info(`processed new announcement #${newAnnouncementsCount}`, {
180
+ contactUserId: result.contactUserId,
181
+ });
182
+ }
183
+ if (result.error)
184
+ errors.push(result.error);
185
+ }
186
+ catch (error) {
187
+ errors.push(error instanceof Error ? error.message : 'Unknown error');
188
+ }
189
+ }
190
+ if (fetchedCounters.length > 0) {
191
+ const highestCounter = fetchedCounters.reduce((a, b) => Number(a) > Number(b) ? a : b);
192
+ await this.db.userProfile.update(this.session.userIdEncoded, {
193
+ lastBulletinCounter: highestCounter,
194
+ });
195
+ log.info('updated lastBulletinCounter', { highestCounter });
196
+ }
197
+ return {
198
+ success: errors.length === 0 || newAnnouncementsCount > 0,
199
+ newAnnouncementsCount,
200
+ error: errors.length > 0 ? errors.join(', ') : undefined,
201
+ };
202
+ }
203
+ catch (error) {
204
+ log.error('unexpected error during fetch/process', error);
205
+ return {
206
+ success: false,
207
+ newAnnouncementsCount: 0,
208
+ error: error instanceof Error ? error.message : 'Unknown error',
209
+ };
210
+ }
211
+ finally {
212
+ this.isProcessingAnnouncements = false;
213
+ }
214
+ }
215
+ async resendAnnouncements(failedDiscussions) {
216
+ const log = logger.forMethod('resendAnnouncements');
217
+ if (!failedDiscussions.length) {
218
+ log.info('no failed discussions to resend');
219
+ return;
220
+ }
221
+ log.info(`starting resend for ${failedDiscussions.length} failed discussions`);
222
+ const sentDiscussions = [];
223
+ const brokenDiscussions = [];
224
+ for (const discussion of failedDiscussions) {
225
+ const { ownerUserId, contactUserId } = discussion;
226
+ try {
227
+ const result = await this.sendAnnouncement(discussion.initiationAnnouncement);
228
+ if (result.success) {
229
+ log.info('resent successfully', { ownerUserId, contactUserId });
230
+ sentDiscussions.push(discussion);
231
+ continue;
232
+ }
233
+ log.info('network send failed (retry)', { ownerUserId, contactUserId });
234
+ const ageMs = Date.now() - (discussion.updatedAt.getTime() ?? 0);
235
+ if (ageMs > this.config.announcements.brokenThresholdMs) {
236
+ log.info(`marking as broken (too old: ${Math.round(ageMs / 60000)}min)`, {
237
+ ownerUserId,
238
+ contactUserId,
239
+ });
240
+ brokenDiscussions.push(discussion.id);
241
+ }
242
+ }
243
+ catch (error) {
244
+ log.error('exception during resend', {
245
+ error: error instanceof Error ? error.message : 'Unknown error',
246
+ ownerUserId,
247
+ contactUserId,
248
+ });
249
+ }
250
+ }
251
+ if (sentDiscussions.length > 0 || brokenDiscussions.length > 0) {
252
+ await this.db.transaction('rw', this.db.discussions, async () => {
253
+ const now = new Date();
254
+ if (sentDiscussions.length > 0) {
255
+ await Promise.all(sentDiscussions.map(async (discussion) => {
256
+ const status = this.session.peerSessionStatus(decodeUserId(discussion.contactUserId));
257
+ const statusStr = sessionStatusToString(status);
258
+ if (status !== SessionStatus.Active &&
259
+ status !== SessionStatus.SelfRequested) {
260
+ log.info('skipping DB update - session not ready', {
261
+ contactUserId: discussion.contactUserId,
262
+ status: statusStr,
263
+ });
264
+ return;
265
+ }
266
+ const newStatus = status === SessionStatus.Active
267
+ ? DiscussionStatus.ACTIVE
268
+ : DiscussionStatus.PENDING;
269
+ await this.db.discussions.update(discussion.id, {
270
+ status: newStatus,
271
+ updatedAt: now,
272
+ });
273
+ log.info('updated discussion status in DB', {
274
+ contactUserId: discussion.contactUserId,
275
+ newStatus,
276
+ });
277
+ // Emit status change event
278
+ const updatedDiscussion = await this.db.discussions.get(discussion.id);
279
+ if (updatedDiscussion) {
280
+ this.events.onDiscussionStatusChanged?.(updatedDiscussion);
281
+ }
282
+ }));
283
+ }
284
+ if (brokenDiscussions.length > 0) {
285
+ // Per spec: announcement failures should trigger session renewal, not BROKEN status
286
+ // Clear the failed announcement and trigger renewal
287
+ log.info(`${brokenDiscussions.length} announcements timed out, triggering renewal`);
288
+ await Promise.all(brokenDiscussions.map(async (id) => {
289
+ await this.db.discussions.update(id, {
290
+ initiationAnnouncement: undefined,
291
+ updatedAt: now,
292
+ });
293
+ // Emit renewal needed event
294
+ const discussion = await this.db.discussions.get(id);
295
+ if (discussion) {
296
+ this.events.onSessionRenewalNeeded?.(discussion.contactUserId);
297
+ }
298
+ }));
299
+ }
300
+ });
301
+ }
302
+ log.info('resend completed', {
303
+ sent: sentDiscussions.length,
304
+ broken: brokenDiscussions.length,
305
+ });
306
+ }
307
+ async _fetchAnnouncements(cursor, limit) {
308
+ const fetchLimit = limit ?? this.config.announcements.fetchLimit;
309
+ const log = logger.forMethod('_fetchAnnouncements');
310
+ try {
311
+ const items = await this.messageProtocol.fetchAnnouncements(fetchLimit, cursor);
312
+ return items;
313
+ }
314
+ catch (error) {
315
+ log.error('network fetch failed', error);
316
+ return [];
317
+ }
318
+ }
319
+ async _generateTemporaryContactName(ownerUserId) {
320
+ const newRequestContacts = await this.db.contacts
321
+ .where('ownerUserId')
322
+ .equals(ownerUserId)
323
+ .filter(contact => contact.name.startsWith('New Request'))
324
+ .toArray();
325
+ const numbers = newRequestContacts
326
+ .map(contact => {
327
+ const match = contact.name.match(/^New Request (\d+)$/);
328
+ return match ? parseInt(match[1], 10) : 0;
329
+ })
330
+ .filter(number => number > 0);
331
+ const next = numbers.length ? Math.max(...numbers) + 1 : 1;
332
+ return `New Request ${next}`;
333
+ }
334
+ async _processIncomingAnnouncement(announcementData) {
335
+ const log = logger.forMethod('_processIncomingAnnouncement');
336
+ const result = await this.session.feedIncomingAnnouncement(announcementData);
337
+ if (!result) {
338
+ return { success: true };
339
+ }
340
+ log.info('announcement intended for us — decrypting');
341
+ let rawMessage;
342
+ if (result.user_data?.length > 0) {
343
+ try {
344
+ rawMessage = new TextDecoder().decode(result.user_data);
345
+ }
346
+ catch (error) {
347
+ log.error('failed to decode user data', error);
348
+ }
349
+ }
350
+ // Parse announcement message format:
351
+ // - JSON format: {"u":"username","m":"message"} (current)
352
+ // - Legacy colon format: "username:message" (backwards compat)
353
+ // - Plain text: "message" (oldest format)
354
+ // The username is used as the initial contact name if present.
355
+ // TODO: Remove legacy colon and plain text format support once all clients are updated
356
+ let extractedUsername;
357
+ let announcementMessage;
358
+ if (rawMessage) {
359
+ // Try JSON format first (starts with '{')
360
+ if (rawMessage.startsWith('{')) {
361
+ try {
362
+ const parsed = JSON.parse(rawMessage);
363
+ extractedUsername = parsed.u?.trim() || undefined;
364
+ announcementMessage = parsed.m?.trim() || undefined;
365
+ }
366
+ catch {
367
+ // Invalid JSON, treat as plain text
368
+ announcementMessage = rawMessage;
369
+ }
370
+ }
371
+ else {
372
+ // Legacy format: check for colon separator
373
+ const colonIndex = rawMessage.indexOf(':');
374
+ if (colonIndex !== -1) {
375
+ extractedUsername =
376
+ rawMessage.slice(0, colonIndex).trim() || undefined;
377
+ announcementMessage =
378
+ rawMessage.slice(colonIndex + 1).trim() || undefined;
379
+ }
380
+ else {
381
+ // Plain text (oldest format)
382
+ announcementMessage = rawMessage;
383
+ }
384
+ }
385
+ }
386
+ const announcerPkeys = result.announcer_public_keys;
387
+ const contactUserIdRaw = announcerPkeys.derive_id();
388
+ const contactUserId = encodeUserId(contactUserIdRaw);
389
+ const sessionStatus = this.session.peerSessionStatus(contactUserIdRaw);
390
+ // Log clearly for debugging
391
+ console.log(`[Announcement] Received from ${contactUserId.slice(0, 12)}... -> session status: ${sessionStatusToString(sessionStatus)}`);
392
+ log.info('session updated', {
393
+ contactUserId,
394
+ status: sessionStatusToString(sessionStatus),
395
+ });
396
+ let contact = await this.db.getContactByOwnerAndUserId(this.session.userIdEncoded, contactUserId);
397
+ const isNewContact = !contact;
398
+ if (isNewContact) {
399
+ // Use extracted username if present, otherwise generate temporary name
400
+ const name = extractedUsername ||
401
+ (await this._generateTemporaryContactName(this.session.userIdEncoded));
402
+ await this.db.contacts.add({
403
+ ownerUserId: this.session.userIdEncoded,
404
+ userId: contactUserId,
405
+ name,
406
+ publicKeys: announcerPkeys.to_bytes(),
407
+ avatar: undefined,
408
+ isOnline: false,
409
+ lastSeen: new Date(),
410
+ createdAt: new Date(),
411
+ });
412
+ contact = await this.db.getContactByOwnerAndUserId(this.session.userIdEncoded, contactUserId);
413
+ log.info('created new contact', { contactUserId, name });
414
+ }
415
+ if (!contact) {
416
+ log.error('contact lookup failed after creation');
417
+ throw new Error('Could not find or create contact');
418
+ }
419
+ const { discussionId } = await this._handleReceivedDiscussion(this.session.userIdEncoded, contactUserId, announcementMessage);
420
+ // Emit event for new discussion request
421
+ if (this.events.onDiscussionRequest) {
422
+ const discussion = await this.db.discussions.get(discussionId);
423
+ if (discussion && contact) {
424
+ this.events.onDiscussionRequest(discussion, contact);
425
+ }
426
+ }
427
+ // Auto-accept ONLY for existing contacts (session recovery scenario).
428
+ // For NEW contacts, the user must manually accept the discussion request.
429
+ // This completes the handshake by sending our announcement back.
430
+ if (sessionStatus === SessionStatus.PeerRequested && !isNewContact) {
431
+ log.info('session is PeerRequested for existing contact, triggering auto-accept', { contactUserId });
432
+ this.events.onSessionAcceptNeeded?.(contactUserId);
433
+ }
434
+ else if (sessionStatus === SessionStatus.PeerRequested && isNewContact) {
435
+ log.info('session is PeerRequested for NEW contact, waiting for manual accept', { contactUserId });
436
+ }
437
+ // When session becomes Active after peer accepts our announcement,
438
+ // trigger processing of WAITING_SESSION messages.
439
+ // This happens when we initiated (SelfRequested) and peer accepted.
440
+ if (sessionStatus === SessionStatus.Active) {
441
+ log.info('session is now Active, triggering WAITING_SESSION message processing', { contactUserId });
442
+ this.events.onSessionBecameActive?.(contactUserId);
443
+ }
444
+ return {
445
+ success: true,
446
+ discussionId,
447
+ contactUserId,
448
+ };
449
+ }
450
+ async _handleReceivedDiscussion(ownerUserId, contactUserId, announcementMessage) {
451
+ const log = logger.forMethod('handleReceivedDiscussion');
452
+ const discussionId = await this.db.transaction('rw', this.db.discussions, async () => {
453
+ const existing = await this.db.getDiscussionByOwnerAndContact(ownerUserId, contactUserId);
454
+ if (existing) {
455
+ const updateData = { updatedAt: new Date() };
456
+ if (announcementMessage)
457
+ updateData.announcementMessage = announcementMessage;
458
+ if (existing.status === DiscussionStatus.PENDING &&
459
+ existing.direction === DiscussionDirection.INITIATED) {
460
+ updateData.status = DiscussionStatus.ACTIVE;
461
+ log.info('transitioning to ACTIVE', {
462
+ discussionId: existing.id,
463
+ contactUserId,
464
+ });
465
+ }
466
+ else {
467
+ log.info('updating existing discussion', {
468
+ discussionId: existing.id,
469
+ status: existing.status,
470
+ direction: existing.direction,
471
+ });
472
+ }
473
+ await this.db.discussions.update(existing.id, updateData);
474
+ return existing.id;
475
+ }
476
+ log.info('creating new RECEIVED/PENDING discussion', { contactUserId });
477
+ return await this.db.discussions.add({
478
+ ownerUserId,
479
+ contactUserId,
480
+ direction: DiscussionDirection.RECEIVED,
481
+ status: DiscussionStatus.PENDING,
482
+ nextSeeker: undefined,
483
+ announcementMessage,
484
+ unreadCount: 0,
485
+ createdAt: new Date(),
486
+ updatedAt: new Date(),
487
+ });
488
+ });
489
+ return { discussionId };
490
+ }
491
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Auth Service
3
+ *
4
+ * Handles storing and retrieving public keys by userId hash via the auth API.
5
+ */
6
+ import { UserPublicKeys } from '../assets/generated/wasm/gossip_wasm';
7
+ import { IMessageProtocol } from '../api/messageProtocol/types';
8
+ import { type GossipDatabase } from '../db';
9
+ export type PublicKeyResult = {
10
+ publicKey: UserPublicKeys;
11
+ error?: never;
12
+ } | {
13
+ publicKey?: never;
14
+ error: string;
15
+ };
16
+ export declare class AuthService {
17
+ private db;
18
+ messageProtocol: IMessageProtocol;
19
+ constructor(db: GossipDatabase, messageProtocol: IMessageProtocol);
20
+ /**
21
+ * Fetch public key by userId
22
+ * @param userId - Bech32-encoded userId (e.g., "gossip1...")
23
+ */
24
+ fetchPublicKeyByUserId(userId: string): Promise<PublicKeyResult>;
25
+ /**
26
+ * Ensure public key is published (check first, then publish if needed)
27
+ * @param publicKeys - UserPublicKeys instance
28
+ * @param userId - Bech32-encoded userId (e.g., "gossip1...")
29
+ */
30
+ ensurePublicKeyPublished(publicKeys: UserPublicKeys, userId: string): Promise<void>;
31
+ }
32
+ export declare const PUBLIC_KEY_NOT_FOUND_ERROR = "Public key not found";
33
+ export declare const PUBLIC_KEY_NOT_FOUND_MESSAGE = "Contact public key not found. It may not be published yet.";
34
+ export declare const FAILED_TO_FETCH_ERROR = "Failed to fetch";
35
+ export declare const FAILED_TO_FETCH_MESSAGE = "Failed to retrieve contact public key. Check your internet connection or try again later.";
36
+ export declare const FAILED_TO_RETRIEVE_CONTACT_PUBLIC_KEY_ERROR = "Failed to retrieve contact public key";
37
+ export declare function getPublicKeyErrorMessage(error: unknown): string;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Auth Service
3
+ *
4
+ * Handles storing and retrieving public keys by userId hash via the auth API.
5
+ */
6
+ import { UserPublicKeys } from '../assets/generated/wasm/gossip_wasm';
7
+ import { decodeUserId } from '../utils/userId';
8
+ import { encodeToBase64, decodeFromBase64 } from '../utils/base64';
9
+ export class AuthService {
10
+ constructor(db, messageProtocol) {
11
+ Object.defineProperty(this, "db", {
12
+ enumerable: true,
13
+ configurable: true,
14
+ writable: true,
15
+ value: db
16
+ });
17
+ Object.defineProperty(this, "messageProtocol", {
18
+ enumerable: true,
19
+ configurable: true,
20
+ writable: true,
21
+ value: messageProtocol
22
+ });
23
+ }
24
+ /**
25
+ * Fetch public key by userId
26
+ * @param userId - Bech32-encoded userId (e.g., "gossip1...")
27
+ */
28
+ async fetchPublicKeyByUserId(userId) {
29
+ try {
30
+ const base64PublicKey = await this.messageProtocol.fetchPublicKeyByUserId(decodeUserId(userId));
31
+ return {
32
+ publicKey: UserPublicKeys.from_bytes(decodeFromBase64(base64PublicKey)),
33
+ };
34
+ }
35
+ catch (err) {
36
+ return {
37
+ error: getPublicKeyErrorMessage(err),
38
+ };
39
+ }
40
+ }
41
+ /**
42
+ * Ensure public key is published (check first, then publish if needed)
43
+ * @param publicKeys - UserPublicKeys instance
44
+ * @param userId - Bech32-encoded userId (e.g., "gossip1...")
45
+ */
46
+ async ensurePublicKeyPublished(publicKeys, userId) {
47
+ const profile = await this.db.userProfile.get(userId);
48
+ if (!profile)
49
+ throw new Error('User profile not found');
50
+ const lastPush = profile.lastPublicKeyPush;
51
+ if (lastPush && !moreThanOneWeekAgo(lastPush)) {
52
+ return;
53
+ }
54
+ await this.messageProtocol.postPublicKey(encodeToBase64(publicKeys.to_bytes()));
55
+ await this.db.userProfile.update(userId, { lastPublicKeyPush: new Date() });
56
+ }
57
+ }
58
+ const ONE_WEEK_IN_MILLIS = 7 * 24 * 60 * 60 * 1000;
59
+ function moreThanOneWeekAgo(date) {
60
+ return Date.now() - date.getTime() >= ONE_WEEK_IN_MILLIS;
61
+ }
62
+ export const PUBLIC_KEY_NOT_FOUND_ERROR = 'Public key not found';
63
+ export const PUBLIC_KEY_NOT_FOUND_MESSAGE = 'Contact public key not found. It may not be published yet.';
64
+ export const FAILED_TO_FETCH_ERROR = 'Failed to fetch';
65
+ export const FAILED_TO_FETCH_MESSAGE = 'Failed to retrieve contact public key. Check your internet connection or try again later.';
66
+ export const FAILED_TO_RETRIEVE_CONTACT_PUBLIC_KEY_ERROR = 'Failed to retrieve contact public key';
67
+ export function getPublicKeyErrorMessage(error) {
68
+ const errorMessage = error instanceof Error ? error.message : String(error);
69
+ if (errorMessage.includes(PUBLIC_KEY_NOT_FOUND_ERROR)) {
70
+ return PUBLIC_KEY_NOT_FOUND_MESSAGE;
71
+ }
72
+ if (errorMessage.includes(FAILED_TO_FETCH_ERROR)) {
73
+ return FAILED_TO_FETCH_MESSAGE;
74
+ }
75
+ return `${FAILED_TO_RETRIEVE_CONTACT_PUBLIC_KEY_ERROR}. ${errorMessage}`;
76
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Discussion Service
3
+ *
4
+ * Class-based service for initializing, accepting, and managing discussions.
5
+ */
6
+ import { type Discussion, type Contact, type GossipDatabase } from '../db';
7
+ import { AnnouncementService } from './announcement';
8
+ import { SessionModule } from '../wasm/session';
9
+ import { GossipSdkEvents } from '../types/events';
10
+ /**
11
+ * Service for managing discussions between users.
12
+ *
13
+ * @example
14
+ * ```typescript
15
+ * const discussionService = new DiscussionService(db, announcementService, session);
16
+ *
17
+ * // Initialize a new discussion
18
+ * const result = await discussionService.initialize(contact, 'Hello!');
19
+ *
20
+ * // Accept a discussion request
21
+ * await discussionService.accept(discussion);
22
+ *
23
+ * // Renew a broken discussion
24
+ * await discussionService.renew(contactUserId);
25
+ * ```
26
+ */
27
+ export declare class DiscussionService {
28
+ private db;
29
+ private announcementService;
30
+ private session;
31
+ private events;
32
+ constructor(db: GossipDatabase, announcementService: AnnouncementService, session: SessionModule, events?: GossipSdkEvents);
33
+ /**
34
+ * Initialize a discussion with a contact using SessionManager
35
+ * @param contact - The contact to start a discussion with
36
+ * @param message - Optional message to include in the announcement
37
+ * @returns The discussion ID and the created announcement
38
+ */
39
+ initialize(contact: Contact, message?: string): Promise<{
40
+ discussionId: number;
41
+ announcement: Uint8Array;
42
+ }>;
43
+ /**
44
+ * Accept a discussion request from a contact using SessionManager
45
+ * @param discussion - The discussion to accept
46
+ */
47
+ accept(discussion: Discussion): Promise<void>;
48
+ /**
49
+ * Renew a discussion by resetting sent outgoing messages and sending a new announcement.
50
+ * @param contactUserId - The user ID of the contact whose discussion should be renewed.
51
+ */
52
+ renew(contactUserId: string): Promise<void>;
53
+ /**
54
+ * Check if new messages can be sent to session manager for encryption.
55
+ * Returns false if the discussion is broken or if there are failed messages
56
+ * that have not been encrypted.
57
+ *
58
+ * @param ownerUserId - The owner user ID
59
+ * @param contactUserId - The contact user ID
60
+ * @returns true if discussion is in stable state for sending messages
61
+ */
62
+ isStableState(ownerUserId: string, contactUserId: string): Promise<boolean>;
63
+ }