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.
- package/dist/SessionManager.d.ts +69 -30
- package/dist/SessionManager.d.ts.map +1 -1
- package/dist/StorageAdapter.d.ts +9 -0
- package/dist/StorageAdapter.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/nostr-double-ratchet.es.js +2705 -2169
- package/dist/nostr-double-ratchet.umd.js +1 -1
- package/package.json +1 -1
- package/src/SessionManager.ts +766 -344
- package/src/StorageAdapter.ts +65 -6
- package/src/index.ts +2 -1
package/src/SessionManager.ts
CHANGED
|
@@ -1,399 +1,821 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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 {
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
33
|
+
interface StoredDeviceRecord {
|
|
34
|
+
deviceId: string
|
|
35
|
+
activeSession: StoredSessionEntry | null
|
|
36
|
+
inactiveSessions: StoredSessionEntry[]
|
|
37
|
+
createdAt: number
|
|
38
|
+
staleAt?: number
|
|
39
|
+
}
|
|
53
40
|
|
|
54
|
-
|
|
55
|
-
|
|
41
|
+
interface StoredUserRecord {
|
|
42
|
+
publicKey: string
|
|
43
|
+
devices: StoredDeviceRecord[]
|
|
44
|
+
}
|
|
56
45
|
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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.
|
|
149
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
193
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
245
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
264
|
-
|
|
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
|
-
|
|
439
|
+
this.userRecords.delete(userPubkey)
|
|
269
440
|
}
|
|
270
441
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
449
|
+
const tombstoneUnsub = this.inviteTombstoneSubscriptions.get(userPubkey)
|
|
450
|
+
if (tombstoneUnsub) {
|
|
451
|
+
tombstoneUnsub()
|
|
452
|
+
this.inviteTombstoneSubscriptions.delete(userPubkey)
|
|
331
453
|
}
|
|
332
454
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
342
|
-
|
|
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
|
-
|
|
345
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
376
|
-
|
|
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
|
-
|
|
381
|
-
|
|
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
|
}
|