@massalabs/gossip-sdk 0.0.1 → 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,690 @@
1
+ /**
2
+ * GossipSdk - Singleton SDK with clean lifecycle API
3
+ *
4
+ * @example
5
+ * ```typescript
6
+ * import { gossipSdk } from 'gossip-sdk';
7
+ *
8
+ * // Initialize once at app startup
9
+ * await gossipSdk.init({
10
+ * db,
11
+ * protocolBaseUrl: 'https://api.example.com',
12
+ * });
13
+ *
14
+ * // Open session (login) - SDK handles keys/session internally
15
+ * await gossipSdk.openSession({
16
+ * mnemonic: 'word1 word2 ...',
17
+ * onPersist: async (blob) => { /* save to db *\/ },
18
+ * });
19
+ *
20
+ * // Or restore existing session
21
+ * await gossipSdk.openSession({
22
+ * mnemonic: 'word1 word2 ...',
23
+ * encryptedSession: savedBlob,
24
+ * encryptionKey: key,
25
+ * onPersist: async (blob) => { /* save to db *\/ },
26
+ * });
27
+ *
28
+ * // Use clean API
29
+ * await gossipSdk.messages.send(contactId, 'Hello!');
30
+ * await gossipSdk.discussions.start(contact);
31
+ * const contacts = await gossipSdk.contacts.list(ownerUserId);
32
+ *
33
+ * // Events
34
+ * gossipSdk.on('message', (msg) => { ... });
35
+ * gossipSdk.on('discussionRequest', (discussion, contact) => { ... });
36
+ *
37
+ * // Logout
38
+ * await gossipSdk.closeSession();
39
+ * ```
40
+ */
41
+ import { MessageStatus, } from './db';
42
+ import { setDb } from './db';
43
+ import { createMessageProtocol } from './api/messageProtocol';
44
+ import { setProtocolBaseUrl } from './config/protocol';
45
+ import { defaultSdkConfig, mergeConfig, } from './config/sdk';
46
+ import { startWasmInitialization, ensureWasmInitialized } from './wasm/loader';
47
+ import { generateUserKeys } from './wasm/userKeys';
48
+ import { SessionModule } from './wasm/session';
49
+ import { AnnouncementService, } from './services/announcement';
50
+ import { DiscussionService } from './services/discussion';
51
+ import { MessageService, } from './services/message';
52
+ import { RefreshService } from './services/refresh';
53
+ import { AuthService } from './services/auth';
54
+ import { validateUserIdFormat, validateUsernameFormat, } from './utils/validation';
55
+ import { QueueManager } from './utils/queue';
56
+ import { encodeUserId, decodeUserId } from './utils/userId';
57
+ import { getContacts, getContact, addContact, updateContactName, deleteContact, } from './contacts';
58
+ import { SdkEventEmitter, } from './core/SdkEventEmitter';
59
+ import { SdkPolling } from './core/SdkPolling';
60
+ // ─────────────────────────────────────────────────────────────────────────────
61
+ // SDK Class
62
+ // ─────────────────────────────────────────────────────────────────────────────
63
+ class GossipSdkImpl {
64
+ constructor() {
65
+ Object.defineProperty(this, "state", {
66
+ enumerable: true,
67
+ configurable: true,
68
+ writable: true,
69
+ value: { status: 'uninitialized' }
70
+ });
71
+ // Core components
72
+ Object.defineProperty(this, "eventEmitter", {
73
+ enumerable: true,
74
+ configurable: true,
75
+ writable: true,
76
+ value: new SdkEventEmitter()
77
+ });
78
+ Object.defineProperty(this, "pollingManager", {
79
+ enumerable: true,
80
+ configurable: true,
81
+ writable: true,
82
+ value: new SdkPolling()
83
+ });
84
+ Object.defineProperty(this, "messageQueues", {
85
+ enumerable: true,
86
+ configurable: true,
87
+ writable: true,
88
+ value: new QueueManager()
89
+ });
90
+ // Services (created when session opens)
91
+ Object.defineProperty(this, "_auth", {
92
+ enumerable: true,
93
+ configurable: true,
94
+ writable: true,
95
+ value: null
96
+ });
97
+ Object.defineProperty(this, "_announcement", {
98
+ enumerable: true,
99
+ configurable: true,
100
+ writable: true,
101
+ value: null
102
+ });
103
+ Object.defineProperty(this, "_discussion", {
104
+ enumerable: true,
105
+ configurable: true,
106
+ writable: true,
107
+ value: null
108
+ });
109
+ Object.defineProperty(this, "_message", {
110
+ enumerable: true,
111
+ configurable: true,
112
+ writable: true,
113
+ value: null
114
+ });
115
+ Object.defineProperty(this, "_refresh", {
116
+ enumerable: true,
117
+ configurable: true,
118
+ writable: true,
119
+ value: null
120
+ });
121
+ // Cached service API wrappers (created in openSession)
122
+ Object.defineProperty(this, "_messagesAPI", {
123
+ enumerable: true,
124
+ configurable: true,
125
+ writable: true,
126
+ value: null
127
+ });
128
+ Object.defineProperty(this, "_discussionsAPI", {
129
+ enumerable: true,
130
+ configurable: true,
131
+ writable: true,
132
+ value: null
133
+ });
134
+ Object.defineProperty(this, "_announcementsAPI", {
135
+ enumerable: true,
136
+ configurable: true,
137
+ writable: true,
138
+ value: null
139
+ });
140
+ Object.defineProperty(this, "_contactsAPI", {
141
+ enumerable: true,
142
+ configurable: true,
143
+ writable: true,
144
+ value: null
145
+ });
146
+ Object.defineProperty(this, "_refreshAPI", {
147
+ enumerable: true,
148
+ configurable: true,
149
+ writable: true,
150
+ value: null
151
+ });
152
+ }
153
+ // ─────────────────────────────────────────────────────────────────
154
+ // Lifecycle
155
+ // ─────────────────────────────────────────────────────────────────
156
+ /**
157
+ * Initialize the SDK. Call once at app startup.
158
+ */
159
+ async init(options) {
160
+ if (this.state.status !== 'uninitialized') {
161
+ console.warn('[GossipSdk] Already initialized');
162
+ return;
163
+ }
164
+ // Merge config with defaults
165
+ const config = mergeConfig(options.config);
166
+ // Configure database
167
+ setDb(options.db);
168
+ // Configure protocol URL (prefer explicit option, then config)
169
+ const baseUrl = options.protocolBaseUrl ?? config.protocol.baseUrl;
170
+ if (baseUrl) {
171
+ setProtocolBaseUrl(baseUrl);
172
+ }
173
+ // Start WASM initialization
174
+ startWasmInitialization();
175
+ // Create message protocol
176
+ const messageProtocol = createMessageProtocol();
177
+ // Create auth service (doesn't need session)
178
+ this._auth = new AuthService(options.db, messageProtocol);
179
+ this.state = {
180
+ status: 'initialized',
181
+ db: options.db,
182
+ messageProtocol,
183
+ config,
184
+ };
185
+ }
186
+ /**
187
+ * Open a session (login).
188
+ * Generates keys from mnemonic and initializes session.
189
+ */
190
+ async openSession(options) {
191
+ if (this.state.status === 'uninitialized') {
192
+ throw new Error('SDK not initialized. Call init() first.');
193
+ }
194
+ if (this.state.status === 'session_open') {
195
+ throw new Error('Session already open. Call closeSession() first.');
196
+ }
197
+ if (options.encryptedSession && !options.encryptionKey) {
198
+ throw new Error('encryptionKey is required when encryptedSession is provided.');
199
+ }
200
+ if (options.onPersist && !options.persistEncryptionKey) {
201
+ throw new Error('persistEncryptionKey is required when onPersist is provided.');
202
+ }
203
+ const { db, messageProtocol } = this.state;
204
+ // Validate session restore options - must have both or neither
205
+ if (options.encryptedSession && !options.encryptionKey) {
206
+ throw new Error('encryptedSession provided without encryptionKey. Session restore requires both.');
207
+ }
208
+ if (options.encryptionKey && !options.encryptedSession) {
209
+ console.warn('[GossipSdk] encryptionKey provided without encryptedSession - key will be ignored');
210
+ }
211
+ // Validate persistence options - warn if incomplete
212
+ if (options.onPersist && !options.persistEncryptionKey) {
213
+ console.warn('[GossipSdk] onPersist provided without persistEncryptionKey - session will not be persisted');
214
+ }
215
+ if (options.persistEncryptionKey && !options.onPersist) {
216
+ console.warn('[GossipSdk] persistEncryptionKey provided without onPersist callback - key will be unused');
217
+ }
218
+ // Ensure WASM is ready
219
+ await ensureWasmInitialized();
220
+ // Generate keys from mnemonic
221
+ const userKeys = await generateUserKeys(options.mnemonic);
222
+ // Create session with persistence callback
223
+ // IMPORTANT: This callback is awaited by the session module before network sends
224
+ const session = new SessionModule(userKeys, async () => {
225
+ await this.handleSessionPersist();
226
+ });
227
+ // Restore existing session state if provided
228
+ if (options.encryptedSession && options.encryptionKey) {
229
+ // Create a minimal profile-like object for load()
230
+ const profileForLoad = {
231
+ session: options.encryptedSession,
232
+ };
233
+ session.load(profileForLoad, options.encryptionKey);
234
+ }
235
+ // Create event handlers that wire to our event system
236
+ const serviceEvents = {
237
+ onMessageReceived: (message) => {
238
+ this.eventEmitter.emit('message', message);
239
+ },
240
+ onMessageSent: (message) => {
241
+ this.eventEmitter.emit('messageSent', message);
242
+ },
243
+ onMessageFailed: (message, error) => {
244
+ this.eventEmitter.emit('messageFailed', message, error);
245
+ },
246
+ onDiscussionRequest: (discussion, contact) => {
247
+ this.eventEmitter.emit('discussionRequest', discussion, contact);
248
+ },
249
+ onDiscussionStatusChanged: (discussion) => {
250
+ this.eventEmitter.emit('discussionStatusChanged', discussion);
251
+ },
252
+ onSessionBroken: (discussion) => {
253
+ this.eventEmitter.emit('sessionBroken', discussion);
254
+ },
255
+ onSessionRenewed: (discussion) => {
256
+ this.eventEmitter.emit('sessionRenewed', discussion);
257
+ },
258
+ onError: (error, context) => {
259
+ this.eventEmitter.emit('error', error, context);
260
+ },
261
+ // Auto-renewal: when session is lost, automatically renew it
262
+ onSessionRenewalNeeded: (contactUserId) => {
263
+ console.log('[GossipSdk] Session renewal needed for', contactUserId);
264
+ this.handleSessionRenewal(contactUserId);
265
+ },
266
+ // Auto-accept: when peer sent us an announcement, accept/respond to establish session
267
+ onSessionAcceptNeeded: (contactUserId) => {
268
+ console.log('[GossipSdk] Session accept needed for', contactUserId);
269
+ this.handleSessionAccept(contactUserId);
270
+ },
271
+ // Session became active: peer accepted our announcement, process waiting messages
272
+ onSessionBecameActive: (contactUserId) => {
273
+ console.log('[GossipSdk] Session became active for', contactUserId);
274
+ this.handleSessionBecameActive(contactUserId);
275
+ },
276
+ };
277
+ // Get config from initialized state
278
+ const { config } = this.state;
279
+ // Create services with config
280
+ this._announcement = new AnnouncementService(db, messageProtocol, session, serviceEvents, config);
281
+ this._discussion = new DiscussionService(db, this._announcement, session, serviceEvents);
282
+ this._message = new MessageService(db, messageProtocol, session, this._discussion, serviceEvents, config);
283
+ this._refresh = new RefreshService(db, this._message, session, serviceEvents);
284
+ // Reset any messages stuck in SENDING status to FAILED
285
+ // This handles app crash/close during message send
286
+ await this.resetStuckSendingMessages(db);
287
+ this.state = {
288
+ status: 'session_open',
289
+ db,
290
+ messageProtocol,
291
+ config,
292
+ session,
293
+ userKeys,
294
+ persistEncryptionKey: options.persistEncryptionKey,
295
+ onPersist: options.onPersist,
296
+ };
297
+ // Create cached service API wrappers
298
+ this.createServiceAPIWrappers(db, session);
299
+ // Auto-start polling if enabled in config
300
+ if (config.polling.enabled) {
301
+ this.startPolling();
302
+ }
303
+ }
304
+ /**
305
+ * Create cached service API wrappers.
306
+ * Called once during openSession to avoid creating new objects on each getter access.
307
+ */
308
+ createServiceAPIWrappers(db, session) {
309
+ this._messagesAPI = {
310
+ send: message => this.messageQueues.enqueue(message.contactUserId, () => this._message.sendMessage(message)),
311
+ fetch: () => this._message.fetchMessages(),
312
+ resend: async (messages) => {
313
+ const promises = [];
314
+ for (const [contactId, contactMessages] of messages.entries()) {
315
+ const singleContactMap = new Map([[contactId, contactMessages]]);
316
+ promises.push(this.messageQueues.enqueue(contactId, () => this._message.resendMessages(singleContactMap)));
317
+ }
318
+ await Promise.all(promises);
319
+ },
320
+ findBySeeker: (seeker, ownerUserId) => this._message.findMessageBySeeker(seeker, ownerUserId),
321
+ };
322
+ this._discussionsAPI = {
323
+ start: (contact, message) => this._discussion.initialize(contact, message),
324
+ accept: discussion => this._discussion.accept(discussion),
325
+ renew: contactUserId => this._discussion.renew(contactUserId),
326
+ isStable: (ownerUserId, contactUserId) => this._discussion.isStableState(ownerUserId, contactUserId),
327
+ list: ownerUserId => db.getDiscussionsByOwner(ownerUserId),
328
+ get: (ownerUserId, contactUserId) => db.getDiscussionByOwnerAndContact(ownerUserId, contactUserId),
329
+ };
330
+ this._announcementsAPI = {
331
+ fetch: () => this._announcement.fetchAndProcessAnnouncements(),
332
+ resend: failedDiscussions => this._announcement.resendAnnouncements(failedDiscussions),
333
+ };
334
+ this._contactsAPI = {
335
+ list: ownerUserId => getContacts(ownerUserId, db),
336
+ get: (ownerUserId, contactUserId) => getContact(ownerUserId, contactUserId, db),
337
+ add: (ownerUserId, userId, name, publicKeys) => addContact(ownerUserId, userId, name, publicKeys, db),
338
+ updateName: (ownerUserId, contactUserId, newName) => updateContactName(ownerUserId, contactUserId, newName, db),
339
+ delete: (ownerUserId, contactUserId) => deleteContact(ownerUserId, contactUserId, db, session),
340
+ };
341
+ this._refreshAPI = {
342
+ handleSessionRefresh: activeDiscussions => this._refresh.handleSessionRefresh(activeDiscussions),
343
+ };
344
+ }
345
+ /**
346
+ * Close the current session (logout).
347
+ */
348
+ async closeSession() {
349
+ if (this.state.status !== 'session_open') {
350
+ return;
351
+ }
352
+ // Stop polling first
353
+ this.pollingManager.stop();
354
+ // Cleanup session
355
+ this.state.session.cleanup();
356
+ // Clear services
357
+ this._announcement = null;
358
+ this._discussion = null;
359
+ this._message = null;
360
+ this._refresh = null;
361
+ // Clear cached API wrappers
362
+ this._messagesAPI = null;
363
+ this._discussionsAPI = null;
364
+ this._announcementsAPI = null;
365
+ this._contactsAPI = null;
366
+ this._refreshAPI = null;
367
+ // Clear message queues
368
+ this.messageQueues.clear();
369
+ // Reset to initialized state
370
+ this.state = {
371
+ status: 'initialized',
372
+ db: this.state.db,
373
+ messageProtocol: this.state.messageProtocol,
374
+ config: this.state.config,
375
+ };
376
+ }
377
+ // ─────────────────────────────────────────────────────────────────
378
+ // Session Info
379
+ // ─────────────────────────────────────────────────────────────────
380
+ /** Current user ID (encoded). Throws if no session is open. */
381
+ get userId() {
382
+ const state = this.requireSession();
383
+ return state.session.userIdEncoded;
384
+ }
385
+ /** Current user ID (raw bytes). Throws if no session is open. */
386
+ get userIdBytes() {
387
+ const state = this.requireSession();
388
+ return state.session.userId;
389
+ }
390
+ /** User's public keys. Throws if no session is open. */
391
+ get publicKeys() {
392
+ const state = this.requireSession();
393
+ return state.session.ourPk;
394
+ }
395
+ /** Whether a session is currently open */
396
+ get isSessionOpen() {
397
+ return this.state.status === 'session_open';
398
+ }
399
+ /** Whether SDK is initialized */
400
+ get isInitialized() {
401
+ return this.state.status !== 'uninitialized';
402
+ }
403
+ /**
404
+ * Get encrypted session blob for persistence.
405
+ * Throws if no session is open.
406
+ */
407
+ getEncryptedSession(encryptionKey) {
408
+ const state = this.requireSession();
409
+ return state.session.toEncryptedBlob(encryptionKey);
410
+ }
411
+ /**
412
+ * Configure session persistence after session is opened.
413
+ * Use this when you need to set up persistence after account creation.
414
+ *
415
+ * @param encryptionKey - Key to encrypt session blob
416
+ * @param onPersist - Callback to save encrypted session blob
417
+ */
418
+ configurePersistence(encryptionKey, onPersist) {
419
+ if (this.state.status !== 'session_open') {
420
+ throw new Error('No session open. Call openSession() first.');
421
+ }
422
+ // Update state with persistence config
423
+ this.state = {
424
+ ...this.state,
425
+ persistEncryptionKey: encryptionKey,
426
+ onPersist,
427
+ };
428
+ console.log('[GossipSdk] Session persistence configured');
429
+ }
430
+ // ─────────────────────────────────────────────────────────────────
431
+ // Services (accessible only when session is open)
432
+ // ─────────────────────────────────────────────────────────────────
433
+ /** Auth service (available after init, before session) */
434
+ get auth() {
435
+ if (!this._auth) {
436
+ throw new Error('SDK not initialized');
437
+ }
438
+ return this._auth;
439
+ }
440
+ /** Message service */
441
+ get messages() {
442
+ this.requireSession();
443
+ if (!this._messagesAPI) {
444
+ throw new Error('Messages API not initialized');
445
+ }
446
+ return this._messagesAPI;
447
+ }
448
+ /** Discussion service */
449
+ get discussions() {
450
+ this.requireSession();
451
+ if (!this._discussionsAPI) {
452
+ throw new Error('Discussions API not initialized');
453
+ }
454
+ return this._discussionsAPI;
455
+ }
456
+ /** Announcement service */
457
+ get announcements() {
458
+ this.requireSession();
459
+ if (!this._announcementsAPI) {
460
+ throw new Error('Announcements API not initialized');
461
+ }
462
+ return this._announcementsAPI;
463
+ }
464
+ /** Contact management */
465
+ get contacts() {
466
+ this.requireSession();
467
+ if (!this._contactsAPI) {
468
+ throw new Error('Contacts API not initialized');
469
+ }
470
+ return this._contactsAPI;
471
+ }
472
+ /** Refresh/sync service */
473
+ get refresh() {
474
+ this.requireSession();
475
+ if (!this._refreshAPI) {
476
+ throw new Error('Refresh API not initialized');
477
+ }
478
+ return this._refreshAPI;
479
+ }
480
+ /** Utility functions */
481
+ get utils() {
482
+ return {
483
+ validateUserId: validateUserIdFormat,
484
+ validateUsername: validateUsernameFormat,
485
+ encodeUserId,
486
+ decodeUserId,
487
+ };
488
+ }
489
+ /** Current SDK configuration (read-only) */
490
+ get config() {
491
+ if (this.state.status === 'uninitialized') {
492
+ return defaultSdkConfig;
493
+ }
494
+ return this.state.config;
495
+ }
496
+ /** Polling control API */
497
+ get polling() {
498
+ return {
499
+ start: () => this.startPolling(),
500
+ stop: () => this.pollingManager.stop(),
501
+ isRunning: this.pollingManager.isRunning(),
502
+ };
503
+ }
504
+ // ─────────────────────────────────────────────────────────────────
505
+ // Polling
506
+ // ─────────────────────────────────────────────────────────────────
507
+ /**
508
+ * Start polling for messages, announcements, and session refresh.
509
+ * Uses intervals from config.polling.
510
+ */
511
+ startPolling() {
512
+ if (this.state.status !== 'session_open') {
513
+ console.warn('[GossipSdk] Cannot start polling - no session open');
514
+ return;
515
+ }
516
+ const { config, db, session } = this.state;
517
+ this.pollingManager.start(config, {
518
+ fetchMessages: async () => {
519
+ await this._message?.fetchMessages();
520
+ },
521
+ fetchAnnouncements: async () => {
522
+ await this._announcement?.fetchAndProcessAnnouncements();
523
+ },
524
+ handleSessionRefresh: async (discussions) => {
525
+ await this._refresh?.handleSessionRefresh(discussions);
526
+ },
527
+ getActiveDiscussions: async () => {
528
+ return db.getActiveDiscussionsByOwner(session.userIdEncoded);
529
+ },
530
+ onError: (error, context) => {
531
+ this.eventEmitter.emit('error', error, context);
532
+ },
533
+ });
534
+ }
535
+ // ─────────────────────────────────────────────────────────────────
536
+ // Events
537
+ // ─────────────────────────────────────────────────────────────────
538
+ /**
539
+ * Register an event handler
540
+ */
541
+ on(event, handler) {
542
+ this.eventEmitter.on(event, handler);
543
+ }
544
+ /**
545
+ * Remove an event handler
546
+ */
547
+ off(event, handler) {
548
+ this.eventEmitter.off(event, handler);
549
+ }
550
+ // ─────────────────────────────────────────────────────────────────
551
+ // Private Helpers
552
+ // ─────────────────────────────────────────────────────────────────
553
+ requireSession() {
554
+ if (this.state.status !== 'session_open') {
555
+ throw new Error('No session open. Call openSession() first.');
556
+ }
557
+ return this.state;
558
+ }
559
+ /**
560
+ * Handle automatic session renewal when session is lost.
561
+ * Called by onSessionRenewalNeeded event.
562
+ */
563
+ async handleSessionRenewal(contactUserId) {
564
+ if (this.state.status !== 'session_open')
565
+ return;
566
+ try {
567
+ await this._discussion.renew(contactUserId);
568
+ console.log('[GossipSdk] Session renewed for', contactUserId);
569
+ // After successful renewal, process any waiting messages
570
+ const sentCount = await this._message.processWaitingMessages(contactUserId);
571
+ if (sentCount > 0) {
572
+ console.log(`[GossipSdk] Sent ${sentCount} waiting messages after renewal`);
573
+ }
574
+ }
575
+ catch (error) {
576
+ console.error('[GossipSdk] Session renewal failed:', error);
577
+ this.eventEmitter.emit('error', error instanceof Error ? error : new Error(String(error)), 'session_renewal');
578
+ }
579
+ }
580
+ /**
581
+ * Handle automatic session accept when peer has sent us an announcement.
582
+ * Called by onSessionAcceptNeeded event.
583
+ * This is different from renewal - we respond to their session request.
584
+ */
585
+ async handleSessionAccept(contactUserId) {
586
+ if (this.state.status !== 'session_open')
587
+ return;
588
+ try {
589
+ const ownerUserId = this.state.session.userIdEncoded;
590
+ const discussion = await this.state.db.getDiscussionByOwnerAndContact(ownerUserId, contactUserId);
591
+ if (!discussion) {
592
+ console.warn('[GossipSdk] No discussion found for accept, contactUserId:', contactUserId);
593
+ return;
594
+ }
595
+ // Accept the discussion (sends our announcement back to establish session)
596
+ await this._discussion.accept(discussion);
597
+ console.log('[GossipSdk] Session accepted for', contactUserId);
598
+ // After successful accept, process any waiting messages
599
+ const sentCount = await this._message.processWaitingMessages(contactUserId);
600
+ if (sentCount > 0) {
601
+ console.log(`[GossipSdk] Sent ${sentCount} waiting messages after accept`);
602
+ }
603
+ }
604
+ catch (error) {
605
+ console.error('[GossipSdk] Session accept failed:', error);
606
+ this.eventEmitter.emit('error', error instanceof Error ? error : new Error(String(error)), 'session_accept');
607
+ }
608
+ }
609
+ /**
610
+ * Handle session becoming Active after peer accepts our announcement.
611
+ * Called by onSessionBecameActive event.
612
+ *
613
+ * This is different from handleSessionAccept:
614
+ * - handleSessionAccept: WE accept a session (peer initiated)
615
+ * - handleSessionBecameActive: PEER accepts our session (we initiated)
616
+ */
617
+ async handleSessionBecameActive(contactUserId) {
618
+ if (this.state.status !== 'session_open')
619
+ return;
620
+ try {
621
+ // Process any messages that were queued as WAITING_SESSION
622
+ const sentCount = await this._message.processWaitingMessages(contactUserId);
623
+ if (sentCount > 0) {
624
+ console.log(`[GossipSdk] Sent ${sentCount} waiting messages after session became active`);
625
+ }
626
+ }
627
+ catch (error) {
628
+ console.error('[GossipSdk] Processing waiting messages failed:', error);
629
+ this.eventEmitter.emit('error', error instanceof Error ? error : new Error(String(error)), 'session_became_active');
630
+ }
631
+ }
632
+ async handleSessionPersist() {
633
+ if (this.state.status !== 'session_open')
634
+ return;
635
+ const { onPersist, persistEncryptionKey, session } = this.state;
636
+ if (!onPersist || !persistEncryptionKey)
637
+ return;
638
+ try {
639
+ const blob = session.toEncryptedBlob(persistEncryptionKey);
640
+ console.log(`[SessionPersist] Saving session blob (${blob.length} bytes)`);
641
+ await onPersist(blob, persistEncryptionKey);
642
+ }
643
+ catch (error) {
644
+ console.error('[GossipSdk] Session persistence failed:', error);
645
+ }
646
+ }
647
+ /**
648
+ * Reset any messages stuck in SENDING status to FAILED.
649
+ * This handles the case where the app crashed or was closed during message send.
650
+ * Per spec: SENDING should never be persisted - if we find it on startup, it failed.
651
+ */
652
+ /**
653
+ * Reset messages stuck in SENDING status to WAITING_SESSION.
654
+ *
655
+ * Per spec: SENDING is a transient state that should never be persisted.
656
+ * If the app crashes/closes during a send, the message would be stuck forever.
657
+ *
658
+ * By resetting to WAITING_SESSION:
659
+ * - Message will be re-encrypted with current session keys
660
+ * - Message will be automatically sent when session is active
661
+ * - No manual user intervention required
662
+ *
663
+ * We also clear encryptedMessage and seeker since they may be stale.
664
+ */
665
+ async resetStuckSendingMessages(db) {
666
+ try {
667
+ const count = await db.messages
668
+ .where('status')
669
+ .equals(MessageStatus.SENDING)
670
+ .modify({
671
+ status: MessageStatus.WAITING_SESSION,
672
+ encryptedMessage: undefined,
673
+ seeker: undefined,
674
+ });
675
+ if (count > 0) {
676
+ console.log(`[GossipSdk] Reset ${count} stuck SENDING message(s) to WAITING_SESSION for auto-retry`);
677
+ }
678
+ }
679
+ catch (error) {
680
+ console.error('[GossipSdk] Failed to reset stuck messages:', error);
681
+ }
682
+ }
683
+ }
684
+ // ─────────────────────────────────────────────────────────────────────────────
685
+ // Singleton Export
686
+ // ─────────────────────────────────────────────────────────────────────────────
687
+ /** The singleton GossipSdk instance */
688
+ export const gossipSdk = new GossipSdkImpl();
689
+ // Also export the class for testing
690
+ export { GossipSdkImpl };