nostr-double-ratchet 0.0.27 → 0.0.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nostr-double-ratchet",
3
- "version": "0.0.27",
3
+ "version": "0.0.29",
4
4
  "type": "module",
5
5
  "packageManager": "yarn@1.22.22",
6
6
  "description": "Nostr double ratchet library",
@@ -14,7 +14,8 @@
14
14
  "build": "vite build && tsc",
15
15
  "examples": "cd examples && vite build && tsc",
16
16
  "examples-dev": "cd examples && vite && tsc",
17
- "docs": "typedoc --out docs src/index.ts"
17
+ "docs": "typedoc --out docs src/index.ts",
18
+ "lint": "eslint src --ext .ts --fix"
18
19
  },
19
20
  "repository": {
20
21
  "type": "git",
@@ -32,25 +33,26 @@
32
33
  "dist"
33
34
  ],
34
35
  "devDependencies": {
35
- "@nostr-dev-kit/ndk": "2.11.2",
36
- "@types/lodash": "^4.17.15",
37
- "@types/node": "^22.13.4",
38
- "@typescript-eslint/eslint-plugin": "^8.24.1",
39
- "@typescript-eslint/parser": "^8.24.1",
40
- "eslint": "^9.20.1",
41
- "eslint-config-prettier": "^10.0.1",
42
- "eslint-plugin-prettier": "^5.2.3",
36
+ "@nostr-dev-kit/ndk": "2.14.23",
37
+ "@types/lodash": "^4.17.17",
38
+ "@types/node": "^22.15.21",
39
+ "@typescript-eslint/eslint-plugin": "^8.33.1",
40
+ "@typescript-eslint/parser": "^8.33.1",
41
+ "eslint": "^9.28.0",
42
+ "eslint-config-prettier": "^10.1.5",
43
+ "eslint-plugin-prettier": "^5.4.0",
43
44
  "eslint-plugin-simple-import-sort": "^12.1.1",
44
45
  "lodash": "^4.17.21",
45
- "tsx": "^4.19.2",
46
- "typedoc": "^0.27.7",
47
- "typescript": "^5.7.3",
46
+ "tsx": "^4.19.4",
47
+ "typedoc": "^0.28.4",
48
+ "typescript": "^5.8.3",
48
49
  "typescript-lru-cache": "^2.0.0",
49
- "vite": "^6.1.0",
50
- "vitest": "^3.0.6",
51
- "ws": "^8.18.0"
50
+ "vite": "^6.3.5",
51
+ "vitest": "^3.1.4",
52
+ "ws": "^8.18.2"
52
53
  },
53
54
  "dependencies": {
54
- "nostr-tools": "^2.10.4"
55
+ "nostr-tools": "^2.15.0",
56
+ "react-blurhash": "^0.3.0"
55
57
  }
56
58
  }
package/src/Invite.ts CHANGED
@@ -25,12 +25,13 @@ export class Invite {
25
25
  public sharedSecret: string,
26
26
  public inviter: string,
27
27
  public inviterEphemeralPrivateKey?: Uint8Array,
28
- public label?: string,
28
+ public deviceId?: string,
29
29
  public maxUses?: number,
30
30
  public usedBy: string[] = [],
31
- ) {}
31
+ ) {
32
+ }
32
33
 
33
- static createNew(inviter: string, label?: string, maxUses?: number): Invite {
34
+ static createNew(inviter: string, deviceId?: string, maxUses?: number): Invite {
34
35
  if (!inviter) {
35
36
  throw new Error("Inviter public key is required");
36
37
  }
@@ -42,7 +43,7 @@ export class Invite {
42
43
  sharedSecret,
43
44
  inviter,
44
45
  inviterEphemeralPrivateKey,
45
- label,
46
+ deviceId,
46
47
  maxUses
47
48
  );
48
49
  }
@@ -55,7 +56,7 @@ export class Invite {
55
56
  }
56
57
 
57
58
  const decodedHash = decodeURIComponent(rawHash);
58
- let data: any;
59
+ let data: { inviter?: string; ephemeralKey?: string; sharedSecret?: string };
59
60
  try {
60
61
  data = JSON.parse(decodedHash);
61
62
  } catch (err) {
@@ -81,7 +82,7 @@ export class Invite {
81
82
  data.sharedSecret,
82
83
  data.inviter,
83
84
  data.inviterEphemeralPrivateKey ? new Uint8Array(data.inviterEphemeralPrivateKey) : undefined,
84
- data.label,
85
+ data.deviceId,
85
86
  data.maxUses,
86
87
  data.usedBy
87
88
  );
@@ -104,6 +105,10 @@ export class Invite {
104
105
  const sharedSecret = tags.find(([key]) => key === 'sharedSecret')?.[1];
105
106
  const inviter = event.pubkey;
106
107
 
108
+ // Extract deviceId from the "d" tag (format: double-ratchet/invites/<deviceId>)
109
+ const deviceTag = tags.find(([key]) => key === 'd')?.[1]
110
+ const deviceId = deviceTag?.split('/')?.[2]
111
+
107
112
  if (!inviterEphemeralPublicKey || !sharedSecret) {
108
113
  throw new Error("Invalid invite event: missing session key or sharedSecret");
109
114
  }
@@ -111,28 +116,26 @@ export class Invite {
111
116
  return new Invite(
112
117
  inviterEphemeralPublicKey,
113
118
  sharedSecret,
114
- inviter
119
+ inviter,
120
+ undefined, // inviterEphemeralPrivateKey not available when parsing from event
121
+ deviceId
115
122
  );
116
123
  }
117
124
 
118
- static fromUser(user: string, subscribe: NostrSubscribe, onInvite: (invite: Invite) => void): Unsubscribe {
125
+ static fromUser(user: string, subscribe: NostrSubscribe, onInvite: (_invite: Invite) => void): Unsubscribe {
119
126
  const filter: Filter = {
120
127
  kinds: [INVITE_EVENT_KIND],
121
128
  authors: [user],
122
- limit: 1,
123
- "#d": ["double-ratchet/invites/public"],
129
+ "#l": ["double-ratchet/invites"]
124
130
  };
125
- let latest = 0;
131
+ const seenIds = new Set<string>()
126
132
  const unsub = subscribe(filter, (event) => {
127
- if (!event.created_at || event.created_at <= latest) {
128
- return;
129
- }
130
- latest = event.created_at;
133
+ if (seenIds.has(event.id)) return
134
+ seenIds.add(event.id)
131
135
  try {
132
136
  const inviteLink = Invite.fromEvent(event);
133
137
  onInvite(inviteLink);
134
- } catch (error) {
135
- console.error("Error processing invite:", error, "event:", event);
138
+ } catch {
136
139
  }
137
140
  });
138
141
 
@@ -148,7 +151,7 @@ export class Invite {
148
151
  sharedSecret: this.sharedSecret,
149
152
  inviter: this.inviter,
150
153
  inviterEphemeralPrivateKey: this.inviterEphemeralPrivateKey ? Array.from(this.inviterEphemeralPrivateKey) : undefined,
151
- label: this.label,
154
+ deviceId: this.deviceId,
152
155
  maxUses: this.maxUses,
153
156
  usedBy: this.usedBy,
154
157
  });
@@ -169,6 +172,9 @@ export class Invite {
169
172
  }
170
173
 
171
174
  getEvent(): UnsignedEvent {
175
+ if (!this.deviceId) {
176
+ throw new Error("Device ID is required");
177
+ }
172
178
  return {
173
179
  kind: INVITE_EVENT_KIND,
174
180
  pubkey: this.inviter,
@@ -177,7 +183,7 @@ export class Invite {
177
183
  tags: [
178
184
  ['ephemeralKey', this.inviterEphemeralPublicKey],
179
185
  ['sharedSecret', this.sharedSecret],
180
- ['d', 'double-ratchet/invites/public'],
186
+ ['d', 'double-ratchet/invites/' + this.deviceId],
181
187
  ['l', 'double-ratchet/invites']
182
188
  ],
183
189
  };
@@ -185,11 +191,12 @@ export class Invite {
185
191
 
186
192
  /**
187
193
  * Called by the invitee. Accepts the invite and creates a new session with the inviter.
188
- *
189
- * @param inviteeSecretKey - The invitee's secret key or a signing function
194
+ *
190
195
  * @param nostrSubscribe - A function to subscribe to Nostr events
196
+ * @param inviteePublicKey - The invitee's public key
197
+ * @param encryptor - The invitee's secret key or a signing/encrypt function
191
198
  * @returns An object containing the new session and an event to be published
192
- *
199
+ *
193
200
  * 1. Inner event: No signature, content encrypted with DH(inviter, invitee).
194
201
  * Purpose: Authenticate invitee. Contains invitee session key.
195
202
  * 2. Envelope: No signature, content encrypted with DH(inviter, random key).
@@ -240,7 +247,7 @@ export class Invite {
240
247
  return { session, event: finalizeEvent(envelope, randomSenderKey) };
241
248
  }
242
249
 
243
- listen(decryptor: Uint8Array | DecryptFunction, nostrSubscribe: NostrSubscribe, onSession: (session: Session, identity?: string) => void): Unsubscribe {
250
+ listen(decryptor: Uint8Array | DecryptFunction, nostrSubscribe: NostrSubscribe, onSession: (_session: Session, _identity?: string) => void): Unsubscribe {
244
251
  if (!this.inviterEphemeralPrivateKey) {
245
252
  throw new Error("Inviter session key is not available");
246
253
  }
@@ -253,7 +260,6 @@ export class Invite {
253
260
  return nostrSubscribe(filter, async (event) => {
254
261
  try {
255
262
  if (this.maxUses && this.usedBy.length >= this.maxUses) {
256
- console.error("Invite has reached maximum number of uses");
257
263
  return;
258
264
  }
259
265
 
@@ -279,9 +285,8 @@ export class Invite {
279
285
  const session = Session.init(nostrSubscribe, inviteeSessionPublicKey, this.inviterEphemeralPrivateKey!, false, sharedSecret, name);
280
286
 
281
287
  onSession(session, inviteeIdentity);
282
- } catch (error) {
283
- console.error("Error processing invite message:", error, "event", event);
288
+ } catch {
284
289
  }
285
290
  });
286
291
  }
287
- }
292
+ }
package/src/Session.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { generateSecretKey, getPublicKey, nip44, finalizeEvent, VerifiedEvent, UnsignedEvent, getEventHash, validateEvent } from "nostr-tools";
2
- import { bytesToHex } from "@noble/hashes/utils";
2
+
3
3
  import {
4
4
  SessionState,
5
5
  Header,
@@ -38,9 +38,9 @@ export class Session {
38
38
 
39
39
  /**
40
40
  * Initializes a new secure communication session
41
- * @param nostrSubscribe Function to subscribe to Nostr events
42
- * @param theirNextNostrPublicKey The public key of the other party
43
- * @param ourCurrentPrivateKey Our current private key for Nostr
41
+ * @param nostrSubscribe Function to subscribe to Nostr events. Make sure it deduplicates events (doesn't return the same event twice), otherwise you'll see decryption errors!
42
+ * @param theirEphemeralNostrPublicKey The ephemeral public key of the other party for the initial handshake
43
+ * @param ourEphemeralNostrPrivateKey Our ephemeral private key for the initial handshake
44
44
  * @param isInitiator Whether we are initiating the conversation (true) or responding (false)
45
45
  * @param sharedSecret Initial shared secret for securing the first message chain
46
46
  * @param name Optional name for the session (for debugging)
@@ -205,17 +205,7 @@ export class Session {
205
205
  this.state.receivingChainKey = newReceivingChainKey;
206
206
  this.state.receivingChainMessageNumber++;
207
207
 
208
- try {
209
- return nip44.decrypt(ciphertext, messageKey);
210
- } catch (error) {
211
- console.error(this.name, 'Decryption failed:', error, {
212
- messageKey: bytesToHex(messageKey).slice(0, 4),
213
- receivingChainKey: bytesToHex(this.state.receivingChainKey).slice(0, 4),
214
- sendingChainKey: this.state.sendingChainKey && bytesToHex(this.state.sendingChainKey).slice(0, 4),
215
- rootKey: bytesToHex(this.state.rootKey).slice(0, 4)
216
- });
217
- throw error;
218
- }
208
+ return nip44.decrypt(ciphertext, messageKey);
219
209
  }
220
210
 
221
211
  private ratchetStep() {
@@ -294,14 +284,14 @@ export class Session {
294
284
  }
295
285
 
296
286
  // 4. NOSTR EVENT HANDLING
297
- private decryptHeader(event: any): [Header, boolean, boolean] {
287
+ private decryptHeader(event: { tags: string[][]; pubkey: string }): [Header, boolean, boolean] {
298
288
  const encryptedHeader = event.tags[0][1];
299
289
  if (this.state.ourCurrentNostrKey) {
300
290
  const currentSecret = nip44.getConversationKey(this.state.ourCurrentNostrKey.privateKey, event.pubkey);
301
291
  try {
302
292
  const header = JSON.parse(nip44.decrypt(encryptedHeader, currentSecret)) as Header;
303
293
  return [header, false, false];
304
- } catch (error) {
294
+ } catch {
305
295
  // Decryption with currentSecret failed, try with nextSecret
306
296
  }
307
297
  }
@@ -310,7 +300,7 @@ export class Session {
310
300
  try {
311
301
  const header = JSON.parse(nip44.decrypt(encryptedHeader, nextSecret)) as Header;
312
302
  return [header, true, false];
313
- } catch (error) {
303
+ } catch {
314
304
  // Decryption with nextSecret also failed
315
305
  }
316
306
 
@@ -320,7 +310,7 @@ export class Session {
320
310
  try {
321
311
  const header = JSON.parse(nip44.decrypt(encryptedHeader, key)) as Header;
322
312
  return [header, false, true];
323
- } catch (error) {
313
+ } catch {
324
314
  // Decryption failed, try next secret
325
315
  }
326
316
  }
@@ -329,7 +319,7 @@ export class Session {
329
319
  throw new Error("Failed to decrypt header with current and skipped header keys");
330
320
  }
331
321
 
332
- private handleNostrEvent(e: any) {
322
+ private handleNostrEvent(e: { tags: string[][]; pubkey: string; content: string }) {
333
323
  const [header, shouldRatchet, isSkipped] = this.decryptHeader(e);
334
324
 
335
325
  if (!isSkipped) {
@@ -358,16 +348,14 @@ export class Session {
358
348
  const text = this.ratchetDecrypt(header, e.content, e.pubkey);
359
349
  const innerEvent = JSON.parse(text);
360
350
  if (!validateEvent(innerEvent)) {
361
- console.error("Invalid event received", innerEvent);
362
351
  return;
363
352
  }
364
353
 
365
354
  if (innerEvent.id !== getEventHash(innerEvent)) {
366
- console.error("Event hash does not match", innerEvent);
367
355
  return;
368
356
  }
369
357
 
370
- this.internalSubscriptions.forEach(callback => callback(innerEvent, e));
358
+ this.internalSubscriptions.forEach(callback => callback(innerEvent, e as VerifiedEvent));
371
359
  }
372
360
 
373
361
  private subscribeToNostrEvents() {
@@ -0,0 +1,328 @@
1
+ import { CHAT_MESSAGE_KIND, NostrPublish, NostrSubscribe, Rumor, Unsubscribe } from "./types"
2
+ import { UserRecord } from "./UserRecord"
3
+ import { Invite } from "./Invite"
4
+ import { getPublicKey } from "nostr-tools"
5
+ import { StorageAdapter, InMemoryStorageAdapter } from "./StorageAdapter"
6
+ import { serializeSessionState, deserializeSessionState } from "./utils"
7
+ import { Session } from "./Session"
8
+
9
+ export default class SessionManager {
10
+ private userRecords: Map<string, UserRecord> = new Map()
11
+ private nostrSubscribe: NostrSubscribe
12
+ private nostrPublish: NostrPublish
13
+ private ourIdentityKey: Uint8Array
14
+ private inviteUnsubscribes: Map<string, Unsubscribe> = new Map()
15
+ private deviceId: string
16
+ private invite?: Invite
17
+ private storage: StorageAdapter
18
+
19
+ constructor(
20
+ ourIdentityKey: Uint8Array,
21
+ deviceId: string,
22
+ nostrSubscribe: NostrSubscribe,
23
+ nostrPublish: NostrPublish,
24
+ storage: StorageAdapter = new InMemoryStorageAdapter(),
25
+ ) {
26
+ this.userRecords = new Map()
27
+ this.nostrSubscribe = nostrSubscribe
28
+ this.nostrPublish = nostrPublish
29
+ this.ourIdentityKey = ourIdentityKey
30
+ this.deviceId = deviceId
31
+ this.storage = storage
32
+
33
+ // Kick off initialisation in background for backwards compatibility
34
+ // Users that need to wait can call await manager.init()
35
+ this.init()
36
+ }
37
+
38
+ private _initialised = false
39
+
40
+ /**
41
+ * Perform asynchronous initialisation steps: create (or load) our invite,
42
+ * publish it, hydrate sessions from storage and subscribe to new invites.
43
+ * Can be awaited by callers that need deterministic readiness.
44
+ */
45
+ public async init(): Promise<void> {
46
+ if (this._initialised) return
47
+
48
+ const ourPublicKey = getPublicKey(this.ourIdentityKey)
49
+
50
+ // 1. Hydrate existing sessions (placeholder for future implementation)
51
+ await this.loadSessions()
52
+
53
+ // 2. Create or load our own invite
54
+ let invite: Invite | undefined
55
+ try {
56
+ const stored = await this.storage.get<string>(`invite/${this.deviceId}`)
57
+ if (stored) {
58
+ invite = Invite.deserialize(stored)
59
+ }
60
+ } catch {/* ignore malformed */}
61
+
62
+ if (!invite) {
63
+ invite = Invite.createNew(ourPublicKey, this.deviceId)
64
+ await this.storage.put(`invite/${this.deviceId}`, invite.serialize()).catch(() => {})
65
+ }
66
+ this.invite = invite
67
+
68
+ // 2b. Listen for acceptances of *our* invite and create sessions
69
+ this.invite.listen(
70
+ this.ourIdentityKey,
71
+ this.nostrSubscribe,
72
+ (session, inviteePubkey) => {
73
+ if (!inviteePubkey) return
74
+ try {
75
+ let userRecord = this.userRecords.get(inviteePubkey)
76
+ if (!userRecord) {
77
+ userRecord = new UserRecord(inviteePubkey, this.nostrSubscribe)
78
+ this.userRecords.set(inviteePubkey, userRecord)
79
+ }
80
+
81
+ const deviceKey = session.name || 'unknown'
82
+ userRecord.upsertSession(deviceKey, session)
83
+ this.saveSession(inviteePubkey, deviceKey, session)
84
+
85
+ session.onEvent((_event: Rumor) => {
86
+ this.internalSubscriptions.forEach(cb => cb(_event))
87
+ })
88
+ } catch {/* ignore errors */}
89
+ }
90
+ )
91
+
92
+ // 3. Subscribe to our own invites from other devices
93
+ Invite.fromUser(ourPublicKey, this.nostrSubscribe, async (invite) => {
94
+ try {
95
+ const inviteDeviceId = invite['deviceId'] || 'unknown'
96
+ if (!inviteDeviceId || inviteDeviceId === this.deviceId) {
97
+ return
98
+ }
99
+
100
+ const existingRecord = this.userRecords.get(ourPublicKey)
101
+ if (existingRecord?.getActiveSessions().some(session => session.name === inviteDeviceId)) {
102
+ return
103
+ }
104
+
105
+ const { session, event } = await invite.accept(
106
+ this.nostrSubscribe,
107
+ ourPublicKey,
108
+ this.ourIdentityKey
109
+ )
110
+ this.nostrPublish(event)?.catch(() => {})
111
+
112
+ this.saveSession(ourPublicKey, inviteDeviceId, session)
113
+
114
+ let userRecord = this.userRecords.get(ourPublicKey)
115
+ if (!userRecord) {
116
+ userRecord = new UserRecord(ourPublicKey, this.nostrSubscribe)
117
+ this.userRecords.set(ourPublicKey, userRecord)
118
+ }
119
+ const deviceId = invite['deviceId'] || event.id || 'unknown'
120
+ userRecord.upsertSession(deviceId, session)
121
+ this.saveSession(ourPublicKey, deviceId, session)
122
+
123
+ session.onEvent((_event: Rumor) => {
124
+ this.internalSubscriptions.forEach(cb => cb(_event))
125
+ })
126
+ } catch (err) {
127
+ // eslint-disable-next-line no-console
128
+ console.error('Own-invite accept failed', err)
129
+ }
130
+ })
131
+
132
+ this._initialised = true
133
+ await this.nostrPublish(this.invite.getEvent()).catch(() => {})
134
+ }
135
+
136
+ private async loadSessions() {
137
+ const base = 'session/'
138
+ const keys = await this.storage.list(base)
139
+ for (const key of keys) {
140
+ const rest = key.substring(base.length)
141
+ const idx = rest.indexOf('/')
142
+ if (idx === -1) continue
143
+ const ownerPubKey = rest.substring(0, idx)
144
+ const deviceId = rest.substring(idx + 1) || 'unknown'
145
+
146
+ const data = await this.storage.get<string>(key)
147
+ if (!data) continue
148
+ try {
149
+ const state = deserializeSessionState(data)
150
+ const session = new Session(this.nostrSubscribe, state)
151
+
152
+ let userRecord = this.userRecords.get(ownerPubKey)
153
+ if (!userRecord) {
154
+ userRecord = new UserRecord(ownerPubKey, this.nostrSubscribe)
155
+ this.userRecords.set(ownerPubKey, userRecord)
156
+ }
157
+ userRecord.upsertSession(deviceId, session)
158
+ this.saveSession(ownerPubKey, deviceId, session)
159
+
160
+ session.onEvent((_event: Rumor) => {
161
+ this.internalSubscriptions.forEach(cb => cb(_event))
162
+ })
163
+ } catch {
164
+ // corrupted entry — ignore
165
+ }
166
+ }
167
+ }
168
+
169
+ private async saveSession(ownerPubKey: string, deviceId: string, session: Session) {
170
+ try {
171
+ const key = `session/${ownerPubKey}/${deviceId}`
172
+ await this.storage.put(key, serializeSessionState(session.state))
173
+ } catch {/* ignore */}
174
+ }
175
+
176
+ getDeviceId(): string {
177
+ return this.deviceId
178
+ }
179
+
180
+ getInvite(): Invite {
181
+ if (!this.invite) {
182
+ throw new Error("SessionManager not initialised yet")
183
+ }
184
+ return this.invite
185
+ }
186
+
187
+ async sendText(recipientIdentityKey: string, text: string) {
188
+ const event = {
189
+ kind: CHAT_MESSAGE_KIND,
190
+ content: text,
191
+ }
192
+ return await this.sendEvent(recipientIdentityKey, event)
193
+ }
194
+
195
+ async sendEvent(recipientIdentityKey: string, event: Partial<Rumor>) {
196
+ const results = []
197
+
198
+ // Send to recipient's devices
199
+ const userRecord = this.userRecords.get(recipientIdentityKey)
200
+ if (!userRecord) {
201
+ // Listen for invites from recipient and return without throwing; caller
202
+ // can await a subsequent session establishment.
203
+ this.listenToUser(recipientIdentityKey)
204
+ return []
205
+ }
206
+
207
+ // Send to all active sessions with recipient
208
+ for (const session of userRecord.getActiveSessions()) {
209
+ const { event: encryptedEvent } = session.sendEvent(event)
210
+ results.push(encryptedEvent)
211
+ }
212
+
213
+ // Send to our own devices (for multi-device sync)
214
+ const ourPublicKey = getPublicKey(this.ourIdentityKey)
215
+ const ownUserRecord = this.userRecords.get(ourPublicKey)
216
+ if (ownUserRecord) {
217
+ for (const session of ownUserRecord.getActiveSessions()) {
218
+ const { event: encryptedEvent } = session.sendEvent(event)
219
+ results.push(encryptedEvent)
220
+ }
221
+ }
222
+
223
+ return results
224
+ }
225
+
226
+ listenToUser(userPubkey: string) {
227
+ // Don't subscribe multiple times to the same user
228
+ if (this.inviteUnsubscribes.has(userPubkey)) return
229
+
230
+ const unsubscribe = Invite.fromUser(userPubkey, this.nostrSubscribe, async (_invite) => {
231
+ try {
232
+ const { session, event } = await _invite.accept(
233
+ this.nostrSubscribe,
234
+ getPublicKey(this.ourIdentityKey),
235
+ this.ourIdentityKey
236
+ )
237
+ this.nostrPublish(event)?.catch(() => {})
238
+
239
+ // Store the new session
240
+ let userRecord = this.userRecords.get(userPubkey)
241
+ if (!userRecord) {
242
+ userRecord = new UserRecord(userPubkey, this.nostrSubscribe)
243
+ this.userRecords.set(userPubkey, userRecord)
244
+ }
245
+ const deviceId = (_invite instanceof Invite && _invite.deviceId) ? _invite.deviceId : event.id || 'unknown'
246
+ this.saveSession(userPubkey, deviceId, session)
247
+
248
+ // Set up event handling for the new session
249
+ session.onEvent((_event: Rumor) => {
250
+ this.internalSubscriptions.forEach(callback => callback(_event))
251
+ })
252
+
253
+ // Return the event to be published
254
+ return event
255
+ } catch {
256
+ }
257
+ })
258
+
259
+ this.inviteUnsubscribes.set(userPubkey, unsubscribe)
260
+ }
261
+
262
+ stopListeningToUser(userPubkey: string) {
263
+ const unsubscribe = this.inviteUnsubscribes.get(userPubkey)
264
+ if (unsubscribe) {
265
+ unsubscribe()
266
+ this.inviteUnsubscribes.delete(userPubkey)
267
+ }
268
+ }
269
+
270
+ // Update onEvent to include internalSubscriptions management
271
+ private internalSubscriptions: Set<(event: Rumor) => void> = new Set()
272
+
273
+ onEvent(callback: (event: Rumor) => void) {
274
+ this.internalSubscriptions.add(callback)
275
+
276
+ // Subscribe to existing sessions
277
+ for (const userRecord of this.userRecords.values()) {
278
+ for (const session of userRecord.getActiveSessions()) {
279
+ session.onEvent((_event: Rumor) => {
280
+ callback(_event)
281
+ })
282
+ }
283
+ }
284
+
285
+ // Return unsubscribe function
286
+ return () => {
287
+ this.internalSubscriptions.delete(callback)
288
+ }
289
+ }
290
+
291
+ close() {
292
+ // Clean up all subscriptions
293
+ for (const unsubscribe of this.inviteUnsubscribes.values()) {
294
+ unsubscribe()
295
+ }
296
+ this.inviteUnsubscribes.clear()
297
+
298
+ // Close all sessions
299
+ for (const userRecord of this.userRecords.values()) {
300
+ for (const session of userRecord.getActiveSessions()) {
301
+ session.close()
302
+ }
303
+ }
304
+ this.userRecords.clear()
305
+ this.internalSubscriptions.clear()
306
+ }
307
+
308
+ /**
309
+ * Accept an invite as our own device, persist the session, and publish the acceptance event.
310
+ * Used for multi-device flows where a user adds a new device.
311
+ */
312
+ public async acceptOwnInvite(invite: Invite) {
313
+ const ourPublicKey = getPublicKey(this.ourIdentityKey);
314
+ const { session, event } = await invite.accept(
315
+ this.nostrSubscribe,
316
+ ourPublicKey,
317
+ this.ourIdentityKey
318
+ );
319
+ let userRecord = this.userRecords.get(ourPublicKey);
320
+ if (!userRecord) {
321
+ userRecord = new UserRecord(ourPublicKey, this.nostrSubscribe);
322
+ this.userRecords.set(ourPublicKey, userRecord);
323
+ }
324
+ userRecord.upsertSession(session.name || 'unknown', session);
325
+ await this.saveSession(ourPublicKey, session.name || 'unknown', session);
326
+ this.nostrPublish(event)?.catch(() => {});
327
+ }
328
+ }