nostr-double-ratchet 0.0.36 → 0.0.37

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,821 @@
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
+ DecryptFunction,
3
+ NostrSubscribe,
4
+ NostrPublish,
5
+ Rumor,
6
+ Unsubscribe,
7
+ INVITE_EVENT_KIND,
8
+ CHAT_MESSAGE_KIND,
9
+ } from "./types"
5
10
  import { StorageAdapter, InMemoryStorageAdapter } from "./StorageAdapter"
6
- import { serializeSessionState, deserializeSessionState } from "./utils"
11
+ import { Invite } from "./Invite"
7
12
  import { Session } from "./Session"
13
+ import { serializeSessionState, deserializeSessionState } from "./utils"
14
+ import { getEventHash, VerifiedEvent } from "nostr-tools"
8
15
 
9
16
  export type OnEventCallback = (event: Rumor, from: string) => void
10
17
 
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
- }
18
+ interface DeviceRecord {
19
+ deviceId: string
20
+ activeSession?: Session
21
+ inactiveSessions: Session[]
22
+ createdAt: number
23
+ staleAt?: number
24
+ }
40
25
 
41
- private _initialised = false
26
+ interface UserRecord {
27
+ publicKey: string
28
+ devices: Map<string, DeviceRecord>
29
+ }
42
30
 
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
31
+ type StoredSessionEntry = ReturnType<typeof serializeSessionState>
51
32
 
52
- const ourPublicKey = getPublicKey(this.ourIdentityKey)
33
+ interface StoredDeviceRecord {
34
+ deviceId: string
35
+ activeSession: StoredSessionEntry | null
36
+ inactiveSessions: StoredSessionEntry[]
37
+ createdAt: number
38
+ staleAt?: number
39
+ }
53
40
 
54
- // 1. Hydrate existing sessions (placeholder for future implementation)
55
- await this.loadSessions()
41
+ interface StoredUserRecord {
42
+ publicKey: string
43
+ devices: StoredDeviceRecord[]
44
+ }
56
45
 
57
- // 2. Create or load our own invite
58
- let invite: Invite | undefined
46
+ export class SessionManager {
47
+ // Versioning
48
+ private readonly storageVersion = "1"
49
+ private readonly versionPrefix: string
50
+
51
+ // Params
52
+ private deviceId: string
53
+ private storage: StorageAdapter
54
+ private nostrSubscribe: NostrSubscribe
55
+ private nostrPublish: NostrPublish
56
+ private ourIdentityKey: Uint8Array | DecryptFunction
57
+ private ourPublicKey: string
58
+
59
+ // Data
60
+ private userRecords: Map<string, UserRecord> = new Map()
61
+ private messageHistory: Map<string, Rumor[]> = new Map()
62
+ private currentDeviceInvite: Invite | null = null
63
+
64
+ // Subscriptions
65
+ private ourDeviceInviteSubscription: Unsubscribe | null = null
66
+ private ourDeviceIntiveTombstoneSubscription: Unsubscribe | null = null
67
+ private inviteSubscriptions: Map<string, Unsubscribe> = new Map()
68
+ private sessionSubscriptions: Map<string, Unsubscribe> = new Map()
69
+ private inviteTombstoneSubscriptions: Map<string, Unsubscribe> = new Map()
70
+
71
+ // Callbacks
72
+ private internalSubscriptions: Set<OnEventCallback> = new Set()
73
+
74
+ // Initialization flag
75
+ private initialized: boolean = false
76
+
77
+ constructor(
78
+ ourPublicKey: string,
79
+ ourIdentityKey: Uint8Array | DecryptFunction,
80
+ deviceId: string,
81
+ nostrSubscribe: NostrSubscribe,
82
+ nostrPublish: NostrPublish,
83
+ storage?: StorageAdapter
84
+ ) {
85
+ this.userRecords = new Map()
86
+ this.nostrSubscribe = nostrSubscribe
87
+ this.nostrPublish = nostrPublish
88
+ this.ourPublicKey = ourPublicKey
89
+ this.ourIdentityKey = ourIdentityKey
90
+ this.deviceId = deviceId
91
+ this.storage = storage || new InMemoryStorageAdapter()
92
+ this.versionPrefix = `v${this.storageVersion}`
93
+ }
94
+
95
+ async init() {
96
+ if (this.initialized) return
97
+ this.initialized = true
98
+
99
+ await this.runMigrations().catch((error) => {
100
+ console.error("Failed to run migrations:", error)
101
+ })
102
+
103
+ await this.loadAllUserRecords().catch((error) => {
104
+ console.error("Failed to load user records:", error)
105
+ })
106
+
107
+ const ourInviteFromStorage: Invite | null = await this.storage
108
+ .get<string>(this.deviceInviteKey(this.deviceId))
109
+ .then((data) => {
110
+ if (!data) return null
59
111
  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 */}
112
+ return Invite.deserialize(data)
113
+ } catch {
114
+ return null
115
+ }
116
+ })
117
+
118
+ const invite =
119
+ ourInviteFromStorage || Invite.createNew(this.ourPublicKey, this.deviceId)
65
120
 
66
- if (!invite) {
67
- invite = Invite.createNew(ourPublicKey, this.deviceId)
68
- await this.storage.put(`invite/${this.deviceId}`, invite.serialize()).catch(() => {})
121
+ this.currentDeviceInvite = invite
122
+
123
+ await this.storage.put(this.deviceInviteKey(this.deviceId), invite.serialize())
124
+
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
69
135
  }
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
136
 
137
+ await this.storage.put(acceptanceKey, "1")
146
138
 
139
+ const userRecord = this.getOrCreateUserRecord(inviteePubkey)
140
+ const deviceRecord = this.upsertDeviceRecord(userRecord, deviceId)
147
141
 
148
- this._initialised = true
149
- await this.nostrPublish(this.invite.getEvent()).catch(() => {})
142
+ this.attachSessionSubscription(inviteePubkey, deviceRecord, session, true)
143
+ }
144
+ )
145
+
146
+ if (!this.ourDeviceIntiveTombstoneSubscription) {
147
+ this.ourDeviceIntiveTombstoneSubscription = this.createInviteTombstoneSubscription(
148
+ this.ourPublicKey
149
+ )
150
150
  }
151
151
 
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
- }
182
- }
152
+ const inviteNostrEvent = invite.getEvent()
153
+ this.nostrPublish(inviteNostrEvent).catch((error) => {
154
+ console.error("Failed to publish our device invite:", error)
155
+ })
156
+ }
157
+
158
+ // -------------------
159
+ // User and Device Records helpers
160
+ // -------------------
161
+ private getOrCreateUserRecord(userPubkey: string): UserRecord {
162
+ let rec = this.userRecords.get(userPubkey)
163
+ if (!rec) {
164
+ rec = { publicKey: userPubkey, devices: new Map() }
165
+ this.userRecords.set(userPubkey, rec)
166
+ }
167
+ return rec
168
+ }
169
+
170
+ private upsertDeviceRecord(userRecord: UserRecord, deviceId: string): DeviceRecord {
171
+ if (!deviceId) {
172
+ throw new Error("Device record must include a deviceId")
173
+ }
174
+ const existing = userRecord.devices.get(deviceId)
175
+ if (existing) {
176
+ return existing
183
177
  }
184
178
 
185
- private async saveSession(ownerPubKey: string, deviceId: string, session: Session) {
179
+ const deviceRecord: DeviceRecord = {
180
+ deviceId,
181
+ inactiveSessions: [],
182
+ createdAt: Date.now(),
183
+ }
184
+ userRecord.devices.set(deviceId, deviceRecord)
185
+ return deviceRecord
186
+ }
187
+
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) => {
186
196
  try {
187
- const key = `session/${ownerPubKey}/${deviceId}`
188
- await this.storage.put(key, serializeSessionState(session.state))
189
- } catch {/* ignore */}
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
+ private sessionKey(userPubkey: string, deviceId: string, sessionName: string) {
218
+ return `${this.sessionKeyPrefix(userPubkey)}${deviceId}/${sessionName}`
219
+ }
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
+
239
+ private sessionKeyPrefix(userPubkey: string) {
240
+ return `${this.versionPrefix}/session/${userPubkey}/`
241
+ }
242
+
243
+ private userRecordKey(publicKey: string) {
244
+ return `${this.userRecordKeyPrefix()}${publicKey}`
245
+ }
246
+
247
+ private userRecordKeyPrefix() {
248
+ return `${this.versionPrefix}/user/`
249
+ }
250
+ private versionKey() {
251
+ return `storage-version`
252
+ }
253
+
254
+ private attachSessionSubscription(
255
+ userPubkey: string,
256
+ deviceRecord: DeviceRecord,
257
+ session: Session,
258
+ // Set to true if only handshake -> not yet sendable -> will be promoted on message
259
+ inactive: boolean = false
260
+ ): void {
261
+ if (deviceRecord.staleAt !== undefined) return
262
+
263
+ const key = this.sessionKey(userPubkey, deviceRecord.deviceId, session.name)
264
+ if (this.sessionSubscriptions.has(key)) return
265
+
266
+ const dr = deviceRecord
267
+ const rotateSession = (nextSession: Session) => {
268
+ const current = dr.activeSession
269
+
270
+ if (!current) {
271
+ dr.activeSession = nextSession
272
+ return
273
+ }
274
+
275
+ if (current === nextSession || current.name === nextSession.name) {
276
+ dr.activeSession = nextSession
277
+ return
278
+ }
279
+
280
+ dr.inactiveSessions = dr.inactiveSessions.filter(
281
+ (session) => session !== current && session.name !== current.name
282
+ )
283
+
284
+ dr.inactiveSessions.push(current)
285
+ dr.inactiveSessions = dr.inactiveSessions.slice(-1)
286
+ dr.activeSession = nextSession
190
287
  }
191
288
 
192
- getDeviceId(): string {
193
- return this.deviceId
289
+ if (inactive) {
290
+ const alreadyTracked = dr.inactiveSessions.some(
291
+ (tracked) => tracked === session || tracked.name === session.name
292
+ )
293
+ if (!alreadyTracked) {
294
+ dr.inactiveSessions.push(session)
295
+ dr.inactiveSessions = dr.inactiveSessions.slice(-1)
296
+ }
297
+ } else {
298
+ rotateSession(session)
194
299
  }
195
300
 
196
- getInvite(): Invite {
197
- if (!this.invite) {
198
- throw new Error("SessionManager not initialised yet")
199
- }
200
- return this.invite
301
+ const unsub = session.onEvent((event) => {
302
+ for (const cb of this.internalSubscriptions) cb(event, userPubkey)
303
+ rotateSession(session)
304
+ this.storeUserRecord(userPubkey).catch(console.error)
305
+ })
306
+ this.storeUserRecord(userPubkey).catch(console.error)
307
+ this.sessionSubscriptions.set(key, unsub)
308
+ }
309
+
310
+ private attachInviteSubscription(
311
+ userPubkey: string,
312
+ onInvite?: (invite: Invite) => void | Promise<void>
313
+ ): void {
314
+ const key = this.inviteKey(userPubkey)
315
+ if (this.inviteSubscriptions.has(key)) return
316
+
317
+ const unsubscribe = Invite.fromUser(
318
+ userPubkey,
319
+ this.nostrSubscribe,
320
+ async (invite) => {
321
+ if (!invite.deviceId) return
322
+ if (onInvite) await onInvite(invite)
323
+ }
324
+ )
325
+
326
+ this.inviteSubscriptions.set(key, unsubscribe)
327
+ }
328
+
329
+ private attachInviteTombstoneSubscription(userPubkey: string): void {
330
+ if (this.inviteTombstoneSubscriptions.has(userPubkey)) {
331
+ return
201
332
  }
202
333
 
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)
334
+ const unsubscribe = this.createInviteTombstoneSubscription(userPubkey)
335
+ this.inviteTombstoneSubscriptions.set(userPubkey, unsubscribe)
336
+ }
337
+
338
+ setupUser(userPubkey: string) {
339
+ const userRecord = this.getOrCreateUserRecord(userPubkey)
340
+
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(
348
+ this.nostrSubscribe,
349
+ this.ourPublicKey,
350
+ this.ourIdentityKey,
351
+ this.deviceId
352
+ )
353
+ return this.nostrPublish(event)
354
+ .then(() => this.upsertDeviceRecord(userRecord, deviceId))
355
+ .then((dr) => this.attachSessionSubscription(userPubkey, dr, session))
356
+ .then(() => this.sendMessageHistory(userPubkey, deviceId))
357
+ .catch(console.error)
209
358
  }
210
359
 
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
- }
360
+ this.attachInviteSubscription(userPubkey, async (invite) => {
361
+ const { deviceId } = invite
362
+ if (!deviceId) return
230
363
 
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
- }
364
+ if (!userRecord.devices.has(deviceId)) {
365
+ await acceptInvite(invite)
366
+ }
367
+ })
368
+ }
243
369
 
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(() => {}))
249
- }
370
+ onEvent(callback: OnEventCallback) {
371
+ this.internalSubscriptions.add(callback)
250
372
 
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
- }
373
+ return () => {
374
+ this.internalSubscriptions.delete(callback)
375
+ }
376
+ }
377
+
378
+ getDeviceId(): string {
379
+ return this.deviceId
380
+ }
381
+
382
+ getDeviceInviteEphemeralKey(): string | null {
383
+ return this.currentDeviceInvite?.inviterEphemeralPublicKey || null
384
+ }
385
+
386
+ getUserRecords(): Map<string, UserRecord> {
387
+ return this.userRecords
388
+ }
389
+
390
+ close() {
391
+ for (const unsubscribe of this.inviteSubscriptions.values()) {
392
+ unsubscribe()
393
+ }
394
+
395
+ for (const unsubscribe of this.sessionSubscriptions.values()) {
396
+ unsubscribe()
397
+ }
398
+
399
+ for (const unsubscribe of this.inviteTombstoneSubscriptions.values()) {
400
+ unsubscribe()
401
+ }
402
+
403
+ this.ourDeviceInviteSubscription?.()
404
+ this.ourDeviceIntiveTombstoneSubscription?.()
405
+ }
406
+
407
+ deactivateCurrentSessions(publicKey: string) {
408
+ const userRecord = this.userRecords.get(publicKey)
409
+ if (!userRecord) return
410
+ for (const device of userRecord.devices.values()) {
411
+ if (device.activeSession) {
412
+ device.inactiveSessions.push(device.activeSession)
413
+ device.activeSession = undefined
414
+ }
415
+ }
416
+ this.storeUserRecord(publicKey).catch(console.error)
417
+ }
418
+
419
+ async deleteUser(userPubkey: string): Promise<void> {
420
+ await this.init()
421
+
422
+ const userRecord = this.userRecords.get(userPubkey)
423
+
424
+ if (userRecord) {
425
+ for (const device of userRecord.devices.values()) {
426
+ if (device.activeSession) {
427
+ this.removeSessionSubscription(
428
+ userPubkey,
429
+ device.deviceId,
430
+ device.activeSession.name
431
+ )
261
432
  }
262
433
 
263
- // Ensure all publish operations settled before returning
264
- if (publishPromises.length > 0) {
265
- await Promise.all(publishPromises)
434
+ for (const session of device.inactiveSessions) {
435
+ this.removeSessionSubscription(userPubkey, device.deviceId, session.name)
266
436
  }
437
+ }
267
438
 
268
- return results
439
+ this.userRecords.delete(userPubkey)
269
440
  }
270
441
 
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
- })
442
+ const inviteKey = this.inviteKey(userPubkey)
443
+ const inviteUnsub = this.inviteSubscriptions.get(inviteKey)
444
+ if (inviteUnsub) {
445
+ inviteUnsub()
446
+ this.inviteSubscriptions.delete(inviteKey)
447
+ }
329
448
 
330
- this.inviteUnsubscribes.set(userPubkey, unsubscribe)
449
+ const tombstoneUnsub = this.inviteTombstoneSubscriptions.get(userPubkey)
450
+ if (tombstoneUnsub) {
451
+ tombstoneUnsub()
452
+ this.inviteTombstoneSubscriptions.delete(userPubkey)
331
453
  }
332
454
 
333
- stopListeningToUser(userPubkey: string) {
334
- const unsubscribe = this.inviteUnsubscribes.get(userPubkey)
335
- if (unsubscribe) {
336
- unsubscribe()
337
- this.inviteUnsubscribes.delete(userPubkey)
338
- }
455
+ this.messageHistory.delete(userPubkey)
456
+
457
+ await Promise.allSettled([
458
+ this.storage.del(this.inviteKey(userPubkey)),
459
+ this.deleteUserSessionsFromStorage(userPubkey),
460
+ this.storage.del(this.userRecordKey(userPubkey)),
461
+ ])
462
+ }
463
+
464
+ private removeSessionSubscription(
465
+ userPubkey: string,
466
+ deviceId: string,
467
+ sessionName: string
468
+ ) {
469
+ const key = this.sessionKey(userPubkey, deviceId, sessionName)
470
+ const unsubscribe = this.sessionSubscriptions.get(key)
471
+ if (unsubscribe) {
472
+ unsubscribe()
473
+ this.sessionSubscriptions.delete(key)
474
+ }
475
+ }
476
+
477
+ private async deleteUserSessionsFromStorage(userPubkey: string): Promise<void> {
478
+ const prefix = this.sessionKeyPrefix(userPubkey)
479
+ const keys = await this.storage.list(prefix)
480
+ await Promise.all(keys.map((key) => this.storage.del(key)))
481
+ }
482
+
483
+ private async sendMessageHistory(
484
+ recipientPublicKey: string,
485
+ deviceId: string
486
+ ): Promise<void> {
487
+ const history = this.messageHistory.get(recipientPublicKey) || []
488
+ const userRecord = this.userRecords.get(recipientPublicKey)
489
+ if (!userRecord) {
490
+ return
339
491
  }
492
+ const device = userRecord.devices.get(deviceId)
493
+ if (!device) {
494
+ return
495
+ }
496
+ if (device.staleAt !== undefined) {
497
+ return
498
+ }
499
+ for (const event of history) {
500
+ const { activeSession } = device
340
501
 
341
- // Update onEvent to include internalSubscriptions management
342
- private internalSubscriptions: Set<OnEventCallback> = new Set()
502
+ if (!activeSession) continue
503
+ const { event: verifiedEvent } = activeSession.sendEvent(event)
504
+ await this.nostrPublish(verifiedEvent)
505
+ await this.storeUserRecord(recipientPublicKey)
506
+ }
507
+ }
508
+
509
+ async sendEvent(
510
+ recipientIdentityKey: string,
511
+ event: Partial<Rumor>
512
+ ): Promise<Rumor | undefined> {
513
+ await this.init()
514
+
515
+ // Add to message history queue (will be sent when session is established)
516
+ const completeEvent = event as Rumor
517
+ const historyTargets = new Set([recipientIdentityKey, this.ourPublicKey])
518
+ for (const key of historyTargets) {
519
+ const existing = this.messageHistory.get(key) || []
520
+ this.messageHistory.set(key, [...existing, completeEvent])
521
+ }
343
522
 
344
- onEvent(callback: OnEventCallback) {
345
- this.internalSubscriptions.add(callback)
523
+ const userRecord = this.getOrCreateUserRecord(recipientIdentityKey)
524
+ const ourUserRecord = this.getOrCreateUserRecord(this.ourPublicKey)
525
+
526
+ 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)
533
+
534
+ // Send to all devices in background (if sessions exist)
535
+ Promise.allSettled(
536
+ devices.map(async (device) => {
537
+ const { activeSession } = device
538
+ if (!activeSession) return
539
+ const { event: verifiedEvent } = activeSession.sendEvent(event)
540
+ await this.nostrPublish(verifiedEvent).catch(console.error)
541
+ })
542
+ )
543
+ .then(() => {
544
+ this.storeUserRecord(recipientIdentityKey)
545
+ })
546
+ .catch(console.error)
547
+
548
+ // Return the event with computed ID (same as library would compute)
549
+ return completeEvent
550
+ }
551
+
552
+ async sendMessage(
553
+ recipientPublicKey: string,
554
+ content: string,
555
+ options: { kind?: number; tags?: string[][] } = {}
556
+ ): Promise<Rumor> {
557
+ const { kind = CHAT_MESSAGE_KIND, tags = [] } = options
558
+
559
+ // Build message exactly as library does (Session.ts sendEvent)
560
+ const now = Date.now()
561
+ const builtTags = this.buildMessageTags(recipientPublicKey, tags)
562
+
563
+ const rumor: Rumor = {
564
+ content,
565
+ kind,
566
+ created_at: Math.floor(now / 1000),
567
+ tags: builtTags,
568
+ pubkey: this.ourPublicKey,
569
+ id: "", // Will compute next
570
+ }
346
571
 
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
- }
572
+ if (!rumor.tags.some(([k]) => k === "ms")) {
573
+ rumor.tags.push(["ms", String(now)])
574
+ }
575
+
576
+ rumor.id = getEventHash(rumor)
577
+
578
+ // Use sendEvent for actual sending (includes queueing)
579
+ this.sendEvent(recipientPublicKey, rumor).catch(console.error)
580
+
581
+ return rumor
582
+ }
583
+
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
+ private async cleanupDevice(publicKey: string, deviceId: string): Promise<void> {
612
+ const userRecord = this.userRecords.get(publicKey)
613
+ if (!userRecord) return
614
+ const deviceRecord = userRecord.devices.get(deviceId)
615
+
616
+ if (!deviceRecord) return
617
+
618
+ if (deviceRecord.activeSession) {
619
+ this.removeSessionSubscription(publicKey, deviceId, deviceRecord.activeSession.name)
620
+ }
621
+
622
+ for (const session of deviceRecord.inactiveSessions) {
623
+ this.removeSessionSubscription(publicKey, deviceId, session.name)
624
+ }
625
+
626
+ deviceRecord.activeSession = undefined
627
+ deviceRecord.inactiveSessions = []
628
+ deviceRecord.staleAt = Date.now()
629
+
630
+ await this.storeUserRecord(publicKey).catch(console.error)
631
+ }
632
+
633
+ private buildMessageTags(
634
+ recipientPublicKey: string,
635
+ extraTags: string[][]
636
+ ): string[][] {
637
+ const hasRecipientPTag = extraTags.some(
638
+ (tag) => tag[0] === "p" && tag[1] === recipientPublicKey
639
+ )
640
+ const tags = hasRecipientPTag
641
+ ? [...extraTags]
642
+ : [["p", recipientPublicKey], ...extraTags]
643
+ return tags
644
+ }
645
+
646
+ private storeUserRecord(publicKey: string) {
647
+ const data: StoredUserRecord = {
648
+ publicKey: publicKey,
649
+ devices: Array.from(this.userRecords.get(publicKey)?.devices.entries() || []).map(
650
+ ([, device]) => ({
651
+ deviceId: device.deviceId,
652
+ activeSession: device.activeSession
653
+ ? serializeSessionState(device.activeSession.state)
654
+ : null,
655
+ inactiveSessions: device.inactiveSessions.map((session) =>
656
+ serializeSessionState(session.state)
657
+ ),
658
+ createdAt: device.createdAt,
659
+ staleAt: device.staleAt,
660
+ })
661
+ ),
662
+ }
663
+ return this.storage.put(this.userRecordKey(publicKey), data)
664
+ }
665
+
666
+ private loadUserRecord(publicKey: string) {
667
+ return this.storage
668
+ .get<StoredUserRecord>(this.userRecordKey(publicKey))
669
+ .then((data) => {
670
+ if (!data) return
671
+
672
+ const devices = new Map<string, DeviceRecord>()
673
+
674
+ for (const deviceData of data.devices) {
675
+ const {
676
+ deviceId,
677
+ activeSession: serializedActive,
678
+ inactiveSessions: serializedInactive,
679
+ createdAt,
680
+ staleAt,
681
+ } = deviceData
682
+
683
+ try {
684
+ const activeSession = serializedActive
685
+ ? new Session(
686
+ this.nostrSubscribe,
687
+ deserializeSessionState(serializedActive)
688
+ )
689
+ : undefined
690
+
691
+ const inactiveSessions = serializedInactive.map(
692
+ (entry) => new Session(this.nostrSubscribe, deserializeSessionState(entry))
693
+ )
694
+
695
+ devices.set(deviceId, {
696
+ deviceId,
697
+ activeSession,
698
+ inactiveSessions,
699
+ createdAt,
700
+ staleAt,
701
+ })
702
+ } catch (e) {
703
+ console.error(
704
+ `Failed to deserialize session for user ${publicKey}, device ${deviceId}:`,
705
+ e
706
+ )
707
+ }
354
708
  }
355
709
 
356
- // Return unsubscribe function
357
- return () => {
358
- this.internalSubscriptions.delete(callback)
710
+ this.userRecords.set(publicKey, {
711
+ publicKey: data.publicKey,
712
+ devices,
713
+ })
714
+
715
+ if (publicKey !== this.ourPublicKey) {
716
+ this.attachInviteTombstoneSubscription(publicKey)
359
717
  }
360
- }
361
718
 
362
- close() {
363
- // Clean up all subscriptions
364
- for (const unsubscribe of this.inviteUnsubscribes.values()) {
365
- unsubscribe()
719
+ for (const device of devices.values()) {
720
+ const { deviceId, activeSession, inactiveSessions, staleAt } = device
721
+ if (!deviceId || staleAt !== undefined) continue
722
+
723
+ for (const session of inactiveSessions.reverse()) {
724
+ this.attachSessionSubscription(publicKey, device, session)
725
+ }
726
+ if (activeSession) {
727
+ this.attachSessionSubscription(publicKey, device, activeSession)
728
+ }
366
729
  }
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()
730
+ })
731
+ .catch((error) => {
732
+ console.error(`Failed to load user record for ${publicKey}:`, error)
733
+ })
734
+ }
735
+
736
+ private loadAllUserRecords() {
737
+ const prefix = this.userRecordKeyPrefix()
738
+ return this.storage.list(prefix).then((keys) => {
739
+ return Promise.all(
740
+ keys.map((key) => {
741
+ const publicKey = key.slice(prefix.length)
742
+ return this.loadUserRecord(publicKey)
743
+ })
744
+ )
745
+ })
746
+ }
747
+
748
+ private async runMigrations() {
749
+ // Run migrations sequentially
750
+ let version = await this.storage.get<string>(this.versionKey())
751
+
752
+ // First migration
753
+ 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
758
+ const oldInvitePrefix = "invite/"
759
+ 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)
373
771
  }
374
- }
375
- this.userRecords.clear()
376
- this.internalSubscriptions.clear()
772
+ } catch (e) {
773
+ console.error("Migration error for invite:", e)
774
+ }
775
+ })
776
+ )
777
+
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
782
+ const oldUserRecordPrefix = "user/"
783
+ const sessionKeys = await this.storage.list(oldUserRecordPrefix)
784
+ await Promise.all(
785
+ sessionKeys.map(async (key) => {
786
+ try {
787
+ const publicKey = key.slice(oldUserRecordPrefix.length)
788
+ const userRecordData = await this.storage.get<StoredUserRecord>(key)
789
+ if (userRecordData) {
790
+ const newKey = this.userRecordKey(publicKey)
791
+ const newUserRecordData: StoredUserRecord = {
792
+ publicKey: userRecordData.publicKey,
793
+ devices: userRecordData.devices.map((device) => ({
794
+ deviceId: device.deviceId,
795
+ activeSession: null,
796
+ createdAt: device.createdAt,
797
+ inactiveSessions: [],
798
+ })),
799
+ }
800
+ await this.storage.put(newKey, newUserRecordData)
801
+ await this.storage.del(key)
802
+ }
803
+ } catch (e) {
804
+ console.error("Migration error for user record:", e)
805
+ }
806
+ })
807
+ )
808
+
809
+ // Set version to 1 so next migration can run
810
+ version = "1"
811
+ await this.storage.put(this.versionKey(), version)
812
+
813
+ return
377
814
  }
378
815
 
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);
394
- }
395
- userRecord.upsertSession(session.name || 'unknown', session);
396
- await this.saveSession(ourPublicKey, session.name || 'unknown', session);
397
- this.nostrPublish(event)?.catch(() => {});
816
+ // Future migrations
817
+ if (version === "1") {
818
+ return
398
819
  }
820
+ }
399
821
  }