nostr-double-ratchet 0.0.38 → 0.0.48
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 +52 -15
- package/dist/AppKeys.d.ts +52 -0
- package/dist/AppKeys.d.ts.map +1 -0
- package/dist/AppKeysManager.d.ts +136 -0
- package/dist/AppKeysManager.d.ts.map +1 -0
- package/dist/Invite.d.ts +5 -6
- package/dist/Invite.d.ts.map +1 -1
- package/dist/Session.d.ts +29 -0
- package/dist/Session.d.ts.map +1 -1
- package/dist/SessionManager.d.ts +15 -8
- package/dist/SessionManager.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/inviteUtils.d.ts +6 -6
- package/dist/inviteUtils.d.ts.map +1 -1
- package/dist/nostr-double-ratchet.es.js +2518 -2168
- package/dist/nostr-double-ratchet.umd.js +1 -1
- package/dist/types.d.ts +24 -5
- package/dist/types.d.ts.map +1 -1
- package/dist/utils.d.ts +20 -2
- package/dist/utils.d.ts.map +1 -1
- package/package.json +5 -19
- package/src/AppKeys.ts +210 -0
- package/src/AppKeysManager.ts +405 -0
- package/src/Invite.ts +9 -8
- package/src/Session.ts +46 -3
- package/src/SessionManager.ts +341 -174
- package/src/index.ts +3 -3
- package/src/inviteUtils.ts +12 -11
- package/src/types.ts +28 -5
- package/src/utils.ts +42 -5
- package/LICENSE +0 -21
- package/dist/DeviceManager.d.ts +0 -127
- package/dist/DeviceManager.d.ts.map +0 -1
- package/dist/InviteList.d.ts +0 -43
- package/dist/InviteList.d.ts.map +0 -1
- package/dist/UserRecord.d.ts +0 -117
- package/dist/UserRecord.d.ts.map +0 -1
- package/src/DeviceManager.ts +0 -565
- package/src/InviteList.ts +0 -333
- package/src/UserRecord.ts +0 -338
package/src/InviteList.ts
DELETED
|
@@ -1,333 +0,0 @@
|
|
|
1
|
-
import { finalizeEvent, VerifiedEvent, UnsignedEvent, verifyEvent } from "nostr-tools"
|
|
2
|
-
import {
|
|
3
|
-
NostrSubscribe,
|
|
4
|
-
Unsubscribe,
|
|
5
|
-
EncryptFunction,
|
|
6
|
-
DecryptFunction,
|
|
7
|
-
INVITE_LIST_EVENT_KIND,
|
|
8
|
-
INVITE_RESPONSE_KIND,
|
|
9
|
-
} from "./types"
|
|
10
|
-
import { Session } from "./Session"
|
|
11
|
-
import {
|
|
12
|
-
generateEphemeralKeypair,
|
|
13
|
-
generateSharedSecret,
|
|
14
|
-
generateDeviceId,
|
|
15
|
-
encryptInviteResponse,
|
|
16
|
-
decryptInviteResponse,
|
|
17
|
-
createSessionFromAccept,
|
|
18
|
-
} from "./inviteUtils"
|
|
19
|
-
|
|
20
|
-
const now = () => Math.round(Date.now() / 1000)
|
|
21
|
-
|
|
22
|
-
type DeviceTag = [
|
|
23
|
-
type: "device",
|
|
24
|
-
ephemeralPublicKey: string,
|
|
25
|
-
sharedSecret: string,
|
|
26
|
-
deviceId: string,
|
|
27
|
-
createdAt: string,
|
|
28
|
-
identityPubkey: string,
|
|
29
|
-
]
|
|
30
|
-
|
|
31
|
-
type RemovedTag = [type: "removed", deviceId: string]
|
|
32
|
-
|
|
33
|
-
const isDeviceTag = (tag: string[]): tag is DeviceTag =>
|
|
34
|
-
tag.length >= 6 &&
|
|
35
|
-
tag[0] === "device" &&
|
|
36
|
-
tag.slice(1, 6).every((v) => typeof v === "string")
|
|
37
|
-
|
|
38
|
-
const isRemovedTag = (tag: string[]): tag is RemovedTag =>
|
|
39
|
-
tag.length >= 2 &&
|
|
40
|
-
tag[0] === "removed" &&
|
|
41
|
-
typeof tag[1] === "string"
|
|
42
|
-
|
|
43
|
-
export interface DeviceEntry {
|
|
44
|
-
ephemeralPublicKey: string
|
|
45
|
-
/** Only stored locally, not published */
|
|
46
|
-
ephemeralPrivateKey?: Uint8Array
|
|
47
|
-
sharedSecret: string
|
|
48
|
-
deviceId: string
|
|
49
|
-
deviceLabel: string
|
|
50
|
-
createdAt: number
|
|
51
|
-
/** Owner's pubkey for owner devices, delegate's own pubkey for delegate devices */
|
|
52
|
-
identityPubkey: string
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
interface SerializedDeviceEntry extends Omit<DeviceEntry, 'ephemeralPrivateKey'> {
|
|
56
|
-
ephemeralPrivateKey?: number[]
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Manages a consolidated list of device invites (kind 10078).
|
|
61
|
-
* Single atomic event containing all device invites for a user.
|
|
62
|
-
* Uses union merge strategy for conflict resolution.
|
|
63
|
-
*/
|
|
64
|
-
export class InviteList {
|
|
65
|
-
private devices: Map<string, DeviceEntry> = new Map()
|
|
66
|
-
private removedDeviceIds: Set<string> = new Set()
|
|
67
|
-
|
|
68
|
-
constructor(
|
|
69
|
-
public readonly ownerPublicKey: string,
|
|
70
|
-
devices: DeviceEntry[] = [],
|
|
71
|
-
removedDeviceIds: string[] = [],
|
|
72
|
-
) {
|
|
73
|
-
this.removedDeviceIds = new Set(removedDeviceIds)
|
|
74
|
-
devices
|
|
75
|
-
.filter((device) => !this.removedDeviceIds.has(device.deviceId))
|
|
76
|
-
.forEach((device) => this.devices.set(device.deviceId, device))
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
createDevice(label: string, deviceId?: string): DeviceEntry {
|
|
80
|
-
const keypair = generateEphemeralKeypair()
|
|
81
|
-
return {
|
|
82
|
-
ephemeralPublicKey: keypair.publicKey,
|
|
83
|
-
ephemeralPrivateKey: keypair.privateKey,
|
|
84
|
-
sharedSecret: generateSharedSecret(),
|
|
85
|
-
deviceId: deviceId || generateDeviceId(),
|
|
86
|
-
deviceLabel: label,
|
|
87
|
-
createdAt: now(),
|
|
88
|
-
identityPubkey: this.ownerPublicKey,
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
addDevice(device: DeviceEntry): void {
|
|
93
|
-
if (this.removedDeviceIds.has(device.deviceId)) {
|
|
94
|
-
return
|
|
95
|
-
}
|
|
96
|
-
if (!this.devices.has(device.deviceId)) {
|
|
97
|
-
this.devices.set(device.deviceId, device)
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
removeDevice(deviceId: string): void {
|
|
102
|
-
this.devices.delete(deviceId)
|
|
103
|
-
this.removedDeviceIds.add(deviceId)
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
getDevice(deviceId: string): DeviceEntry | undefined {
|
|
107
|
-
return this.devices.get(deviceId)
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
getAllDevices(): DeviceEntry[] {
|
|
111
|
-
return Array.from(this.devices.values())
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
getRemovedDeviceIds(): string[] {
|
|
115
|
-
return Array.from(this.removedDeviceIds)
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
updateDeviceLabel(deviceId: string, newLabel: string): void {
|
|
119
|
-
const device = this.devices.get(deviceId)
|
|
120
|
-
if (device) {
|
|
121
|
-
device.deviceLabel = newLabel
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
getEvent(): UnsignedEvent {
|
|
126
|
-
const deviceTags = this.getAllDevices().map((device) => [
|
|
127
|
-
"device",
|
|
128
|
-
device.ephemeralPublicKey,
|
|
129
|
-
device.sharedSecret,
|
|
130
|
-
device.deviceId,
|
|
131
|
-
String(device.createdAt),
|
|
132
|
-
device.identityPubkey,
|
|
133
|
-
])
|
|
134
|
-
|
|
135
|
-
const removedTags = this.getRemovedDeviceIds().map((deviceId) => [
|
|
136
|
-
"removed",
|
|
137
|
-
deviceId,
|
|
138
|
-
])
|
|
139
|
-
|
|
140
|
-
return {
|
|
141
|
-
kind: INVITE_LIST_EVENT_KIND,
|
|
142
|
-
pubkey: this.ownerPublicKey,
|
|
143
|
-
content: "",
|
|
144
|
-
created_at: now(),
|
|
145
|
-
tags: [
|
|
146
|
-
["d", "double-ratchet/invite-list"],
|
|
147
|
-
["version", "1"],
|
|
148
|
-
...deviceTags,
|
|
149
|
-
...removedTags,
|
|
150
|
-
],
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
static fromEvent(event: VerifiedEvent): InviteList {
|
|
155
|
-
if (!event.sig) {
|
|
156
|
-
throw new Error("Event is not signed")
|
|
157
|
-
}
|
|
158
|
-
if (!verifyEvent(event)) {
|
|
159
|
-
throw new Error("Event signature is invalid")
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
const devices = event.tags
|
|
163
|
-
.filter(isDeviceTag)
|
|
164
|
-
.map(([, ephemeralPublicKey, sharedSecret, deviceId, createdAt, identityPubkey]) => ({
|
|
165
|
-
ephemeralPublicKey,
|
|
166
|
-
sharedSecret,
|
|
167
|
-
deviceId,
|
|
168
|
-
deviceLabel: deviceId,
|
|
169
|
-
createdAt: parseInt(createdAt, 10) || event.created_at,
|
|
170
|
-
identityPubkey,
|
|
171
|
-
}))
|
|
172
|
-
|
|
173
|
-
const removedDeviceIds = event.tags
|
|
174
|
-
.filter(isRemovedTag)
|
|
175
|
-
.map(([, deviceId]) => deviceId)
|
|
176
|
-
|
|
177
|
-
return new InviteList(event.pubkey, devices, removedDeviceIds)
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
serialize(): string {
|
|
181
|
-
const devices = this.getAllDevices().map((d) => ({
|
|
182
|
-
...d,
|
|
183
|
-
ephemeralPrivateKey: d.ephemeralPrivateKey
|
|
184
|
-
? Array.from(d.ephemeralPrivateKey)
|
|
185
|
-
: undefined,
|
|
186
|
-
}))
|
|
187
|
-
|
|
188
|
-
return JSON.stringify({
|
|
189
|
-
ownerPublicKey: this.ownerPublicKey,
|
|
190
|
-
devices,
|
|
191
|
-
removedDeviceIds: this.getRemovedDeviceIds(),
|
|
192
|
-
})
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
static deserialize(json: string): InviteList {
|
|
196
|
-
const data = JSON.parse(json) as { ownerPublicKey: string; devices: SerializedDeviceEntry[]; removedDeviceIds?: string[] }
|
|
197
|
-
const devices: DeviceEntry[] = data.devices.map((d) => ({
|
|
198
|
-
...d,
|
|
199
|
-
ephemeralPrivateKey: d.ephemeralPrivateKey
|
|
200
|
-
? new Uint8Array(d.ephemeralPrivateKey)
|
|
201
|
-
: undefined,
|
|
202
|
-
}))
|
|
203
|
-
|
|
204
|
-
return new InviteList(data.ownerPublicKey, devices, data.removedDeviceIds || [])
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
merge(other: InviteList): InviteList {
|
|
208
|
-
const mergedRemovedIds = new Set([
|
|
209
|
-
...this.removedDeviceIds,
|
|
210
|
-
...other.removedDeviceIds,
|
|
211
|
-
])
|
|
212
|
-
|
|
213
|
-
const mergedDevices = [...this.devices.values(), ...other.devices.values()]
|
|
214
|
-
.reduce((map, device) => {
|
|
215
|
-
const existing = map.get(device.deviceId)
|
|
216
|
-
map.set(device.deviceId, existing
|
|
217
|
-
? { ...device, ephemeralPrivateKey: existing.ephemeralPrivateKey || device.ephemeralPrivateKey }
|
|
218
|
-
: device
|
|
219
|
-
)
|
|
220
|
-
return map
|
|
221
|
-
}, new Map<string, DeviceEntry>())
|
|
222
|
-
|
|
223
|
-
const activeDevices = Array.from(mergedDevices.values())
|
|
224
|
-
.filter((device) => !mergedRemovedIds.has(device.deviceId))
|
|
225
|
-
|
|
226
|
-
return new InviteList(
|
|
227
|
-
this.ownerPublicKey,
|
|
228
|
-
activeDevices,
|
|
229
|
-
Array.from(mergedRemovedIds)
|
|
230
|
-
)
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
async accept(
|
|
234
|
-
deviceId: string,
|
|
235
|
-
nostrSubscribe: NostrSubscribe,
|
|
236
|
-
inviteePublicKey: string,
|
|
237
|
-
encryptor: Uint8Array | EncryptFunction,
|
|
238
|
-
inviteeDeviceId?: string
|
|
239
|
-
): Promise<{ session: Session; event: VerifiedEvent }> {
|
|
240
|
-
const device = this.devices.get(deviceId)
|
|
241
|
-
if (!device) {
|
|
242
|
-
throw new Error(`Device ${deviceId} not found in invite list`)
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
const inviteeSessionKeypair = generateEphemeralKeypair()
|
|
246
|
-
|
|
247
|
-
const session = createSessionFromAccept({
|
|
248
|
-
nostrSubscribe,
|
|
249
|
-
theirPublicKey: device.ephemeralPublicKey,
|
|
250
|
-
ourSessionPrivateKey: inviteeSessionKeypair.privateKey,
|
|
251
|
-
sharedSecret: device.sharedSecret,
|
|
252
|
-
isSender: true,
|
|
253
|
-
})
|
|
254
|
-
|
|
255
|
-
const encrypt = typeof encryptor === "function" ? encryptor : undefined
|
|
256
|
-
const inviteePrivateKey = typeof encryptor === "function" ? undefined : encryptor
|
|
257
|
-
|
|
258
|
-
const encrypted = await encryptInviteResponse({
|
|
259
|
-
inviteeSessionPublicKey: inviteeSessionKeypair.publicKey,
|
|
260
|
-
inviteePublicKey,
|
|
261
|
-
inviteePrivateKey,
|
|
262
|
-
inviterPublicKey: device.identityPubkey,
|
|
263
|
-
inviterEphemeralPublicKey: device.ephemeralPublicKey,
|
|
264
|
-
sharedSecret: device.sharedSecret,
|
|
265
|
-
deviceId: inviteeDeviceId,
|
|
266
|
-
encrypt,
|
|
267
|
-
})
|
|
268
|
-
|
|
269
|
-
return {
|
|
270
|
-
session,
|
|
271
|
-
event: finalizeEvent(encrypted.envelope, encrypted.randomSenderPrivateKey),
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
listen(
|
|
276
|
-
decryptor: Uint8Array | DecryptFunction,
|
|
277
|
-
nostrSubscribe: NostrSubscribe,
|
|
278
|
-
onSession: (
|
|
279
|
-
session: Session,
|
|
280
|
-
identity: string,
|
|
281
|
-
deviceId?: string,
|
|
282
|
-
ourDeviceId?: string
|
|
283
|
-
) => void
|
|
284
|
-
): Unsubscribe {
|
|
285
|
-
const devices = this.getAllDevices()
|
|
286
|
-
const decryptableDevices = devices.filter((d) => !!d.ephemeralPrivateKey)
|
|
287
|
-
|
|
288
|
-
// If we don't have any devices we can decrypt for, do nothing gracefully
|
|
289
|
-
if (decryptableDevices.length === 0) {
|
|
290
|
-
return () => {}
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
const ephemeralPubkeys = decryptableDevices.map((d) => d.ephemeralPublicKey)
|
|
294
|
-
const decrypt = typeof decryptor === "function" ? decryptor : undefined
|
|
295
|
-
const ownerPrivateKey = typeof decryptor === "function" ? undefined : decryptor
|
|
296
|
-
|
|
297
|
-
return nostrSubscribe(
|
|
298
|
-
{ kinds: [INVITE_RESPONSE_KIND], "#p": ephemeralPubkeys },
|
|
299
|
-
async (event) => {
|
|
300
|
-
const targetPubkey = event.tags.find((t) => t[0] === "p")?.[1]
|
|
301
|
-
const device = decryptableDevices.find((d) => d.ephemeralPublicKey === targetPubkey)
|
|
302
|
-
|
|
303
|
-
if (!device || !device.ephemeralPrivateKey) {
|
|
304
|
-
return
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
try {
|
|
308
|
-
const decrypted = await decryptInviteResponse({
|
|
309
|
-
envelopeContent: event.content,
|
|
310
|
-
envelopeSenderPubkey: event.pubkey,
|
|
311
|
-
inviterEphemeralPrivateKey: device.ephemeralPrivateKey,
|
|
312
|
-
inviterPrivateKey: ownerPrivateKey,
|
|
313
|
-
sharedSecret: device.sharedSecret,
|
|
314
|
-
decrypt,
|
|
315
|
-
})
|
|
316
|
-
|
|
317
|
-
const session = createSessionFromAccept({
|
|
318
|
-
nostrSubscribe,
|
|
319
|
-
theirPublicKey: decrypted.inviteeSessionPublicKey,
|
|
320
|
-
ourSessionPrivateKey: device.ephemeralPrivateKey,
|
|
321
|
-
sharedSecret: device.sharedSecret,
|
|
322
|
-
isSender: false,
|
|
323
|
-
name: event.id,
|
|
324
|
-
})
|
|
325
|
-
|
|
326
|
-
onSession(session, decrypted.inviteeIdentity, decrypted.deviceId, device.deviceId)
|
|
327
|
-
} catch {
|
|
328
|
-
// Invalid response
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
)
|
|
332
|
-
}
|
|
333
|
-
}
|
package/src/UserRecord.ts
DELETED
|
@@ -1,338 +0,0 @@
|
|
|
1
|
-
import { Session } from './Session';
|
|
2
|
-
import { NostrSubscribe } from './types';
|
|
3
|
-
|
|
4
|
-
interface DeviceRecord {
|
|
5
|
-
deviceId: string;
|
|
6
|
-
publicKey: string;
|
|
7
|
-
activeSession?: Session;
|
|
8
|
-
inactiveSessions: Session[];
|
|
9
|
-
isStale: boolean;
|
|
10
|
-
staleTimestamp?: number;
|
|
11
|
-
lastActivity?: number;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Manages sessions for a single user across multiple devices
|
|
16
|
-
* Structure: UserRecord → DeviceRecord → Sessions
|
|
17
|
-
*/
|
|
18
|
-
export class UserRecord {
|
|
19
|
-
private deviceRecords: Map<string, DeviceRecord> = new Map();
|
|
20
|
-
private isStale: boolean = false;
|
|
21
|
-
private staleTimestamp?: number;
|
|
22
|
-
|
|
23
|
-
constructor(
|
|
24
|
-
public readonly userId: string,
|
|
25
|
-
private readonly nostrSubscribe: NostrSubscribe,
|
|
26
|
-
) {
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// ============================================================================
|
|
30
|
-
// Device Management
|
|
31
|
-
// ============================================================================
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Creates or updates a device record for this user
|
|
35
|
-
*/
|
|
36
|
-
public upsertDevice(deviceId: string, publicKey: string): DeviceRecord {
|
|
37
|
-
let record = this.deviceRecords.get(deviceId);
|
|
38
|
-
|
|
39
|
-
if (!record) {
|
|
40
|
-
record = {
|
|
41
|
-
deviceId,
|
|
42
|
-
publicKey,
|
|
43
|
-
inactiveSessions: [],
|
|
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();
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
return record;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Gets a device record by deviceId
|
|
63
|
-
*/
|
|
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);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Removes a device record and closes all its sessions
|
|
85
|
-
*/
|
|
86
|
-
public removeDevice(deviceId: string): boolean {
|
|
87
|
-
const record = this.deviceRecords.get(deviceId);
|
|
88
|
-
if (!record) return false;
|
|
89
|
-
|
|
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
|
-
}
|
|
96
|
-
|
|
97
|
-
// ============================================================================
|
|
98
|
-
// Session Management
|
|
99
|
-
// ============================================================================
|
|
100
|
-
|
|
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);
|
|
110
|
-
}
|
|
111
|
-
|
|
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();
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Gets the active session for a specific device
|
|
120
|
-
*/
|
|
121
|
-
public getActiveSession(deviceId: string): Session | undefined {
|
|
122
|
-
const device = this.deviceRecords.get(deviceId);
|
|
123
|
-
return device?.isStale ? undefined : device?.activeSession;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Gets all sessions (active + inactive) for a specific device
|
|
128
|
-
*/
|
|
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;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Gets all active sessions across all devices for this user
|
|
143
|
-
*/
|
|
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
|
-
});
|
|
162
|
-
|
|
163
|
-
return sessions;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* Gets all sessions (active + inactive) across all devices
|
|
168
|
-
*/
|
|
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);
|
|
177
|
-
}
|
|
178
|
-
|
|
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)
|
|
188
|
-
);
|
|
189
|
-
}
|
|
190
|
-
|
|
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
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/**
|
|
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
|
|
216
|
-
*/
|
|
217
|
-
public pruneStaleRecords(maxLatency: number): void {
|
|
218
|
-
const now = Date.now();
|
|
219
|
-
|
|
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);
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// Remove entire user record if stale
|
|
229
|
-
if (this.isStale && this.staleTimestamp &&
|
|
230
|
-
(now - this.staleTimestamp) > maxLatency) {
|
|
231
|
-
this.close();
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// ============================================================================
|
|
236
|
-
// Utility Methods
|
|
237
|
-
// ============================================================================
|
|
238
|
-
|
|
239
|
-
/**
|
|
240
|
-
* Gets the most recently active device
|
|
241
|
-
*/
|
|
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;
|
|
250
|
-
});
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
/**
|
|
254
|
-
* Gets the preferred session (from most active device)
|
|
255
|
-
*/
|
|
256
|
-
public getPreferredSession(): Session | undefined {
|
|
257
|
-
const mostActive = this.getMostActiveDevice();
|
|
258
|
-
return mostActive?.activeSession;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
/**
|
|
262
|
-
* Checks if this user has any active sessions
|
|
263
|
-
*/
|
|
264
|
-
public hasActiveSessions(): boolean {
|
|
265
|
-
return this.getActiveSessions().length > 0;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
/**
|
|
269
|
-
* Gets total count of devices
|
|
270
|
-
*/
|
|
271
|
-
public getDeviceCount(): number {
|
|
272
|
-
return this.deviceRecords.size;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
/**
|
|
276
|
-
* Gets total count of active sessions
|
|
277
|
-
*/
|
|
278
|
-
public getActiveSessionCount(): number {
|
|
279
|
-
return this.getActiveSessions().length;
|
|
280
|
-
}
|
|
281
|
-
|
|
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());
|
|
289
|
-
}
|
|
290
|
-
this.deviceRecords.clear();
|
|
291
|
-
}
|
|
292
|
-
|
|
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);
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
/**
|
|
305
|
-
* @deprecated Use upsertSession instead
|
|
306
|
-
*/
|
|
307
|
-
public insertSession(deviceId: string, session: Session): void {
|
|
308
|
-
this.upsertSession(deviceId, session);
|
|
309
|
-
}
|
|
310
|
-
|
|
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}`);
|
|
324
|
-
}
|
|
325
|
-
|
|
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;
|
|
337
|
-
}
|
|
338
|
-
}
|