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/README.md +1 -1
- package/dist/Invite.d.ts +6 -5
- package/dist/Invite.d.ts.map +1 -1
- package/dist/Session.d.ts +3 -3
- package/dist/Session.d.ts.map +1 -1
- package/dist/SessionManager.d.ts +25 -9
- package/dist/SessionManager.d.ts.map +1 -1
- package/dist/StorageAdapter.d.ts +18 -0
- package/dist/StorageAdapter.d.ts.map +1 -0
- package/dist/UserRecord.d.ts +7 -15
- package/dist/UserRecord.d.ts.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/nostr-double-ratchet.es.js +783 -740
- package/dist/nostr-double-ratchet.umd.js +1 -1
- package/dist/types.d.ts +2 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/Invite.ts +23 -18
- package/src/Session.ts +41 -34
- package/src/SessionManager.ts +266 -42
- package/src/StorageAdapter.ts +43 -0
- package/src/UserRecord.ts +41 -32
- package/src/index.ts +3 -2
- package/src/types.ts +2 -1
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
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
131
|
+
const seenIds = new Set<string>()
|
|
126
132
|
const unsub = subscribe(filter, (event) => {
|
|
127
|
-
if (
|
|
128
|
-
|
|
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
|
-
|
|
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(
|
|
171
|
-
if (!
|
|
172
|
-
throw new Error("Device
|
|
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/' +
|
|
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 (
|
|
42
|
-
* @param
|
|
43
|
-
* @param
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
if (
|
|
327
|
-
this.state.
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
return;
|
|
352
|
-
}
|
|
355
|
+
if (innerEvent.id !== getEventHash(innerEvent)) {
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
353
358
|
|
|
354
|
-
|
|
355
|
-
|
|
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() {
|
package/src/SessionManager.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
41
|
-
for (const session of
|
|
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
|
-
|
|
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
|
|
74
|
-
if (!
|
|
75
|
-
|
|
76
|
-
this.userRecords.set(userPubkey,
|
|
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
|
-
|
|
290
|
+
currentUserRecord.upsertSession(deviceId, session)
|
|
291
|
+
this.saveSession(userPubkey, deviceId, session)
|
|
79
292
|
|
|
80
|
-
//
|
|
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<(
|
|
332
|
+
private internalSubscriptions: Set<(event: Rumor) => void> = new Set()
|
|
104
333
|
|
|
105
|
-
onEvent(callback: (
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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
|
+
}
|