nostr-double-ratchet 0.0.37 → 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 +7 -7
- 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 +46 -22
- package/dist/SessionManager.d.ts.map +1 -1
- package/dist/StorageAdapter.d.ts.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/inviteUtils.d.ts +122 -0
- package/dist/inviteUtils.d.ts.map +1 -0
- package/dist/nostr-double-ratchet.es.js +2843 -1901
- package/dist/nostr-double-ratchet.umd.js +1 -1
- package/dist/types.d.ts +32 -1
- 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 +69 -89
- package/src/Session.ts +47 -4
- package/src/SessionManager.ts +478 -300
- package/src/StorageAdapter.ts +5 -6
- package/src/index.ts +4 -1
- package/src/inviteUtils.ts +271 -0
- package/src/types.ts +37 -2
- package/src/utils.ts +45 -8
- package/LICENSE +0 -21
- package/dist/UserRecord.d.ts +0 -117
- package/dist/UserRecord.d.ts.map +0 -1
- package/src/UserRecord.ts +0 -338
package/src/SessionManager.ts
CHANGED
|
@@ -1,46 +1,60 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
2
|
+
IdentityKey,
|
|
3
3
|
NostrSubscribe,
|
|
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 { AppKeys, DeviceEntry } from "./AppKeys"
|
|
11
12
|
import { Invite } from "./Invite"
|
|
12
13
|
import { Session } from "./Session"
|
|
13
14
|
import { serializeSessionState, deserializeSessionState } from "./utils"
|
|
14
|
-
import {
|
|
15
|
+
import { decryptInviteResponse, createSessionFromAccept } from "./inviteUtils"
|
|
16
|
+
import { getEventHash } from "nostr-tools"
|
|
15
17
|
|
|
16
18
|
export type OnEventCallback = (event: Rumor, from: string) => void
|
|
17
19
|
|
|
18
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Credentials for the invite handshake - used to listen for and decrypt invite responses
|
|
22
|
+
*/
|
|
23
|
+
export interface InviteCredentials {
|
|
24
|
+
ephemeralKeypair: { publicKey: string; privateKey: Uint8Array }
|
|
25
|
+
sharedSecret: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface DeviceRecord {
|
|
19
29
|
deviceId: string
|
|
20
30
|
activeSession?: Session
|
|
21
31
|
inactiveSessions: Session[]
|
|
22
32
|
createdAt: number
|
|
23
|
-
staleAt?: number
|
|
24
33
|
}
|
|
25
34
|
|
|
26
|
-
interface UserRecord {
|
|
35
|
+
export interface UserRecord {
|
|
27
36
|
publicKey: string
|
|
28
37
|
devices: Map<string, DeviceRecord>
|
|
38
|
+
/** Device identity pubkeys from AppKeys - used to rebuild delegateToOwner on load */
|
|
39
|
+
knownDeviceIdentities: string[]
|
|
29
40
|
}
|
|
30
41
|
|
|
31
|
-
|
|
42
|
+
interface StoredSessionEntry {
|
|
43
|
+
name: string
|
|
44
|
+
state: string
|
|
45
|
+
}
|
|
32
46
|
|
|
33
47
|
interface StoredDeviceRecord {
|
|
34
48
|
deviceId: string
|
|
35
49
|
activeSession: StoredSessionEntry | null
|
|
36
50
|
inactiveSessions: StoredSessionEntry[]
|
|
37
51
|
createdAt: number
|
|
38
|
-
staleAt?: number
|
|
39
52
|
}
|
|
40
53
|
|
|
41
54
|
interface StoredUserRecord {
|
|
42
55
|
publicKey: string
|
|
43
56
|
devices: StoredDeviceRecord[]
|
|
57
|
+
knownDeviceIdentities?: string[]
|
|
44
58
|
}
|
|
45
59
|
|
|
46
60
|
export class SessionManager {
|
|
@@ -53,20 +67,26 @@ export class SessionManager {
|
|
|
53
67
|
private storage: StorageAdapter
|
|
54
68
|
private nostrSubscribe: NostrSubscribe
|
|
55
69
|
private nostrPublish: NostrPublish
|
|
56
|
-
private
|
|
70
|
+
private identityKey: IdentityKey
|
|
57
71
|
private ourPublicKey: string
|
|
72
|
+
// Owner's public key - used for grouping devices together (all devices are delegates)
|
|
73
|
+
private ownerPublicKey: string
|
|
74
|
+
|
|
75
|
+
// Credentials for invite handshake
|
|
76
|
+
private inviteKeys: InviteCredentials
|
|
58
77
|
|
|
59
78
|
// Data
|
|
60
79
|
private userRecords: Map<string, UserRecord> = new Map()
|
|
61
80
|
private messageHistory: Map<string, Rumor[]> = new Map()
|
|
62
|
-
|
|
81
|
+
// Map delegate device pubkeys to their owner's pubkey
|
|
82
|
+
private delegateToOwner: Map<string, string> = new Map()
|
|
83
|
+
// Track processed InviteResponse event IDs to prevent replay
|
|
84
|
+
private processedInviteResponses: Set<string> = new Set()
|
|
63
85
|
|
|
64
86
|
// Subscriptions
|
|
65
|
-
private
|
|
66
|
-
private ourDeviceIntiveTombstoneSubscription: Unsubscribe | null = null
|
|
87
|
+
private ourInviteResponseSubscription: Unsubscribe | null = null
|
|
67
88
|
private inviteSubscriptions: Map<string, Unsubscribe> = new Map()
|
|
68
89
|
private sessionSubscriptions: Map<string, Unsubscribe> = new Map()
|
|
69
|
-
private inviteTombstoneSubscriptions: Map<string, Unsubscribe> = new Map()
|
|
70
90
|
|
|
71
91
|
// Callbacks
|
|
72
92
|
private internalSubscriptions: Set<OnEventCallback> = new Set()
|
|
@@ -76,18 +96,22 @@ export class SessionManager {
|
|
|
76
96
|
|
|
77
97
|
constructor(
|
|
78
98
|
ourPublicKey: string,
|
|
79
|
-
|
|
99
|
+
identityKey: IdentityKey,
|
|
80
100
|
deviceId: string,
|
|
81
101
|
nostrSubscribe: NostrSubscribe,
|
|
82
102
|
nostrPublish: NostrPublish,
|
|
83
|
-
|
|
103
|
+
ownerPublicKey: string,
|
|
104
|
+
inviteKeys: InviteCredentials,
|
|
105
|
+
storage?: StorageAdapter,
|
|
84
106
|
) {
|
|
85
107
|
this.userRecords = new Map()
|
|
86
108
|
this.nostrSubscribe = nostrSubscribe
|
|
87
109
|
this.nostrPublish = nostrPublish
|
|
88
110
|
this.ourPublicKey = ourPublicKey
|
|
89
|
-
this.
|
|
111
|
+
this.identityKey = identityKey
|
|
90
112
|
this.deviceId = deviceId
|
|
113
|
+
this.ownerPublicKey = ownerPublicKey
|
|
114
|
+
this.inviteKeys = inviteKeys
|
|
91
115
|
this.storage = storage || new InMemoryStorageAdapter()
|
|
92
116
|
this.versionPrefix = `v${this.storageVersion}`
|
|
93
117
|
}
|
|
@@ -96,62 +120,155 @@ export class SessionManager {
|
|
|
96
120
|
if (this.initialized) return
|
|
97
121
|
this.initialized = true
|
|
98
122
|
|
|
99
|
-
await this.runMigrations().catch((
|
|
100
|
-
|
|
123
|
+
await this.runMigrations().catch(() => {
|
|
124
|
+
// Failed to run migrations
|
|
101
125
|
})
|
|
102
126
|
|
|
103
|
-
await this.loadAllUserRecords().catch((
|
|
104
|
-
|
|
127
|
+
await this.loadAllUserRecords().catch(() => {
|
|
128
|
+
// Failed to load user records
|
|
105
129
|
})
|
|
106
130
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
131
|
+
// Add our own device to user record to prevent accepting our own invite
|
|
132
|
+
// Use ownerPublicKey so delegates are added to the owner's record
|
|
133
|
+
const ourUserRecord = this.getOrCreateUserRecord(this.ownerPublicKey)
|
|
134
|
+
this.upsertDeviceRecord(ourUserRecord, this.deviceId)
|
|
135
|
+
|
|
136
|
+
// Start invite response listener BEFORE setting up users
|
|
137
|
+
// This ensures we're listening when other devices respond to our invites
|
|
138
|
+
this.startInviteResponseListener()
|
|
139
|
+
// Setup sessions with our own other devices
|
|
140
|
+
// Use ownerPublicKey to find sibling devices (important for delegates)
|
|
141
|
+
this.setupUser(this.ownerPublicKey)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Start listening for invite responses on our ephemeral key.
|
|
146
|
+
* This is used by devices to receive session establishment responses.
|
|
147
|
+
*/
|
|
148
|
+
private startInviteResponseListener(): void {
|
|
149
|
+
const { publicKey: ephemeralPubkey, privateKey: ephemeralPrivkey } = this.inviteKeys.ephemeralKeypair
|
|
150
|
+
const sharedSecret = this.inviteKeys.sharedSecret
|
|
151
|
+
|
|
152
|
+
// Subscribe to invite responses tagged to our ephemeral key
|
|
153
|
+
this.ourInviteResponseSubscription = this.nostrSubscribe(
|
|
154
|
+
{
|
|
155
|
+
kinds: [1059], // INVITE_RESPONSE_KIND
|
|
156
|
+
"#p": [ephemeralPubkey],
|
|
157
|
+
},
|
|
158
|
+
async (event) => {
|
|
159
|
+
// Skip already processed InviteResponses (prevents replay issues on restart)
|
|
160
|
+
if (this.processedInviteResponses.has(event.id)) {
|
|
161
|
+
return
|
|
115
162
|
}
|
|
116
|
-
|
|
163
|
+
this.processedInviteResponses.add(event.id)
|
|
117
164
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
165
|
+
try {
|
|
166
|
+
const decrypted = await decryptInviteResponse({
|
|
167
|
+
envelopeContent: event.content,
|
|
168
|
+
envelopeSenderPubkey: event.pubkey,
|
|
169
|
+
inviterEphemeralPrivateKey: ephemeralPrivkey,
|
|
170
|
+
inviterPrivateKey: this.identityKey instanceof Uint8Array ? this.identityKey : undefined,
|
|
171
|
+
sharedSecret,
|
|
172
|
+
decrypt: this.identityKey instanceof Uint8Array ? undefined : this.identityKey.decrypt,
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
// Skip our own responses - this happens when we publish an invite response
|
|
176
|
+
// and our own listener receives it back from relays
|
|
177
|
+
// inviteeIdentity serves as the device ID
|
|
178
|
+
if (decrypted.inviteeIdentity === this.deviceId) {
|
|
179
|
+
return
|
|
180
|
+
}
|
|
124
181
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
135
|
-
}
|
|
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)
|
|
136
185
|
|
|
137
|
-
|
|
186
|
+
// Verify the device is authorized by fetching owner's AppKeys
|
|
187
|
+
const appKeys = await this.fetchAppKeys(claimedOwner)
|
|
138
188
|
|
|
139
|
-
|
|
140
|
-
|
|
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
|
+
}
|
|
205
|
+
}
|
|
141
206
|
|
|
142
|
-
|
|
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
|
+
|
|
212
|
+
const session = createSessionFromAccept({
|
|
213
|
+
nostrSubscribe: this.nostrSubscribe,
|
|
214
|
+
theirPublicKey: decrypted.inviteeSessionPublicKey,
|
|
215
|
+
ourSessionPrivateKey: ephemeralPrivkey,
|
|
216
|
+
sharedSecret,
|
|
217
|
+
isSender: false,
|
|
218
|
+
name: event.id,
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
this.attachSessionSubscription(ownerPubkey, deviceRecord, session, true)
|
|
222
|
+
this.storeUserRecord(ownerPubkey).catch(() => {})
|
|
223
|
+
} catch {
|
|
224
|
+
}
|
|
143
225
|
}
|
|
144
226
|
)
|
|
227
|
+
}
|
|
145
228
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
+
}
|
|
151
245
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
+
)
|
|
155
272
|
})
|
|
156
273
|
}
|
|
157
274
|
|
|
@@ -161,7 +278,7 @@ export class SessionManager {
|
|
|
161
278
|
private getOrCreateUserRecord(userPubkey: string): UserRecord {
|
|
162
279
|
let rec = this.userRecords.get(userPubkey)
|
|
163
280
|
if (!rec) {
|
|
164
|
-
rec = { publicKey: userPubkey, devices: new Map() }
|
|
281
|
+
rec = { publicKey: userPubkey, devices: new Map(), knownDeviceIdentities: [] }
|
|
165
282
|
this.userRecords.set(userPubkey, rec)
|
|
166
283
|
}
|
|
167
284
|
return rec
|
|
@@ -185,56 +302,9 @@ export class SessionManager {
|
|
|
185
302
|
return deviceRecord
|
|
186
303
|
}
|
|
187
304
|
|
|
188
|
-
private createInviteTombstoneSubscription(authorPublicKey: string): Unsubscribe {
|
|
189
|
-
return this.nostrSubscribe(
|
|
190
|
-
{
|
|
191
|
-
kinds: [INVITE_EVENT_KIND],
|
|
192
|
-
authors: [authorPublicKey],
|
|
193
|
-
"#l": ["double-ratchet/invites"],
|
|
194
|
-
},
|
|
195
|
-
(event: VerifiedEvent) => {
|
|
196
|
-
try {
|
|
197
|
-
const isTombstone = !event.tags?.some(
|
|
198
|
-
([key]) => key === "ephemeralKey" || key === "sharedSecret"
|
|
199
|
-
)
|
|
200
|
-
if (isTombstone) {
|
|
201
|
-
const deviceIdTag = event.tags.find(
|
|
202
|
-
([key, value]) => key === "d" && value.startsWith("double-ratchet/invites/")
|
|
203
|
-
)
|
|
204
|
-
const [, deviceIdTagValue] = deviceIdTag || []
|
|
205
|
-
const deviceId = deviceIdTagValue.split("/").pop()
|
|
206
|
-
if (!deviceId) return
|
|
207
|
-
|
|
208
|
-
this.cleanupDevice(authorPublicKey, deviceId)
|
|
209
|
-
}
|
|
210
|
-
} catch (error) {
|
|
211
|
-
console.error("Failed to handle device tombstone:", error)
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
)
|
|
215
|
-
}
|
|
216
|
-
|
|
217
305
|
private sessionKey(userPubkey: string, deviceId: string, sessionName: string) {
|
|
218
306
|
return `${this.sessionKeyPrefix(userPubkey)}${deviceId}/${sessionName}`
|
|
219
307
|
}
|
|
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
308
|
|
|
239
309
|
private sessionKeyPrefix(userPubkey: string) {
|
|
240
310
|
return `${this.versionPrefix}/session/${userPubkey}/`
|
|
@@ -251,6 +321,62 @@ export class SessionManager {
|
|
|
251
321
|
return `storage-version`
|
|
252
322
|
}
|
|
253
323
|
|
|
324
|
+
/**
|
|
325
|
+
* Resolve a pubkey to its owner if it's a known delegate device.
|
|
326
|
+
* Returns the input pubkey if not a known delegate.
|
|
327
|
+
*/
|
|
328
|
+
private resolveToOwner(pubkey: string): string {
|
|
329
|
+
return this.delegateToOwner.get(pubkey) || pubkey
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Update the delegate-to-owner mapping from an AppKeys.
|
|
334
|
+
* Extracts delegate device pubkeys and maps them to the owner.
|
|
335
|
+
* Persists the mapping in the user record for restart recovery.
|
|
336
|
+
*/
|
|
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)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Persist
|
|
352
|
+
this.storeUserRecord(ownerPubkey).catch(() => {})
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
private subscribeToUserAppKeys(
|
|
356
|
+
pubkey: string,
|
|
357
|
+
onAppKeys: (list: AppKeys) => void
|
|
358
|
+
): Unsubscribe {
|
|
359
|
+
return this.nostrSubscribe(
|
|
360
|
+
{
|
|
361
|
+
kinds: [APP_KEYS_EVENT_KIND],
|
|
362
|
+
authors: [pubkey],
|
|
363
|
+
"#d": ["double-ratchet/app-keys"],
|
|
364
|
+
},
|
|
365
|
+
(event) => {
|
|
366
|
+
try {
|
|
367
|
+
const list = AppKeys.fromEvent(event)
|
|
368
|
+
// Update delegate mapping whenever we receive an AppKeys
|
|
369
|
+
this.updateDelegateMapping(pubkey, list)
|
|
370
|
+
onAppKeys(list)
|
|
371
|
+
} catch {
|
|
372
|
+
// Invalid event, ignore
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private static MAX_INACTIVE_SESSIONS = 10
|
|
379
|
+
|
|
254
380
|
private attachSessionSubscription(
|
|
255
381
|
userPubkey: string,
|
|
256
382
|
deviceRecord: DeviceRecord,
|
|
@@ -258,111 +384,212 @@ export class SessionManager {
|
|
|
258
384
|
// Set to true if only handshake -> not yet sendable -> will be promoted on message
|
|
259
385
|
inactive: boolean = false
|
|
260
386
|
): void {
|
|
261
|
-
if (deviceRecord.staleAt !== undefined) return
|
|
262
|
-
|
|
263
387
|
const key = this.sessionKey(userPubkey, deviceRecord.deviceId, session.name)
|
|
264
|
-
if (this.sessionSubscriptions.has(key))
|
|
388
|
+
if (this.sessionSubscriptions.has(key)) {
|
|
389
|
+
return
|
|
390
|
+
}
|
|
265
391
|
|
|
266
392
|
const dr = deviceRecord
|
|
267
|
-
const rotateSession = (nextSession: Session) => {
|
|
268
|
-
const current = dr.activeSession
|
|
269
393
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
|
274
398
|
|
|
275
|
-
|
|
276
|
-
|
|
399
|
+
// Already active, nothing to do
|
|
400
|
+
if (current === nextSession || current?.name === nextSession.name) {
|
|
277
401
|
return
|
|
278
402
|
}
|
|
279
403
|
|
|
404
|
+
// Remove nextSession from inactive if present
|
|
280
405
|
dr.inactiveSessions = dr.inactiveSessions.filter(
|
|
281
|
-
(
|
|
406
|
+
(s) => s !== nextSession && s.name !== nextSession.name
|
|
282
407
|
)
|
|
283
408
|
|
|
284
|
-
|
|
285
|
-
|
|
409
|
+
// Move current active to top of inactive queue
|
|
410
|
+
if (current) {
|
|
411
|
+
dr.inactiveSessions.unshift(current)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Set new active
|
|
286
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
|
+
}
|
|
287
425
|
}
|
|
288
426
|
|
|
427
|
+
// Add new session: if inactive, add to top of inactive queue; otherwise set as active
|
|
289
428
|
if (inactive) {
|
|
290
429
|
const alreadyTracked = dr.inactiveSessions.some(
|
|
291
|
-
(
|
|
430
|
+
(s) => s === session || s.name === session.name
|
|
292
431
|
)
|
|
293
432
|
if (!alreadyTracked) {
|
|
294
|
-
|
|
295
|
-
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
|
+
}
|
|
296
442
|
}
|
|
297
443
|
} else {
|
|
298
|
-
|
|
444
|
+
promoteToActive(session)
|
|
299
445
|
}
|
|
300
446
|
|
|
447
|
+
// Subscribe to session events - when message received, promote to active
|
|
301
448
|
const unsub = session.onEvent((event) => {
|
|
302
449
|
for (const cb of this.internalSubscriptions) cb(event, userPubkey)
|
|
303
|
-
|
|
304
|
-
this.storeUserRecord(userPubkey).catch(
|
|
450
|
+
promoteToActive(session)
|
|
451
|
+
this.storeUserRecord(userPubkey).catch(() => {})
|
|
305
452
|
})
|
|
306
|
-
this.storeUserRecord(userPubkey).catch(
|
|
453
|
+
this.storeUserRecord(userPubkey).catch(() => {})
|
|
307
454
|
this.sessionSubscriptions.set(key, unsub)
|
|
308
455
|
}
|
|
309
456
|
|
|
310
|
-
private
|
|
457
|
+
private attachAppKeysSubscription(
|
|
311
458
|
userPubkey: string,
|
|
312
|
-
|
|
459
|
+
onAppKeys?: (appKeys: AppKeys) => void | Promise<void>
|
|
313
460
|
): void {
|
|
314
|
-
const key =
|
|
461
|
+
const key = `appkeys:${userPubkey}`
|
|
315
462
|
if (this.inviteSubscriptions.has(key)) return
|
|
316
463
|
|
|
317
|
-
const unsubscribe =
|
|
464
|
+
const unsubscribe = this.subscribeToUserAppKeys(
|
|
318
465
|
userPubkey,
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
if (!invite.deviceId) return
|
|
322
|
-
if (onInvite) await onInvite(invite)
|
|
466
|
+
async (appKeys) => {
|
|
467
|
+
if (onAppKeys) await onAppKeys(appKeys)
|
|
323
468
|
}
|
|
324
469
|
)
|
|
325
470
|
|
|
326
471
|
this.inviteSubscriptions.set(key, unsubscribe)
|
|
327
472
|
}
|
|
328
473
|
|
|
329
|
-
private attachInviteTombstoneSubscription(userPubkey: string): void {
|
|
330
|
-
if (this.inviteTombstoneSubscriptions.has(userPubkey)) {
|
|
331
|
-
return
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
const unsubscribe = this.createInviteTombstoneSubscription(userPubkey)
|
|
335
|
-
this.inviteTombstoneSubscriptions.set(userPubkey, unsubscribe)
|
|
336
|
-
}
|
|
337
|
-
|
|
338
474
|
setupUser(userPubkey: string) {
|
|
339
475
|
const userRecord = this.getOrCreateUserRecord(userPubkey)
|
|
340
476
|
|
|
341
|
-
|
|
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
|
+
*/
|
|
486
|
+
const acceptInviteFromDevice = async (
|
|
487
|
+
device: DeviceEntry,
|
|
488
|
+
invite: Invite
|
|
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
|
+
}
|
|
342
496
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
497
|
+
// Add device record IMMEDIATELY to prevent duplicate acceptance from race conditions
|
|
498
|
+
// Use identityPubkey as the device identifier
|
|
499
|
+
const deviceRecord = this.upsertDeviceRecord(userRecord, device.identityPubkey)
|
|
346
500
|
|
|
501
|
+
const encryptor = this.identityKey instanceof Uint8Array ? this.identityKey : this.identityKey.encrypt
|
|
502
|
+
// ourPublicKey serves as both identity and device ID
|
|
347
503
|
const { session, event } = await invite.accept(
|
|
348
504
|
this.nostrSubscribe,
|
|
349
505
|
this.ourPublicKey,
|
|
350
|
-
|
|
351
|
-
this.
|
|
506
|
+
encryptor,
|
|
507
|
+
this.ownerPublicKey
|
|
352
508
|
)
|
|
353
509
|
return this.nostrPublish(event)
|
|
354
|
-
.then(() =>
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
.
|
|
510
|
+
.then(() => {
|
|
511
|
+
this.attachSessionSubscription(userPubkey, deviceRecord, session)
|
|
512
|
+
})
|
|
513
|
+
.then(() => this.sendMessageHistory(userPubkey, device.identityPubkey))
|
|
514
|
+
.catch(() => {})
|
|
515
|
+
}
|
|
516
|
+
|
|
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)
|
|
527
|
+
|
|
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
|
|
532
|
+
}
|
|
533
|
+
|
|
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)
|
|
358
566
|
}
|
|
359
567
|
|
|
360
|
-
this.
|
|
361
|
-
const
|
|
362
|
-
|
|
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
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
363
589
|
|
|
364
|
-
|
|
365
|
-
|
|
590
|
+
// For each device in AppKeys, subscribe to their Invite event
|
|
591
|
+
for (const device of devices) {
|
|
592
|
+
subscribeToDeviceInvite(device)
|
|
366
593
|
}
|
|
367
594
|
})
|
|
368
595
|
}
|
|
@@ -379,10 +606,6 @@ export class SessionManager {
|
|
|
379
606
|
return this.deviceId
|
|
380
607
|
}
|
|
381
608
|
|
|
382
|
-
getDeviceInviteEphemeralKey(): string | null {
|
|
383
|
-
return this.currentDeviceInvite?.inviterEphemeralPublicKey || null
|
|
384
|
-
}
|
|
385
|
-
|
|
386
609
|
getUserRecords(): Map<string, UserRecord> {
|
|
387
610
|
return this.userRecords
|
|
388
611
|
}
|
|
@@ -396,12 +619,7 @@ export class SessionManager {
|
|
|
396
619
|
unsubscribe()
|
|
397
620
|
}
|
|
398
621
|
|
|
399
|
-
|
|
400
|
-
unsubscribe()
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
this.ourDeviceInviteSubscription?.()
|
|
404
|
-
this.ourDeviceIntiveTombstoneSubscription?.()
|
|
622
|
+
this.ourInviteResponseSubscription?.()
|
|
405
623
|
}
|
|
406
624
|
|
|
407
625
|
deactivateCurrentSessions(publicKey: string) {
|
|
@@ -413,7 +631,7 @@ export class SessionManager {
|
|
|
413
631
|
device.activeSession = undefined
|
|
414
632
|
}
|
|
415
633
|
}
|
|
416
|
-
this.storeUserRecord(publicKey).catch(
|
|
634
|
+
this.storeUserRecord(publicKey).catch(() => {})
|
|
417
635
|
}
|
|
418
636
|
|
|
419
637
|
async deleteUser(userPubkey: string): Promise<void> {
|
|
@@ -439,23 +657,16 @@ export class SessionManager {
|
|
|
439
657
|
this.userRecords.delete(userPubkey)
|
|
440
658
|
}
|
|
441
659
|
|
|
442
|
-
const
|
|
443
|
-
const
|
|
444
|
-
if (
|
|
445
|
-
|
|
446
|
-
this.inviteSubscriptions.delete(
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
const tombstoneUnsub = this.inviteTombstoneSubscriptions.get(userPubkey)
|
|
450
|
-
if (tombstoneUnsub) {
|
|
451
|
-
tombstoneUnsub()
|
|
452
|
-
this.inviteTombstoneSubscriptions.delete(userPubkey)
|
|
660
|
+
const appKeysKey = `appkeys:${userPubkey}`
|
|
661
|
+
const appKeysUnsub = this.inviteSubscriptions.get(appKeysKey)
|
|
662
|
+
if (appKeysUnsub) {
|
|
663
|
+
appKeysUnsub()
|
|
664
|
+
this.inviteSubscriptions.delete(appKeysKey)
|
|
453
665
|
}
|
|
454
666
|
|
|
455
667
|
this.messageHistory.delete(userPubkey)
|
|
456
668
|
|
|
457
669
|
await Promise.allSettled([
|
|
458
|
-
this.storage.del(this.inviteKey(userPubkey)),
|
|
459
670
|
this.deleteUserSessionsFromStorage(userPubkey),
|
|
460
671
|
this.storage.del(this.userRecordKey(userPubkey)),
|
|
461
672
|
])
|
|
@@ -493,13 +704,12 @@ export class SessionManager {
|
|
|
493
704
|
if (!device) {
|
|
494
705
|
return
|
|
495
706
|
}
|
|
496
|
-
if (device.staleAt !== undefined) {
|
|
497
|
-
return
|
|
498
|
-
}
|
|
499
707
|
for (const event of history) {
|
|
500
708
|
const { activeSession } = device
|
|
501
709
|
|
|
502
|
-
if (!activeSession)
|
|
710
|
+
if (!activeSession) {
|
|
711
|
+
continue
|
|
712
|
+
}
|
|
503
713
|
const { event: verifiedEvent } = activeSession.sendEvent(event)
|
|
504
714
|
await this.nostrPublish(verifiedEvent)
|
|
505
715
|
await this.storeUserRecord(recipientPublicKey)
|
|
@@ -514,36 +724,56 @@ export class SessionManager {
|
|
|
514
724
|
|
|
515
725
|
// Add to message history queue (will be sent when session is established)
|
|
516
726
|
const completeEvent = event as Rumor
|
|
517
|
-
|
|
727
|
+
// Use ownerPublicKey for history targets so delegates share history with owner
|
|
728
|
+
const historyTargets = new Set([recipientIdentityKey, this.ownerPublicKey])
|
|
518
729
|
for (const key of historyTargets) {
|
|
519
730
|
const existing = this.messageHistory.get(key) || []
|
|
520
731
|
this.messageHistory.set(key, [...existing, completeEvent])
|
|
521
732
|
}
|
|
522
733
|
|
|
523
734
|
const userRecord = this.getOrCreateUserRecord(recipientIdentityKey)
|
|
524
|
-
|
|
735
|
+
// Use ownerPublicKey to find sibling devices (important for delegates)
|
|
736
|
+
const ourUserRecord = this.getOrCreateUserRecord(this.ownerPublicKey)
|
|
525
737
|
|
|
526
738
|
this.setupUser(recipientIdentityKey)
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
739
|
+
// Use ownerPublicKey to setup sessions with sibling devices
|
|
740
|
+
this.setupUser(this.ownerPublicKey)
|
|
741
|
+
|
|
742
|
+
const recipientDevices = Array.from(userRecord.devices.values())
|
|
743
|
+
const ownDevices = Array.from(ourUserRecord.devices.values())
|
|
744
|
+
|
|
745
|
+
// Merge and deduplicate by deviceId, excluding our own sending device
|
|
746
|
+
// This fixes the self-message bug where sending to yourself would duplicate devices
|
|
747
|
+
const deviceMap = new Map<string, DeviceRecord>()
|
|
748
|
+
for (const d of [...recipientDevices, ...ownDevices]) {
|
|
749
|
+
if (d.deviceId !== this.deviceId) { // Exclude sender's own device
|
|
750
|
+
deviceMap.set(d.deviceId, d)
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
const devices = Array.from(deviceMap.values())
|
|
533
754
|
|
|
534
|
-
// Send to all devices
|
|
535
|
-
|
|
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(
|
|
536
758
|
devices.map(async (device) => {
|
|
537
759
|
const { activeSession } = device
|
|
538
|
-
if (!activeSession)
|
|
760
|
+
if (!activeSession) {
|
|
761
|
+
return
|
|
762
|
+
}
|
|
539
763
|
const { event: verifiedEvent } = activeSession.sendEvent(event)
|
|
540
|
-
await this.nostrPublish(verifiedEvent).catch(
|
|
764
|
+
await this.nostrPublish(verifiedEvent).catch(() => {})
|
|
541
765
|
})
|
|
542
766
|
)
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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
|
+
}
|
|
547
777
|
|
|
548
778
|
// Return the event with computed ID (same as library would compute)
|
|
549
779
|
return completeEvent
|
|
@@ -576,58 +806,30 @@ export class SessionManager {
|
|
|
576
806
|
rumor.id = getEventHash(rumor)
|
|
577
807
|
|
|
578
808
|
// Use sendEvent for actual sending (includes queueing)
|
|
579
|
-
|
|
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(() => {})
|
|
580
812
|
|
|
581
813
|
return rumor
|
|
582
814
|
}
|
|
583
815
|
|
|
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
816
|
private async cleanupDevice(publicKey: string, deviceId: string): Promise<void> {
|
|
612
817
|
const userRecord = this.userRecords.get(publicKey)
|
|
613
818
|
if (!userRecord) return
|
|
614
819
|
const deviceRecord = userRecord.devices.get(deviceId)
|
|
615
|
-
|
|
616
820
|
if (!deviceRecord) return
|
|
617
821
|
|
|
822
|
+
// Unsubscribe from sessions
|
|
618
823
|
if (deviceRecord.activeSession) {
|
|
619
824
|
this.removeSessionSubscription(publicKey, deviceId, deviceRecord.activeSession.name)
|
|
620
825
|
}
|
|
621
|
-
|
|
622
826
|
for (const session of deviceRecord.inactiveSessions) {
|
|
623
827
|
this.removeSessionSubscription(publicKey, deviceId, session.name)
|
|
624
828
|
}
|
|
625
829
|
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
await this.storeUserRecord(publicKey).catch(console.error)
|
|
830
|
+
// Delete the device record entirely
|
|
831
|
+
userRecord.devices.delete(deviceId)
|
|
832
|
+
await this.storeUserRecord(publicKey).catch(() => {})
|
|
631
833
|
}
|
|
632
834
|
|
|
633
835
|
private buildMessageTags(
|
|
@@ -644,21 +846,26 @@ export class SessionManager {
|
|
|
644
846
|
}
|
|
645
847
|
|
|
646
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
|
+
|
|
647
856
|
const data: StoredUserRecord = {
|
|
648
857
|
publicKey: publicKey,
|
|
649
|
-
devices:
|
|
858
|
+
devices: devices.map(
|
|
650
859
|
([, device]) => ({
|
|
651
860
|
deviceId: device.deviceId,
|
|
652
861
|
activeSession: device.activeSession
|
|
653
|
-
?
|
|
862
|
+
? serializeSession(device.activeSession)
|
|
654
863
|
: null,
|
|
655
|
-
inactiveSessions: device.inactiveSessions.map(
|
|
656
|
-
serializeSessionState(session.state)
|
|
657
|
-
),
|
|
864
|
+
inactiveSessions: device.inactiveSessions.map(serializeSession),
|
|
658
865
|
createdAt: device.createdAt,
|
|
659
|
-
staleAt: device.staleAt,
|
|
660
866
|
})
|
|
661
867
|
),
|
|
868
|
+
knownDeviceIdentities: userRecord?.knownDeviceIdentities || [],
|
|
662
869
|
}
|
|
663
870
|
return this.storage.put(this.userRecordKey(publicKey), data)
|
|
664
871
|
}
|
|
@@ -671,65 +878,66 @@ export class SessionManager {
|
|
|
671
878
|
|
|
672
879
|
const devices = new Map<string, DeviceRecord>()
|
|
673
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
|
+
|
|
674
888
|
for (const deviceData of data.devices) {
|
|
675
889
|
const {
|
|
676
890
|
deviceId,
|
|
677
891
|
activeSession: serializedActive,
|
|
678
892
|
inactiveSessions: serializedInactive,
|
|
679
893
|
createdAt,
|
|
680
|
-
staleAt,
|
|
681
894
|
} = deviceData
|
|
682
895
|
|
|
683
896
|
try {
|
|
684
897
|
const activeSession = serializedActive
|
|
685
|
-
?
|
|
686
|
-
this.nostrSubscribe,
|
|
687
|
-
deserializeSessionState(serializedActive)
|
|
688
|
-
)
|
|
898
|
+
? deserializeSession(serializedActive)
|
|
689
899
|
: undefined
|
|
690
900
|
|
|
691
|
-
const inactiveSessions = serializedInactive.map(
|
|
692
|
-
(entry) => new Session(this.nostrSubscribe, deserializeSessionState(entry))
|
|
693
|
-
)
|
|
901
|
+
const inactiveSessions = serializedInactive.map(deserializeSession)
|
|
694
902
|
|
|
695
903
|
devices.set(deviceId, {
|
|
696
904
|
deviceId,
|
|
697
905
|
activeSession,
|
|
698
906
|
inactiveSessions,
|
|
699
907
|
createdAt,
|
|
700
|
-
staleAt,
|
|
701
908
|
})
|
|
702
|
-
} catch
|
|
703
|
-
|
|
704
|
-
`Failed to deserialize session for user ${publicKey}, device ${deviceId}:`,
|
|
705
|
-
e
|
|
706
|
-
)
|
|
909
|
+
} catch {
|
|
910
|
+
// Failed to deserialize session
|
|
707
911
|
}
|
|
708
912
|
}
|
|
709
913
|
|
|
914
|
+
const knownDeviceIdentities = data.knownDeviceIdentities || []
|
|
915
|
+
|
|
710
916
|
this.userRecords.set(publicKey, {
|
|
711
917
|
publicKey: data.publicKey,
|
|
712
918
|
devices,
|
|
919
|
+
knownDeviceIdentities,
|
|
713
920
|
})
|
|
714
921
|
|
|
715
|
-
|
|
716
|
-
|
|
922
|
+
// Rebuild delegateToOwner mapping from stored device identities
|
|
923
|
+
for (const identity of knownDeviceIdentities) {
|
|
924
|
+
this.delegateToOwner.set(identity, publicKey)
|
|
717
925
|
}
|
|
718
926
|
|
|
719
927
|
for (const device of devices.values()) {
|
|
720
|
-
const { deviceId, activeSession, inactiveSessions
|
|
721
|
-
if (!deviceId
|
|
928
|
+
const { deviceId, activeSession, inactiveSessions } = device
|
|
929
|
+
if (!deviceId) continue
|
|
722
930
|
|
|
723
931
|
for (const session of inactiveSessions.reverse()) {
|
|
724
|
-
this.attachSessionSubscription(publicKey, device, session)
|
|
932
|
+
this.attachSessionSubscription(publicKey, device, session, true) // Restore as inactive
|
|
725
933
|
}
|
|
726
934
|
if (activeSession) {
|
|
727
|
-
this.attachSessionSubscription(publicKey, device, activeSession)
|
|
935
|
+
this.attachSessionSubscription(publicKey, device, activeSession) // Restore as active
|
|
728
936
|
}
|
|
729
937
|
}
|
|
730
938
|
})
|
|
731
|
-
.catch((
|
|
732
|
-
|
|
939
|
+
.catch(() => {
|
|
940
|
+
// Failed to load user record
|
|
733
941
|
})
|
|
734
942
|
}
|
|
735
943
|
|
|
@@ -751,34 +959,12 @@ export class SessionManager {
|
|
|
751
959
|
|
|
752
960
|
// First migration
|
|
753
961
|
if (!version) {
|
|
754
|
-
//
|
|
755
|
-
// Assume no version prefix
|
|
756
|
-
// Deserialize and serialize to start using persistent createdAt
|
|
757
|
-
// Re-save invites with proper keys
|
|
962
|
+
// Delete old invite data (legacy format no longer supported)
|
|
758
963
|
const oldInvitePrefix = "invite/"
|
|
759
964
|
const inviteKeys = await this.storage.list(oldInvitePrefix)
|
|
760
|
-
await Promise.all(
|
|
761
|
-
inviteKeys.map(async (key) => {
|
|
762
|
-
try {
|
|
763
|
-
const publicKey = key.slice(oldInvitePrefix.length)
|
|
764
|
-
const inviteData = await this.storage.get<string>(key)
|
|
765
|
-
if (inviteData) {
|
|
766
|
-
const newKey = this.userInviteKey(publicKey)
|
|
767
|
-
const invite = Invite.deserialize(inviteData)
|
|
768
|
-
const serializedInvite = invite.serialize()
|
|
769
|
-
await this.storage.put(newKey, serializedInvite)
|
|
770
|
-
await this.storage.del(key)
|
|
771
|
-
}
|
|
772
|
-
} catch (e) {
|
|
773
|
-
console.error("Migration error for invite:", e)
|
|
774
|
-
}
|
|
775
|
-
})
|
|
776
|
-
)
|
|
965
|
+
await Promise.all(inviteKeys.map((key) => this.storage.del(key)))
|
|
777
966
|
|
|
778
|
-
//
|
|
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
|
|
967
|
+
// Migrate old user records (clear sessions, keep device records)
|
|
782
968
|
const oldUserRecordPrefix = "user/"
|
|
783
969
|
const sessionKeys = await this.storage.list(oldUserRecordPrefix)
|
|
784
970
|
await Promise.all(
|
|
@@ -800,22 +986,14 @@ export class SessionManager {
|
|
|
800
986
|
await this.storage.put(newKey, newUserRecordData)
|
|
801
987
|
await this.storage.del(key)
|
|
802
988
|
}
|
|
803
|
-
} catch
|
|
804
|
-
|
|
989
|
+
} catch {
|
|
990
|
+
// Migration error for user record
|
|
805
991
|
}
|
|
806
992
|
})
|
|
807
993
|
)
|
|
808
994
|
|
|
809
|
-
// Set version to 1 so next migration can run
|
|
810
995
|
version = "1"
|
|
811
996
|
await this.storage.put(this.versionKey(), version)
|
|
812
|
-
|
|
813
|
-
return
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
// Future migrations
|
|
817
|
-
if (version === "1") {
|
|
818
|
-
return
|
|
819
997
|
}
|
|
820
998
|
}
|
|
821
999
|
}
|