nostr-double-ratchet 0.0.37 → 0.0.38

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.
@@ -1,26 +1,38 @@
1
1
  import {
2
- DecryptFunction,
2
+ IdentityKey,
3
3
  NostrSubscribe,
4
4
  NostrPublish,
5
5
  Rumor,
6
6
  Unsubscribe,
7
- INVITE_EVENT_KIND,
7
+ INVITE_LIST_EVENT_KIND,
8
8
  CHAT_MESSAGE_KIND,
9
9
  } from "./types"
10
10
  import { StorageAdapter, InMemoryStorageAdapter } from "./StorageAdapter"
11
- import { Invite } from "./Invite"
11
+ import { InviteList } from "./InviteList"
12
12
  import { Session } from "./Session"
13
13
  import { serializeSessionState, deserializeSessionState } from "./utils"
14
- import { getEventHash, VerifiedEvent } from "nostr-tools"
14
+ import { decryptInviteResponse, createSessionFromAccept } from "./inviteUtils"
15
+ import { getEventHash } from "nostr-tools"
15
16
 
16
17
  export type OnEventCallback = (event: Rumor, from: string) => void
17
18
 
19
+ /**
20
+ * Credentials for the invite handshake - used to listen for and decrypt invite responses
21
+ */
22
+ export interface InviteCredentials {
23
+ ephemeralKeypair: { publicKey: string; privateKey: Uint8Array }
24
+ sharedSecret: string
25
+ }
26
+
18
27
  interface DeviceRecord {
19
28
  deviceId: string
20
29
  activeSession?: Session
21
30
  inactiveSessions: Session[]
22
31
  createdAt: number
23
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
24
36
  }
25
37
 
26
38
  interface UserRecord {
@@ -36,6 +48,7 @@ interface StoredDeviceRecord {
36
48
  inactiveSessions: StoredSessionEntry[]
37
49
  createdAt: number
38
50
  staleAt?: number
51
+ hasResponderSession?: boolean
39
52
  }
40
53
 
41
54
  interface StoredUserRecord {
@@ -53,20 +66,24 @@ export class SessionManager {
53
66
  private storage: StorageAdapter
54
67
  private nostrSubscribe: NostrSubscribe
55
68
  private nostrPublish: NostrPublish
56
- private ourIdentityKey: Uint8Array | DecryptFunction
69
+ private identityKey: IdentityKey
57
70
  private ourPublicKey: string
71
+ // Owner's public key - used for grouping devices together (all devices are delegates)
72
+ private ownerPublicKey: string
73
+
74
+ // Credentials for invite handshake
75
+ private inviteKeys: InviteCredentials
58
76
 
59
77
  // Data
60
78
  private userRecords: Map<string, UserRecord> = new Map()
61
79
  private messageHistory: Map<string, Rumor[]> = new Map()
62
- private currentDeviceInvite: Invite | null = null
80
+ // Map delegate device pubkeys to their owner's pubkey
81
+ private delegateToOwner: Map<string, string> = new Map()
63
82
 
64
83
  // Subscriptions
65
- private ourDeviceInviteSubscription: Unsubscribe | null = null
66
- private ourDeviceIntiveTombstoneSubscription: Unsubscribe | null = null
84
+ private ourInviteResponseSubscription: Unsubscribe | null = null
67
85
  private inviteSubscriptions: Map<string, Unsubscribe> = new Map()
68
86
  private sessionSubscriptions: Map<string, Unsubscribe> = new Map()
69
- private inviteTombstoneSubscriptions: Map<string, Unsubscribe> = new Map()
70
87
 
71
88
  // Callbacks
72
89
  private internalSubscriptions: Set<OnEventCallback> = new Set()
@@ -76,18 +93,22 @@ export class SessionManager {
76
93
 
77
94
  constructor(
78
95
  ourPublicKey: string,
79
- ourIdentityKey: Uint8Array | DecryptFunction,
96
+ identityKey: IdentityKey,
80
97
  deviceId: string,
81
98
  nostrSubscribe: NostrSubscribe,
82
99
  nostrPublish: NostrPublish,
83
- storage?: StorageAdapter
100
+ ownerPublicKey: string,
101
+ inviteKeys: InviteCredentials,
102
+ storage?: StorageAdapter,
84
103
  ) {
85
104
  this.userRecords = new Map()
86
105
  this.nostrSubscribe = nostrSubscribe
87
106
  this.nostrPublish = nostrPublish
88
107
  this.ourPublicKey = ourPublicKey
89
- this.ourIdentityKey = ourIdentityKey
108
+ this.identityKey = identityKey
90
109
  this.deviceId = deviceId
110
+ this.ownerPublicKey = ownerPublicKey
111
+ this.inviteKeys = inviteKeys
91
112
  this.storage = storage || new InMemoryStorageAdapter()
92
113
  this.versionPrefix = `v${this.storageVersion}`
93
114
  }
@@ -104,55 +125,100 @@ export class SessionManager {
104
125
  console.error("Failed to load user records:", error)
105
126
  })
106
127
 
107
- const ourInviteFromStorage: Invite | null = await this.storage
108
- .get<string>(this.deviceInviteKey(this.deviceId))
109
- .then((data) => {
110
- if (!data) return null
128
+ // Add our own device to user record to prevent accepting our own invite
129
+ // Use ownerPublicKey so delegates are added to the owner's record
130
+ const ourUserRecord = this.getOrCreateUserRecord(this.ownerPublicKey)
131
+ this.upsertDeviceRecord(ourUserRecord, this.deviceId)
132
+
133
+ // Start invite response listener BEFORE setting up users
134
+ // This ensures we're listening when other devices respond to our invites
135
+ this.startInviteResponseListener()
136
+ // Setup sessions with our own other devices
137
+ // Use ownerPublicKey to find sibling devices (important for delegates)
138
+ this.setupUser(this.ownerPublicKey)
139
+ }
140
+
141
+ /**
142
+ * Start listening for invite responses on our ephemeral key.
143
+ * This is used by devices to receive session establishment responses.
144
+ */
145
+ private startInviteResponseListener(): void {
146
+ const { publicKey: ephemeralPubkey, privateKey: ephemeralPrivkey } = this.inviteKeys.ephemeralKeypair
147
+ const sharedSecret = this.inviteKeys.sharedSecret
148
+
149
+ // Subscribe to invite responses tagged to our ephemeral key
150
+ this.ourInviteResponseSubscription = this.nostrSubscribe(
151
+ {
152
+ kinds: [1059], // INVITE_RESPONSE_KIND
153
+ "#p": [ephemeralPubkey],
154
+ },
155
+ async (event) => {
111
156
  try {
112
- return Invite.deserialize(data)
113
- } catch {
114
- return null
115
- }
116
- })
157
+ const decrypted = await decryptInviteResponse({
158
+ envelopeContent: event.content,
159
+ envelopeSenderPubkey: event.pubkey,
160
+ inviterEphemeralPrivateKey: ephemeralPrivkey,
161
+ inviterPrivateKey: this.identityKey instanceof Uint8Array ? this.identityKey : undefined,
162
+ sharedSecret,
163
+ decrypt: this.identityKey instanceof Uint8Array ? undefined : this.identityKey.decrypt,
164
+ })
165
+
166
+ // Skip our own responses - this happens when we publish an invite response
167
+ // and our own listener receives it back from relays
168
+ if (decrypted.deviceId === this.deviceId) {
169
+ return
170
+ }
117
171
 
118
- const invite =
119
- ourInviteFromStorage || Invite.createNew(this.ourPublicKey, this.deviceId)
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")
120
176
 
121
- this.currentDeviceInvite = invite
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
+ }
122
182
 
123
- await this.storage.put(this.deviceInviteKey(this.deviceId), invite.serialize())
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
199
+ }
124
200
 
125
- this.ourDeviceInviteSubscription = invite.listen(
126
- this.ourIdentityKey,
127
- this.nostrSubscribe,
128
- async (session, inviteePubkey, deviceId) => {
129
- if (!deviceId || deviceId === this.deviceId) return
130
- const nostrEventId = session.name
131
- const acceptanceKey = this.inviteAcceptKey(nostrEventId, inviteePubkey, deviceId)
132
- const nostrEventIdInStorage = await this.storage.get<string>(acceptanceKey)
133
- if (nostrEventIdInStorage) {
134
- return
201
+ const session = createSessionFromAccept({
202
+ nostrSubscribe: this.nostrSubscribe,
203
+ theirPublicKey: decrypted.inviteeSessionPublicKey,
204
+ ourSessionPrivateKey: ephemeralPrivkey,
205
+ sharedSecret,
206
+ isSender: false,
207
+ name: event.id,
208
+ })
209
+
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
+ this.attachSessionSubscription(ownerPubkey, deviceRecord, session, true)
215
+ // Persist the flag
216
+ this.storeUserRecord(ownerPubkey).catch(console.error)
217
+ } catch {
218
+ // Invalid response, ignore
135
219
  }
136
-
137
- await this.storage.put(acceptanceKey, "1")
138
-
139
- const userRecord = this.getOrCreateUserRecord(inviteePubkey)
140
- const deviceRecord = this.upsertDeviceRecord(userRecord, deviceId)
141
-
142
- this.attachSessionSubscription(inviteePubkey, deviceRecord, session, true)
143
220
  }
144
221
  )
145
-
146
- if (!this.ourDeviceIntiveTombstoneSubscription) {
147
- this.ourDeviceIntiveTombstoneSubscription = this.createInviteTombstoneSubscription(
148
- this.ourPublicKey
149
- )
150
- }
151
-
152
- const inviteNostrEvent = invite.getEvent()
153
- this.nostrPublish(inviteNostrEvent).catch((error) => {
154
- console.error("Failed to publish our device invite:", error)
155
- })
156
222
  }
157
223
 
158
224
  // -------------------
@@ -185,56 +251,9 @@ export class SessionManager {
185
251
  return deviceRecord
186
252
  }
187
253
 
188
- private createInviteTombstoneSubscription(authorPublicKey: string): Unsubscribe {
189
- return this.nostrSubscribe(
190
- {
191
- kinds: [INVITE_EVENT_KIND],
192
- authors: [authorPublicKey],
193
- "#l": ["double-ratchet/invites"],
194
- },
195
- (event: VerifiedEvent) => {
196
- try {
197
- const isTombstone = !event.tags?.some(
198
- ([key]) => key === "ephemeralKey" || key === "sharedSecret"
199
- )
200
- if (isTombstone) {
201
- const deviceIdTag = event.tags.find(
202
- ([key, value]) => key === "d" && value.startsWith("double-ratchet/invites/")
203
- )
204
- const [, deviceIdTagValue] = deviceIdTag || []
205
- const deviceId = deviceIdTagValue.split("/").pop()
206
- if (!deviceId) return
207
-
208
- this.cleanupDevice(authorPublicKey, deviceId)
209
- }
210
- } catch (error) {
211
- console.error("Failed to handle device tombstone:", error)
212
- }
213
- }
214
- )
215
- }
216
-
217
254
  private sessionKey(userPubkey: string, deviceId: string, sessionName: string) {
218
255
  return `${this.sessionKeyPrefix(userPubkey)}${deviceId}/${sessionName}`
219
256
  }
220
- private inviteKey(userPubkey: string) {
221
- return this.userInviteKey(userPubkey)
222
- }
223
- private inviteAcceptKey(nostrEventId: string, userPubkey: string, deviceId: string) {
224
- return `${this.inviteAcceptKeyPrefix(userPubkey)}${deviceId}/${nostrEventId}`
225
- }
226
-
227
- private deviceInviteKey(deviceId: string) {
228
- return `${this.versionPrefix}/device-invite/${deviceId}`
229
- }
230
-
231
- private userInviteKey(userPubkey: string) {
232
- return `${this.versionPrefix}/invite/${userPubkey}`
233
- }
234
-
235
- private inviteAcceptKeyPrefix(userPublicKey: string) {
236
- return `${this.versionPrefix}/invite-accept/${userPublicKey}/`
237
- }
238
257
 
239
258
  private sessionKeyPrefix(userPubkey: string) {
240
259
  return `${this.versionPrefix}/session/${userPubkey}/`
@@ -251,6 +270,49 @@ export class SessionManager {
251
270
  return `storage-version`
252
271
  }
253
272
 
273
+ /**
274
+ * Resolve a pubkey to its owner if it's a known delegate device.
275
+ * Returns the input pubkey if not a known delegate.
276
+ */
277
+ private resolveToOwner(pubkey: string): string {
278
+ return this.delegateToOwner.get(pubkey) || pubkey
279
+ }
280
+
281
+ /**
282
+ * Update the delegate-to-owner mapping from an InviteList.
283
+ * Extracts delegate device pubkeys and maps them to the owner.
284
+ */
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
+ }
290
+ }
291
+ }
292
+
293
+ private subscribeToUserInviteList(
294
+ pubkey: string,
295
+ onInviteList: (list: InviteList) => void
296
+ ): Unsubscribe {
297
+ return this.nostrSubscribe(
298
+ {
299
+ kinds: [INVITE_LIST_EVENT_KIND],
300
+ authors: [pubkey],
301
+ "#d": ["double-ratchet/invite-list"],
302
+ },
303
+ (event) => {
304
+ try {
305
+ const list = InviteList.fromEvent(event)
306
+ // Update delegate mapping whenever we receive an InviteList
307
+ this.updateDelegateMapping(pubkey, list)
308
+ onInviteList(list)
309
+ } catch {
310
+ // Invalid event, ignore
311
+ }
312
+ }
313
+ )
314
+ }
315
+
254
316
  private attachSessionSubscription(
255
317
  userPubkey: string,
256
318
  deviceRecord: DeviceRecord,
@@ -258,10 +320,14 @@ export class SessionManager {
258
320
  // Set to true if only handshake -> not yet sendable -> will be promoted on message
259
321
  inactive: boolean = false
260
322
  ): void {
261
- if (deviceRecord.staleAt !== undefined) return
323
+ if (deviceRecord.staleAt !== undefined) {
324
+ return
325
+ }
262
326
 
263
327
  const key = this.sessionKey(userPubkey, deviceRecord.deviceId, session.name)
264
- if (this.sessionSubscriptions.has(key)) return
328
+ if (this.sessionSubscriptions.has(key)) {
329
+ return
330
+ }
265
331
 
266
332
  const dr = deviceRecord
267
333
  const rotateSession = (nextSession: Session) => {
@@ -307,62 +373,61 @@ export class SessionManager {
307
373
  this.sessionSubscriptions.set(key, unsub)
308
374
  }
309
375
 
310
- private attachInviteSubscription(
376
+ private attachInviteListSubscription(
311
377
  userPubkey: string,
312
- onInvite?: (invite: Invite) => void | Promise<void>
378
+ onInviteList?: (inviteList: InviteList) => void | Promise<void>
313
379
  ): void {
314
- const key = this.inviteKey(userPubkey)
380
+ const key = `invitelist:${userPubkey}`
315
381
  if (this.inviteSubscriptions.has(key)) return
316
382
 
317
- const unsubscribe = Invite.fromUser(
383
+ const unsubscribe = this.subscribeToUserInviteList(
318
384
  userPubkey,
319
- this.nostrSubscribe,
320
- async (invite) => {
321
- if (!invite.deviceId) return
322
- if (onInvite) await onInvite(invite)
385
+ async (inviteList) => {
386
+ if (onInviteList) await onInviteList(inviteList)
323
387
  }
324
388
  )
325
389
 
326
390
  this.inviteSubscriptions.set(key, unsubscribe)
327
391
  }
328
392
 
329
- private attachInviteTombstoneSubscription(userPubkey: string): void {
330
- if (this.inviteTombstoneSubscriptions.has(userPubkey)) {
331
- return
332
- }
333
-
334
- const unsubscribe = this.createInviteTombstoneSubscription(userPubkey)
335
- this.inviteTombstoneSubscriptions.set(userPubkey, unsubscribe)
336
- }
337
-
338
393
  setupUser(userPubkey: string) {
339
394
  const userRecord = this.getOrCreateUserRecord(userPubkey)
340
395
 
341
- this.attachInviteTombstoneSubscription(userPubkey)
342
-
343
- const acceptInvite = async (invite: Invite) => {
344
- const { deviceId } = invite
345
- if (!deviceId) return
346
-
347
- const { session, event } = await invite.accept(
396
+ const acceptInviteFromDevice = async (
397
+ inviteList: InviteList,
398
+ deviceId: string
399
+ ) => {
400
+ // 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)
403
+
404
+ const encryptor = this.identityKey instanceof Uint8Array ? this.identityKey : this.identityKey.encrypt
405
+ const { session, event } = await inviteList.accept(
406
+ deviceId,
348
407
  this.nostrSubscribe,
349
408
  this.ourPublicKey,
350
- this.ourIdentityKey,
409
+ encryptor,
351
410
  this.deviceId
352
411
  )
353
412
  return this.nostrPublish(event)
354
- .then(() => this.upsertDeviceRecord(userRecord, deviceId))
355
- .then((dr) => this.attachSessionSubscription(userPubkey, dr, session))
413
+ .then(() => this.attachSessionSubscription(userPubkey, deviceRecord, session))
356
414
  .then(() => this.sendMessageHistory(userPubkey, deviceId))
357
415
  .catch(console.error)
358
416
  }
359
417
 
360
- this.attachInviteSubscription(userPubkey, async (invite) => {
361
- const { deviceId } = invite
362
- if (!deviceId) return
418
+ this.attachInviteListSubscription(userPubkey, async (inviteList) => {
419
+ const devices = inviteList.getAllDevices()
363
420
 
364
- if (!userRecord.devices.has(deviceId)) {
365
- await acceptInvite(invite)
421
+ // Handle removed devices (source of truth for revocation)
422
+ for (const deviceId of inviteList.getRemovedDeviceIds()) {
423
+ await this.cleanupDevice(userPubkey, deviceId)
424
+ }
425
+
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)
430
+ }
366
431
  }
367
432
  })
368
433
  }
@@ -379,10 +444,6 @@ export class SessionManager {
379
444
  return this.deviceId
380
445
  }
381
446
 
382
- getDeviceInviteEphemeralKey(): string | null {
383
- return this.currentDeviceInvite?.inviterEphemeralPublicKey || null
384
- }
385
-
386
447
  getUserRecords(): Map<string, UserRecord> {
387
448
  return this.userRecords
388
449
  }
@@ -396,12 +457,7 @@ export class SessionManager {
396
457
  unsubscribe()
397
458
  }
398
459
 
399
- for (const unsubscribe of this.inviteTombstoneSubscriptions.values()) {
400
- unsubscribe()
401
- }
402
-
403
- this.ourDeviceInviteSubscription?.()
404
- this.ourDeviceIntiveTombstoneSubscription?.()
460
+ this.ourInviteResponseSubscription?.()
405
461
  }
406
462
 
407
463
  deactivateCurrentSessions(publicKey: string) {
@@ -439,23 +495,16 @@ export class SessionManager {
439
495
  this.userRecords.delete(userPubkey)
440
496
  }
441
497
 
442
- const inviteKey = this.inviteKey(userPubkey)
443
- const inviteUnsub = this.inviteSubscriptions.get(inviteKey)
444
- if (inviteUnsub) {
445
- inviteUnsub()
446
- this.inviteSubscriptions.delete(inviteKey)
447
- }
448
-
449
- const tombstoneUnsub = this.inviteTombstoneSubscriptions.get(userPubkey)
450
- if (tombstoneUnsub) {
451
- tombstoneUnsub()
452
- this.inviteTombstoneSubscriptions.delete(userPubkey)
498
+ const inviteListKey = `invitelist:${userPubkey}`
499
+ const inviteListUnsub = this.inviteSubscriptions.get(inviteListKey)
500
+ if (inviteListUnsub) {
501
+ inviteListUnsub()
502
+ this.inviteSubscriptions.delete(inviteListKey)
453
503
  }
454
504
 
455
505
  this.messageHistory.delete(userPubkey)
456
506
 
457
507
  await Promise.allSettled([
458
- this.storage.del(this.inviteKey(userPubkey)),
459
508
  this.deleteUserSessionsFromStorage(userPubkey),
460
509
  this.storage.del(this.userRecordKey(userPubkey)),
461
510
  ])
@@ -514,34 +563,54 @@ export class SessionManager {
514
563
 
515
564
  // Add to message history queue (will be sent when session is established)
516
565
  const completeEvent = event as Rumor
517
- const historyTargets = new Set([recipientIdentityKey, this.ourPublicKey])
566
+ // Use ownerPublicKey for history targets so delegates share history with owner
567
+ const historyTargets = new Set([recipientIdentityKey, this.ownerPublicKey])
518
568
  for (const key of historyTargets) {
519
569
  const existing = this.messageHistory.get(key) || []
520
570
  this.messageHistory.set(key, [...existing, completeEvent])
521
571
  }
522
572
 
523
573
  const userRecord = this.getOrCreateUserRecord(recipientIdentityKey)
524
- const ourUserRecord = this.getOrCreateUserRecord(this.ourPublicKey)
574
+ // Use ownerPublicKey to find sibling devices (important for delegates)
575
+ const ourUserRecord = this.getOrCreateUserRecord(this.ownerPublicKey)
525
576
 
526
577
  this.setupUser(recipientIdentityKey)
527
- this.setupUser(this.ourPublicKey)
528
-
529
- const devices = [
530
- ...Array.from(userRecord.devices.values()),
531
- ...Array.from(ourUserRecord.devices.values()),
532
- ].filter((device) => device.staleAt === undefined)
578
+ // Use ownerPublicKey to setup sessions with sibling devices
579
+ this.setupUser(this.ownerPublicKey)
580
+
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)
583
+
584
+ // Merge and deduplicate by deviceId, excluding our own sending device
585
+ // This fixes the self-message bug where sending to yourself would duplicate devices
586
+ const deviceMap = new Map<string, DeviceRecord>()
587
+ for (const d of [...recipientDevices, ...ownDevices]) {
588
+ if (d.deviceId !== this.deviceId) { // Exclude sender's own device
589
+ deviceMap.set(d.deviceId, d)
590
+ }
591
+ }
592
+ const devices = Array.from(deviceMap.values())
533
593
 
534
594
  // Send to all devices in background (if sessions exist)
535
595
  Promise.allSettled(
536
596
  devices.map(async (device) => {
537
597
  const { activeSession } = device
538
- if (!activeSession) return
598
+ if (!activeSession) {
599
+ return
600
+ }
539
601
  const { event: verifiedEvent } = activeSession.sendEvent(event)
540
602
  await this.nostrPublish(verifiedEvent).catch(console.error)
541
603
  })
542
604
  )
543
605
  .then(() => {
606
+ // Store recipient's user record
544
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
+ }
545
614
  })
546
615
  .catch(console.error)
547
616
 
@@ -581,33 +650,6 @@ export class SessionManager {
581
650
  return rumor
582
651
  }
583
652
 
584
- async revokeDevice(deviceId: string): Promise<void> {
585
- await this.init()
586
-
587
- await this.publishDeviceTombstone(deviceId).catch((error) => {
588
- console.error("Failed to publish device tombstone:", error)
589
- })
590
-
591
- await this.cleanupDevice(this.ourPublicKey, deviceId)
592
- }
593
-
594
- private async publishDeviceTombstone(deviceId: string): Promise<void> {
595
- const tags: string[][] = [
596
- ["l", "double-ratchet/invites"],
597
- ["d", `double-ratchet/invites/${deviceId}`],
598
- ]
599
-
600
- const deletionEvent = {
601
- content: "",
602
- kind: INVITE_EVENT_KIND,
603
- created_at: Math.floor(Date.now() / 1000),
604
- tags,
605
- pubkey: this.ourPublicKey,
606
- }
607
-
608
- await this.nostrPublish(deletionEvent)
609
- }
610
-
611
653
  private async cleanupDevice(publicKey: string, deviceId: string): Promise<void> {
612
654
  const userRecord = this.userRecords.get(publicKey)
613
655
  if (!userRecord) return
@@ -657,6 +699,7 @@ export class SessionManager {
657
699
  ),
658
700
  createdAt: device.createdAt,
659
701
  staleAt: device.staleAt,
702
+ hasResponderSession: device.hasResponderSession,
660
703
  })
661
704
  ),
662
705
  }
@@ -678,6 +721,7 @@ export class SessionManager {
678
721
  inactiveSessions: serializedInactive,
679
722
  createdAt,
680
723
  staleAt,
724
+ hasResponderSession,
681
725
  } = deviceData
682
726
 
683
727
  try {
@@ -698,6 +742,7 @@ export class SessionManager {
698
742
  inactiveSessions,
699
743
  createdAt,
700
744
  staleAt,
745
+ hasResponderSession,
701
746
  })
702
747
  } catch (e) {
703
748
  console.error(
@@ -712,10 +757,6 @@ export class SessionManager {
712
757
  devices,
713
758
  })
714
759
 
715
- if (publicKey !== this.ourPublicKey) {
716
- this.attachInviteTombstoneSubscription(publicKey)
717
- }
718
-
719
760
  for (const device of devices.values()) {
720
761
  const { deviceId, activeSession, inactiveSessions, staleAt } = device
721
762
  if (!deviceId || staleAt !== undefined) continue
@@ -751,34 +792,12 @@ export class SessionManager {
751
792
 
752
793
  // First migration
753
794
  if (!version) {
754
- // Fetch all existing invites
755
- // Assume no version prefix
756
- // Deserialize and serialize to start using persistent createdAt
757
- // Re-save invites with proper keys
795
+ // Delete old invite data (legacy format no longer supported)
758
796
  const oldInvitePrefix = "invite/"
759
797
  const inviteKeys = await this.storage.list(oldInvitePrefix)
760
- await Promise.all(
761
- inviteKeys.map(async (key) => {
762
- try {
763
- const publicKey = key.slice(oldInvitePrefix.length)
764
- const inviteData = await this.storage.get<string>(key)
765
- if (inviteData) {
766
- const newKey = this.userInviteKey(publicKey)
767
- const invite = Invite.deserialize(inviteData)
768
- const serializedInvite = invite.serialize()
769
- await this.storage.put(newKey, serializedInvite)
770
- await this.storage.del(key)
771
- }
772
- } catch (e) {
773
- console.error("Migration error for invite:", e)
774
- }
775
- })
776
- )
798
+ await Promise.all(inviteKeys.map((key) => this.storage.del(key)))
777
799
 
778
- // Fetch all existing user records
779
- // Assume no version prefix
780
- // Remove all old sessions as these may have key issues
781
- // Re-save user records without sessions with proper keys
800
+ // Migrate old user records (clear sessions, keep device records)
782
801
  const oldUserRecordPrefix = "user/"
783
802
  const sessionKeys = await this.storage.list(oldUserRecordPrefix)
784
803
  await Promise.all(
@@ -806,16 +825,8 @@ export class SessionManager {
806
825
  })
807
826
  )
808
827
 
809
- // Set version to 1 so next migration can run
810
828
  version = "1"
811
829
  await this.storage.put(this.versionKey(), version)
812
-
813
- return
814
- }
815
-
816
- // Future migrations
817
- if (version === "1") {
818
- return
819
830
  }
820
831
  }
821
832
  }