nostr-double-ratchet 0.0.28 → 0.0.30

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/src/Invite.ts CHANGED
@@ -25,13 +25,13 @@ export class Invite {
25
25
  public sharedSecret: string,
26
26
  public inviter: string,
27
27
  public inviterEphemeralPrivateKey?: Uint8Array,
28
- public label?: string,
28
+ public deviceId?: string,
29
29
  public maxUses?: number,
30
30
  public usedBy: string[] = [],
31
31
  ) {
32
32
  }
33
33
 
34
- static createNew(inviter: string, label?: string, maxUses?: number): Invite {
34
+ static createNew(inviter: string, deviceId?: string, maxUses?: number): Invite {
35
35
  if (!inviter) {
36
36
  throw new Error("Inviter public key is required");
37
37
  }
@@ -43,7 +43,7 @@ export class Invite {
43
43
  sharedSecret,
44
44
  inviter,
45
45
  inviterEphemeralPrivateKey,
46
- label,
46
+ deviceId,
47
47
  maxUses
48
48
  );
49
49
  }
@@ -82,7 +82,7 @@ export class Invite {
82
82
  data.sharedSecret,
83
83
  data.inviter,
84
84
  data.inviterEphemeralPrivateKey ? new Uint8Array(data.inviterEphemeralPrivateKey) : undefined,
85
- data.label,
85
+ data.deviceId,
86
86
  data.maxUses,
87
87
  data.usedBy
88
88
  );
@@ -105,6 +105,10 @@ export class Invite {
105
105
  const sharedSecret = tags.find(([key]) => key === 'sharedSecret')?.[1];
106
106
  const inviter = event.pubkey;
107
107
 
108
+ // Extract deviceId from the "d" tag (format: double-ratchet/invites/<deviceId>)
109
+ const deviceTag = tags.find(([key]) => key === 'd')?.[1]
110
+ const deviceId = deviceTag?.split('/')?.[2]
111
+
108
112
  if (!inviterEphemeralPublicKey || !sharedSecret) {
109
113
  throw new Error("Invalid invite event: missing session key or sharedSecret");
110
114
  }
@@ -112,7 +116,9 @@ export class Invite {
112
116
  return new Invite(
113
117
  inviterEphemeralPublicKey,
114
118
  sharedSecret,
115
- inviter
119
+ inviter,
120
+ undefined, // inviterEphemeralPrivateKey not available when parsing from event
121
+ deviceId
116
122
  );
117
123
  }
118
124
 
@@ -122,12 +128,10 @@ export class Invite {
122
128
  authors: [user],
123
129
  "#l": ["double-ratchet/invites"]
124
130
  };
125
- let latest = 0;
131
+ const seenIds = new Set<string>()
126
132
  const unsub = subscribe(filter, (event) => {
127
- if (!event.created_at || event.created_at <= latest) {
128
- return;
129
- }
130
- latest = event.created_at;
133
+ if (seenIds.has(event.id)) return
134
+ seenIds.add(event.id)
131
135
  try {
132
136
  const inviteLink = Invite.fromEvent(event);
133
137
  onInvite(inviteLink);
@@ -147,7 +151,7 @@ export class Invite {
147
151
  sharedSecret: this.sharedSecret,
148
152
  inviter: this.inviter,
149
153
  inviterEphemeralPrivateKey: this.inviterEphemeralPrivateKey ? Array.from(this.inviterEphemeralPrivateKey) : undefined,
150
- label: this.label,
154
+ deviceId: this.deviceId,
151
155
  maxUses: this.maxUses,
152
156
  usedBy: this.usedBy,
153
157
  });
@@ -167,9 +171,9 @@ export class Invite {
167
171
  return url.toString();
168
172
  }
169
173
 
170
- getEvent(deviceName: string): UnsignedEvent {
171
- if (!deviceName) {
172
- throw new Error("Device name is required");
174
+ getEvent(): UnsignedEvent {
175
+ if (!this.deviceId) {
176
+ throw new Error("Device ID is required");
173
177
  }
174
178
  return {
175
179
  kind: INVITE_EVENT_KIND,
@@ -179,7 +183,7 @@ export class Invite {
179
183
  tags: [
180
184
  ['ephemeralKey', this.inviterEphemeralPublicKey],
181
185
  ['sharedSecret', this.sharedSecret],
182
- ['d', 'double-ratchet/invites/' + deviceName],
186
+ ['d', 'double-ratchet/invites/' + this.deviceId],
183
187
  ['l', 'double-ratchet/invites']
184
188
  ],
185
189
  };
@@ -187,11 +191,12 @@ export class Invite {
187
191
 
188
192
  /**
189
193
  * Called by the invitee. Accepts the invite and creates a new session with the inviter.
190
- *
191
- * @param inviteeSecretKey - The invitee's secret key or a signing function
194
+ *
192
195
  * @param nostrSubscribe - A function to subscribe to Nostr events
196
+ * @param inviteePublicKey - The invitee's public key
197
+ * @param encryptor - The invitee's secret key or a signing/encrypt function
193
198
  * @returns An object containing the new session and an event to be published
194
- *
199
+ *
195
200
  * 1. Inner event: No signature, content encrypted with DH(inviter, invitee).
196
201
  * Purpose: Authenticate invitee. Contains invitee session key.
197
202
  * 2. Envelope: No signature, content encrypted with DH(inviter, random key).
package/src/Session.ts CHANGED
@@ -38,9 +38,9 @@ export class Session {
38
38
 
39
39
  /**
40
40
  * Initializes a new secure communication session
41
- * @param nostrSubscribe Function to subscribe to Nostr events. Make sure it deduplicates events (doesnt return the same event twice), otherwise you'll see decryption errors!
42
- * @param theirNextNostrPublicKey The public key of the other party
43
- * @param ourCurrentPrivateKey Our current private key for Nostr
41
+ * @param nostrSubscribe Function to subscribe to Nostr events. Make sure it deduplicates events (doesn't return the same event twice), otherwise you'll see decryption errors!
42
+ * @param theirEphemeralNostrPublicKey The ephemeral public key of the other party for the initial handshake
43
+ * @param ourEphemeralNostrPrivateKey Our ephemeral private key for the initial handshake
44
44
  * @param isInitiator Whether we are initiating the conversation (true) or responding (false)
45
45
  * @param sharedSecret Initial shared secret for securing the first message chain
46
46
  * @param name Optional name for the session (for debugging)
@@ -320,42 +320,49 @@ export class Session {
320
320
  }
321
321
 
322
322
  private handleNostrEvent(e: { tags: string[][]; pubkey: string; content: string }) {
323
- const [header, shouldRatchet, isSkipped] = this.decryptHeader(e);
324
-
325
- if (!isSkipped) {
326
- if (this.state.theirNextNostrPublicKey !== header.nextPublicKey) {
327
- this.state.theirCurrentNostrPublicKey = this.state.theirNextNostrPublicKey;
328
- this.state.theirNextNostrPublicKey = header.nextPublicKey;
329
- this.nostrUnsubscribe?.();
330
- this.nostrUnsubscribe = this.nostrNextUnsubscribe;
331
- this.nostrNextUnsubscribe = this.nostrSubscribe(
332
- {authors: [this.state.theirNextNostrPublicKey], kinds: [MESSAGE_EVENT_KIND]},
333
- (e) => this.handleNostrEvent(e)
334
- );
335
- }
336
-
337
- if (shouldRatchet) {
338
- this.skipMessageKeys(header.previousChainLength, e.pubkey);
339
- this.ratchetStep();
323
+ try {
324
+ const [header, shouldRatchet, isSkipped] = this.decryptHeader(e);
325
+
326
+ if (!isSkipped) {
327
+ if (this.state.theirNextNostrPublicKey !== header.nextPublicKey) {
328
+ this.state.theirCurrentNostrPublicKey = this.state.theirNextNostrPublicKey;
329
+ this.state.theirNextNostrPublicKey = header.nextPublicKey;
330
+ this.nostrUnsubscribe?.();
331
+ this.nostrUnsubscribe = this.nostrNextUnsubscribe;
332
+ this.nostrNextUnsubscribe = this.nostrSubscribe(
333
+ {authors: [this.state.theirNextNostrPublicKey], kinds: [MESSAGE_EVENT_KIND]},
334
+ (e) => this.handleNostrEvent(e)
335
+ );
336
+ }
337
+
338
+ if (shouldRatchet) {
339
+ this.skipMessageKeys(header.previousChainLength, e.pubkey);
340
+ this.ratchetStep();
341
+ }
342
+ } else {
343
+ if (!this.state.skippedKeys[e.pubkey]?.messageKeys[header.number]) {
344
+ // Maybe we already processed this message — no error
345
+ return
346
+ }
340
347
  }
341
- } else {
342
- if (!this.state.skippedKeys[e.pubkey]?.messageKeys[header.number]) {
343
- // Maybe we already processed this message — no error
344
- return
348
+
349
+ const text = this.ratchetDecrypt(header, e.content, e.pubkey);
350
+ const innerEvent = JSON.parse(text);
351
+ if (!validateEvent(innerEvent)) {
352
+ return;
345
353
  }
346
- }
347
354
 
348
- const text = this.ratchetDecrypt(header, e.content, e.pubkey);
349
- const innerEvent = JSON.parse(text);
350
- if (!validateEvent(innerEvent)) {
351
- return;
352
- }
355
+ if (innerEvent.id !== getEventHash(innerEvent)) {
356
+ return;
357
+ }
353
358
 
354
- if (innerEvent.id !== getEventHash(innerEvent)) {
355
- return;
359
+ this.internalSubscriptions.forEach(callback => callback(innerEvent, e as VerifiedEvent));
360
+ } catch (error) {
361
+ if (error instanceof Error && error.message.includes("Failed to decrypt header")) {
362
+ return;
363
+ }
364
+ throw error;
356
365
  }
357
-
358
- this.internalSubscriptions.forEach(callback => callback(innerEvent, e as VerifiedEvent));
359
366
  }
360
367
 
361
368
  private subscribeToNostrEvents() {
@@ -2,6 +2,9 @@ import { CHAT_MESSAGE_KIND, NostrPublish, NostrSubscribe, Rumor, Unsubscribe } f
2
2
  import { UserRecord } from "./UserRecord"
3
3
  import { Invite } from "./Invite"
4
4
  import { getPublicKey } from "nostr-tools"
5
+ import { StorageAdapter, InMemoryStorageAdapter } from "./StorageAdapter"
6
+ import { serializeSessionState, deserializeSessionState } from "./utils"
7
+ import { Session } from "./Session"
5
8
 
6
9
  export default class SessionManager {
7
10
  private userRecords: Map<string, UserRecord> = new Map()
@@ -9,13 +12,182 @@ export default class SessionManager {
9
12
  private nostrPublish: NostrPublish
10
13
  private ourIdentityKey: Uint8Array
11
14
  private inviteUnsubscribes: Map<string, Unsubscribe> = new Map()
12
- private ownDeviceInvites: Map<string, Invite | null> = new Map()
15
+ private deviceId: string
16
+ private invite?: Invite
17
+ private storage: StorageAdapter
18
+ private messageQueue: Map<string, Array<{event: Partial<Rumor>, resolve: (results: any[]) => void}>> = new Map()
13
19
 
14
- constructor(ourIdentityKey: Uint8Array, nostrSubscribe: NostrSubscribe, nostrPublish: NostrPublish) {
20
+ constructor(
21
+ ourIdentityKey: Uint8Array,
22
+ deviceId: string,
23
+ nostrSubscribe: NostrSubscribe,
24
+ nostrPublish: NostrPublish,
25
+ storage: StorageAdapter = new InMemoryStorageAdapter(),
26
+ ) {
15
27
  this.userRecords = new Map()
16
28
  this.nostrSubscribe = nostrSubscribe
17
29
  this.nostrPublish = nostrPublish
18
30
  this.ourIdentityKey = ourIdentityKey
31
+ this.deviceId = deviceId
32
+ this.storage = storage
33
+
34
+ // Kick off initialisation in background for backwards compatibility
35
+ // Users that need to wait can call await manager.init()
36
+ this.init()
37
+ }
38
+
39
+ private _initialised = false
40
+
41
+ /**
42
+ * Perform asynchronous initialisation steps: create (or load) our invite,
43
+ * publish it, hydrate sessions from storage and subscribe to new invites.
44
+ * Can be awaited by callers that need deterministic readiness.
45
+ */
46
+ public async init(): Promise<void> {
47
+ if (this._initialised) return
48
+
49
+ const ourPublicKey = getPublicKey(this.ourIdentityKey)
50
+
51
+ // 1. Hydrate existing sessions (placeholder for future implementation)
52
+ await this.loadSessions()
53
+
54
+ // 2. Create or load our own invite
55
+ let invite: Invite | undefined
56
+ try {
57
+ const stored = await this.storage.get<string>(`invite/${this.deviceId}`)
58
+ if (stored) {
59
+ invite = Invite.deserialize(stored)
60
+ }
61
+ } catch {/* ignore malformed */}
62
+
63
+ if (!invite) {
64
+ invite = Invite.createNew(ourPublicKey, this.deviceId)
65
+ await this.storage.put(`invite/${this.deviceId}`, invite.serialize()).catch(() => {})
66
+ }
67
+ this.invite = invite
68
+
69
+ // 2b. Listen for acceptances of *our* invite and create sessions
70
+ this.invite.listen(
71
+ this.ourIdentityKey,
72
+ this.nostrSubscribe,
73
+ (session, inviteePubkey) => {
74
+ if (!inviteePubkey) return
75
+
76
+ const targetUserKey = inviteePubkey
77
+
78
+ try {
79
+ let userRecord = this.userRecords.get(targetUserKey)
80
+ if (!userRecord) {
81
+ userRecord = new UserRecord(targetUserKey, this.nostrSubscribe)
82
+ this.userRecords.set(targetUserKey, userRecord)
83
+ }
84
+
85
+ const deviceKey = session.name || 'unknown'
86
+ userRecord.upsertSession(deviceKey, session)
87
+ this.saveSession(targetUserKey, deviceKey, session)
88
+
89
+ session.onEvent((_event: Rumor) => {
90
+ this.internalSubscriptions.forEach(cb => cb(_event))
91
+ })
92
+ } catch {/* ignore errors */}
93
+ }
94
+ )
95
+
96
+ // 3. Subscribe to our own invites from other devices
97
+ Invite.fromUser(ourPublicKey, this.nostrSubscribe, async (invite) => {
98
+ try {
99
+ const inviteDeviceId = invite['deviceId'] || 'unknown'
100
+ if (!inviteDeviceId || inviteDeviceId === this.deviceId) {
101
+ return
102
+ }
103
+
104
+ const existingRecord = this.userRecords.get(ourPublicKey)
105
+ if (existingRecord?.getActiveSessions().some(session => session.name === inviteDeviceId)) {
106
+ return
107
+ }
108
+
109
+ const { session, event } = await invite.accept(
110
+ this.nostrSubscribe,
111
+ ourPublicKey,
112
+ this.ourIdentityKey
113
+ )
114
+ this.nostrPublish(event)?.catch(() => {})
115
+
116
+ this.saveSession(ourPublicKey, inviteDeviceId, session)
117
+
118
+ let userRecord = this.userRecords.get(ourPublicKey)
119
+ if (!userRecord) {
120
+ userRecord = new UserRecord(ourPublicKey, this.nostrSubscribe)
121
+ this.userRecords.set(ourPublicKey, userRecord)
122
+ }
123
+ const deviceId = invite['deviceId'] || event.id || 'unknown'
124
+ userRecord.upsertSession(deviceId, session)
125
+ this.saveSession(ourPublicKey, deviceId, session)
126
+
127
+ session.onEvent((_event: Rumor) => {
128
+ this.internalSubscriptions.forEach(cb => cb(_event))
129
+ })
130
+ } catch (err) {
131
+ // eslint-disable-next-line no-console
132
+ console.error('Own-invite accept failed', err)
133
+ }
134
+ })
135
+
136
+
137
+
138
+ this._initialised = true
139
+ await this.nostrPublish(this.invite.getEvent()).catch(() => {})
140
+ }
141
+
142
+ private async loadSessions() {
143
+ const base = 'session/'
144
+ const keys = await this.storage.list(base)
145
+ for (const key of keys) {
146
+ const rest = key.substring(base.length)
147
+ const idx = rest.indexOf('/')
148
+ if (idx === -1) continue
149
+ const ownerPubKey = rest.substring(0, idx)
150
+ const deviceId = rest.substring(idx + 1) || 'unknown'
151
+
152
+ const data = await this.storage.get<string>(key)
153
+ if (!data) continue
154
+ try {
155
+ const state = deserializeSessionState(data)
156
+ const session = new Session(this.nostrSubscribe, state)
157
+
158
+ let userRecord = this.userRecords.get(ownerPubKey)
159
+ if (!userRecord) {
160
+ userRecord = new UserRecord(ownerPubKey, this.nostrSubscribe)
161
+ this.userRecords.set(ownerPubKey, userRecord)
162
+ }
163
+ userRecord.upsertSession(deviceId, session)
164
+ this.saveSession(ownerPubKey, deviceId, session)
165
+
166
+ session.onEvent((_event: Rumor) => {
167
+ this.internalSubscriptions.forEach(cb => cb(_event))
168
+ })
169
+ } catch {
170
+ // corrupted entry — ignore
171
+ }
172
+ }
173
+ }
174
+
175
+ private async saveSession(ownerPubKey: string, deviceId: string, session: Session) {
176
+ try {
177
+ const key = `session/${ownerPubKey}/${deviceId}`
178
+ await this.storage.put(key, serializeSessionState(session.state))
179
+ } catch {/* ignore */}
180
+ }
181
+
182
+ getDeviceId(): string {
183
+ return this.deviceId
184
+ }
185
+
186
+ getInvite(): Invite {
187
+ if (!this.invite) {
188
+ throw new Error("SessionManager not initialised yet")
189
+ }
190
+ return this.invite
19
191
  }
20
192
 
21
193
  async sendText(recipientIdentityKey: string, text: string) {
@@ -27,32 +199,62 @@ export default class SessionManager {
27
199
  }
28
200
 
29
201
  async sendEvent(recipientIdentityKey: string, event: Partial<Rumor>) {
202
+ console.log("Sending event to", recipientIdentityKey, event)
203
+ // Immediately notify local subscribers so that UI can render sent message optimistically
204
+ this.internalSubscriptions.forEach(cb => cb(event as Rumor))
205
+
30
206
  const results = []
31
-
207
+ const publishPromises: Promise<any>[] = []
208
+
32
209
  // Send to recipient's devices
33
210
  const userRecord = this.userRecords.get(recipientIdentityKey)
34
211
  if (!userRecord) {
35
- // Listen for invites from recipient
36
- this.listenToUser(recipientIdentityKey)
37
- throw new Error("No active session with user. Listening for invites.")
212
+ return new Promise<any[]>((resolve) => {
213
+ if (!this.messageQueue.has(recipientIdentityKey)) {
214
+ this.messageQueue.set(recipientIdentityKey, [])
215
+ }
216
+ this.messageQueue.get(recipientIdentityKey)!.push({event, resolve})
217
+ this.listenToUser(recipientIdentityKey)
218
+ })
219
+ }
220
+
221
+ const activeSessions = userRecord.getActiveSessions()
222
+ const sendableSessions = activeSessions.filter(s => !!(s.state?.theirNextNostrPublicKey && s.state?.ourCurrentNostrKey))
223
+
224
+ if (sendableSessions.length === 0) {
225
+ return new Promise<any[]>((resolve) => {
226
+ if (!this.messageQueue.has(recipientIdentityKey)) {
227
+ this.messageQueue.set(recipientIdentityKey, [])
228
+ }
229
+ this.messageQueue.get(recipientIdentityKey)!.push({event, resolve})
230
+ this.listenToUser(recipientIdentityKey)
231
+ })
38
232
  }
39
233
 
40
- // Send to all active sessions with recipient
41
- for (const session of userRecord.getActiveSessions()) {
234
+ // Send to all sendable sessions with recipient
235
+ for (const session of sendableSessions) {
42
236
  const { event: encryptedEvent } = session.sendEvent(event)
43
237
  results.push(encryptedEvent)
238
+ publishPromises.push(this.nostrPublish(encryptedEvent).catch(() => {}))
44
239
  }
45
240
 
46
241
  // Send to our own devices (for multi-device sync)
47
242
  const ourPublicKey = getPublicKey(this.ourIdentityKey)
48
243
  const ownUserRecord = this.userRecords.get(ourPublicKey)
49
244
  if (ownUserRecord) {
50
- for (const session of ownUserRecord.getActiveSessions()) {
245
+ const ownSendableSessions = ownUserRecord.getActiveSessions().filter(s => !!(s.state?.theirNextNostrPublicKey && s.state?.ourCurrentNostrKey))
246
+ for (const session of ownSendableSessions) {
51
247
  const { event: encryptedEvent } = session.sendEvent(event)
52
248
  results.push(encryptedEvent)
249
+ publishPromises.push(this.nostrPublish(encryptedEvent).catch(() => {}))
53
250
  }
54
251
  }
55
252
 
253
+ // Ensure all publish operations settled before returning
254
+ if (publishPromises.length > 0) {
255
+ await Promise.all(publishPromises)
256
+ }
257
+
56
258
  return results
57
259
  }
58
260
 
@@ -62,26 +264,53 @@ export default class SessionManager {
62
264
 
63
265
  const unsubscribe = Invite.fromUser(userPubkey, this.nostrSubscribe, async (_invite) => {
64
266
  try {
267
+ const deviceId = (_invite instanceof Invite && _invite.deviceId) ? _invite.deviceId : 'unknown'
268
+
269
+ const userRecord = this.userRecords.get(userPubkey)
270
+ if (userRecord) {
271
+ const existingSessions = userRecord.getActiveSessions()
272
+ if (existingSessions.some(session => session.name === deviceId)) {
273
+ return // Already have session with this device
274
+ }
275
+ }
276
+
65
277
  const { session, event } = await _invite.accept(
66
278
  this.nostrSubscribe,
67
279
  getPublicKey(this.ourIdentityKey),
68
280
  this.ourIdentityKey
69
281
  )
70
- this.nostrPublish(event)
282
+ this.nostrPublish(event)?.catch(() => {})
71
283
 
72
284
  // Store the new session
73
- let userRecord = this.userRecords.get(userPubkey)
74
- if (!userRecord) {
75
- userRecord = new UserRecord(userPubkey, this.nostrSubscribe)
76
- this.userRecords.set(userPubkey, userRecord)
285
+ let currentUserRecord = this.userRecords.get(userPubkey)
286
+ if (!currentUserRecord) {
287
+ currentUserRecord = new UserRecord(userPubkey, this.nostrSubscribe)
288
+ this.userRecords.set(userPubkey, currentUserRecord)
77
289
  }
78
- userRecord.insertSession(event.id || 'unknown', session)
290
+ currentUserRecord.upsertSession(deviceId, session)
291
+ this.saveSession(userPubkey, deviceId, session)
79
292
 
80
- // Set up event handling for the new session
81
- session.onEvent((_event) => {
293
+ // Register all existing callbacks on the new session
294
+ session.onEvent((_event: Rumor) => {
82
295
  this.internalSubscriptions.forEach(callback => callback(_event))
83
296
  })
84
297
 
298
+ const queuedMessages = this.messageQueue.get(userPubkey)
299
+ if (queuedMessages && queuedMessages.length > 0) {
300
+ setTimeout(async () => {
301
+ const currentQueuedMessages = this.messageQueue.get(userPubkey)
302
+ if (currentQueuedMessages && currentQueuedMessages.length > 0) {
303
+ const messagesToProcess = [...currentQueuedMessages]
304
+ this.messageQueue.delete(userPubkey)
305
+
306
+ for (const {event: queuedEvent, resolve} of messagesToProcess) {
307
+ const results = await this.sendEvent(userPubkey, queuedEvent)
308
+ resolve(results)
309
+ }
310
+ }
311
+ }, 1000) // Increased delay for CI compatibility
312
+ }
313
+
85
314
  // Return the event to be published
86
315
  return event
87
316
  } catch {
@@ -100,9 +329,9 @@ export default class SessionManager {
100
329
  }
101
330
 
102
331
  // Update onEvent to include internalSubscriptions management
103
- private internalSubscriptions: Set<(_event: Rumor) => void> = new Set()
332
+ private internalSubscriptions: Set<(event: Rumor) => void> = new Set()
104
333
 
105
- onEvent(callback: (_event: Rumor) => void) {
334
+ onEvent(callback: (event: Rumor) => void) {
106
335
  this.internalSubscriptions.add(callback)
107
336
 
108
337
  // Subscribe to existing sessions
@@ -135,31 +364,26 @@ export default class SessionManager {
135
364
  }
136
365
  this.userRecords.clear()
137
366
  this.internalSubscriptions.clear()
138
- this.ownDeviceInvites.clear()
139
- }
140
-
141
- createOwnDeviceInvite(deviceName: string, label?: string, maxUses?: number): Invite {
142
- const ourPublicKey = getPublicKey(this.ourIdentityKey)
143
- const invite = Invite.createNew(ourPublicKey, label, maxUses)
144
- this.ownDeviceInvites.set(deviceName, invite)
145
- return invite
146
- }
147
-
148
- removeOwnDevice(deviceName: string): void {
149
- this.ownDeviceInvites.set(deviceName, null)
150
- }
151
-
152
- getOwnDeviceInvites(): Map<string, Invite | null> {
153
- return new Map(this.ownDeviceInvites)
154
367
  }
155
368
 
156
- getActiveOwnDeviceInvites(): Map<string, Invite> {
157
- const active = new Map<string, Invite>()
158
- for (const [deviceName, invite] of this.ownDeviceInvites) {
159
- if (invite !== null) {
160
- active.set(deviceName, invite)
161
- }
369
+ /**
370
+ * Accept an invite as our own device, persist the session, and publish the acceptance event.
371
+ * Used for multi-device flows where a user adds a new device.
372
+ */
373
+ public async acceptOwnInvite(invite: Invite) {
374
+ const ourPublicKey = getPublicKey(this.ourIdentityKey);
375
+ const { session, event } = await invite.accept(
376
+ this.nostrSubscribe,
377
+ ourPublicKey,
378
+ this.ourIdentityKey
379
+ );
380
+ let userRecord = this.userRecords.get(ourPublicKey);
381
+ if (!userRecord) {
382
+ userRecord = new UserRecord(ourPublicKey, this.nostrSubscribe);
383
+ this.userRecords.set(ourPublicKey, userRecord);
162
384
  }
163
- return active
385
+ userRecord.upsertSession(session.name || 'unknown', session);
386
+ await this.saveSession(ourPublicKey, session.name || 'unknown', session);
387
+ this.nostrPublish(event)?.catch(() => {});
164
388
  }
165
389
  }
@@ -0,0 +1,43 @@
1
+ /*
2
+ * Simple async key value storage interface plus an in memory implementation.
3
+ *
4
+ * All methods are Promise based to accommodate back ends like
5
+ * IndexedDB, SQLite, remote HTTP APIs, etc. For environments where you only
6
+ * need ephemeral data (tests, Node scripts) the InMemoryStorageAdapter can be
7
+ * used directly.
8
+ */
9
+
10
+ export interface StorageAdapter {
11
+ /** Retrieve a value by key. */
12
+ get<T = unknown>(key: string): Promise<T | undefined>
13
+ /** Store a value by key. */
14
+ put<T = unknown>(key: string, value: T): Promise<void>
15
+ /** Delete a stored value by key. */
16
+ del(key: string): Promise<void>
17
+ /** List all keys that start with the given prefix. */
18
+ list(prefix?: string): Promise<string[]>
19
+ }
20
+
21
+ export class InMemoryStorageAdapter implements StorageAdapter {
22
+ private store = new Map<string, unknown>()
23
+
24
+ async get<T = unknown>(key: string): Promise<T | undefined> {
25
+ return this.store.get(key) as T | undefined
26
+ }
27
+
28
+ async put<T = unknown>(key: string, value: T): Promise<void> {
29
+ this.store.set(key, value)
30
+ }
31
+
32
+ async del(key: string): Promise<void> {
33
+ this.store.delete(key)
34
+ }
35
+
36
+ async list(prefix = ''): Promise<string[]> {
37
+ const keys: string[] = []
38
+ for (const k of this.store.keys()) {
39
+ if (k.startsWith(prefix)) keys.push(k)
40
+ }
41
+ return keys
42
+ }
43
+ }