nostr-double-ratchet 0.0.36 → 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,399 +1,832 @@
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"
1
+ import {
2
+ IdentityKey,
3
+ NostrSubscribe,
4
+ NostrPublish,
5
+ Rumor,
6
+ Unsubscribe,
7
+ INVITE_LIST_EVENT_KIND,
8
+ CHAT_MESSAGE_KIND,
9
+ } from "./types"
5
10
  import { StorageAdapter, InMemoryStorageAdapter } from "./StorageAdapter"
6
- import { serializeSessionState, deserializeSessionState } from "./utils"
11
+ import { InviteList } from "./InviteList"
7
12
  import { Session } from "./Session"
13
+ import { serializeSessionState, deserializeSessionState } from "./utils"
14
+ import { decryptInviteResponse, createSessionFromAccept } from "./inviteUtils"
15
+ import { getEventHash } from "nostr-tools"
8
16
 
9
17
  export type OnEventCallback = (event: Rumor, from: string) => void
10
18
 
11
- export default class SessionManager {
12
- private userRecords: Map<string, UserRecord> = new Map()
13
- private nostrSubscribe: NostrSubscribe
14
- private nostrPublish: NostrPublish
15
- private ourIdentityKey: Uint8Array
16
- private inviteUnsubscribes: Map<string, Unsubscribe> = new Map()
17
- private deviceId: string
18
- private invite?: Invite
19
- private storage: StorageAdapter
20
- private messageQueue: Map<string, Array<{event: Partial<Rumor>, resolve: (results: any[]) => void}>> = new Map()
21
-
22
- constructor(
23
- ourIdentityKey: Uint8Array,
24
- deviceId: string,
25
- nostrSubscribe: NostrSubscribe,
26
- nostrPublish: NostrPublish,
27
- storage: StorageAdapter = new InMemoryStorageAdapter(),
28
- ) {
29
- this.userRecords = new Map()
30
- this.nostrSubscribe = nostrSubscribe
31
- this.nostrPublish = nostrPublish
32
- this.ourIdentityKey = ourIdentityKey
33
- this.deviceId = deviceId
34
- this.storage = storage
35
-
36
- // Kick off initialisation in background for backwards compatibility
37
- // Users that need to wait can call await manager.init()
38
- this.init()
39
- }
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
+ }
40
26
 
41
- private _initialised = false
27
+ interface DeviceRecord {
28
+ deviceId: string
29
+ activeSession?: Session
30
+ inactiveSessions: Session[]
31
+ 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
+ }
42
37
 
43
- /**
44
- * Perform asynchronous initialisation steps: create (or load) our invite,
45
- * publish it, hydrate sessions from storage and subscribe to new invites.
46
- * Can be awaited by callers that need deterministic readiness.
47
- */
48
- public async init(): Promise<void> {
49
- console.log("Initialising SessionManager")
50
- if (this._initialised) return
38
+ interface UserRecord {
39
+ publicKey: string
40
+ devices: Map<string, DeviceRecord>
41
+ }
51
42
 
52
- const ourPublicKey = getPublicKey(this.ourIdentityKey)
43
+ type StoredSessionEntry = ReturnType<typeof serializeSessionState>
53
44
 
54
- // 1. Hydrate existing sessions (placeholder for future implementation)
55
- await this.loadSessions()
45
+ interface StoredDeviceRecord {
46
+ deviceId: string
47
+ activeSession: StoredSessionEntry | null
48
+ inactiveSessions: StoredSessionEntry[]
49
+ createdAt: number
50
+ staleAt?: number
51
+ hasResponderSession?: boolean
52
+ }
56
53
 
57
- // 2. Create or load our own invite
58
- let invite: Invite | undefined
59
- try {
60
- const stored = await this.storage.get<string>(`invite/${this.deviceId}`)
61
- if (stored) {
62
- invite = Invite.deserialize(stored)
63
- }
64
- } catch {/* ignore malformed */}
54
+ interface StoredUserRecord {
55
+ publicKey: string
56
+ devices: StoredDeviceRecord[]
57
+ }
65
58
 
66
- if (!invite) {
67
- invite = Invite.createNew(ourPublicKey, this.deviceId)
68
- await this.storage.put(`invite/${this.deviceId}`, invite.serialize()).catch(() => {})
59
+ export class SessionManager {
60
+ // Versioning
61
+ private readonly storageVersion = "1"
62
+ private readonly versionPrefix: string
63
+
64
+ // Params
65
+ private deviceId: string
66
+ private storage: StorageAdapter
67
+ private nostrSubscribe: NostrSubscribe
68
+ private nostrPublish: NostrPublish
69
+ private identityKey: IdentityKey
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
76
+
77
+ // Data
78
+ private userRecords: Map<string, UserRecord> = new Map()
79
+ private messageHistory: Map<string, Rumor[]> = new Map()
80
+ // Map delegate device pubkeys to their owner's pubkey
81
+ private delegateToOwner: Map<string, string> = new Map()
82
+
83
+ // Subscriptions
84
+ private ourInviteResponseSubscription: Unsubscribe | null = null
85
+ private inviteSubscriptions: Map<string, Unsubscribe> = new Map()
86
+ private sessionSubscriptions: Map<string, Unsubscribe> = new Map()
87
+
88
+ // Callbacks
89
+ private internalSubscriptions: Set<OnEventCallback> = new Set()
90
+
91
+ // Initialization flag
92
+ private initialized: boolean = false
93
+
94
+ constructor(
95
+ ourPublicKey: string,
96
+ identityKey: IdentityKey,
97
+ deviceId: string,
98
+ nostrSubscribe: NostrSubscribe,
99
+ nostrPublish: NostrPublish,
100
+ ownerPublicKey: string,
101
+ inviteKeys: InviteCredentials,
102
+ storage?: StorageAdapter,
103
+ ) {
104
+ this.userRecords = new Map()
105
+ this.nostrSubscribe = nostrSubscribe
106
+ this.nostrPublish = nostrPublish
107
+ this.ourPublicKey = ourPublicKey
108
+ this.identityKey = identityKey
109
+ this.deviceId = deviceId
110
+ this.ownerPublicKey = ownerPublicKey
111
+ this.inviteKeys = inviteKeys
112
+ this.storage = storage || new InMemoryStorageAdapter()
113
+ this.versionPrefix = `v${this.storageVersion}`
114
+ }
115
+
116
+ async init() {
117
+ if (this.initialized) return
118
+ this.initialized = true
119
+
120
+ await this.runMigrations().catch((error) => {
121
+ console.error("Failed to run migrations:", error)
122
+ })
123
+
124
+ await this.loadAllUserRecords().catch((error) => {
125
+ console.error("Failed to load user records:", error)
126
+ })
127
+
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) => {
156
+ try {
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
+ }
171
+
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")
176
+
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
+ }
182
+
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
+ }
200
+
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
69
219
  }
70
- this.invite = invite
71
-
72
- // Publish our own invite
73
- console.log("Publishing our own invite", invite)
74
- const event = invite.getEvent()
75
- this.nostrPublish(event).then((verifiedEvent) => {
76
- console.log("Invite published", verifiedEvent)
77
- }).catch((e) => console.error("Failed to publish our own invite", e))
78
-
79
- // 2b. Listen for acceptances of *our* invite and create sessions
80
- this.invite.listen(
81
- this.ourIdentityKey,
82
- this.nostrSubscribe,
83
- (session, inviteePubkey) => {
84
- if (!inviteePubkey) return
85
-
86
- const targetUserKey = inviteePubkey
87
-
88
- try {
89
- let userRecord = this.userRecords.get(targetUserKey)
90
- if (!userRecord) {
91
- userRecord = new UserRecord(targetUserKey, this.nostrSubscribe)
92
- this.userRecords.set(targetUserKey, userRecord)
93
- }
94
-
95
- const deviceKey = session.name || 'unknown'
96
- userRecord.upsertSession(deviceKey, session)
97
- this.saveSession(targetUserKey, deviceKey, session)
98
-
99
- session.onEvent((_event: Rumor) => {
100
- this.internalSubscriptions.forEach(cb => cb(_event, targetUserKey))
101
- })
102
- } catch {/* ignore errors */}
103
- }
104
- )
105
-
106
- // 3. Subscribe to our own invites from other devices
107
- Invite.fromUser(ourPublicKey, this.nostrSubscribe, async (invite) => {
108
- try {
109
- const inviteDeviceId = invite['deviceId'] || 'unknown'
110
- if (!inviteDeviceId || inviteDeviceId === this.deviceId) {
111
- return
112
- }
113
-
114
- const existingRecord = this.userRecords.get(ourPublicKey)
115
- if (existingRecord?.getActiveSessions().some(session => session.name === inviteDeviceId)) {
116
- return
117
- }
118
-
119
- const { session, event } = await invite.accept(
120
- this.nostrSubscribe,
121
- ourPublicKey,
122
- this.ourIdentityKey
123
- )
124
- this.nostrPublish(event)?.catch(() => {})
125
-
126
- this.saveSession(ourPublicKey, inviteDeviceId, session)
127
-
128
- let userRecord = this.userRecords.get(ourPublicKey)
129
- if (!userRecord) {
130
- userRecord = new UserRecord(ourPublicKey, this.nostrSubscribe)
131
- this.userRecords.set(ourPublicKey, userRecord)
132
- }
133
- const deviceId = invite['deviceId'] || event.id || 'unknown'
134
- userRecord.upsertSession(deviceId, session)
135
- this.saveSession(ourPublicKey, deviceId, session)
136
-
137
- session.onEvent((_event: Rumor) => {
138
- this.internalSubscriptions.forEach(cb => cb(_event, ourPublicKey))
139
- })
140
- } catch (err) {
141
- // eslint-disable-next-line no-console
142
- console.error('Own-invite accept failed', err)
143
- }
144
- })
145
-
146
-
220
+ }
221
+ )
222
+ }
223
+
224
+ // -------------------
225
+ // User and Device Records helpers
226
+ // -------------------
227
+ private getOrCreateUserRecord(userPubkey: string): UserRecord {
228
+ let rec = this.userRecords.get(userPubkey)
229
+ if (!rec) {
230
+ rec = { publicKey: userPubkey, devices: new Map() }
231
+ this.userRecords.set(userPubkey, rec)
232
+ }
233
+ return rec
234
+ }
147
235
 
148
- this._initialised = true
149
- await this.nostrPublish(this.invite.getEvent()).catch(() => {})
236
+ private upsertDeviceRecord(userRecord: UserRecord, deviceId: string): DeviceRecord {
237
+ if (!deviceId) {
238
+ throw new Error("Device record must include a deviceId")
239
+ }
240
+ const existing = userRecord.devices.get(deviceId)
241
+ if (existing) {
242
+ return existing
150
243
  }
151
244
 
152
- private async loadSessions() {
153
- const base = 'session/'
154
- const keys = await this.storage.list(base)
155
- for (const key of keys) {
156
- const rest = key.substring(base.length)
157
- const idx = rest.indexOf('/')
158
- if (idx === -1) continue
159
- const ownerPubKey = rest.substring(0, idx)
160
- const deviceId = rest.substring(idx + 1) || 'unknown'
161
-
162
- const data = await this.storage.get<string>(key)
163
- if (!data) continue
164
- try {
165
- const state = deserializeSessionState(data)
166
- const session = new Session(this.nostrSubscribe, state)
167
-
168
- let userRecord = this.userRecords.get(ownerPubKey)
169
- if (!userRecord) {
170
- userRecord = new UserRecord(ownerPubKey, this.nostrSubscribe)
171
- this.userRecords.set(ownerPubKey, userRecord)
172
- }
173
- userRecord.upsertSession(deviceId, session)
174
- this.saveSession(ownerPubKey, deviceId, session)
175
-
176
- session.onEvent((_event: Rumor) => {
177
- this.internalSubscriptions.forEach(cb => cb(_event, ownerPubKey))
178
- })
179
- } catch {
180
- // corrupted entry — ignore
181
- }
245
+ const deviceRecord: DeviceRecord = {
246
+ deviceId,
247
+ inactiveSessions: [],
248
+ createdAt: Date.now(),
249
+ }
250
+ userRecord.devices.set(deviceId, deviceRecord)
251
+ return deviceRecord
252
+ }
253
+
254
+ private sessionKey(userPubkey: string, deviceId: string, sessionName: string) {
255
+ return `${this.sessionKeyPrefix(userPubkey)}${deviceId}/${sessionName}`
256
+ }
257
+
258
+ private sessionKeyPrefix(userPubkey: string) {
259
+ return `${this.versionPrefix}/session/${userPubkey}/`
260
+ }
261
+
262
+ private userRecordKey(publicKey: string) {
263
+ return `${this.userRecordKeyPrefix()}${publicKey}`
264
+ }
265
+
266
+ private userRecordKeyPrefix() {
267
+ return `${this.versionPrefix}/user/`
268
+ }
269
+ private versionKey() {
270
+ return `storage-version`
271
+ }
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
182
311
  }
312
+ }
313
+ )
314
+ }
315
+
316
+ private attachSessionSubscription(
317
+ userPubkey: string,
318
+ deviceRecord: DeviceRecord,
319
+ session: Session,
320
+ // Set to true if only handshake -> not yet sendable -> will be promoted on message
321
+ inactive: boolean = false
322
+ ): void {
323
+ if (deviceRecord.staleAt !== undefined) {
324
+ return
183
325
  }
184
326
 
185
- private async saveSession(ownerPubKey: string, deviceId: string, session: Session) {
186
- try {
187
- const key = `session/${ownerPubKey}/${deviceId}`
188
- await this.storage.put(key, serializeSessionState(session.state))
189
- } catch {/* ignore */}
327
+ const key = this.sessionKey(userPubkey, deviceRecord.deviceId, session.name)
328
+ if (this.sessionSubscriptions.has(key)) {
329
+ return
190
330
  }
191
331
 
192
- getDeviceId(): string {
193
- return this.deviceId
332
+ const dr = deviceRecord
333
+ const rotateSession = (nextSession: Session) => {
334
+ const current = dr.activeSession
335
+
336
+ if (!current) {
337
+ dr.activeSession = nextSession
338
+ return
339
+ }
340
+
341
+ if (current === nextSession || current.name === nextSession.name) {
342
+ dr.activeSession = nextSession
343
+ return
344
+ }
345
+
346
+ dr.inactiveSessions = dr.inactiveSessions.filter(
347
+ (session) => session !== current && session.name !== current.name
348
+ )
349
+
350
+ dr.inactiveSessions.push(current)
351
+ dr.inactiveSessions = dr.inactiveSessions.slice(-1)
352
+ dr.activeSession = nextSession
194
353
  }
195
354
 
196
- getInvite(): Invite {
197
- if (!this.invite) {
198
- throw new Error("SessionManager not initialised yet")
199
- }
200
- return this.invite
355
+ if (inactive) {
356
+ const alreadyTracked = dr.inactiveSessions.some(
357
+ (tracked) => tracked === session || tracked.name === session.name
358
+ )
359
+ if (!alreadyTracked) {
360
+ dr.inactiveSessions.push(session)
361
+ dr.inactiveSessions = dr.inactiveSessions.slice(-1)
362
+ }
363
+ } else {
364
+ rotateSession(session)
201
365
  }
202
366
 
203
- async sendText(recipientIdentityKey: string, text: string) {
204
- const event = {
205
- kind: CHAT_MESSAGE_KIND,
206
- content: text,
207
- }
208
- return await this.sendEvent(recipientIdentityKey, event)
367
+ const unsub = session.onEvent((event) => {
368
+ for (const cb of this.internalSubscriptions) cb(event, userPubkey)
369
+ rotateSession(session)
370
+ this.storeUserRecord(userPubkey).catch(console.error)
371
+ })
372
+ this.storeUserRecord(userPubkey).catch(console.error)
373
+ this.sessionSubscriptions.set(key, unsub)
374
+ }
375
+
376
+ private attachInviteListSubscription(
377
+ userPubkey: string,
378
+ onInviteList?: (inviteList: InviteList) => void | Promise<void>
379
+ ): void {
380
+ const key = `invitelist:${userPubkey}`
381
+ if (this.inviteSubscriptions.has(key)) return
382
+
383
+ const unsubscribe = this.subscribeToUserInviteList(
384
+ userPubkey,
385
+ async (inviteList) => {
386
+ if (onInviteList) await onInviteList(inviteList)
387
+ }
388
+ )
389
+
390
+ this.inviteSubscriptions.set(key, unsubscribe)
391
+ }
392
+
393
+ setupUser(userPubkey: string) {
394
+ const userRecord = this.getOrCreateUserRecord(userPubkey)
395
+
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,
407
+ this.nostrSubscribe,
408
+ this.ourPublicKey,
409
+ encryptor,
410
+ this.deviceId
411
+ )
412
+ return this.nostrPublish(event)
413
+ .then(() => this.attachSessionSubscription(userPubkey, deviceRecord, session))
414
+ .then(() => this.sendMessageHistory(userPubkey, deviceId))
415
+ .catch(console.error)
209
416
  }
210
417
 
211
- async sendEvent(recipientIdentityKey: string, event: Partial<Rumor>) {
212
- console.log("Sending event to", recipientIdentityKey, event)
213
- // Immediately notify local subscribers so that UI can render sent message optimistically
214
- this.internalSubscriptions.forEach(cb => cb(event as Rumor, recipientIdentityKey))
215
-
216
- const results = []
217
- const publishPromises: Promise<any>[] = []
218
-
219
- // Send to recipient's devices
220
- const userRecord = this.userRecords.get(recipientIdentityKey)
221
- if (!userRecord) {
222
- return new Promise<any[]>((resolve) => {
223
- if (!this.messageQueue.has(recipientIdentityKey)) {
224
- this.messageQueue.set(recipientIdentityKey, [])
225
- }
226
- this.messageQueue.get(recipientIdentityKey)!.push({event, resolve})
227
- this.listenToUser(recipientIdentityKey)
228
- })
229
- }
418
+ this.attachInviteListSubscription(userPubkey, async (inviteList) => {
419
+ const devices = inviteList.getAllDevices()
230
420
 
231
- const activeSessions = userRecord.getActiveSessions()
232
- const sendableSessions = activeSessions.filter(s => !!(s.state?.theirNextNostrPublicKey && s.state?.ourCurrentNostrKey))
233
-
234
- if (sendableSessions.length === 0) {
235
- return new Promise<any[]>((resolve) => {
236
- if (!this.messageQueue.has(recipientIdentityKey)) {
237
- this.messageQueue.set(recipientIdentityKey, [])
238
- }
239
- this.messageQueue.get(recipientIdentityKey)!.push({event, resolve})
240
- this.listenToUser(recipientIdentityKey)
241
- })
242
- }
421
+ // Handle removed devices (source of truth for revocation)
422
+ for (const deviceId of inviteList.getRemovedDeviceIds()) {
423
+ await this.cleanupDevice(userPubkey, deviceId)
424
+ }
243
425
 
244
- // Send to all sendable sessions with recipient
245
- for (const session of sendableSessions) {
246
- const { event: encryptedEvent } = session.sendEvent(event)
247
- results.push(encryptedEvent)
248
- publishPromises.push(this.nostrPublish(encryptedEvent).catch(() => {}))
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)
249
430
  }
431
+ }
432
+ })
433
+ }
250
434
 
251
- // Send to our own devices (for multi-device sync)
252
- const ourPublicKey = getPublicKey(this.ourIdentityKey)
253
- const ownUserRecord = this.userRecords.get(ourPublicKey)
254
- if (ownUserRecord) {
255
- const ownSendableSessions = ownUserRecord.getActiveSessions().filter(s => !!(s.state?.theirNextNostrPublicKey && s.state?.ourCurrentNostrKey))
256
- for (const session of ownSendableSessions) {
257
- const { event: encryptedEvent } = session.sendEvent(event)
258
- results.push(encryptedEvent)
259
- publishPromises.push(this.nostrPublish(encryptedEvent).catch(() => {}))
260
- }
435
+ onEvent(callback: OnEventCallback) {
436
+ this.internalSubscriptions.add(callback)
437
+
438
+ return () => {
439
+ this.internalSubscriptions.delete(callback)
440
+ }
441
+ }
442
+
443
+ getDeviceId(): string {
444
+ return this.deviceId
445
+ }
446
+
447
+ getUserRecords(): Map<string, UserRecord> {
448
+ return this.userRecords
449
+ }
450
+
451
+ close() {
452
+ for (const unsubscribe of this.inviteSubscriptions.values()) {
453
+ unsubscribe()
454
+ }
455
+
456
+ for (const unsubscribe of this.sessionSubscriptions.values()) {
457
+ unsubscribe()
458
+ }
459
+
460
+ this.ourInviteResponseSubscription?.()
461
+ }
462
+
463
+ deactivateCurrentSessions(publicKey: string) {
464
+ const userRecord = this.userRecords.get(publicKey)
465
+ if (!userRecord) return
466
+ for (const device of userRecord.devices.values()) {
467
+ if (device.activeSession) {
468
+ device.inactiveSessions.push(device.activeSession)
469
+ device.activeSession = undefined
470
+ }
471
+ }
472
+ this.storeUserRecord(publicKey).catch(console.error)
473
+ }
474
+
475
+ async deleteUser(userPubkey: string): Promise<void> {
476
+ await this.init()
477
+
478
+ const userRecord = this.userRecords.get(userPubkey)
479
+
480
+ if (userRecord) {
481
+ for (const device of userRecord.devices.values()) {
482
+ if (device.activeSession) {
483
+ this.removeSessionSubscription(
484
+ userPubkey,
485
+ device.deviceId,
486
+ device.activeSession.name
487
+ )
261
488
  }
262
489
 
263
- // Ensure all publish operations settled before returning
264
- if (publishPromises.length > 0) {
265
- await Promise.all(publishPromises)
490
+ for (const session of device.inactiveSessions) {
491
+ this.removeSessionSubscription(userPubkey, device.deviceId, session.name)
266
492
  }
493
+ }
267
494
 
268
- return results
495
+ this.userRecords.delete(userPubkey)
269
496
  }
270
497
 
271
- listenToUser(userPubkey: string) {
272
- // Don't subscribe multiple times to the same user
273
- if (this.inviteUnsubscribes.has(userPubkey)) return
274
-
275
- const unsubscribe = Invite.fromUser(userPubkey, this.nostrSubscribe, async (_invite) => {
276
- try {
277
- const deviceId = (_invite instanceof Invite && _invite.deviceId) ? _invite.deviceId : 'unknown'
278
-
279
- const userRecord = this.userRecords.get(userPubkey)
280
- if (userRecord) {
281
- const existingSessions = userRecord.getActiveSessions()
282
- if (existingSessions.some(session => session.name === deviceId)) {
283
- return // Already have session with this device
284
- }
285
- }
286
-
287
- const { session, event } = await _invite.accept(
288
- this.nostrSubscribe,
289
- getPublicKey(this.ourIdentityKey),
290
- this.ourIdentityKey
291
- )
292
- this.nostrPublish(event)?.catch(() => {})
293
-
294
- // Store the new session
295
- let currentUserRecord = this.userRecords.get(userPubkey)
296
- if (!currentUserRecord) {
297
- currentUserRecord = new UserRecord(userPubkey, this.nostrSubscribe)
298
- this.userRecords.set(userPubkey, currentUserRecord)
299
- }
300
- currentUserRecord.upsertSession(deviceId, session)
301
- this.saveSession(userPubkey, deviceId, session)
302
-
303
- // Register all existing callbacks on the new session
304
- session.onEvent((_event: Rumor) => {
305
- this.internalSubscriptions.forEach(callback => callback(_event, userPubkey))
306
- })
307
-
308
- const queuedMessages = this.messageQueue.get(userPubkey)
309
- if (queuedMessages && queuedMessages.length > 0) {
310
- setTimeout(async () => {
311
- const currentQueuedMessages = this.messageQueue.get(userPubkey)
312
- if (currentQueuedMessages && currentQueuedMessages.length > 0) {
313
- const messagesToProcess = [...currentQueuedMessages]
314
- this.messageQueue.delete(userPubkey)
315
-
316
- for (const {event: queuedEvent, resolve} of messagesToProcess) {
317
- const results = await this.sendEvent(userPubkey, queuedEvent)
318
- resolve(results)
319
- }
320
- }
321
- }, 1000) // Increased delay for CI compatibility
322
- }
323
-
324
- // Return the event to be published
325
- return event
326
- } catch {
327
- }
328
- })
498
+ const inviteListKey = `invitelist:${userPubkey}`
499
+ const inviteListUnsub = this.inviteSubscriptions.get(inviteListKey)
500
+ if (inviteListUnsub) {
501
+ inviteListUnsub()
502
+ this.inviteSubscriptions.delete(inviteListKey)
503
+ }
329
504
 
330
- this.inviteUnsubscribes.set(userPubkey, unsubscribe)
505
+ this.messageHistory.delete(userPubkey)
506
+
507
+ await Promise.allSettled([
508
+ this.deleteUserSessionsFromStorage(userPubkey),
509
+ this.storage.del(this.userRecordKey(userPubkey)),
510
+ ])
511
+ }
512
+
513
+ private removeSessionSubscription(
514
+ userPubkey: string,
515
+ deviceId: string,
516
+ sessionName: string
517
+ ) {
518
+ const key = this.sessionKey(userPubkey, deviceId, sessionName)
519
+ const unsubscribe = this.sessionSubscriptions.get(key)
520
+ if (unsubscribe) {
521
+ unsubscribe()
522
+ this.sessionSubscriptions.delete(key)
523
+ }
524
+ }
525
+
526
+ private async deleteUserSessionsFromStorage(userPubkey: string): Promise<void> {
527
+ const prefix = this.sessionKeyPrefix(userPubkey)
528
+ const keys = await this.storage.list(prefix)
529
+ await Promise.all(keys.map((key) => this.storage.del(key)))
530
+ }
531
+
532
+ private async sendMessageHistory(
533
+ recipientPublicKey: string,
534
+ deviceId: string
535
+ ): Promise<void> {
536
+ const history = this.messageHistory.get(recipientPublicKey) || []
537
+ const userRecord = this.userRecords.get(recipientPublicKey)
538
+ if (!userRecord) {
539
+ return
540
+ }
541
+ const device = userRecord.devices.get(deviceId)
542
+ if (!device) {
543
+ return
331
544
  }
545
+ if (device.staleAt !== undefined) {
546
+ return
547
+ }
548
+ for (const event of history) {
549
+ const { activeSession } = device
332
550
 
333
- stopListeningToUser(userPubkey: string) {
334
- const unsubscribe = this.inviteUnsubscribes.get(userPubkey)
335
- if (unsubscribe) {
336
- unsubscribe()
337
- this.inviteUnsubscribes.delete(userPubkey)
338
- }
551
+ if (!activeSession) continue
552
+ const { event: verifiedEvent } = activeSession.sendEvent(event)
553
+ await this.nostrPublish(verifiedEvent)
554
+ await this.storeUserRecord(recipientPublicKey)
555
+ }
556
+ }
557
+
558
+ async sendEvent(
559
+ recipientIdentityKey: string,
560
+ event: Partial<Rumor>
561
+ ): Promise<Rumor | undefined> {
562
+ await this.init()
563
+
564
+ // Add to message history queue (will be sent when session is established)
565
+ const completeEvent = event as Rumor
566
+ // Use ownerPublicKey for history targets so delegates share history with owner
567
+ const historyTargets = new Set([recipientIdentityKey, this.ownerPublicKey])
568
+ for (const key of historyTargets) {
569
+ const existing = this.messageHistory.get(key) || []
570
+ this.messageHistory.set(key, [...existing, completeEvent])
339
571
  }
340
572
 
341
- // Update onEvent to include internalSubscriptions management
342
- private internalSubscriptions: Set<OnEventCallback> = new Set()
573
+ const userRecord = this.getOrCreateUserRecord(recipientIdentityKey)
574
+ // Use ownerPublicKey to find sibling devices (important for delegates)
575
+ const ourUserRecord = this.getOrCreateUserRecord(this.ownerPublicKey)
343
576
 
344
- onEvent(callback: OnEventCallback) {
345
- this.internalSubscriptions.add(callback)
577
+ this.setupUser(recipientIdentityKey)
578
+ // Use ownerPublicKey to setup sessions with sibling devices
579
+ this.setupUser(this.ownerPublicKey)
346
580
 
347
- // Subscribe to existing sessions
348
- for (const [pubkey, userRecord] of this.userRecords.entries()) {
349
- for (const session of userRecord.getActiveSessions()) {
350
- session.onEvent((event: Rumor) => {
351
- callback(event, pubkey)
352
- })
353
- }
354
- }
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)
355
583
 
356
- // Return unsubscribe function
357
- return () => {
358
- this.internalSubscriptions.delete(callback)
359
- }
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
+ }
360
591
  }
361
-
362
- close() {
363
- // Clean up all subscriptions
364
- for (const unsubscribe of this.inviteUnsubscribes.values()) {
365
- unsubscribe()
592
+ const devices = Array.from(deviceMap.values())
593
+
594
+ // Send to all devices in background (if sessions exist)
595
+ Promise.allSettled(
596
+ devices.map(async (device) => {
597
+ const { activeSession } = device
598
+ if (!activeSession) {
599
+ return
366
600
  }
367
- this.inviteUnsubscribes.clear()
368
-
369
- // Close all sessions
370
- for (const userRecord of this.userRecords.values()) {
371
- for (const session of userRecord.getActiveSessions()) {
372
- session.close()
373
- }
601
+ const { event: verifiedEvent } = activeSession.sendEvent(event)
602
+ await this.nostrPublish(verifiedEvent).catch(console.error)
603
+ })
604
+ )
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)
374
613
  }
375
- this.userRecords.clear()
376
- this.internalSubscriptions.clear()
614
+ })
615
+ .catch(console.error)
616
+
617
+ // Return the event with computed ID (same as library would compute)
618
+ return completeEvent
619
+ }
620
+
621
+ async sendMessage(
622
+ recipientPublicKey: string,
623
+ content: string,
624
+ options: { kind?: number; tags?: string[][] } = {}
625
+ ): Promise<Rumor> {
626
+ const { kind = CHAT_MESSAGE_KIND, tags = [] } = options
627
+
628
+ // Build message exactly as library does (Session.ts sendEvent)
629
+ const now = Date.now()
630
+ const builtTags = this.buildMessageTags(recipientPublicKey, tags)
631
+
632
+ const rumor: Rumor = {
633
+ content,
634
+ kind,
635
+ created_at: Math.floor(now / 1000),
636
+ tags: builtTags,
637
+ pubkey: this.ourPublicKey,
638
+ id: "", // Will compute next
639
+ }
640
+
641
+ if (!rumor.tags.some(([k]) => k === "ms")) {
642
+ rumor.tags.push(["ms", String(now)])
377
643
  }
378
644
 
379
- /**
380
- * Accept an invite as our own device, persist the session, and publish the acceptance event.
381
- * Used for multi-device flows where a user adds a new device.
382
- */
383
- public async acceptOwnInvite(invite: Invite) {
384
- const ourPublicKey = getPublicKey(this.ourIdentityKey);
385
- const { session, event } = await invite.accept(
386
- this.nostrSubscribe,
387
- ourPublicKey,
388
- this.ourIdentityKey
389
- );
390
- let userRecord = this.userRecords.get(ourPublicKey);
391
- if (!userRecord) {
392
- userRecord = new UserRecord(ourPublicKey, this.nostrSubscribe);
393
- this.userRecords.set(ourPublicKey, userRecord);
645
+ rumor.id = getEventHash(rumor)
646
+
647
+ // Use sendEvent for actual sending (includes queueing)
648
+ this.sendEvent(recipientPublicKey, rumor).catch(console.error)
649
+
650
+ return rumor
651
+ }
652
+
653
+ private async cleanupDevice(publicKey: string, deviceId: string): Promise<void> {
654
+ const userRecord = this.userRecords.get(publicKey)
655
+ if (!userRecord) return
656
+ const deviceRecord = userRecord.devices.get(deviceId)
657
+
658
+ if (!deviceRecord) return
659
+
660
+ if (deviceRecord.activeSession) {
661
+ this.removeSessionSubscription(publicKey, deviceId, deviceRecord.activeSession.name)
662
+ }
663
+
664
+ for (const session of deviceRecord.inactiveSessions) {
665
+ this.removeSessionSubscription(publicKey, deviceId, session.name)
666
+ }
667
+
668
+ deviceRecord.activeSession = undefined
669
+ deviceRecord.inactiveSessions = []
670
+ deviceRecord.staleAt = Date.now()
671
+
672
+ await this.storeUserRecord(publicKey).catch(console.error)
673
+ }
674
+
675
+ private buildMessageTags(
676
+ recipientPublicKey: string,
677
+ extraTags: string[][]
678
+ ): string[][] {
679
+ const hasRecipientPTag = extraTags.some(
680
+ (tag) => tag[0] === "p" && tag[1] === recipientPublicKey
681
+ )
682
+ const tags = hasRecipientPTag
683
+ ? [...extraTags]
684
+ : [["p", recipientPublicKey], ...extraTags]
685
+ return tags
686
+ }
687
+
688
+ private storeUserRecord(publicKey: string) {
689
+ const data: StoredUserRecord = {
690
+ publicKey: publicKey,
691
+ devices: Array.from(this.userRecords.get(publicKey)?.devices.entries() || []).map(
692
+ ([, device]) => ({
693
+ deviceId: device.deviceId,
694
+ activeSession: device.activeSession
695
+ ? serializeSessionState(device.activeSession.state)
696
+ : null,
697
+ inactiveSessions: device.inactiveSessions.map((session) =>
698
+ serializeSessionState(session.state)
699
+ ),
700
+ createdAt: device.createdAt,
701
+ staleAt: device.staleAt,
702
+ hasResponderSession: device.hasResponderSession,
703
+ })
704
+ ),
705
+ }
706
+ return this.storage.put(this.userRecordKey(publicKey), data)
707
+ }
708
+
709
+ private loadUserRecord(publicKey: string) {
710
+ return this.storage
711
+ .get<StoredUserRecord>(this.userRecordKey(publicKey))
712
+ .then((data) => {
713
+ if (!data) return
714
+
715
+ const devices = new Map<string, DeviceRecord>()
716
+
717
+ for (const deviceData of data.devices) {
718
+ const {
719
+ deviceId,
720
+ activeSession: serializedActive,
721
+ inactiveSessions: serializedInactive,
722
+ createdAt,
723
+ staleAt,
724
+ hasResponderSession,
725
+ } = deviceData
726
+
727
+ try {
728
+ const activeSession = serializedActive
729
+ ? new Session(
730
+ this.nostrSubscribe,
731
+ deserializeSessionState(serializedActive)
732
+ )
733
+ : undefined
734
+
735
+ const inactiveSessions = serializedInactive.map(
736
+ (entry) => new Session(this.nostrSubscribe, deserializeSessionState(entry))
737
+ )
738
+
739
+ devices.set(deviceId, {
740
+ deviceId,
741
+ activeSession,
742
+ inactiveSessions,
743
+ createdAt,
744
+ staleAt,
745
+ hasResponderSession,
746
+ })
747
+ } catch (e) {
748
+ console.error(
749
+ `Failed to deserialize session for user ${publicKey}, device ${deviceId}:`,
750
+ e
751
+ )
752
+ }
753
+ }
754
+
755
+ this.userRecords.set(publicKey, {
756
+ publicKey: data.publicKey,
757
+ devices,
758
+ })
759
+
760
+ for (const device of devices.values()) {
761
+ const { deviceId, activeSession, inactiveSessions, staleAt } = device
762
+ if (!deviceId || staleAt !== undefined) continue
763
+
764
+ for (const session of inactiveSessions.reverse()) {
765
+ this.attachSessionSubscription(publicKey, device, session)
766
+ }
767
+ if (activeSession) {
768
+ this.attachSessionSubscription(publicKey, device, activeSession)
769
+ }
394
770
  }
395
- userRecord.upsertSession(session.name || 'unknown', session);
396
- await this.saveSession(ourPublicKey, session.name || 'unknown', session);
397
- this.nostrPublish(event)?.catch(() => {});
771
+ })
772
+ .catch((error) => {
773
+ console.error(`Failed to load user record for ${publicKey}:`, error)
774
+ })
775
+ }
776
+
777
+ private loadAllUserRecords() {
778
+ const prefix = this.userRecordKeyPrefix()
779
+ return this.storage.list(prefix).then((keys) => {
780
+ return Promise.all(
781
+ keys.map((key) => {
782
+ const publicKey = key.slice(prefix.length)
783
+ return this.loadUserRecord(publicKey)
784
+ })
785
+ )
786
+ })
787
+ }
788
+
789
+ private async runMigrations() {
790
+ // Run migrations sequentially
791
+ let version = await this.storage.get<string>(this.versionKey())
792
+
793
+ // First migration
794
+ if (!version) {
795
+ // Delete old invite data (legacy format no longer supported)
796
+ const oldInvitePrefix = "invite/"
797
+ const inviteKeys = await this.storage.list(oldInvitePrefix)
798
+ await Promise.all(inviteKeys.map((key) => this.storage.del(key)))
799
+
800
+ // Migrate old user records (clear sessions, keep device records)
801
+ const oldUserRecordPrefix = "user/"
802
+ const sessionKeys = await this.storage.list(oldUserRecordPrefix)
803
+ await Promise.all(
804
+ sessionKeys.map(async (key) => {
805
+ try {
806
+ const publicKey = key.slice(oldUserRecordPrefix.length)
807
+ const userRecordData = await this.storage.get<StoredUserRecord>(key)
808
+ if (userRecordData) {
809
+ const newKey = this.userRecordKey(publicKey)
810
+ const newUserRecordData: StoredUserRecord = {
811
+ publicKey: userRecordData.publicKey,
812
+ devices: userRecordData.devices.map((device) => ({
813
+ deviceId: device.deviceId,
814
+ activeSession: null,
815
+ createdAt: device.createdAt,
816
+ inactiveSessions: [],
817
+ })),
818
+ }
819
+ await this.storage.put(newKey, newUserRecordData)
820
+ await this.storage.del(key)
821
+ }
822
+ } catch (e) {
823
+ console.error("Migration error for user record:", e)
824
+ }
825
+ })
826
+ )
827
+
828
+ version = "1"
829
+ await this.storage.put(this.versionKey(), version)
398
830
  }
831
+ }
399
832
  }