nostr-double-ratchet 0.0.31 → 0.0.33

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.
@@ -6,6 +6,8 @@ import { StorageAdapter, InMemoryStorageAdapter } from "./StorageAdapter"
6
6
  import { serializeSessionState, deserializeSessionState } from "./utils"
7
7
  import { Session } from "./Session"
8
8
 
9
+ export type OnEventCallback = (event: Rumor, from: string) => void
10
+
9
11
  export default class SessionManager {
10
12
  private userRecords: Map<string, UserRecord> = new Map()
11
13
  private nostrSubscribe: NostrSubscribe
@@ -44,6 +46,7 @@ export default class SessionManager {
44
46
  * Can be awaited by callers that need deterministic readiness.
45
47
  */
46
48
  public async init(): Promise<void> {
49
+ console.log("Initialising SessionManager")
47
50
  if (this._initialised) return
48
51
 
49
52
  const ourPublicKey = getPublicKey(this.ourIdentityKey)
@@ -67,7 +70,11 @@ export default class SessionManager {
67
70
  this.invite = invite
68
71
 
69
72
  // Publish our own invite
70
- this.nostrPublish(invite.getEvent())?.catch(() => {})
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))
71
78
 
72
79
  // 2b. Listen for acceptances of *our* invite and create sessions
73
80
  this.invite.listen(
@@ -90,7 +97,7 @@ export default class SessionManager {
90
97
  this.saveSession(targetUserKey, deviceKey, session)
91
98
 
92
99
  session.onEvent((_event: Rumor) => {
93
- this.internalSubscriptions.forEach(cb => cb(_event))
100
+ this.internalSubscriptions.forEach(cb => cb(_event, targetUserKey))
94
101
  })
95
102
  } catch {/* ignore errors */}
96
103
  }
@@ -128,7 +135,7 @@ export default class SessionManager {
128
135
  this.saveSession(ourPublicKey, deviceId, session)
129
136
 
130
137
  session.onEvent((_event: Rumor) => {
131
- this.internalSubscriptions.forEach(cb => cb(_event))
138
+ this.internalSubscriptions.forEach(cb => cb(_event, ourPublicKey))
132
139
  })
133
140
  } catch (err) {
134
141
  // eslint-disable-next-line no-console
@@ -167,7 +174,7 @@ export default class SessionManager {
167
174
  this.saveSession(ownerPubKey, deviceId, session)
168
175
 
169
176
  session.onEvent((_event: Rumor) => {
170
- this.internalSubscriptions.forEach(cb => cb(_event))
177
+ this.internalSubscriptions.forEach(cb => cb(_event, ownerPubKey))
171
178
  })
172
179
  } catch {
173
180
  // corrupted entry — ignore
@@ -204,7 +211,7 @@ export default class SessionManager {
204
211
  async sendEvent(recipientIdentityKey: string, event: Partial<Rumor>) {
205
212
  console.log("Sending event to", recipientIdentityKey, event)
206
213
  // Immediately notify local subscribers so that UI can render sent message optimistically
207
- this.internalSubscriptions.forEach(cb => cb(event as Rumor))
214
+ this.internalSubscriptions.forEach(cb => cb(event as Rumor, recipientIdentityKey))
208
215
 
209
216
  const results = []
210
217
  const publishPromises: Promise<any>[] = []
@@ -295,7 +302,7 @@ export default class SessionManager {
295
302
 
296
303
  // Register all existing callbacks on the new session
297
304
  session.onEvent((_event: Rumor) => {
298
- this.internalSubscriptions.forEach(callback => callback(_event))
305
+ this.internalSubscriptions.forEach(callback => callback(_event, userPubkey))
299
306
  })
300
307
 
301
308
  const queuedMessages = this.messageQueue.get(userPubkey)
@@ -332,16 +339,16 @@ export default class SessionManager {
332
339
  }
333
340
 
334
341
  // Update onEvent to include internalSubscriptions management
335
- private internalSubscriptions: Set<(event: Rumor) => void> = new Set()
342
+ private internalSubscriptions: Set<OnEventCallback> = new Set()
336
343
 
337
- onEvent(callback: (event: Rumor) => void) {
344
+ onEvent(callback: OnEventCallback) {
338
345
  this.internalSubscriptions.add(callback)
339
346
 
340
347
  // Subscribe to existing sessions
341
- for (const userRecord of this.userRecords.values()) {
348
+ for (const [pubkey, userRecord] of this.userRecords.entries()) {
342
349
  for (const session of userRecord.getActiveSessions()) {
343
- session.onEvent((_event: Rumor) => {
344
- callback(_event)
350
+ session.onEvent((event: Rumor) => {
351
+ callback(event, pubkey)
345
352
  })
346
353
  }
347
354
  }
package/src/UserRecord.ts CHANGED
@@ -2,16 +2,18 @@ import { Session } from './Session';
2
2
  import { NostrSubscribe } from './types';
3
3
 
4
4
  interface DeviceRecord {
5
+ deviceId: string;
5
6
  publicKey: string;
6
7
  activeSession?: Session;
7
8
  inactiveSessions: Session[];
8
9
  isStale: boolean;
9
10
  staleTimestamp?: number;
11
+ lastActivity?: number;
10
12
  }
11
13
 
12
14
  /**
13
- * WIP: Conversation management system similar to Signal's Sesame
14
- * https://signal.org/docs/specifications/sesame/
15
+ * Manages sessions for a single user across multiple devices
16
+ * Structure: UserRecord → DeviceRecord → Sessions
15
17
  */
16
18
  export class UserRecord {
17
19
  private deviceRecords: Map<string, DeviceRecord> = new Map();
@@ -19,231 +21,318 @@ export class UserRecord {
19
21
  private staleTimestamp?: number;
20
22
 
21
23
  constructor(
22
- public _userId: string,
23
- private _nostrSubscribe: NostrSubscribe,
24
+ public readonly userId: string,
25
+ private readonly nostrSubscribe: NostrSubscribe,
24
26
  ) {
25
27
  }
26
28
 
29
+ // ============================================================================
30
+ // Device Management
31
+ // ============================================================================
32
+
27
33
  /**
28
- * Adds or updates a device record for this user
34
+ * Creates or updates a device record for this user
29
35
  */
30
- public conditionalUpdate(deviceId: string, publicKey: string): void {
31
- const existingRecord = this.deviceRecords.get(deviceId);
36
+ public upsertDevice(deviceId: string, publicKey: string): DeviceRecord {
37
+ let record = this.deviceRecords.get(deviceId);
32
38
 
33
- // If device record doesn't exist or public key changed, create new empty record
34
- if (!existingRecord || existingRecord.publicKey !== publicKey) {
35
- this.deviceRecords.set(deviceId, {
39
+ if (!record) {
40
+ record = {
41
+ deviceId,
36
42
  publicKey,
37
43
  inactiveSessions: [],
38
- isStale: false
39
- });
44
+ isStale: false,
45
+ lastActivity: Date.now()
46
+ };
47
+ this.deviceRecords.set(deviceId, record);
48
+ } else if (record.publicKey !== publicKey) {
49
+ // Public key changed - mark old sessions as inactive and update
50
+ if (record.activeSession) {
51
+ record.inactiveSessions.push(record.activeSession);
52
+ record.activeSession = undefined;
53
+ }
54
+ record.publicKey = publicKey;
55
+ record.lastActivity = Date.now();
40
56
  }
57
+
58
+ return record;
41
59
  }
42
60
 
43
61
  /**
44
- * Inserts a new session for a device, making it the active session
62
+ * Gets a device record by deviceId
45
63
  */
46
- public insertSession(deviceId: string, session: Session): void {
47
- this.upsertSession(deviceId, session)
64
+ public getDevice(deviceId: string): DeviceRecord | undefined {
65
+ return this.deviceRecords.get(deviceId);
66
+ }
67
+
68
+ /**
69
+ * Gets all device records for this user
70
+ */
71
+ public getAllDevices(): DeviceRecord[] {
72
+ return Array.from(this.deviceRecords.values());
73
+ }
74
+
75
+ /**
76
+ * Gets all active (non-stale) device records
77
+ */
78
+ public getActiveDevices(): DeviceRecord[] {
79
+ if (this.isStale) return [];
80
+ return Array.from(this.deviceRecords.values()).filter(device => !device.isStale);
48
81
  }
49
82
 
50
83
  /**
51
- * Activates an inactive session for a device
84
+ * Removes a device record and closes all its sessions
52
85
  */
53
- public activateSession(deviceId: string, session: Session): void {
86
+ public removeDevice(deviceId: string): boolean {
54
87
  const record = this.deviceRecords.get(deviceId);
55
- if (!record) {
56
- throw new Error(`No device record found for ${deviceId}`);
57
- }
88
+ if (!record) return false;
58
89
 
59
- const sessionIndex = record.inactiveSessions.indexOf(session);
60
- if (sessionIndex === -1) {
61
- throw new Error('Session not found in inactive sessions');
62
- }
90
+ // Close all sessions for this device
91
+ record.activeSession?.close();
92
+ record.inactiveSessions.forEach(session => session.close());
93
+
94
+ return this.deviceRecords.delete(deviceId);
95
+ }
63
96
 
64
- // Remove session from inactive list
65
- record.inactiveSessions.splice(sessionIndex, 1);
97
+ // ============================================================================
98
+ // Session Management
99
+ // ============================================================================
66
100
 
67
- // Move current active session to inactive list if it exists
68
- if (record.activeSession) {
69
- record.inactiveSessions.unshift(record.activeSession);
101
+ /**
102
+ * Adds or updates a session for a specific device
103
+ */
104
+ public upsertSession(deviceId: string, session: Session, publicKey?: string): void {
105
+ const device = this.upsertDevice(deviceId, publicKey || session.state?.theirNextNostrPublicKey || '');
106
+
107
+ // If there's an active session, move it to inactive
108
+ if (device.activeSession) {
109
+ device.inactiveSessions.unshift(device.activeSession);
70
110
  }
71
111
 
72
- // Set selected session as active
73
- record.activeSession = session;
112
+ // Set the new session as active
113
+ session.name = deviceId; // Ensure session name matches deviceId
114
+ device.activeSession = session;
115
+ device.lastActivity = Date.now();
74
116
  }
75
117
 
76
118
  /**
77
- * Marks a device record as stale
119
+ * Gets the active session for a specific device
78
120
  */
79
- public markDeviceStale(deviceId: string): void {
80
- const record = this.deviceRecords.get(deviceId);
81
- if (record) {
82
- record.isStale = true;
83
- record.staleTimestamp = Date.now();
84
- }
121
+ public getActiveSession(deviceId: string): Session | undefined {
122
+ const device = this.deviceRecords.get(deviceId);
123
+ return device?.isStale ? undefined : device?.activeSession;
85
124
  }
86
125
 
87
126
  /**
88
- * Marks the entire user record as stale
127
+ * Gets all sessions (active + inactive) for a specific device
89
128
  */
90
- public markUserStale(): void {
91
- this.isStale = true;
92
- this.staleTimestamp = Date.now();
129
+ public getDeviceSessions(deviceId: string): Session[] {
130
+ const device = this.deviceRecords.get(deviceId);
131
+ if (!device) return [];
132
+
133
+ const sessions: Session[] = [];
134
+ if (device.activeSession) {
135
+ sessions.push(device.activeSession);
136
+ }
137
+ sessions.push(...device.inactiveSessions);
138
+ return sessions;
93
139
  }
94
140
 
95
141
  /**
96
- * Gets all non-stale device records with active sessions
142
+ * Gets all active sessions across all devices for this user
97
143
  */
98
- public getActiveDevices(): Array<[string, Session]> {
99
- if (this.isStale) return [];
144
+ public getActiveSessions(): Session[] {
145
+ const sessions: Session[] = [];
146
+
147
+ for (const device of this.getActiveDevices()) {
148
+ if (device.activeSession) {
149
+ sessions.push(device.activeSession);
150
+ }
151
+ }
152
+
153
+ // Sort by ability to send messages (prioritize ready sessions)
154
+ sessions.sort((a, b) => {
155
+ const aCanSend = !!(a.state?.theirNextNostrPublicKey && a.state?.ourCurrentNostrKey);
156
+ const bCanSend = !!(b.state?.theirNextNostrPublicKey && b.state?.ourCurrentNostrKey);
157
+
158
+ if (aCanSend && !bCanSend) return -1;
159
+ if (!aCanSend && bCanSend) return 1;
160
+ return 0;
161
+ });
100
162
 
101
- return Array.from(this.deviceRecords.entries())
102
- .filter(([, record]) => !record.isStale && record.activeSession)
103
- .map(([deviceId, record]) => [deviceId, record.activeSession!]);
163
+ return sessions;
104
164
  }
105
165
 
106
166
  /**
107
- * Creates a new session for a device
167
+ * Gets all sessions (active + inactive) across all devices
108
168
  */
109
- public createSession(
110
- deviceId: string,
111
- sharedSecret: Uint8Array,
112
- ourCurrentPrivateKey: Uint8Array,
113
- isInitiator: boolean,
114
- name?: string
115
- ): Session {
116
- const record = this.deviceRecords.get(deviceId);
117
- if (!record) {
118
- throw new Error(`No device record found for ${deviceId}`);
169
+ public getAllSessions(): Session[] {
170
+ const sessions: Session[] = [];
171
+
172
+ for (const device of this.deviceRecords.values()) {
173
+ if (device.activeSession) {
174
+ sessions.push(device.activeSession);
175
+ }
176
+ sessions.push(...device.inactiveSessions);
119
177
  }
120
178
 
121
- const session = Session.init(
122
- this._nostrSubscribe,
123
- record.publicKey,
124
- ourCurrentPrivateKey,
125
- isInitiator,
126
- sharedSecret,
127
- name
179
+ return sessions;
180
+ }
181
+
182
+ /**
183
+ * Gets sessions that are ready to send messages
184
+ */
185
+ public getSendableSessions(): Session[] {
186
+ return this.getActiveSessions().filter(session =>
187
+ !!(session.state?.theirNextNostrPublicKey && session.state?.ourCurrentNostrKey)
128
188
  );
189
+ }
129
190
 
130
- this.insertSession(deviceId, session);
131
- return session;
191
+ // ============================================================================
192
+ // Stale Management
193
+ // ============================================================================
194
+
195
+ /**
196
+ * Marks a specific device as stale
197
+ */
198
+ public markDeviceStale(deviceId: string): void {
199
+ const device = this.deviceRecords.get(deviceId);
200
+ if (device) {
201
+ device.isStale = true;
202
+ device.staleTimestamp = Date.now();
203
+ }
132
204
  }
133
205
 
134
206
  /**
135
- * Deletes stale records that are older than maxLatency
207
+ * Marks the entire user record as stale
208
+ */
209
+ public markUserStale(): void {
210
+ this.isStale = true;
211
+ this.staleTimestamp = Date.now();
212
+ }
213
+
214
+ /**
215
+ * Removes stale devices and sessions older than maxLatency
136
216
  */
137
217
  public pruneStaleRecords(maxLatency: number): void {
138
218
  const now = Date.now();
139
219
 
140
- // Delete stale device records
141
- for (const [deviceId, record] of this.deviceRecords.entries()) {
142
- if (record.isStale && record.staleTimestamp &&
143
- (now - record.staleTimestamp) > maxLatency) {
144
- // Close all sessions
145
- record.activeSession?.close();
146
- record.inactiveSessions.forEach(session => session.close());
147
- this.deviceRecords.delete(deviceId);
220
+ // Remove stale devices
221
+ for (const [deviceId, device] of this.deviceRecords.entries()) {
222
+ if (device.isStale && device.staleTimestamp &&
223
+ (now - device.staleTimestamp) > maxLatency) {
224
+ this.removeDevice(deviceId);
148
225
  }
149
226
  }
150
227
 
151
- // Delete entire user record if stale
228
+ // Remove entire user record if stale
152
229
  if (this.isStale && this.staleTimestamp &&
153
230
  (now - this.staleTimestamp) > maxLatency) {
154
- this.deviceRecords.forEach(record => {
155
- record.activeSession?.close();
156
- record.inactiveSessions.forEach(session => session.close());
157
- });
158
- this.deviceRecords.clear();
231
+ this.close();
159
232
  }
160
233
  }
161
234
 
235
+ // ============================================================================
236
+ // Utility Methods
237
+ // ============================================================================
238
+
162
239
  /**
163
- * Cleanup when destroying the user record
240
+ * Gets the most recently active device
164
241
  */
165
- public close(): void {
166
- this.deviceRecords.forEach(record => {
167
- record.activeSession?.close();
168
- record.inactiveSessions.forEach(session => session.close());
242
+ public getMostActiveDevice(): DeviceRecord | undefined {
243
+ const activeDevices = this.getActiveDevices();
244
+ if (activeDevices.length === 0) return undefined;
245
+
246
+ return activeDevices.reduce((most, current) => {
247
+ const mostActivity = most.lastActivity || 0;
248
+ const currentActivity = current.lastActivity || 0;
249
+ return currentActivity > mostActivity ? current : most;
169
250
  });
170
- this.deviceRecords.clear();
171
251
  }
172
252
 
173
- // ---------------------------------------------------------------------------
174
- // Helper methods used by SessionManager (WIP):
175
- // ---------------------------------------------------------------------------
176
-
177
253
  /**
178
- * Return all sessions that are currently considered *active*.
179
- * For now this means any session in a non-stale device record as well as
180
- * all sessions added through `addSession`.
181
- * Prioritizes initiator sessions (can send first message) over responder sessions.
254
+ * Gets the preferred session (from most active device)
182
255
  */
183
- public getActiveSessions(): Session[] {
184
- const sessions: Session[] = [];
185
-
186
- for (const record of this.deviceRecords.values()) {
187
- if (!record.isStale && record.activeSession) {
188
- sessions.push(record.activeSession);
189
- }
190
- }
256
+ public getPreferredSession(): Session | undefined {
257
+ const mostActive = this.getMostActiveDevice();
258
+ return mostActive?.activeSession;
259
+ }
191
260
 
192
- sessions.sort((a, b) => {
193
- const aCanSend = !!(a.state?.theirNextNostrPublicKey && a.state?.ourCurrentNostrKey);
194
- const bCanSend = !!(b.state?.theirNextNostrPublicKey && b.state?.ourCurrentNostrKey);
195
-
196
- if (aCanSend && !bCanSend) return -1; // a comes first
197
- if (!aCanSend && bCanSend) return 1; // b comes first
198
- return 0; // equal priority
199
- });
261
+ /**
262
+ * Checks if this user has any active sessions
263
+ */
264
+ public hasActiveSessions(): boolean {
265
+ return this.getActiveSessions().length > 0;
266
+ }
200
267
 
201
- return sessions;
268
+ /**
269
+ * Gets total count of devices
270
+ */
271
+ public getDeviceCount(): number {
272
+ return this.deviceRecords.size;
202
273
  }
203
274
 
204
275
  /**
205
- * Return *all* sessions active or inactive — that we have stored for this
206
- * user. This is required for `SessionManager.onEvent` so that it can attach
207
- * listeners to existing sessions.
276
+ * Gets total count of active sessions
208
277
  */
209
- public getAllSessions(): Session[] {
210
- const sessions: Session[] = [];
278
+ public getActiveSessionCount(): number {
279
+ return this.getActiveSessions().length;
280
+ }
211
281
 
212
- for (const record of this.deviceRecords.values()) {
213
- if (record.activeSession) {
214
- sessions.push(record.activeSession);
215
- }
216
- sessions.push(...record.inactiveSessions);
282
+ /**
283
+ * Cleanup when destroying the user record
284
+ */
285
+ public close(): void {
286
+ for (const device of this.deviceRecords.values()) {
287
+ device.activeSession?.close();
288
+ device.inactiveSessions.forEach(session => session.close());
217
289
  }
290
+ this.deviceRecords.clear();
291
+ }
218
292
 
219
- return sessions;
293
+ // ============================================================================
294
+ // Legacy Compatibility Methods
295
+ // ============================================================================
296
+
297
+ /**
298
+ * @deprecated Use upsertDevice instead
299
+ */
300
+ public conditionalUpdate(deviceId: string, publicKey: string): void {
301
+ this.upsertDevice(deviceId, publicKey);
220
302
  }
221
303
 
222
304
  /**
223
- * Unified helper that either associates the session with a device record
224
- * (if deviceId provided **and** the record exists) or falls back to the
225
- * legacy extraSessions list.
305
+ * @deprecated Use upsertSession instead
226
306
  */
227
- public upsertSession(deviceId: string | undefined, session: Session) {
228
- if (!deviceId) {
229
- deviceId = 'unknown'
230
- }
307
+ public insertSession(deviceId: string, session: Session): void {
308
+ this.upsertSession(deviceId, session);
309
+ }
231
310
 
232
- let record = this.deviceRecords.get(deviceId)
233
- if (!record) {
234
- record = {
235
- publicKey: session.state?.theirNextNostrPublicKey || '',
236
- inactiveSessions: [],
237
- isStale: false
238
- }
239
- this.deviceRecords.set(deviceId, record)
311
+ /**
312
+ * Creates a new session for a device
313
+ */
314
+ public createSession(
315
+ deviceId: string,
316
+ sharedSecret: Uint8Array,
317
+ ourCurrentPrivateKey: Uint8Array,
318
+ isInitiator: boolean,
319
+ name?: string
320
+ ): Session {
321
+ const device = this.getDevice(deviceId);
322
+ if (!device) {
323
+ throw new Error(`No device record found for ${deviceId}`);
240
324
  }
241
325
 
242
- if (record.activeSession) {
243
- record.inactiveSessions.unshift(record.activeSession)
244
- }
245
- // Ensure session name matches deviceId for easier identification
246
- session.name = deviceId
247
- record.activeSession = session
326
+ const session = Session.init(
327
+ this.nostrSubscribe,
328
+ device.publicKey,
329
+ ourCurrentPrivateKey,
330
+ isInitiator,
331
+ sharedSecret,
332
+ name || deviceId
333
+ );
334
+
335
+ this.upsertSession(deviceId, session);
336
+ return session;
248
337
  }
249
338
  }