nostr-double-ratchet 0.0.28 → 0.0.29
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/SessionManager.d.ts +22 -7
- 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 +6 -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 +764 -729
- 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 +3 -3
- package/src/SessionManager.ts +195 -32
- package/src/StorageAdapter.ts +43 -0
- package/src/UserRecord.ts +31 -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)
|
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,176 @@ 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
|
|
13
18
|
|
|
14
|
-
constructor(
|
|
19
|
+
constructor(
|
|
20
|
+
ourIdentityKey: Uint8Array,
|
|
21
|
+
deviceId: string,
|
|
22
|
+
nostrSubscribe: NostrSubscribe,
|
|
23
|
+
nostrPublish: NostrPublish,
|
|
24
|
+
storage: StorageAdapter = new InMemoryStorageAdapter(),
|
|
25
|
+
) {
|
|
15
26
|
this.userRecords = new Map()
|
|
16
27
|
this.nostrSubscribe = nostrSubscribe
|
|
17
28
|
this.nostrPublish = nostrPublish
|
|
18
29
|
this.ourIdentityKey = ourIdentityKey
|
|
30
|
+
this.deviceId = deviceId
|
|
31
|
+
this.storage = storage
|
|
32
|
+
|
|
33
|
+
// Kick off initialisation in background for backwards compatibility
|
|
34
|
+
// Users that need to wait can call await manager.init()
|
|
35
|
+
this.init()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private _initialised = false
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Perform asynchronous initialisation steps: create (or load) our invite,
|
|
42
|
+
* publish it, hydrate sessions from storage and subscribe to new invites.
|
|
43
|
+
* Can be awaited by callers that need deterministic readiness.
|
|
44
|
+
*/
|
|
45
|
+
public async init(): Promise<void> {
|
|
46
|
+
if (this._initialised) return
|
|
47
|
+
|
|
48
|
+
const ourPublicKey = getPublicKey(this.ourIdentityKey)
|
|
49
|
+
|
|
50
|
+
// 1. Hydrate existing sessions (placeholder for future implementation)
|
|
51
|
+
await this.loadSessions()
|
|
52
|
+
|
|
53
|
+
// 2. Create or load our own invite
|
|
54
|
+
let invite: Invite | undefined
|
|
55
|
+
try {
|
|
56
|
+
const stored = await this.storage.get<string>(`invite/${this.deviceId}`)
|
|
57
|
+
if (stored) {
|
|
58
|
+
invite = Invite.deserialize(stored)
|
|
59
|
+
}
|
|
60
|
+
} catch {/* ignore malformed */}
|
|
61
|
+
|
|
62
|
+
if (!invite) {
|
|
63
|
+
invite = Invite.createNew(ourPublicKey, this.deviceId)
|
|
64
|
+
await this.storage.put(`invite/${this.deviceId}`, invite.serialize()).catch(() => {})
|
|
65
|
+
}
|
|
66
|
+
this.invite = invite
|
|
67
|
+
|
|
68
|
+
// 2b. Listen for acceptances of *our* invite and create sessions
|
|
69
|
+
this.invite.listen(
|
|
70
|
+
this.ourIdentityKey,
|
|
71
|
+
this.nostrSubscribe,
|
|
72
|
+
(session, inviteePubkey) => {
|
|
73
|
+
if (!inviteePubkey) return
|
|
74
|
+
try {
|
|
75
|
+
let userRecord = this.userRecords.get(inviteePubkey)
|
|
76
|
+
if (!userRecord) {
|
|
77
|
+
userRecord = new UserRecord(inviteePubkey, this.nostrSubscribe)
|
|
78
|
+
this.userRecords.set(inviteePubkey, userRecord)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const deviceKey = session.name || 'unknown'
|
|
82
|
+
userRecord.upsertSession(deviceKey, session)
|
|
83
|
+
this.saveSession(inviteePubkey, deviceKey, session)
|
|
84
|
+
|
|
85
|
+
session.onEvent((_event: Rumor) => {
|
|
86
|
+
this.internalSubscriptions.forEach(cb => cb(_event))
|
|
87
|
+
})
|
|
88
|
+
} catch {/* ignore errors */}
|
|
89
|
+
}
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
// 3. Subscribe to our own invites from other devices
|
|
93
|
+
Invite.fromUser(ourPublicKey, this.nostrSubscribe, async (invite) => {
|
|
94
|
+
try {
|
|
95
|
+
const inviteDeviceId = invite['deviceId'] || 'unknown'
|
|
96
|
+
if (!inviteDeviceId || inviteDeviceId === this.deviceId) {
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const existingRecord = this.userRecords.get(ourPublicKey)
|
|
101
|
+
if (existingRecord?.getActiveSessions().some(session => session.name === inviteDeviceId)) {
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const { session, event } = await invite.accept(
|
|
106
|
+
this.nostrSubscribe,
|
|
107
|
+
ourPublicKey,
|
|
108
|
+
this.ourIdentityKey
|
|
109
|
+
)
|
|
110
|
+
this.nostrPublish(event)?.catch(() => {})
|
|
111
|
+
|
|
112
|
+
this.saveSession(ourPublicKey, inviteDeviceId, session)
|
|
113
|
+
|
|
114
|
+
let userRecord = this.userRecords.get(ourPublicKey)
|
|
115
|
+
if (!userRecord) {
|
|
116
|
+
userRecord = new UserRecord(ourPublicKey, this.nostrSubscribe)
|
|
117
|
+
this.userRecords.set(ourPublicKey, userRecord)
|
|
118
|
+
}
|
|
119
|
+
const deviceId = invite['deviceId'] || event.id || 'unknown'
|
|
120
|
+
userRecord.upsertSession(deviceId, session)
|
|
121
|
+
this.saveSession(ourPublicKey, deviceId, session)
|
|
122
|
+
|
|
123
|
+
session.onEvent((_event: Rumor) => {
|
|
124
|
+
this.internalSubscriptions.forEach(cb => cb(_event))
|
|
125
|
+
})
|
|
126
|
+
} catch (err) {
|
|
127
|
+
// eslint-disable-next-line no-console
|
|
128
|
+
console.error('Own-invite accept failed', err)
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
this._initialised = true
|
|
133
|
+
await this.nostrPublish(this.invite.getEvent()).catch(() => {})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private async loadSessions() {
|
|
137
|
+
const base = 'session/'
|
|
138
|
+
const keys = await this.storage.list(base)
|
|
139
|
+
for (const key of keys) {
|
|
140
|
+
const rest = key.substring(base.length)
|
|
141
|
+
const idx = rest.indexOf('/')
|
|
142
|
+
if (idx === -1) continue
|
|
143
|
+
const ownerPubKey = rest.substring(0, idx)
|
|
144
|
+
const deviceId = rest.substring(idx + 1) || 'unknown'
|
|
145
|
+
|
|
146
|
+
const data = await this.storage.get<string>(key)
|
|
147
|
+
if (!data) continue
|
|
148
|
+
try {
|
|
149
|
+
const state = deserializeSessionState(data)
|
|
150
|
+
const session = new Session(this.nostrSubscribe, state)
|
|
151
|
+
|
|
152
|
+
let userRecord = this.userRecords.get(ownerPubKey)
|
|
153
|
+
if (!userRecord) {
|
|
154
|
+
userRecord = new UserRecord(ownerPubKey, this.nostrSubscribe)
|
|
155
|
+
this.userRecords.set(ownerPubKey, userRecord)
|
|
156
|
+
}
|
|
157
|
+
userRecord.upsertSession(deviceId, session)
|
|
158
|
+
this.saveSession(ownerPubKey, deviceId, session)
|
|
159
|
+
|
|
160
|
+
session.onEvent((_event: Rumor) => {
|
|
161
|
+
this.internalSubscriptions.forEach(cb => cb(_event))
|
|
162
|
+
})
|
|
163
|
+
} catch {
|
|
164
|
+
// corrupted entry — ignore
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private async saveSession(ownerPubKey: string, deviceId: string, session: Session) {
|
|
170
|
+
try {
|
|
171
|
+
const key = `session/${ownerPubKey}/${deviceId}`
|
|
172
|
+
await this.storage.put(key, serializeSessionState(session.state))
|
|
173
|
+
} catch {/* ignore */}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
getDeviceId(): string {
|
|
177
|
+
return this.deviceId
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
getInvite(): Invite {
|
|
181
|
+
if (!this.invite) {
|
|
182
|
+
throw new Error("SessionManager not initialised yet")
|
|
183
|
+
}
|
|
184
|
+
return this.invite
|
|
19
185
|
}
|
|
20
186
|
|
|
21
187
|
async sendText(recipientIdentityKey: string, text: string) {
|
|
@@ -32,9 +198,10 @@ export default class SessionManager {
|
|
|
32
198
|
// Send to recipient's devices
|
|
33
199
|
const userRecord = this.userRecords.get(recipientIdentityKey)
|
|
34
200
|
if (!userRecord) {
|
|
35
|
-
// Listen for invites from recipient
|
|
201
|
+
// Listen for invites from recipient and return without throwing; caller
|
|
202
|
+
// can await a subsequent session establishment.
|
|
36
203
|
this.listenToUser(recipientIdentityKey)
|
|
37
|
-
|
|
204
|
+
return []
|
|
38
205
|
}
|
|
39
206
|
|
|
40
207
|
// Send to all active sessions with recipient
|
|
@@ -67,7 +234,7 @@ export default class SessionManager {
|
|
|
67
234
|
getPublicKey(this.ourIdentityKey),
|
|
68
235
|
this.ourIdentityKey
|
|
69
236
|
)
|
|
70
|
-
this.nostrPublish(event)
|
|
237
|
+
this.nostrPublish(event)?.catch(() => {})
|
|
71
238
|
|
|
72
239
|
// Store the new session
|
|
73
240
|
let userRecord = this.userRecords.get(userPubkey)
|
|
@@ -75,10 +242,11 @@ export default class SessionManager {
|
|
|
75
242
|
userRecord = new UserRecord(userPubkey, this.nostrSubscribe)
|
|
76
243
|
this.userRecords.set(userPubkey, userRecord)
|
|
77
244
|
}
|
|
78
|
-
|
|
245
|
+
const deviceId = (_invite instanceof Invite && _invite.deviceId) ? _invite.deviceId : event.id || 'unknown'
|
|
246
|
+
this.saveSession(userPubkey, deviceId, session)
|
|
79
247
|
|
|
80
248
|
// Set up event handling for the new session
|
|
81
|
-
session.onEvent((_event) => {
|
|
249
|
+
session.onEvent((_event: Rumor) => {
|
|
82
250
|
this.internalSubscriptions.forEach(callback => callback(_event))
|
|
83
251
|
})
|
|
84
252
|
|
|
@@ -100,9 +268,9 @@ export default class SessionManager {
|
|
|
100
268
|
}
|
|
101
269
|
|
|
102
270
|
// Update onEvent to include internalSubscriptions management
|
|
103
|
-
private internalSubscriptions: Set<(
|
|
271
|
+
private internalSubscriptions: Set<(event: Rumor) => void> = new Set()
|
|
104
272
|
|
|
105
|
-
onEvent(callback: (
|
|
273
|
+
onEvent(callback: (event: Rumor) => void) {
|
|
106
274
|
this.internalSubscriptions.add(callback)
|
|
107
275
|
|
|
108
276
|
// Subscribe to existing sessions
|
|
@@ -135,31 +303,26 @@ export default class SessionManager {
|
|
|
135
303
|
}
|
|
136
304
|
this.userRecords.clear()
|
|
137
305
|
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
306
|
}
|
|
151
307
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
308
|
+
/**
|
|
309
|
+
* Accept an invite as our own device, persist the session, and publish the acceptance event.
|
|
310
|
+
* Used for multi-device flows where a user adds a new device.
|
|
311
|
+
*/
|
|
312
|
+
public async acceptOwnInvite(invite: Invite) {
|
|
313
|
+
const ourPublicKey = getPublicKey(this.ourIdentityKey);
|
|
314
|
+
const { session, event } = await invite.accept(
|
|
315
|
+
this.nostrSubscribe,
|
|
316
|
+
ourPublicKey,
|
|
317
|
+
this.ourIdentityKey
|
|
318
|
+
);
|
|
319
|
+
let userRecord = this.userRecords.get(ourPublicKey);
|
|
320
|
+
if (!userRecord) {
|
|
321
|
+
userRecord = new UserRecord(ourPublicKey, this.nostrSubscribe);
|
|
322
|
+
this.userRecords.set(ourPublicKey, userRecord);
|
|
162
323
|
}
|
|
163
|
-
|
|
324
|
+
userRecord.upsertSession(session.name || 'unknown', session);
|
|
325
|
+
await this.saveSession(ourPublicKey, session.name || 'unknown', session);
|
|
326
|
+
this.nostrPublish(event)?.catch(() => {});
|
|
164
327
|
}
|
|
165
328
|
}
|
|
@@ -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
|
+
}
|
package/src/UserRecord.ts
CHANGED
|
@@ -17,15 +17,6 @@ export class UserRecord {
|
|
|
17
17
|
private deviceRecords: Map<string, DeviceRecord> = new Map();
|
|
18
18
|
private isStale: boolean = false;
|
|
19
19
|
private staleTimestamp?: number;
|
|
20
|
-
/**
|
|
21
|
-
* Temporary store for sessions when the corresponding deviceId is unknown.
|
|
22
|
-
*
|
|
23
|
-
* SessionManager currently operates at a per-user granularity (it is not
|
|
24
|
-
* yet aware of individual devices). Until full Sesame device handling is
|
|
25
|
-
* implemented we keep sessions in this simple list so that
|
|
26
|
-
* SessionManager.getActiveSessions / getAllSessions work as expected.
|
|
27
|
-
*/
|
|
28
|
-
private extraSessions: Session[] = [];
|
|
29
20
|
|
|
30
21
|
constructor(
|
|
31
22
|
public _userId: string,
|
|
@@ -53,18 +44,7 @@ export class UserRecord {
|
|
|
53
44
|
* Inserts a new session for a device, making it the active session
|
|
54
45
|
*/
|
|
55
46
|
public insertSession(deviceId: string, session: Session): void {
|
|
56
|
-
|
|
57
|
-
if (!record) {
|
|
58
|
-
throw new Error(`No device record found for ${deviceId}`);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// Move current active session to inactive list if it exists
|
|
62
|
-
if (record.activeSession) {
|
|
63
|
-
record.inactiveSessions.unshift(record.activeSession);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// Set new session as active
|
|
67
|
-
record.activeSession = session;
|
|
47
|
+
this.upsertSession(deviceId, session)
|
|
68
48
|
}
|
|
69
49
|
|
|
70
50
|
/**
|
|
@@ -194,22 +174,13 @@ export class UserRecord {
|
|
|
194
174
|
// Helper methods used by SessionManager (WIP):
|
|
195
175
|
// ---------------------------------------------------------------------------
|
|
196
176
|
|
|
197
|
-
/**
|
|
198
|
-
* Add a session without associating it with a specific device.
|
|
199
|
-
* This is mainly used by SessionManager which does not yet keep track of
|
|
200
|
-
* device identifiers. The session will be considered active.
|
|
201
|
-
*/
|
|
202
|
-
public addSession(session: Session): void {
|
|
203
|
-
this.extraSessions.push(session);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
177
|
/**
|
|
207
178
|
* Return all sessions that are currently considered *active*.
|
|
208
179
|
* For now this means any session in a non-stale device record as well as
|
|
209
180
|
* all sessions added through `addSession`.
|
|
210
181
|
*/
|
|
211
182
|
public getActiveSessions(): Session[] {
|
|
212
|
-
const sessions: Session[] = [
|
|
183
|
+
const sessions: Session[] = [];
|
|
213
184
|
|
|
214
185
|
for (const record of this.deviceRecords.values()) {
|
|
215
186
|
if (!record.isStale && record.activeSession) {
|
|
@@ -226,7 +197,7 @@ export class UserRecord {
|
|
|
226
197
|
* listeners to existing sessions.
|
|
227
198
|
*/
|
|
228
199
|
public getAllSessions(): Session[] {
|
|
229
|
-
const sessions: Session[] = [
|
|
200
|
+
const sessions: Session[] = [];
|
|
230
201
|
|
|
231
202
|
for (const record of this.deviceRecords.values()) {
|
|
232
203
|
if (record.activeSession) {
|
|
@@ -237,4 +208,32 @@ export class UserRecord {
|
|
|
237
208
|
|
|
238
209
|
return sessions;
|
|
239
210
|
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Unified helper that either associates the session with a device record
|
|
214
|
+
* (if deviceId provided **and** the record exists) or falls back to the
|
|
215
|
+
* legacy extraSessions list.
|
|
216
|
+
*/
|
|
217
|
+
public upsertSession(deviceId: string | undefined, session: Session) {
|
|
218
|
+
if (!deviceId) {
|
|
219
|
+
deviceId = 'unknown'
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
let record = this.deviceRecords.get(deviceId)
|
|
223
|
+
if (!record) {
|
|
224
|
+
record = {
|
|
225
|
+
publicKey: session.state?.theirNextNostrPublicKey || '',
|
|
226
|
+
inactiveSessions: [],
|
|
227
|
+
isStale: false
|
|
228
|
+
}
|
|
229
|
+
this.deviceRecords.set(deviceId, record)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (record.activeSession) {
|
|
233
|
+
record.inactiveSessions.unshift(record.activeSession)
|
|
234
|
+
}
|
|
235
|
+
// Ensure session name matches deviceId for easier identification
|
|
236
|
+
session.name = deviceId
|
|
237
|
+
record.activeSession = session
|
|
238
|
+
}
|
|
240
239
|
}
|
package/src/index.ts
CHANGED
package/src/types.ts
CHANGED
|
@@ -74,7 +74,8 @@ export type Rumor = UnsignedEvent & { id: string }
|
|
|
74
74
|
|
|
75
75
|
/**
|
|
76
76
|
* Callback function for handling decrypted messages
|
|
77
|
-
* @param
|
|
77
|
+
* @param _event - The decrypted message object (Rumor)
|
|
78
|
+
* @param _outerEvent - The outer Nostr event (VerifiedEvent)
|
|
78
79
|
*/
|
|
79
80
|
export type EventCallback = (_event: Rumor, _outerEvent: VerifiedEvent) => void;
|
|
80
81
|
|