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