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