nostr-double-ratchet 0.0.38 → 0.0.48

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.
@@ -4,11 +4,12 @@ import {
4
4
  NostrPublish,
5
5
  Rumor,
6
6
  Unsubscribe,
7
- INVITE_LIST_EVENT_KIND,
7
+ APP_KEYS_EVENT_KIND,
8
8
  CHAT_MESSAGE_KIND,
9
9
  } from "./types"
10
10
  import { StorageAdapter, InMemoryStorageAdapter } from "./StorageAdapter"
11
- import { InviteList } from "./InviteList"
11
+ import { AppKeys, DeviceEntry } from "./AppKeys"
12
+ import { Invite } from "./Invite"
12
13
  import { Session } from "./Session"
13
14
  import { serializeSessionState, deserializeSessionState } from "./utils"
14
15
  import { decryptInviteResponse, createSessionFromAccept } from "./inviteUtils"
@@ -24,36 +25,36 @@ export interface InviteCredentials {
24
25
  sharedSecret: string
25
26
  }
26
27
 
27
- interface DeviceRecord {
28
+ export interface DeviceRecord {
28
29
  deviceId: string
29
30
  activeSession?: Session
30
31
  inactiveSessions: Session[]
31
32
  createdAt: number
32
- staleAt?: number
33
- // Set to true when we've processed an invite response from this device
34
- // This survives restarts and prevents duplicate RESPONDER session creation
35
- hasResponderSession?: boolean
36
33
  }
37
34
 
38
- interface UserRecord {
35
+ export interface UserRecord {
39
36
  publicKey: string
40
37
  devices: Map<string, DeviceRecord>
38
+ /** Device identity pubkeys from AppKeys - used to rebuild delegateToOwner on load */
39
+ knownDeviceIdentities: string[]
41
40
  }
42
41
 
43
- type StoredSessionEntry = ReturnType<typeof serializeSessionState>
42
+ interface StoredSessionEntry {
43
+ name: string
44
+ state: string
45
+ }
44
46
 
45
47
  interface StoredDeviceRecord {
46
48
  deviceId: string
47
49
  activeSession: StoredSessionEntry | null
48
50
  inactiveSessions: StoredSessionEntry[]
49
51
  createdAt: number
50
- staleAt?: number
51
- hasResponderSession?: boolean
52
52
  }
53
53
 
54
54
  interface StoredUserRecord {
55
55
  publicKey: string
56
56
  devices: StoredDeviceRecord[]
57
+ knownDeviceIdentities?: string[]
57
58
  }
58
59
 
59
60
  export class SessionManager {
@@ -79,6 +80,8 @@ export class SessionManager {
79
80
  private messageHistory: Map<string, Rumor[]> = new Map()
80
81
  // Map delegate device pubkeys to their owner's pubkey
81
82
  private delegateToOwner: Map<string, string> = new Map()
83
+ // Track processed InviteResponse event IDs to prevent replay
84
+ private processedInviteResponses: Set<string> = new Set()
82
85
 
83
86
  // Subscriptions
84
87
  private ourInviteResponseSubscription: Unsubscribe | null = null
@@ -117,12 +120,12 @@ export class SessionManager {
117
120
  if (this.initialized) return
118
121
  this.initialized = true
119
122
 
120
- await this.runMigrations().catch((error) => {
121
- console.error("Failed to run migrations:", error)
123
+ await this.runMigrations().catch(() => {
124
+ // Failed to run migrations
122
125
  })
123
126
 
124
- await this.loadAllUserRecords().catch((error) => {
125
- console.error("Failed to load user records:", error)
127
+ await this.loadAllUserRecords().catch(() => {
128
+ // Failed to load user records
126
129
  })
127
130
 
128
131
  // Add our own device to user record to prevent accepting our own invite
@@ -153,6 +156,12 @@ export class SessionManager {
153
156
  "#p": [ephemeralPubkey],
154
157
  },
155
158
  async (event) => {
159
+ // Skip already processed InviteResponses (prevents replay issues on restart)
160
+ if (this.processedInviteResponses.has(event.id)) {
161
+ return
162
+ }
163
+ this.processedInviteResponses.add(event.id)
164
+
156
165
  try {
157
166
  const decrypted = await decryptInviteResponse({
158
167
  envelopeContent: event.content,
@@ -165,39 +174,41 @@ export class SessionManager {
165
174
 
166
175
  // Skip our own responses - this happens when we publish an invite response
167
176
  // and our own listener receives it back from relays
168
- if (decrypted.deviceId === this.deviceId) {
177
+ // inviteeIdentity serves as the device ID
178
+ if (decrypted.inviteeIdentity === this.deviceId) {
169
179
  return
170
180
  }
171
181
 
172
- // Resolve delegate pubkey to owner for correct UserRecord attribution
173
- const ownerPubkey = this.resolveToOwner(decrypted.inviteeIdentity)
174
- const userRecord = this.getOrCreateUserRecord(ownerPubkey)
175
- const deviceRecord = this.upsertDeviceRecord(userRecord, decrypted.deviceId || "default")
182
+ // Get owner pubkey from response (required for proper chat routing)
183
+ // If not present (old client), fall back to resolveToOwner
184
+ const claimedOwner = decrypted.ownerPublicKey || this.resolveToOwner(decrypted.inviteeIdentity)
176
185
 
177
- // Check for duplicate/stale responses using the persisted flag
178
- // This flag survives restarts and prevents creating duplicate RESPONDER sessions
179
- if (deviceRecord.hasResponderSession) {
180
- return
181
- }
186
+ // Verify the device is authorized by fetching owner's AppKeys
187
+ const appKeys = await this.fetchAppKeys(claimedOwner)
182
188
 
183
- // Also check session state as a fallback (for existing sessions before the flag was added)
184
- const responseSessionKey = decrypted.inviteeSessionPublicKey
185
- const existingSession = deviceRecord.activeSession
186
- const existingInactive = deviceRecord.inactiveSessions || []
187
- const allSessions = existingSession ? [existingSession, ...existingInactive] : existingInactive
188
-
189
- // Check if any existing session can already receive from this device:
190
- // - Has receivingChainKey set (RESPONDER session has received messages)
191
- // - Has the same theirNextNostrPublicKey (same session, duplicate response)
192
- const canAlreadyReceive = allSessions.some(s =>
193
- s.state?.receivingChainKey !== undefined ||
194
- s.state?.theirNextNostrPublicKey === responseSessionKey ||
195
- s.state?.theirCurrentNostrPublicKey === responseSessionKey
196
- )
197
- if (canAlreadyReceive) {
198
- return
189
+ if (appKeys) {
190
+ const deviceInList = appKeys.getAllDevices().some(
191
+ d => d.identityPubkey === decrypted.inviteeIdentity
192
+ )
193
+ if (!deviceInList) {
194
+ return
195
+ }
196
+ this.updateDelegateMapping(claimedOwner, appKeys)
197
+ } else {
198
+ // No AppKeys - check cached identities or single-device case
199
+ const cachedIdentities = this.userRecords.get(claimedOwner)?.knownDeviceIdentities || []
200
+ const isCached = cachedIdentities.includes(decrypted.inviteeIdentity)
201
+ const isSingleDevice = decrypted.inviteeIdentity === claimedOwner
202
+ if (!isCached && !isSingleDevice) {
203
+ return
204
+ }
199
205
  }
200
206
 
207
+ const ownerPubkey = claimedOwner
208
+ const userRecord = this.getOrCreateUserRecord(ownerPubkey)
209
+ // inviteeIdentity serves as the device ID
210
+ const deviceRecord = this.upsertDeviceRecord(userRecord, decrypted.inviteeIdentity)
211
+
201
212
  const session = createSessionFromAccept({
202
213
  nostrSubscribe: this.nostrSubscribe,
203
214
  theirPublicKey: decrypted.inviteeSessionPublicKey,
@@ -207,27 +218,67 @@ export class SessionManager {
207
218
  name: event.id,
208
219
  })
209
220
 
210
- // Mark that we've processed a responder session for this device
211
- // This flag is persisted and survives restarts
212
- deviceRecord.hasResponderSession = true
213
-
214
221
  this.attachSessionSubscription(ownerPubkey, deviceRecord, session, true)
215
- // Persist the flag
216
- this.storeUserRecord(ownerPubkey).catch(console.error)
222
+ this.storeUserRecord(ownerPubkey).catch(() => {})
217
223
  } catch {
218
- // Invalid response, ignore
219
224
  }
220
225
  }
221
226
  )
222
227
  }
223
228
 
229
+ /**
230
+ * Fetch a user's AppKeys from relays.
231
+ * Returns null if not found within timeout.
232
+ */
233
+ private fetchAppKeys(pubkey: string, timeoutMs = 2000): Promise<AppKeys | null> {
234
+ return new Promise((resolve) => {
235
+ let latestEvent: { created_at: number; appKeys: AppKeys } | null = null
236
+ let resolved = false
237
+
238
+ // Use a short initial delay before resolving to allow event delivery
239
+ const resolveResult = () => {
240
+ if (resolved) return
241
+ resolved = true
242
+ unsubscribe()
243
+ resolve(latestEvent?.appKeys ?? null)
244
+ }
245
+
246
+ // Start timeout
247
+ const timeout = setTimeout(resolveResult, timeoutMs)
248
+
249
+ const unsubscribe = this.nostrSubscribe(
250
+ {
251
+ kinds: [APP_KEYS_EVENT_KIND],
252
+ authors: [pubkey],
253
+ "#d": ["double-ratchet/app-keys"],
254
+ },
255
+ (event) => {
256
+ if (resolved) return
257
+ try {
258
+ const appKeys = AppKeys.fromEvent(event)
259
+ // Use >= to prefer later-delivered events when timestamps are equal
260
+ // This handles replaceable events created within the same second
261
+ if (!latestEvent || event.created_at >= latestEvent.created_at) {
262
+ latestEvent = { created_at: event.created_at, appKeys }
263
+ }
264
+ // Resolve quickly after receiving an event (allow for more events to arrive)
265
+ clearTimeout(timeout)
266
+ setTimeout(resolveResult, 100) // Short delay to collect any late events
267
+ } catch {
268
+ // Invalid event, ignore
269
+ }
270
+ }
271
+ )
272
+ })
273
+ }
274
+
224
275
  // -------------------
225
276
  // User and Device Records helpers
226
277
  // -------------------
227
278
  private getOrCreateUserRecord(userPubkey: string): UserRecord {
228
279
  let rec = this.userRecords.get(userPubkey)
229
280
  if (!rec) {
230
- rec = { publicKey: userPubkey, devices: new Map() }
281
+ rec = { publicKey: userPubkey, devices: new Map(), knownDeviceIdentities: [] }
231
282
  this.userRecords.set(userPubkey, rec)
232
283
  }
233
284
  return rec
@@ -279,33 +330,44 @@ export class SessionManager {
279
330
  }
280
331
 
281
332
  /**
282
- * Update the delegate-to-owner mapping from an InviteList.
333
+ * Update the delegate-to-owner mapping from an AppKeys.
283
334
  * Extracts delegate device pubkeys and maps them to the owner.
335
+ * Persists the mapping in the user record for restart recovery.
284
336
  */
285
- private updateDelegateMapping(ownerPubkey: string, inviteList: InviteList): void {
286
- for (const device of inviteList.getAllDevices()) {
287
- if (device.identityPubkey) {
288
- this.delegateToOwner.set(device.identityPubkey, ownerPubkey)
289
- }
337
+ private updateDelegateMapping(ownerPubkey: string, appKeys: AppKeys): void {
338
+ const userRecord = this.getOrCreateUserRecord(ownerPubkey)
339
+ const deviceIdentities = appKeys.getAllDevices()
340
+ .map(d => d.identityPubkey)
341
+ .filter(Boolean) as string[]
342
+
343
+ // Update user record with known device identities
344
+ userRecord.knownDeviceIdentities = deviceIdentities
345
+
346
+ // Update in-memory mapping
347
+ for (const identity of deviceIdentities) {
348
+ this.delegateToOwner.set(identity, ownerPubkey)
290
349
  }
350
+
351
+ // Persist
352
+ this.storeUserRecord(ownerPubkey).catch(() => {})
291
353
  }
292
354
 
293
- private subscribeToUserInviteList(
355
+ private subscribeToUserAppKeys(
294
356
  pubkey: string,
295
- onInviteList: (list: InviteList) => void
357
+ onAppKeys: (list: AppKeys) => void
296
358
  ): Unsubscribe {
297
359
  return this.nostrSubscribe(
298
360
  {
299
- kinds: [INVITE_LIST_EVENT_KIND],
361
+ kinds: [APP_KEYS_EVENT_KIND],
300
362
  authors: [pubkey],
301
- "#d": ["double-ratchet/invite-list"],
363
+ "#d": ["double-ratchet/app-keys"],
302
364
  },
303
365
  (event) => {
304
366
  try {
305
- const list = InviteList.fromEvent(event)
306
- // Update delegate mapping whenever we receive an InviteList
367
+ const list = AppKeys.fromEvent(event)
368
+ // Update delegate mapping whenever we receive an AppKeys
307
369
  this.updateDelegateMapping(pubkey, list)
308
- onInviteList(list)
370
+ onAppKeys(list)
309
371
  } catch {
310
372
  // Invalid event, ignore
311
373
  }
@@ -313,6 +375,8 @@ export class SessionManager {
313
375
  )
314
376
  }
315
377
 
378
+ private static MAX_INACTIVE_SESSIONS = 10
379
+
316
380
  private attachSessionSubscription(
317
381
  userPubkey: string,
318
382
  deviceRecord: DeviceRecord,
@@ -320,70 +384,87 @@ export class SessionManager {
320
384
  // Set to true if only handshake -> not yet sendable -> will be promoted on message
321
385
  inactive: boolean = false
322
386
  ): void {
323
- if (deviceRecord.staleAt !== undefined) {
324
- return
325
- }
326
-
327
387
  const key = this.sessionKey(userPubkey, deviceRecord.deviceId, session.name)
328
388
  if (this.sessionSubscriptions.has(key)) {
329
389
  return
330
390
  }
331
391
 
332
392
  const dr = deviceRecord
333
- const rotateSession = (nextSession: Session) => {
334
- const current = dr.activeSession
335
393
 
336
- if (!current) {
337
- dr.activeSession = nextSession
338
- return
339
- }
394
+ // Promote a session to active when it receives a message
395
+ // Current active goes to top of inactive queue
396
+ const promoteToActive = (nextSession: Session) => {
397
+ const current = dr.activeSession
340
398
 
341
- if (current === nextSession || current.name === nextSession.name) {
342
- dr.activeSession = nextSession
399
+ // Already active, nothing to do
400
+ if (current === nextSession || current?.name === nextSession.name) {
343
401
  return
344
402
  }
345
403
 
404
+ // Remove nextSession from inactive if present
346
405
  dr.inactiveSessions = dr.inactiveSessions.filter(
347
- (session) => session !== current && session.name !== current.name
406
+ (s) => s !== nextSession && s.name !== nextSession.name
348
407
  )
349
408
 
350
- dr.inactiveSessions.push(current)
351
- dr.inactiveSessions = dr.inactiveSessions.slice(-1)
409
+ // Move current active to top of inactive queue
410
+ if (current) {
411
+ dr.inactiveSessions.unshift(current)
412
+ }
413
+
414
+ // Set new active
352
415
  dr.activeSession = nextSession
416
+
417
+ // Trim inactive queue to max size (remove oldest from end)
418
+ if (dr.inactiveSessions.length > SessionManager.MAX_INACTIVE_SESSIONS) {
419
+ const removed = dr.inactiveSessions.splice(SessionManager.MAX_INACTIVE_SESSIONS)
420
+ // Unsubscribe from removed sessions
421
+ for (const s of removed) {
422
+ this.removeSessionSubscription(userPubkey, dr.deviceId, s.name)
423
+ }
424
+ }
353
425
  }
354
426
 
427
+ // Add new session: if inactive, add to top of inactive queue; otherwise set as active
355
428
  if (inactive) {
356
429
  const alreadyTracked = dr.inactiveSessions.some(
357
- (tracked) => tracked === session || tracked.name === session.name
430
+ (s) => s === session || s.name === session.name
358
431
  )
359
432
  if (!alreadyTracked) {
360
- dr.inactiveSessions.push(session)
361
- dr.inactiveSessions = dr.inactiveSessions.slice(-1)
433
+ // Add to top of inactive queue
434
+ dr.inactiveSessions.unshift(session)
435
+ // Trim to max size
436
+ if (dr.inactiveSessions.length > SessionManager.MAX_INACTIVE_SESSIONS) {
437
+ const removed = dr.inactiveSessions.splice(SessionManager.MAX_INACTIVE_SESSIONS)
438
+ for (const s of removed) {
439
+ this.removeSessionSubscription(userPubkey, dr.deviceId, s.name)
440
+ }
441
+ }
362
442
  }
363
443
  } else {
364
- rotateSession(session)
444
+ promoteToActive(session)
365
445
  }
366
446
 
447
+ // Subscribe to session events - when message received, promote to active
367
448
  const unsub = session.onEvent((event) => {
368
449
  for (const cb of this.internalSubscriptions) cb(event, userPubkey)
369
- rotateSession(session)
370
- this.storeUserRecord(userPubkey).catch(console.error)
450
+ promoteToActive(session)
451
+ this.storeUserRecord(userPubkey).catch(() => {})
371
452
  })
372
- this.storeUserRecord(userPubkey).catch(console.error)
453
+ this.storeUserRecord(userPubkey).catch(() => {})
373
454
  this.sessionSubscriptions.set(key, unsub)
374
455
  }
375
456
 
376
- private attachInviteListSubscription(
457
+ private attachAppKeysSubscription(
377
458
  userPubkey: string,
378
- onInviteList?: (inviteList: InviteList) => void | Promise<void>
459
+ onAppKeys?: (appKeys: AppKeys) => void | Promise<void>
379
460
  ): void {
380
- const key = `invitelist:${userPubkey}`
461
+ const key = `appkeys:${userPubkey}`
381
462
  if (this.inviteSubscriptions.has(key)) return
382
463
 
383
- const unsubscribe = this.subscribeToUserInviteList(
464
+ const unsubscribe = this.subscribeToUserAppKeys(
384
465
  userPubkey,
385
- async (inviteList) => {
386
- if (onInviteList) await onInviteList(inviteList)
466
+ async (appKeys) => {
467
+ if (onAppKeys) await onAppKeys(appKeys)
387
468
  }
388
469
  )
389
470
 
@@ -393,42 +474,123 @@ export class SessionManager {
393
474
  setupUser(userPubkey: string) {
394
475
  const userRecord = this.getOrCreateUserRecord(userPubkey)
395
476
 
477
+ // Track which device identities we've subscribed to for invites
478
+ const subscribedDeviceIdentities = new Set<string>()
479
+ // Track devices currently being accepted (to prevent duplicate acceptance)
480
+ const pendingAcceptances = new Set<string>()
481
+
482
+ /**
483
+ * Accept an invite from a device.
484
+ * The invite is fetched separately from the device's own Invite event.
485
+ */
396
486
  const acceptInviteFromDevice = async (
397
- inviteList: InviteList,
398
- deviceId: string
487
+ device: DeviceEntry,
488
+ invite: Invite
399
489
  ) => {
490
+ // Double-check for active session (race condition guard)
491
+ // Another concurrent call may have already established a session
492
+ const existingRecord = userRecord.devices.get(device.identityPubkey)
493
+ if (existingRecord?.activeSession) {
494
+ return
495
+ }
496
+
400
497
  // Add device record IMMEDIATELY to prevent duplicate acceptance from race conditions
401
- // (InviteList callback can fire multiple times before async accept completes)
402
- const deviceRecord = this.upsertDeviceRecord(userRecord, deviceId)
498
+ // Use identityPubkey as the device identifier
499
+ const deviceRecord = this.upsertDeviceRecord(userRecord, device.identityPubkey)
403
500
 
404
501
  const encryptor = this.identityKey instanceof Uint8Array ? this.identityKey : this.identityKey.encrypt
405
- const { session, event } = await inviteList.accept(
406
- deviceId,
502
+ // ourPublicKey serves as both identity and device ID
503
+ const { session, event } = await invite.accept(
407
504
  this.nostrSubscribe,
408
505
  this.ourPublicKey,
409
506
  encryptor,
410
- this.deviceId
507
+ this.ownerPublicKey
411
508
  )
412
509
  return this.nostrPublish(event)
413
- .then(() => this.attachSessionSubscription(userPubkey, deviceRecord, session))
414
- .then(() => this.sendMessageHistory(userPubkey, deviceId))
415
- .catch(console.error)
510
+ .then(() => {
511
+ this.attachSessionSubscription(userPubkey, deviceRecord, session)
512
+ })
513
+ .then(() => this.sendMessageHistory(userPubkey, device.identityPubkey))
514
+ .catch(() => {})
416
515
  }
417
516
 
418
- this.attachInviteListSubscription(userPubkey, async (inviteList) => {
419
- const devices = inviteList.getAllDevices()
517
+ /**
518
+ * Subscribe to a device's Invite event and accept it when received.
519
+ */
520
+ const subscribeToDeviceInvite = (device: DeviceEntry) => {
521
+ // identityPubkey is the device identifier
522
+ const deviceKey = device.identityPubkey
523
+ if (subscribedDeviceIdentities.has(deviceKey)) {
524
+ return
525
+ }
526
+ subscribedDeviceIdentities.add(deviceKey)
420
527
 
421
- // Handle removed devices (source of truth for revocation)
422
- for (const deviceId of inviteList.getRemovedDeviceIds()) {
423
- await this.cleanupDevice(userPubkey, deviceId)
528
+ // Already have a record with active session for this device? Skip.
529
+ const existingRecord = userRecord.devices.get(device.identityPubkey)
530
+ if (existingRecord?.activeSession) {
531
+ return
424
532
  }
425
533
 
426
- // Accept invites from new devices
427
- for (const device of devices) {
428
- if (!userRecord.devices.has(device.deviceId)) {
429
- await acceptInviteFromDevice(inviteList, device.deviceId)
534
+ const inviteSubKey = `invite:${device.identityPubkey}`
535
+ if (this.inviteSubscriptions.has(inviteSubKey)) {
536
+ return
537
+ }
538
+
539
+ // Subscribe to this device's Invite event
540
+ const unsub = Invite.fromUser(device.identityPubkey, this.nostrSubscribe, async (invite) => {
541
+ // Verify the invite is for this device (identityPubkey is the device identifier)
542
+ if (invite.deviceId !== device.identityPubkey) {
543
+ return
544
+ }
545
+
546
+ // Skip if we already have an active session (race condition guard)
547
+ const existingDeviceRecord = userRecord.devices.get(device.identityPubkey)
548
+ if (existingDeviceRecord?.activeSession) {
549
+ return
550
+ }
551
+
552
+ // Skip if acceptance is already in progress (race condition guard)
553
+ if (pendingAcceptances.has(device.identityPubkey)) {
554
+ return
555
+ }
556
+
557
+ pendingAcceptances.add(device.identityPubkey)
558
+ try {
559
+ await acceptInviteFromDevice(device, invite)
560
+ } finally {
561
+ pendingAcceptances.delete(device.identityPubkey)
562
+ }
563
+ })
564
+
565
+ this.inviteSubscriptions.set(inviteSubKey, unsub)
566
+ }
567
+
568
+ this.attachAppKeysSubscription(userPubkey, async (appKeys) => {
569
+ const devices = appKeys.getAllDevices()
570
+ const activeDeviceIds = new Set(devices.map(d => d.identityPubkey))
571
+
572
+ // Handle devices no longer in list (revoked or AppKeys recreated from scratch)
573
+ const userRecord = this.userRecords.get(userPubkey)
574
+ if (userRecord) {
575
+ for (const [deviceId] of userRecord.devices) {
576
+ if (!activeDeviceIds.has(deviceId)) {
577
+ // Remove from tracking so device can be re-subscribed if re-added
578
+ subscribedDeviceIdentities.delete(deviceId)
579
+ const inviteSubKey = `invite:${deviceId}`
580
+ const inviteUnsub = this.inviteSubscriptions.get(inviteSubKey)
581
+ if (inviteUnsub) {
582
+ inviteUnsub()
583
+ this.inviteSubscriptions.delete(inviteSubKey)
584
+ }
585
+ await this.cleanupDevice(userPubkey, deviceId)
586
+ }
430
587
  }
431
588
  }
589
+
590
+ // For each device in AppKeys, subscribe to their Invite event
591
+ for (const device of devices) {
592
+ subscribeToDeviceInvite(device)
593
+ }
432
594
  })
433
595
  }
434
596
 
@@ -469,7 +631,7 @@ export class SessionManager {
469
631
  device.activeSession = undefined
470
632
  }
471
633
  }
472
- this.storeUserRecord(publicKey).catch(console.error)
634
+ this.storeUserRecord(publicKey).catch(() => {})
473
635
  }
474
636
 
475
637
  async deleteUser(userPubkey: string): Promise<void> {
@@ -495,11 +657,11 @@ export class SessionManager {
495
657
  this.userRecords.delete(userPubkey)
496
658
  }
497
659
 
498
- const inviteListKey = `invitelist:${userPubkey}`
499
- const inviteListUnsub = this.inviteSubscriptions.get(inviteListKey)
500
- if (inviteListUnsub) {
501
- inviteListUnsub()
502
- this.inviteSubscriptions.delete(inviteListKey)
660
+ const appKeysKey = `appkeys:${userPubkey}`
661
+ const appKeysUnsub = this.inviteSubscriptions.get(appKeysKey)
662
+ if (appKeysUnsub) {
663
+ appKeysUnsub()
664
+ this.inviteSubscriptions.delete(appKeysKey)
503
665
  }
504
666
 
505
667
  this.messageHistory.delete(userPubkey)
@@ -542,13 +704,12 @@ export class SessionManager {
542
704
  if (!device) {
543
705
  return
544
706
  }
545
- if (device.staleAt !== undefined) {
546
- return
547
- }
548
707
  for (const event of history) {
549
708
  const { activeSession } = device
550
709
 
551
- if (!activeSession) continue
710
+ if (!activeSession) {
711
+ continue
712
+ }
552
713
  const { event: verifiedEvent } = activeSession.sendEvent(event)
553
714
  await this.nostrPublish(verifiedEvent)
554
715
  await this.storeUserRecord(recipientPublicKey)
@@ -578,8 +739,8 @@ export class SessionManager {
578
739
  // Use ownerPublicKey to setup sessions with sibling devices
579
740
  this.setupUser(this.ownerPublicKey)
580
741
 
581
- const recipientDevices = Array.from(userRecord.devices.values()).filter(d => d.staleAt === undefined)
582
- const ownDevices = Array.from(ourUserRecord.devices.values()).filter(d => d.staleAt === undefined)
742
+ const recipientDevices = Array.from(userRecord.devices.values())
743
+ const ownDevices = Array.from(ourUserRecord.devices.values())
583
744
 
584
745
  // Merge and deduplicate by deviceId, excluding our own sending device
585
746
  // This fixes the self-message bug where sending to yourself would duplicate devices
@@ -591,28 +752,28 @@ export class SessionManager {
591
752
  }
592
753
  const devices = Array.from(deviceMap.values())
593
754
 
594
- // Send to all devices in background (if sessions exist)
595
- Promise.allSettled(
755
+ // Send to all devices and await completion before returning
756
+ // This ensures session state is ratcheted and persisted before function returns
757
+ await Promise.allSettled(
596
758
  devices.map(async (device) => {
597
759
  const { activeSession } = device
598
760
  if (!activeSession) {
599
761
  return
600
762
  }
601
763
  const { event: verifiedEvent } = activeSession.sendEvent(event)
602
- await this.nostrPublish(verifiedEvent).catch(console.error)
764
+ await this.nostrPublish(verifiedEvent).catch(() => {})
603
765
  })
604
766
  )
605
- .then(() => {
606
- // Store recipient's user record
607
- this.storeUserRecord(recipientIdentityKey)
608
- // Also store owner's record if different (for sibling device sessions)
609
- // This ensures session state is persisted after ratcheting
610
- // TODO: check if really necessary, if yes, why?
611
- if (this.ownerPublicKey !== recipientIdentityKey) {
612
- this.storeUserRecord(this.ownerPublicKey)
613
- }
614
- })
615
- .catch(console.error)
767
+
768
+ // Store recipient's user record after all messages sent
769
+ await this.storeUserRecord(recipientIdentityKey)
770
+ // Also store owner's record if different (for sibling device sessions)
771
+ // This ensures session state is persisted after ratcheting for both:
772
+ // - recipientDevices stored under recipientIdentityKey
773
+ // - Own sibling devices stored under ownerPublicKey
774
+ if (this.ownerPublicKey !== recipientIdentityKey) {
775
+ await this.storeUserRecord(this.ownerPublicKey)
776
+ }
616
777
 
617
778
  // Return the event with computed ID (same as library would compute)
618
779
  return completeEvent
@@ -645,7 +806,9 @@ export class SessionManager {
645
806
  rumor.id = getEventHash(rumor)
646
807
 
647
808
  // Use sendEvent for actual sending (includes queueing)
648
- this.sendEvent(recipientPublicKey, rumor).catch(console.error)
809
+ // Note: sendEvent is not awaited to maintain backward compatibility
810
+ // The message is queued and will be sent when sessions are established
811
+ this.sendEvent(recipientPublicKey, rumor).catch(() => {})
649
812
 
650
813
  return rumor
651
814
  }
@@ -654,22 +817,19 @@ export class SessionManager {
654
817
  const userRecord = this.userRecords.get(publicKey)
655
818
  if (!userRecord) return
656
819
  const deviceRecord = userRecord.devices.get(deviceId)
657
-
658
820
  if (!deviceRecord) return
659
821
 
822
+ // Unsubscribe from sessions
660
823
  if (deviceRecord.activeSession) {
661
824
  this.removeSessionSubscription(publicKey, deviceId, deviceRecord.activeSession.name)
662
825
  }
663
-
664
826
  for (const session of deviceRecord.inactiveSessions) {
665
827
  this.removeSessionSubscription(publicKey, deviceId, session.name)
666
828
  }
667
829
 
668
- deviceRecord.activeSession = undefined
669
- deviceRecord.inactiveSessions = []
670
- deviceRecord.staleAt = Date.now()
671
-
672
- await this.storeUserRecord(publicKey).catch(console.error)
830
+ // Delete the device record entirely
831
+ userRecord.devices.delete(deviceId)
832
+ await this.storeUserRecord(publicKey).catch(() => {})
673
833
  }
674
834
 
675
835
  private buildMessageTags(
@@ -686,22 +846,26 @@ export class SessionManager {
686
846
  }
687
847
 
688
848
  private storeUserRecord(publicKey: string) {
849
+ const userRecord = this.userRecords.get(publicKey)
850
+ const devices = Array.from(userRecord?.devices.entries() || [])
851
+ const serializeSession = (session: Session): StoredSessionEntry => ({
852
+ name: session.name,
853
+ state: serializeSessionState(session.state)
854
+ })
855
+
689
856
  const data: StoredUserRecord = {
690
857
  publicKey: publicKey,
691
- devices: Array.from(this.userRecords.get(publicKey)?.devices.entries() || []).map(
858
+ devices: devices.map(
692
859
  ([, device]) => ({
693
860
  deviceId: device.deviceId,
694
861
  activeSession: device.activeSession
695
- ? serializeSessionState(device.activeSession.state)
862
+ ? serializeSession(device.activeSession)
696
863
  : null,
697
- inactiveSessions: device.inactiveSessions.map((session) =>
698
- serializeSessionState(session.state)
699
- ),
864
+ inactiveSessions: device.inactiveSessions.map(serializeSession),
700
865
  createdAt: device.createdAt,
701
- staleAt: device.staleAt,
702
- hasResponderSession: device.hasResponderSession,
703
866
  })
704
867
  ),
868
+ knownDeviceIdentities: userRecord?.knownDeviceIdentities || [],
705
869
  }
706
870
  return this.storage.put(this.userRecordKey(publicKey), data)
707
871
  }
@@ -714,63 +878,66 @@ export class SessionManager {
714
878
 
715
879
  const devices = new Map<string, DeviceRecord>()
716
880
 
881
+ const deserializeSession = (entry: StoredSessionEntry): Session => {
882
+ const session = new Session(this.nostrSubscribe, deserializeSessionState(entry.state))
883
+ session.name = entry.name
884
+ this.processedInviteResponses.add(entry.name)
885
+ return session
886
+ }
887
+
717
888
  for (const deviceData of data.devices) {
718
889
  const {
719
890
  deviceId,
720
891
  activeSession: serializedActive,
721
892
  inactiveSessions: serializedInactive,
722
893
  createdAt,
723
- staleAt,
724
- hasResponderSession,
725
894
  } = deviceData
726
895
 
727
896
  try {
728
897
  const activeSession = serializedActive
729
- ? new Session(
730
- this.nostrSubscribe,
731
- deserializeSessionState(serializedActive)
732
- )
898
+ ? deserializeSession(serializedActive)
733
899
  : undefined
734
900
 
735
- const inactiveSessions = serializedInactive.map(
736
- (entry) => new Session(this.nostrSubscribe, deserializeSessionState(entry))
737
- )
901
+ const inactiveSessions = serializedInactive.map(deserializeSession)
738
902
 
739
903
  devices.set(deviceId, {
740
904
  deviceId,
741
905
  activeSession,
742
906
  inactiveSessions,
743
907
  createdAt,
744
- staleAt,
745
- hasResponderSession,
746
908
  })
747
- } catch (e) {
748
- console.error(
749
- `Failed to deserialize session for user ${publicKey}, device ${deviceId}:`,
750
- e
751
- )
909
+ } catch {
910
+ // Failed to deserialize session
752
911
  }
753
912
  }
754
913
 
914
+ const knownDeviceIdentities = data.knownDeviceIdentities || []
915
+
755
916
  this.userRecords.set(publicKey, {
756
917
  publicKey: data.publicKey,
757
918
  devices,
919
+ knownDeviceIdentities,
758
920
  })
759
921
 
922
+ // Rebuild delegateToOwner mapping from stored device identities
923
+ for (const identity of knownDeviceIdentities) {
924
+ this.delegateToOwner.set(identity, publicKey)
925
+ }
926
+
760
927
  for (const device of devices.values()) {
761
- const { deviceId, activeSession, inactiveSessions, staleAt } = device
762
- if (!deviceId || staleAt !== undefined) continue
928
+ const { deviceId, activeSession, inactiveSessions } = device
929
+ if (!deviceId) continue
763
930
 
764
931
  for (const session of inactiveSessions.reverse()) {
765
- this.attachSessionSubscription(publicKey, device, session)
932
+ this.attachSessionSubscription(publicKey, device, session, true) // Restore as inactive
766
933
  }
767
934
  if (activeSession) {
768
- this.attachSessionSubscription(publicKey, device, activeSession)
935
+ this.attachSessionSubscription(publicKey, device, activeSession) // Restore as active
769
936
  }
770
937
  }
771
938
  })
772
- .catch((error) => {
773
- console.error(`Failed to load user record for ${publicKey}:`, error)
939
+ .catch(() => {
940
+ // Failed to load user record
774
941
  })
775
942
  }
776
943
 
@@ -819,8 +986,8 @@ export class SessionManager {
819
986
  await this.storage.put(newKey, newUserRecordData)
820
987
  await this.storage.del(key)
821
988
  }
822
- } catch (e) {
823
- console.error("Migration error for user record:", e)
989
+ } catch {
990
+ // Migration error for user record
824
991
  }
825
992
  })
826
993
  )