libp2r2p 0.0.1

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.
Files changed (63) hide show
  1. package/AGENTS.md +20 -0
  2. package/README.md +129 -0
  3. package/base16/index.js +14 -0
  4. package/base64/index.js +29 -0
  5. package/content-key/event/index.js +85 -0
  6. package/content-key/index.js +1 -0
  7. package/content-key/services/iykc-proof.js +129 -0
  8. package/double-dh/index.js +166 -0
  9. package/ecdh/index.js +8 -0
  10. package/idb/index.js +64 -0
  11. package/idb-queue/index.js +793 -0
  12. package/index.js +21 -0
  13. package/key/index.js +139 -0
  14. package/network/index.js +68 -0
  15. package/nip44-v3/index.js +175 -0
  16. package/nip46/constants/index.js +5 -0
  17. package/nip46/helpers/frame.js +51 -0
  18. package/nip46/helpers/url.js +96 -0
  19. package/nip46/index.js +5 -0
  20. package/nip46/services/bunker-signer.js +49 -0
  21. package/nip46/services/client.js +252 -0
  22. package/nip46/services/server-session.js +225 -0
  23. package/nip46/services/transport.js +265 -0
  24. package/package.json +49 -0
  25. package/private-channel/constants/index.js +5 -0
  26. package/private-channel/helpers/chunk-size.js +61 -0
  27. package/private-channel/helpers/chunks.js +282 -0
  28. package/private-channel/helpers/event.js +68 -0
  29. package/private-channel/index.js +942 -0
  30. package/private-channel/services/received-chunks.js +398 -0
  31. package/private-message/index.js +672 -0
  32. package/private-messenger/constants/index.js +1 -0
  33. package/private-messenger/index.js +1431 -0
  34. package/private-messenger/recovery/index.js +316 -0
  35. package/relay/constants/index.js +18 -0
  36. package/relay/helpers/hll.js +62 -0
  37. package/relay/helpers/publish.js +100 -0
  38. package/relay/helpers/routing.js +36 -0
  39. package/relay/helpers/timer.js +4 -0
  40. package/relay/index.js +4 -0
  41. package/relay/services/query.js +224 -0
  42. package/relay/services/relay-connection.js +156 -0
  43. package/relay/services/relay-pool.js +908 -0
  44. package/temporary-storage/index.js +85 -0
  45. package/tests/base16.test.js +19 -0
  46. package/tests/base64.test.js +18 -0
  47. package/tests/content-key-event.test.js +48 -0
  48. package/tests/fixtures/nip44v3-vectors.json +1 -0
  49. package/tests/helpers/test-signer.js +185 -0
  50. package/tests/idb-queue.test.js +153 -0
  51. package/tests/idb.test.js +91 -0
  52. package/tests/nip44-v3.test.js +73 -0
  53. package/tests/nip46.test.js +668 -0
  54. package/tests/private-channel.test.js +1899 -0
  55. package/tests/private-message.test.js +460 -0
  56. package/tests/private-messenger.test.js +1715 -0
  57. package/tests/queries.test.js +101 -0
  58. package/tests/queue-parity.test.js +105 -0
  59. package/tests/relay-hll.test.js +32 -0
  60. package/tests/relay-pool.test.js +2067 -0
  61. package/tests/temporary-storage.test.js +89 -0
  62. package/tests/web-storage-queue.test.js +480 -0
  63. package/web-storage-queue/index.js +652 -0
@@ -0,0 +1,1431 @@
1
+ // Expected use:
2
+ // const messenger = await createPrivateMessenger({
3
+ // userSigner,
4
+ // contentKeySigner, // optional when userSigner handles content keys internally
5
+ // nymSigner: optionalDefaultNymSigner,
6
+ // channels: [{ signer: privateChannelSigner, relays, mode: 'leecher', seeders: optionalSeederPubkeys }],
7
+ // onContentKeyChange: event => reviewContentKeyUse(event),
8
+ // onError: err => reportPrivateMessengerError(err)
9
+ // })
10
+ //
11
+ // Channel roles:
12
+ // - Default channel: { signer } signs, publishes, and decrypts the outer router with the same channel key.
13
+ // - Split reader channel: { signer, readerPubkey } signs as the channel key but encrypts/decrypts the outer router with the reader pubkey.
14
+ // - Reader-secret channel: { signer, readerSigner } is also valid; the reader signer decrypts the router.
15
+ // - Reader-only channel: { pubkey, readerSigner } can watch/fetch/drain messages but cannot send or seed recovery replies.
16
+ // for await (const msg of messenger.messages()) handlePrivateMessage(msg)
17
+ // await messenger.ask({ receiverPubkey, payload: { ping: true } })
18
+ // await messenger.reply({ question: msg.question, payload: { ok: true } })
19
+ // await messenger.tell({ receiverPubkey, payload: { note: 'hello' } })
20
+ // await messenger.yell({ receiverPubkeys, payload: { notice: 'hello all' } })
21
+ // await messenger.broadcastRumor({ receiverPubkeys, rumor: { kind, tags: [], content } })
22
+ // await messenger.broadcastEvent({ receiverPubkeys, event: signedNostrEvent })
23
+ // await messenger.broadcastNymRumor({ rumor: { kind, tags: [], content } })
24
+ // await messenger.broadcastNymEvent({ event: signedNostrEvent })
25
+ // await messenger.update({ channels: [{ signer: privateChannelSigner, relays, seeders: nextOptionalSeederPubkeys }] })
26
+ // await messenger.clearChannel(channelPubkey)
27
+ //
28
+ // Missed-message recovery:
29
+ // - Each watched channel stores lastSeenAt/lastWatchedAt in localStorage.
30
+ // - Re-watching after reload fetches the gap from lastSeenAt to now.
31
+ // - Browser offline/online events add explicit offline ranges with a small skew.
32
+ // - Ranges older than 7 days are ignored; channel state not watched for 45 days is pruned.
33
+ // - Seeders announce presence every 10min and are used for the relay-uncovered left edge of a missed range.
34
+ // - Configured seeders are all asked; auto-discovered seeders are capped to the 8 most recently active.
35
+ // - Seeder/watchtower channels store reconstructed router events in a separate IndexedDB queue and auto-reply to recovery asks.
36
+ // - Seeder replies stream compact routers with createMissingMessageReplyPacker({ messenger, question }).update(seed), then finalize(optionalLastSeed).
37
+ // - For other event-list replies, use createEventReplyPacker({ messenger, question, code }).update(event).
38
+
39
+ import * as privateMessage from '../private-message/index.js'
40
+ import { bytesToBase64 } from '../base64/index.js'
41
+ import { getRelaysByPubkey, pickRelaysForPubkeys, subscribeRelayListUpdates } from '../relay/index.js'
42
+ import * as privateChannel from '../private-channel/index.js'
43
+ import { cleanupTemporaryStorage as cleanupTemporaryChannelStorage } from '../temporary-storage/index.js'
44
+ import { createQueue } from '../idb-queue/index.js'
45
+ import { DEFAULT_STALE_CHANNEL_SECONDS } from './constants/index.js'
46
+ import {
47
+ compactSeedNymCarriers,
48
+ compactSeedRouterRows,
49
+ createEventReplyPacker,
50
+ createMissingMessageReplyPacker,
51
+ MISSING_MESSAGES_ASK_CODE,
52
+ MISSING_MESSAGES_REPLY_CODE,
53
+ NYM_CARRIER_SEED_RECORD_TYPE,
54
+ ROUTER_SEED_RECORD_TYPE,
55
+ routerSeedRowKey,
56
+ SEEDER_PRESENCE_CODE
57
+ } from './recovery/index.js'
58
+
59
+ export { DEFAULT_STALE_CHANNEL_SECONDS } from './constants/index.js'
60
+ export {
61
+ compactSeedNymCarriers,
62
+ compactSeedRouterRows,
63
+ createEventReplyPacker,
64
+ createMissingMessageReplyPacker,
65
+ MISSING_MESSAGES_ASK_CODE,
66
+ MISSING_MESSAGES_REPLY_CODE,
67
+ NYM_CARRIER_SEED_RECORD_TYPE,
68
+ ROUTER_SEED_RECORD_TYPE,
69
+ routerSeedRowKey,
70
+ SEEDER_PRESENCE_CODE
71
+ } from './recovery/index.js'
72
+
73
+ const DEFAULT_OFFLINE_RECOVERY_SECONDS = 7 * 24 * 60 * 60
74
+ const DEFAULT_OFFLINE_SKEW_SECONDS = 30
75
+ const DEFAULT_RELOAD_GAP_DELAY_MS = 500
76
+ const DEFAULT_SEEDER_PRESENCE_INTERVAL_MS = 10 * 60 * 1000
77
+ const DEFAULT_SEEDER_ONLINE_SECONDS = 20 * 60
78
+ const DEFAULT_MAX_DYNAMIC_RECOVERY_SEEDERS = 8
79
+ const DEFAULT_MESSAGE_QUEUE_MAX_BYTES = 16 * 1024 * 1024 // 16 MiB
80
+ const DEFAULT_SEED_QUEUE_MAX_BYTES = 64 * 1024 * 1024 // 64 MiB
81
+ const SEED_KEY = '__p2r2pSeedKey'
82
+ const SEED_TIME = '__p2r2pSeedTime'
83
+ const MESSAGE_QUEUE_INDEXES = {
84
+ byChannel: 'channelPubkey',
85
+ byChannelTypeEventId: {
86
+ keyPath: ['channelPubkey', 'type', 'event.id'],
87
+ unique: true
88
+ }
89
+ }
90
+ const SEED_QUEUE_INDEXES = {
91
+ byChannel: 'channelPubkey',
92
+ bySeedKey: { keyPath: SEED_KEY, unique: true },
93
+ byChannelTime: ['channelPubkey', SEED_TIME],
94
+ byTime: SEED_TIME
95
+ }
96
+ const encoder = new TextEncoder()
97
+ const noContentKeys = async () => ({})
98
+
99
+ function textToBase64 (text) {
100
+ return bytesToBase64(encoder.encode(text))
101
+ }
102
+
103
+ function defaultOnError (err) {
104
+ console.warn('private-messenger failed', err?.message ?? err)
105
+ }
106
+
107
+ function nowSeconds () {
108
+ return Math.floor(Date.now() / 1000)
109
+ }
110
+
111
+ function uniq (values) {
112
+ return [...new Set((values || []).filter(Boolean))]
113
+ }
114
+
115
+ function isPlainObject (value) {
116
+ return value && typeof value === 'object' && !Array.isArray(value)
117
+ }
118
+
119
+ function storesRecoverySeeds (mode) {
120
+ return mode === 'seeder' || mode === 'watchtower'
121
+ }
122
+
123
+ function parseJson (raw, fallback) {
124
+ try { return JSON.parse(raw || '') } catch { return fallback }
125
+ }
126
+
127
+ export class PrivateMessenger {
128
+ static cleanupTemporaryStorage ({ storageArea = globalThis.sessionStorage } = {}) {
129
+ cleanupTemporaryChannelStorage({ storageArea })
130
+ }
131
+
132
+ constructor ({
133
+ offlineRecoverySeconds = DEFAULT_OFFLINE_RECOVERY_SECONDS,
134
+ staleChannelSeconds = DEFAULT_STALE_CHANNEL_SECONDS,
135
+ offlineSkewSeconds = DEFAULT_OFFLINE_SKEW_SECONDS,
136
+ reloadGapDelayMs = DEFAULT_RELOAD_GAP_DELAY_MS,
137
+ seederPresenceIntervalMs = DEFAULT_SEEDER_PRESENCE_INTERVAL_MS,
138
+ seederOnlineSeconds = DEFAULT_SEEDER_ONLINE_SECONDS,
139
+ maxDynamicRecoverySeeders = DEFAULT_MAX_DYNAMIC_RECOVERY_SEEDERS,
140
+ messageQueueMaxBytes = DEFAULT_MESSAGE_QUEUE_MAX_BYTES,
141
+ seedQueueMaxBytes = DEFAULT_SEED_QUEUE_MAX_BYTES,
142
+ temporaryStorageArea = globalThis.sessionStorage,
143
+ _indexedDB = globalThis.indexedDB,
144
+ useContentKeys = true,
145
+ onContentKeyChange,
146
+ onMessageQueued,
147
+ onDebug,
148
+ onError = defaultOnError,
149
+ _privateMessage = privateMessage,
150
+ _privateChannel = privateChannel,
151
+ _getRelaysByPubkey = getRelaysByPubkey,
152
+ _pickRelaysForPubkeys = pickRelaysForPubkeys,
153
+ _subscribeRelayListUpdates = subscribeRelayListUpdates,
154
+ _setTimeout = globalThis.setTimeout.bind(globalThis),
155
+ _setInterval = globalThis.setInterval.bind(globalThis),
156
+ _clearInterval = globalThis.clearInterval.bind(globalThis)
157
+ } = {}) {
158
+ this.offlineRecoverySeconds = offlineRecoverySeconds
159
+ this.staleChannelSeconds = staleChannelSeconds
160
+ this.offlineSkewSeconds = offlineSkewSeconds
161
+ this.reloadGapDelayMs = reloadGapDelayMs
162
+ this.seederPresenceIntervalMs = seederPresenceIntervalMs
163
+ this.seederOnlineSeconds = seederOnlineSeconds
164
+ this.maxDynamicRecoverySeeders = maxDynamicRecoverySeeders
165
+ this.messageQueueMaxBytes = messageQueueMaxBytes
166
+ this.seedQueueMaxBytes = seedQueueMaxBytes
167
+ this.temporaryStorageArea = temporaryStorageArea
168
+ this._indexedDB = _indexedDB
169
+ this.useContentKeys = useContentKeys
170
+ this.onContentKeyChange = onContentKeyChange
171
+ this.onMessageQueued = onMessageQueued
172
+ this.onDebug = onDebug
173
+ this.onError = onError
174
+ this._privateMessage = _privateMessage
175
+ this._privateChannel = _privateChannel
176
+ this._getRelaysByPubkey = _getRelaysByPubkey
177
+ this._pickRelaysForPubkeys = _pickRelaysForPubkeys
178
+ this._subscribeRelayListUpdates = _subscribeRelayListUpdates
179
+ this._setTimeout = _setTimeout
180
+ this._setInterval = _setInterval
181
+ this._clearInterval = _clearInterval
182
+
183
+ this.userSigner = null
184
+ this.contentKeySigner = null
185
+ this.nymSigner = null
186
+ this.userPubkey = ''
187
+ this.contentKeyPubkey = ''
188
+ this.prefix = ''
189
+ this.queue = null
190
+ this.seedQueue = null
191
+ this.channels = new Map()
192
+ this.stopByChannel = new Map()
193
+ this.presenceTimers = new Map()
194
+ this.stopRelayListWatcher = null
195
+ this.relayListWatcherPubkey = ''
196
+ this.relayListRefreshPromise = null
197
+ this.stopOnline = null
198
+ this.stopOffline = null
199
+ this.queueOperationTail = Promise.resolve()
200
+ }
201
+
202
+ async init ({ userSigner, contentKeySigner, nymSigner, channels = [], relays = [], mode = 'leecher' }) {
203
+ if (!userSigner?.getPublicKey) throw new Error('USER_SIGNER_REQUIRED')
204
+ PrivateMessenger.cleanupTemporaryStorage({ storageArea: this.temporaryStorageArea })
205
+ this.userSigner = userSigner
206
+ this.contentKeySigner = contentKeySigner || null
207
+ this.nymSigner = nymSigner || null
208
+ this.userPubkey = await userSigner.getPublicKey()
209
+ this.contentKeyPubkey = await this.contentKeySigner?.getPublicKey?.() || ''
210
+ this.prefix = `libp2r2p:private-messenger:${this.userPubkey}`
211
+ this.queue = await createQueue({
212
+ prefix: this.prefix,
213
+ indexes: MESSAGE_QUEUE_INDEXES,
214
+ maxBytes: this.messageQueueMaxBytes,
215
+ evictionPolicy: 'fifo',
216
+ indexedDB: this._indexedDB
217
+ })
218
+ this.seedQueue = await createQueue({
219
+ prefix: `${this.prefix}:seeds`,
220
+ indexes: SEED_QUEUE_INDEXES,
221
+ maxBytes: this.seedQueueMaxBytes,
222
+ evictionPolicy: 'fifo',
223
+ indexedDB: this._indexedDB
224
+ })
225
+ await this.cleanupStaleChannels()
226
+ await this.update({ userSigner, contentKeySigner, nymSigner: this.nymSigner, channels, relays, mode })
227
+ return this
228
+ }
229
+
230
+ runQueueOperation (operation) {
231
+ const run = this.queueOperationTail.then(operation)
232
+ this.queueOperationTail = run.catch(err => {
233
+ try { this.onError?.(err) } catch {}
234
+ })
235
+ return run
236
+ }
237
+
238
+ queueIncoming (operation) {
239
+ return this.runQueueOperation(operation).catch(() => undefined)
240
+ }
241
+
242
+ debug (action, detail = {}) {
243
+ try {
244
+ this.onDebug?.({ source: 'private-messenger', action, ...detail })
245
+ } catch (err) {
246
+ this.onError?.(err)
247
+ }
248
+ }
249
+
250
+ debugSend (method, channelPubkey, detail = {}) {
251
+ const receiverPubkeys = uniq(detail.receiverPubkeys || (detail.receiverPubkey ? [detail.receiverPubkey] : []))
252
+ this.debug('send', {
253
+ method,
254
+ type: method,
255
+ code: detail.code || '',
256
+ channelPubkey,
257
+ senderPubkey: this.userPubkey,
258
+ receiverPubkey: detail.receiverPubkey || '',
259
+ receiverPubkeys,
260
+ receiverCount: receiverPubkeys.length
261
+ })
262
+ }
263
+
264
+ async update ({ userSigner = this.userSigner, contentKeySigner = this.contentKeySigner, nymSigner = this.nymSigner, channels = [...this.channels.values()], relays = [], mode = 'leecher' } = {}) {
265
+ if (userSigner) {
266
+ const userPubkey = await userSigner.getPublicKey?.()
267
+ if (!userPubkey) throw new Error('USER_SIGNER_REQUIRED')
268
+ if (this.userPubkey && userPubkey !== this.userPubkey) throw new Error('USER_SIGNER_MISMATCH')
269
+ this.userSigner = userSigner
270
+ }
271
+ this.contentKeySigner = contentKeySigner || null
272
+ this.nymSigner = nymSigner || null
273
+ this.contentKeyPubkey = await this.contentKeySigner?.getPublicKey?.() || ''
274
+ const nextChannels = await this.normalizeChannels(channels, { relays, mode })
275
+ const nextPubkeys = new Set(nextChannels.map(channel => channel.pubkey))
276
+
277
+ for (const pubkey of [...this.channels.keys()]) {
278
+ if (!nextPubkeys.has(pubkey)) {
279
+ this.unwatch(pubkey)
280
+ this.channels.delete(pubkey)
281
+ }
282
+ }
283
+ for (const channel of nextChannels) this.channels.set(channel.pubkey, channel)
284
+
285
+ await this.cleanupStaleChannels()
286
+ await this.watch()
287
+ await this.reconcilePresencePublishers()
288
+ return this
289
+ }
290
+
291
+ async normalizeChannels (channels, defaults) {
292
+ const out = []
293
+ for (const entry of channels || []) {
294
+ const channel = typeof entry === 'string' ? { pubkey: entry } : entry
295
+ const signer = channel.signer || channel.privateChannelSigner || null
296
+ const readerSigner = channel.readerSigner || channel.privateChannelReaderSigner || signer || null
297
+ const nymSigner = channel.nymSigner || null
298
+ const hasChannelRelays = Boolean(channel.relays?.length)
299
+ const hasChannelSendRelays = Boolean(channel.sendRelays?.length)
300
+ const hasDefaultRelays = Boolean(defaults.relays?.length)
301
+ const pubkey = channel.pubkey || await signer?.getPublicKey?.()
302
+ if (!pubkey) throw new Error('CHANNEL_PUBKEY_REQUIRED')
303
+ if (!signer && !readerSigner) throw new Error('CHANNEL_SIGNER_REQUIRED')
304
+ const mode = channel.mode || defaults.mode || 'leecher'
305
+ if (!signer && storesRecoverySeeds(mode)) throw new Error('PRIVATE_CHANNEL_WRITER_REQUIRED')
306
+ const readerPubkey = channel.readerPubkey || channel.privateChannelReaderPubkey || await readerSigner?.getPublicKey?.() || pubkey
307
+ out.push({
308
+ pubkey,
309
+ signer,
310
+ readerSigner,
311
+ readerPubkey,
312
+ nymSigner,
313
+ relays: uniq(hasChannelRelays ? channel.relays : defaults.relays),
314
+ sendRelays: uniq(hasChannelSendRelays ? channel.sendRelays : []),
315
+ usesNip65WatchRelays: !hasChannelRelays && !hasDefaultRelays,
316
+ mode,
317
+ seeders: uniq(channel.seeders)
318
+ })
319
+ }
320
+ return out
321
+ }
322
+
323
+ async readRelayToReceivers (receiverPubkeys) {
324
+ const pubkeys = uniq(receiverPubkeys)
325
+ if (!pubkeys.length) return new Map()
326
+ const relaysByPubkey = await this._getRelaysByPubkey(pubkeys)
327
+ return this._pickRelaysForPubkeys(pubkeys, relaysByPubkey, { relayType: 'read' })
328
+ }
329
+
330
+ async readRelaysForPubkey (pubkey) {
331
+ const relaysByPubkey = await this._getRelaysByPubkey([pubkey])
332
+ const readRelays = uniq(relaysByPubkey?.[pubkey]?.read)
333
+ if (readRelays.length) return readRelays
334
+ return relayMapRelays(this._pickRelaysForPubkeys([pubkey], relaysByPubkey, { relayType: 'read' }))
335
+ }
336
+
337
+ async recoveryMirrorRelays (channelPubkey) {
338
+ const seeders = this.recoverySeeders(channelPubkey)
339
+ if (!seeders.length) return []
340
+ try {
341
+ return relayMapRelays(await this.readRelayToReceivers(seeders))
342
+ } catch (err) {
343
+ this.onError?.(err)
344
+ return []
345
+ }
346
+ }
347
+
348
+ async resolveWatchRelays (channel) {
349
+ if (!channel.usesNip65WatchRelays && channel.relays.length) return channel.relays
350
+ return this.readRelaysForPubkey(this.userPubkey)
351
+ }
352
+
353
+ async resolveSendRouting ({ channel, receiverPubkeys, relays, relayToReceivers }) {
354
+ const recoveryRelays = await this.recoveryMirrorRelays(channel.pubkey)
355
+ if (relayToReceivers) return { relayToReceivers, recoveryRelays }
356
+ if (relays?.length) return { relays: uniq(relays), recoveryRelays }
357
+ if (channel.sendRelays.length) return { relays: channel.sendRelays, recoveryRelays }
358
+ if (channel.relays.length) return { relays: channel.relays, recoveryRelays }
359
+ const derived = await this.readRelayToReceivers(receiverPubkeys)
360
+ if (!relayMapRelays(derived).length) throw new Error('NO_RELAYS')
361
+ return { relayToReceivers: derived, recoveryRelays }
362
+ }
363
+
364
+ stateKey () {
365
+ return `${this.prefix}:state`
366
+ }
367
+
368
+ readState () {
369
+ const state = parseJson(localStorage.getItem(this.stateKey()), { channels: {} })
370
+ if (!isPlainObject(state.channels)) state.channels = {}
371
+ return state
372
+ }
373
+
374
+ writeState (state) {
375
+ localStorage.setItem(this.stateKey(), JSON.stringify(state))
376
+ }
377
+
378
+ updateChannelState (pubkey, patch) {
379
+ const state = this.readState()
380
+ const current = state.channels[pubkey] || {}
381
+ state.channels[pubkey] = { ...current, ...patch }
382
+ this.writeState(state)
383
+ return state.channels[pubkey]
384
+ }
385
+
386
+ removeChannelState (pubkey) {
387
+ const state = this.readState()
388
+ delete state.channels[pubkey]
389
+ this.writeState(state)
390
+ }
391
+
392
+ markSeen (pubkey, createdAt = nowSeconds()) {
393
+ const state = this.readState()
394
+ const current = state.channels[pubkey] || {}
395
+ current.lastSeenAt = Math.max(current.lastSeenAt || 0, createdAt || 0)
396
+ state.channels[pubkey] = current
397
+ this.writeState(state)
398
+ }
399
+
400
+ knownSeeders (pubkey) {
401
+ const channel = this.channels.get(pubkey)
402
+ if (channel?.seeders?.length) return channel.seeders
403
+ const activity = this.readState().channels[pubkey]?.seederActivity || {}
404
+ return Object.keys(activity)
405
+ }
406
+
407
+ recoverySeeders (pubkey) {
408
+ const channel = this.channels.get(pubkey)
409
+ const configuredSeeders = channel?.seeders || []
410
+ if (configuredSeeders.length) return configuredSeeders.filter(seeder => seeder !== this.userPubkey)
411
+
412
+ const activity = this.readState().channels[pubkey]?.seederActivity || {}
413
+ const cutoff = nowSeconds() - this.seederOnlineSeconds
414
+ return Object.entries(activity)
415
+ .filter(([seeder, entry]) => seeder !== this.userPubkey && (entry.lastActiveAt || 0) >= cutoff)
416
+ .sort((a, b) => (b[1].lastActiveAt || 0) - (a[1].lastActiveAt || 0))
417
+ .slice(0, this.maxDynamicRecoverySeeders)
418
+ .map(([seeder]) => seeder)
419
+ }
420
+
421
+ markSeederActive (channelPubkey, seederPubkey, { announced = false, at = nowSeconds() } = {}) {
422
+ const state = this.readState()
423
+ const current = state.channels[channelPubkey] || {}
424
+ const activity = current.seederActivity || {}
425
+ const entry = activity[seederPubkey] || {}
426
+ activity[seederPubkey] = {
427
+ ...entry,
428
+ firstSeenAt: entry.firstSeenAt || at,
429
+ lastActiveAt: Math.max(entry.lastActiveAt || 0, at)
430
+ }
431
+ if (announced) activity[seederPubkey].announcedAt = at
432
+ current.seederActivity = activity
433
+ state.channels[channelPubkey] = current
434
+ this.writeState(state)
435
+ }
436
+
437
+ trackSeederActivity (channelPubkey, message) {
438
+ const senderPubkey = message.event?.pubkey
439
+ if (!senderPubkey) return false
440
+
441
+ const channel = this.channels.get(channelPubkey)
442
+ if (!channel) return false
443
+
444
+ const activity = this.readState().channels[channelPubkey]?.seederActivity || {}
445
+ const isPresence = messageCode(message) === SEEDER_PRESENCE_CODE
446
+ const configuredSeeders = channel.seeders || []
447
+ const isConfiguredSeeder = configuredSeeders.includes(senderPubkey)
448
+ const isKnownDynamicSeeder = Boolean(activity[senderPubkey])
449
+ const at = messageTime(message)
450
+
451
+ if (isPresence) {
452
+ if (configuredSeeders.length && !isConfiguredSeeder) return false
453
+ this.markSeederActive(channelPubkey, senderPubkey, { announced: true, at })
454
+ return true
455
+ }
456
+
457
+ if (!isConfiguredSeeder && !isKnownDynamicSeeder) return false
458
+ this.markSeederActive(channelPubkey, senderPubkey, { at })
459
+ return true
460
+ }
461
+
462
+ contentKeyStatus (contentKeyPubkey) {
463
+ if (!contentKeyPubkey) return 'none'
464
+ return contentKeyPubkey === this.contentKeyPubkey ? 'known' : 'unknown'
465
+ }
466
+
467
+ handleContentKeyUsage (channelPubkey, usage) {
468
+ const direction = usage.direction === 'sent' ? 'sent' : 'received'
469
+ const contentKeyPubkey = usage.contentKeyPubkey || ''
470
+ const state = this.readState()
471
+ const current = state.channels[channelPubkey] || {}
472
+ const contentKeyUsage = current.contentKeyUsage || {}
473
+ const previous = contentKeyUsage[direction] || null
474
+
475
+ const contentKeyStatus = this.contentKeyStatus(contentKeyPubkey)
476
+ if (
477
+ previous &&
478
+ (previous.contentKeyPubkey || '') === contentKeyPubkey &&
479
+ previous.contentKeyStatus === contentKeyStatus
480
+ ) {
481
+ return false
482
+ }
483
+ const event = {
484
+ type: 'content-key-change',
485
+ channelPubkey,
486
+ direction,
487
+ keyRole: usage.keyRole || (direction === 'sent' ? 'sender' : 'receiver'),
488
+ contentKeyPubkey,
489
+ hasContentKey: Boolean(contentKeyPubkey),
490
+ contentKeyStatus,
491
+ previousContentKeyPubkey: previous?.contentKeyPubkey ?? null,
492
+ previousContentKeyStatus: previous?.contentKeyStatus ?? null,
493
+ senderPubkey: usage.senderPubkey || '',
494
+ receiverPubkey: usage.receiverPubkey || '',
495
+ receiverPubkeys: usage.receiverPubkeys || [],
496
+ counterpartyPubkey: direction === 'sent' ? (usage.receiverPubkey || '') : (usage.senderPubkey || ''),
497
+ isBroadcast: Boolean(usage.isBroadcast),
498
+ outerId: usage.outer?.id || '',
499
+ outerCreatedAt: usage.outer?.created_at || 0,
500
+ routerPubkey: usage.router?.pubkey || '',
501
+ routerCreatedAt: usage.router?.created_at || 0
502
+ }
503
+
504
+ contentKeyUsage[direction] = {
505
+ contentKeyPubkey,
506
+ contentKeyStatus,
507
+ changedAt: nowSeconds(),
508
+ senderPubkey: event.senderPubkey,
509
+ receiverPubkey: event.receiverPubkey,
510
+ isBroadcast: event.isBroadcast
511
+ }
512
+ current.contentKeyUsage = contentKeyUsage
513
+ state.channels[channelPubkey] = current
514
+ this.writeState(state)
515
+ this.onContentKeyChange?.(event)
516
+ return true
517
+ }
518
+
519
+ addOfflineRange (pubkey, start, end) {
520
+ const now = nowSeconds()
521
+ const minStart = now - this.offlineRecoverySeconds
522
+ const normalized = {
523
+ start: Math.max(0, Math.floor(start)),
524
+ end: Math.floor(end)
525
+ }
526
+ if (normalized.end <= normalized.start || normalized.end < minStart) return
527
+ normalized.start = Math.max(normalized.start, minStart)
528
+
529
+ const state = this.readState()
530
+ const current = state.channels[pubkey] || {}
531
+ const ranges = (current.offlineRanges || [])
532
+ .filter(range => range.end >= minStart)
533
+ .concat([normalized])
534
+ .sort((a, b) => a.start - b.start)
535
+ current.offlineRanges = mergeRanges(ranges)
536
+ state.channels[pubkey] = current
537
+ this.writeState(state)
538
+ }
539
+
540
+ closeOpenOfflineRanges () {
541
+ const state = this.readState()
542
+ const end = nowSeconds()
543
+ const minStart = end - this.offlineRecoverySeconds
544
+ for (const pubkey of Object.keys(state.channels)) {
545
+ const current = state.channels[pubkey]
546
+ if (!current.openOfflineStart) continue
547
+ const start = Math.max(minStart, Math.max(0, current.openOfflineStart))
548
+ if (end > start) {
549
+ current.offlineRanges = mergeRanges((current.offlineRanges || []).concat([{ start, end }]))
550
+ }
551
+ delete current.openOfflineStart
552
+ state.channels[pubkey] = current
553
+ }
554
+ this.writeState(state)
555
+ }
556
+
557
+ async watch (channels = [...this.channels.keys()], { scheduleReloadGap = true } = {}) {
558
+ const channelPubkeys = uniq(channels)
559
+ for (const pubkey of channelPubkeys) {
560
+ const channel = this.channels.get(pubkey)
561
+ if (!channel) throw new Error('UNKNOWN_CHANNEL')
562
+ const watchRelays = await this.resolveWatchRelays(channel)
563
+ const stop = await this._privateMessage.watch({
564
+ channels: [pubkey],
565
+ relays: watchRelays,
566
+ receiverSigner: this.userSigner,
567
+ iykcSigner: this.contentKeySigner,
568
+ privateChannelSigner: channel.signer,
569
+ privateChannelReaderSigner: channel.readerSigner,
570
+ privateChannelReaderPubkey: channel.readerPubkey,
571
+ mode: channel.mode,
572
+ onAsk: message => this.queueIncoming(() => this.handleAsk(pubkey, message)),
573
+ onReply: message => this.queueIncoming(() => this.handleReply(pubkey, message)),
574
+ onTell: message => this.queueIncoming(() => this.handleTell(pubkey, message)),
575
+ onYell: message => this.queueIncoming(() => this.handleYell(pubkey, message)),
576
+ onNym: message => this.queueIncoming(() => this.handleNym(pubkey, message)),
577
+ onMessage: message => this.queueIncoming(() => this.handleMessage(pubkey, message)),
578
+ onSeed: seed => this.queueIncoming(() => this.enqueueSeed(pubkey, seed)),
579
+ onContentKeyUsage: usage => this.handleContentKeyUsage(pubkey, usage),
580
+ receivedChunkTtlMs: this.offlineRecoverySeconds * 1000,
581
+ onError: err => this.onError?.(err)
582
+ })
583
+ this.stopByChannel.set(pubkey, stop)
584
+ this.updateChannelState(pubkey, {
585
+ lastWatchedAt: nowSeconds(),
586
+ mode: channel.mode,
587
+ relays: watchRelays,
588
+ seeders: channel.seeders
589
+ })
590
+ this.debug('watch', {
591
+ channelPubkey: pubkey,
592
+ relays: watchRelays,
593
+ mode: channel.mode,
594
+ seeders: channel.seeders,
595
+ seederCount: channel.seeders.length
596
+ })
597
+ if (scheduleReloadGap) this.scheduleReloadGap(pubkey)
598
+ }
599
+ this.ensureNetworkWatchers()
600
+ this.ensureRelayListWatcher()
601
+ return this
602
+ }
603
+
604
+ unwatch (channels) {
605
+ const channelPubkeys = channels ? uniq(Array.isArray(channels) ? channels : [channels]) : [...this.stopByChannel.keys()]
606
+ for (const pubkey of channelPubkeys) {
607
+ this.stopByChannel.get(pubkey)?.()
608
+ this.stopByChannel.delete(pubkey)
609
+ this.stopPresencePublisher(pubkey)
610
+ }
611
+ this.ensureRelayListWatcher()
612
+ }
613
+
614
+ nip65WatchChannelPubkeys () {
615
+ return [...this.channels.values()]
616
+ .filter(channel => channel.usesNip65WatchRelays && this.stopByChannel.has(channel.pubkey))
617
+ .map(channel => channel.pubkey)
618
+ }
619
+
620
+ ensureRelayListWatcher () {
621
+ const channelPubkeys = this.nip65WatchChannelPubkeys()
622
+ if (!channelPubkeys.length) {
623
+ this.stopRelayListWatcher?.()
624
+ this.stopRelayListWatcher = null
625
+ this.relayListWatcherPubkey = ''
626
+ return
627
+ }
628
+ if (this.stopRelayListWatcher && this.relayListWatcherPubkey === this.userPubkey) return
629
+ this.stopRelayListWatcher?.()
630
+ if (typeof window === 'undefined' && this._subscribeRelayListUpdates === subscribeRelayListUpdates) return
631
+ this.relayListWatcherPubkey = this.userPubkey
632
+ this.stopRelayListWatcher = this._subscribeRelayListUpdates([this.userPubkey], {
633
+ relayType: 'read',
634
+ onChange: () => this.refreshNip65WatchRelays()
635
+ })
636
+ }
637
+
638
+ refreshNip65WatchRelays () {
639
+ if (!this.relayListRefreshPromise) {
640
+ this.relayListRefreshPromise = Promise.resolve()
641
+ .then(() => this.refreshNip65WatchRelaysNow())
642
+ .catch(err => this.onError?.(err))
643
+ .finally(() => { this.relayListRefreshPromise = null })
644
+ }
645
+ return this.relayListRefreshPromise
646
+ }
647
+
648
+ async refreshNip65WatchRelaysNow () {
649
+ const channelPubkeys = this.nip65WatchChannelPubkeys()
650
+ if (!channelPubkeys.length) {
651
+ this.ensureRelayListWatcher()
652
+ return
653
+ }
654
+ const until = nowSeconds()
655
+ for (const pubkey of channelPubkeys) {
656
+ const lastSeenAt = this.readState().channels[pubkey]?.lastSeenAt
657
+ if (lastSeenAt) this.addOfflineRange(pubkey, Math.max(0, lastSeenAt - this.offlineSkewSeconds), until)
658
+ }
659
+ await this.watch(channelPubkeys, { scheduleReloadGap: false })
660
+ await this.recoverOfflineRanges(channelPubkeys)
661
+ }
662
+
663
+ async handleAsk (channelPubkey, message) {
664
+ this.trackSeederActivity(channelPubkey, message)
665
+ if (storesRecoverySeeds(this.channels.get(channelPubkey)?.mode) && messageCode(message) === MISSING_MESSAGES_ASK_CODE) {
666
+ await this.replyWithStoredSeeds(channelPubkey, message)
667
+ return
668
+ }
669
+ await this.enqueueRumor('ask', channelPubkey, message)
670
+ }
671
+
672
+ async handleReply (channelPubkey, message) {
673
+ this.trackSeederActivity(channelPubkey, message)
674
+ if (messageCode(message) === MISSING_MESSAGES_REPLY_CODE) {
675
+ await this.consumeMissingMessagesReply(channelPubkey, message)
676
+ return
677
+ }
678
+ await this.enqueueRumor('reply', channelPubkey, message)
679
+ }
680
+
681
+ async handleTell (channelPubkey, message) {
682
+ this.trackSeederActivity(channelPubkey, message)
683
+ await this.enqueueRumor('tell', channelPubkey, message)
684
+ }
685
+
686
+ async handleYell (channelPubkey, message) {
687
+ this.trackSeederActivity(channelPubkey, message)
688
+ if (messageCode(message) === SEEDER_PRESENCE_CODE) return
689
+ await this.enqueueRumor('yell', channelPubkey, message)
690
+ }
691
+
692
+ async handleNym (channelPubkey, message) {
693
+ await this.enqueueRumor('nym', channelPubkey, message)
694
+ }
695
+
696
+ async handleMessage (channelPubkey, message) {
697
+ if (eventType(message.event) !== 'message') return
698
+ this.trackSeederActivity(channelPubkey, message)
699
+ await this.enqueueRumor('message', channelPubkey, message)
700
+ }
701
+
702
+ async enqueueRumor (type, channelPubkey, message) {
703
+ const channel = this.channels.get(channelPubkey)
704
+ if (channel?.mode === 'watchtower' && type !== 'ask') return
705
+ this.markSeen(channelPubkey, message.outer?.created_at || message.event?.created_at || nowSeconds())
706
+ const eventId = message.event?.id || ''
707
+ const dedupeKey = eventId ? [channelPubkey, type, eventId] : null
708
+ if (dedupeKey && await this.queue.someBy('byChannelTypeEventId', dedupeKey)) {
709
+ this.debug('dedupe', debugMessageInfo(type, channelPubkey, message))
710
+ return
711
+ }
712
+ try {
713
+ await this.queue.enqueue({
714
+ type,
715
+ channelPubkey,
716
+ receivedAt: nowSeconds(),
717
+ event: message.event,
718
+ payload: message.payload,
719
+ question: message.question || null,
720
+ questionId: message.questionId || null,
721
+ outer: message.outer || null,
722
+ meta: message.meta || null
723
+ })
724
+ } catch (err) {
725
+ // The unique index closes the small cross-instance race after `someBy`.
726
+ if (dedupeKey && err?.name === 'ConstraintError') {
727
+ this.debug('dedupe', debugMessageInfo(type, channelPubkey, message))
728
+ return
729
+ }
730
+ throw err
731
+ }
732
+ this.debug('enqueue', debugMessageInfo(type, channelPubkey, message))
733
+ this.onMessageQueued?.()
734
+ }
735
+
736
+ async enqueueSeed (channelPubkey, seed) {
737
+ const receivedAt = nowSeconds()
738
+ if (seed.recordType === NYM_CARRIER_SEED_RECORD_TYPE || seed.carriers?.length) {
739
+ const carriers = compactSeedNymCarriers(seed.carriers)
740
+ const recordTime = nymCarrierRecordTime({ carriers }) || seed.outer?.created_at || receivedAt
741
+ this.markSeen(channelPubkey, recordTime)
742
+ const key = nymCarrierSeedKey({ channelPubkey, carriers })
743
+ const seedKey = key ? `nym:${key}` : ''
744
+ if (seedKey && await this.seedQueue.someBy('bySeedKey', seedKey)) return
745
+ await this.seedQueue.enqueue({
746
+ type: 'seed',
747
+ recordType: NYM_CARRIER_SEED_RECORD_TYPE,
748
+ channelPubkey,
749
+ receivedAt,
750
+ carriers,
751
+ meta: { channelPubkey: seed.channelPubkey },
752
+ [SEED_KEY]: seedKey || undefined,
753
+ [SEED_TIME]: recordTime
754
+ })
755
+ await this.pruneStoredSeeds(channelPubkey)
756
+ return
757
+ }
758
+
759
+ const rows = compactSeedRouterRows(seed)
760
+ let newest = seed.outer?.created_at || receivedAt
761
+ for (const row of rows) {
762
+ const rowTime = row.lastSeenAt || row.router?.created_at || receivedAt
763
+ newest = Math.max(newest, rowTime)
764
+ const rowKey = routerSeedRowKey({ ...row, channelPubkey })
765
+ const seedKey = `router:${rowKey}`
766
+ const [previous] = await this.seedQueue.removeBy('bySeedKey', seedKey)
767
+ const firstSeenAt = Math.min(previous?.firstSeenAt ?? rowTime, row.firstSeenAt ?? rowTime)
768
+ const lastSeenAt = Math.max(previous?.lastSeenAt ?? rowTime, row.lastSeenAt ?? rowTime)
769
+ await this.seedQueue.enqueue({
770
+ ...row,
771
+ type: 'seed',
772
+ recordType: ROUTER_SEED_RECORD_TYPE,
773
+ channelPubkey,
774
+ receivedAt,
775
+ firstSeenAt,
776
+ lastSeenAt,
777
+ meta: { channelPubkey: seed.channelPubkey },
778
+ [SEED_KEY]: seedKey,
779
+ [SEED_TIME]: lastSeenAt || rowTime
780
+ })
781
+ }
782
+ this.markSeen(channelPubkey, newest)
783
+ await this.pruneStoredSeeds(channelPubkey)
784
+ }
785
+
786
+ async * messages () {
787
+ // seedQueue is retained replay material for recovery replies, not an app-message stream.
788
+ for await (const item of this.queue.items()) yield withoutQueueMetadata(item)
789
+ }
790
+
791
+ async nextMessage () {
792
+ // seedQueue is retained replay material for recovery replies, not an app-message stream.
793
+ await this.queueOperationTail
794
+ return withoutQueueMetadata(await this.queue.shift())
795
+ }
796
+
797
+ async ask ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkey, relays, relayToReceivers, message, code, payload, error, content, deletionPubkey }) {
798
+ const channel = this.requireWritableChannel(channelPubkey)
799
+ const routing = await this.resolveSendRouting({ channel, receiverPubkeys: [receiverPubkey], relays, relayToReceivers })
800
+ this.debugSend('ask', channelPubkey, { code, receiverPubkey })
801
+ return this._privateMessage.ask({
802
+ senderSigner: this.userSigner,
803
+ imkcSigner: this.contentKeySigner,
804
+ privateChannelSigner: channel.signer,
805
+ privateChannelReaderPubkey: channel.readerPubkey,
806
+ receiverPubkey,
807
+ ...routing,
808
+ expirationSeconds: this.offlineRecoverySeconds,
809
+ temporaryStorageArea: this.temporaryStorageArea,
810
+ message,
811
+ code,
812
+ payload,
813
+ error,
814
+ content,
815
+ deletionPubkey,
816
+ _getIykcProofs: this.contentKeyLookup()
817
+ })
818
+ }
819
+
820
+ async reply ({ channelPubkey = this.defaultChannelPubkey(), question, receiverPubkey, relays, relayToReceivers, message, code, payload, error, content, deletionPubkey }) {
821
+ const channel = this.requireWritableChannel(channelPubkey)
822
+ const resolvedReceiverPubkey = receiverPubkey || question?.pubkey || ''
823
+ const routing = await this.resolveSendRouting({ channel, receiverPubkeys: [resolvedReceiverPubkey], relays, relayToReceivers })
824
+ this.debugSend('reply', channelPubkey, { code, receiverPubkey: receiverPubkey || question?.pubkey || '' })
825
+ return this._privateMessage.reply({
826
+ senderSigner: this.userSigner,
827
+ imkcSigner: this.contentKeySigner,
828
+ privateChannelSigner: channel.signer,
829
+ privateChannelReaderPubkey: channel.readerPubkey,
830
+ question,
831
+ receiverPubkey,
832
+ ...routing,
833
+ expirationSeconds: this.offlineRecoverySeconds,
834
+ temporaryStorageArea: this.temporaryStorageArea,
835
+ message,
836
+ code,
837
+ payload,
838
+ error,
839
+ content,
840
+ deletionPubkey,
841
+ _getIykcProofs: this.contentKeyLookup()
842
+ })
843
+ }
844
+
845
+ async tell ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkey, relays, relayToReceivers, message, code, payload, error, content, deletionPubkey }) {
846
+ const channel = this.requireWritableChannel(channelPubkey)
847
+ const routing = await this.resolveSendRouting({ channel, receiverPubkeys: [receiverPubkey], relays, relayToReceivers })
848
+ this.debugSend('tell', channelPubkey, { code, receiverPubkey })
849
+ return this._privateMessage.tell({
850
+ senderSigner: this.userSigner,
851
+ imkcSigner: this.contentKeySigner,
852
+ privateChannelSigner: channel.signer,
853
+ privateChannelReaderPubkey: channel.readerPubkey,
854
+ receiverPubkey,
855
+ ...routing,
856
+ expirationSeconds: this.offlineRecoverySeconds,
857
+ temporaryStorageArea: this.temporaryStorageArea,
858
+ message,
859
+ code,
860
+ payload,
861
+ error,
862
+ content,
863
+ deletionPubkey,
864
+ _getIykcProofs: this.contentKeyLookup()
865
+ })
866
+ }
867
+
868
+ async yell ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, message, code, payload, error, content, deletionPubkey }) {
869
+ const channel = this.requireWritableChannel(channelPubkey)
870
+ const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers })
871
+ this.debugSend('yell', channelPubkey, { code, receiverPubkeys })
872
+ return this._privateMessage.yell({
873
+ senderSigner: this.userSigner,
874
+ imkcSigner: this.contentKeySigner,
875
+ privateChannelSigner: channel.signer,
876
+ privateChannelReaderPubkey: channel.readerPubkey,
877
+ receiverPubkeys,
878
+ ...routing,
879
+ expirationSeconds: this.offlineRecoverySeconds,
880
+ temporaryStorageArea: this.temporaryStorageArea,
881
+ message,
882
+ code,
883
+ payload,
884
+ error,
885
+ content,
886
+ deletionPubkey,
887
+ _getIykcProofs: this.contentKeyLookup()
888
+ })
889
+ }
890
+
891
+ async broadcastRumor ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, rumor, deletionPubkey }) {
892
+ const channel = this.requireWritableChannel(channelPubkey)
893
+ const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers })
894
+ this.debugSend('broadcastRumor', channelPubkey, { receiverPubkeys })
895
+ return this._privateMessage.broadcastRumor({
896
+ senderSigner: this.userSigner,
897
+ imkcSigner: this.contentKeySigner,
898
+ privateChannelSigner: channel.signer,
899
+ privateChannelReaderPubkey: channel.readerPubkey,
900
+ receiverPubkeys,
901
+ ...routing,
902
+ expirationSeconds: this.offlineRecoverySeconds,
903
+ temporaryStorageArea: this.temporaryStorageArea,
904
+ rumor,
905
+ deletionPubkey,
906
+ _getIykcProofs: this.contentKeyLookup()
907
+ })
908
+ }
909
+
910
+ async broadcastEvent ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, event, deletionPubkey }) {
911
+ const channel = this.requireWritableChannel(channelPubkey)
912
+ const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers })
913
+ this.debugSend('broadcastEvent', channelPubkey, { receiverPubkeys })
914
+ return this._privateMessage.broadcastEvent({
915
+ senderSigner: this.userSigner,
916
+ imkcSigner: this.contentKeySigner,
917
+ privateChannelSigner: channel.signer,
918
+ privateChannelReaderPubkey: channel.readerPubkey,
919
+ receiverPubkeys,
920
+ ...routing,
921
+ expirationSeconds: this.offlineRecoverySeconds,
922
+ temporaryStorageArea: this.temporaryStorageArea,
923
+ event,
924
+ deletionPubkey,
925
+ _getIykcProofs: this.contentKeyLookup()
926
+ })
927
+ }
928
+
929
+ async broadcastNymRumor ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, rumor, nymSigner, deletionPubkey }) {
930
+ const channel = this.requireWritableChannel(channelPubkey)
931
+ const resolvedNymSigner = this.requireNymSigner(channel, nymSigner)
932
+ const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers })
933
+ this.debugSend('broadcastNymRumor', channelPubkey, { receiverPubkeys })
934
+ return this._privateMessage.broadcastNymRumor({
935
+ nymSigner: resolvedNymSigner,
936
+ privateChannelSigner: channel.signer,
937
+ privateChannelReaderPubkey: channel.readerPubkey,
938
+ ...routing,
939
+ expirationSeconds: this.offlineRecoverySeconds,
940
+ rumor,
941
+ deletionPubkey
942
+ })
943
+ }
944
+
945
+ async broadcastNymEvent ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, event, nymSigner, deletionPubkey }) {
946
+ const channel = this.requireWritableChannel(channelPubkey)
947
+ const resolvedNymSigner = this.requireNymSigner(channel, nymSigner)
948
+ const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers })
949
+ this.debugSend('broadcastNymEvent', channelPubkey, { receiverPubkeys })
950
+ return this._privateMessage.broadcastNymEvent({
951
+ nymSigner: resolvedNymSigner,
952
+ privateChannelSigner: channel.signer,
953
+ privateChannelReaderPubkey: channel.readerPubkey,
954
+ ...routing,
955
+ expirationSeconds: this.offlineRecoverySeconds,
956
+ event,
957
+ deletionPubkey
958
+ })
959
+ }
960
+
961
+ async publishSeederPresence (channelPubkey = this.defaultChannelPubkey()) {
962
+ const channel = this.requireWritableChannel(channelPubkey)
963
+ const receiverPubkeys = uniq([...this.knownSeeders(channelPubkey), this.userPubkey])
964
+ const routing = await this.resolveSendRouting({ channel, receiverPubkeys })
965
+ this.debugSend('yell', channelPubkey, { code: SEEDER_PRESENCE_CODE, receiverPubkeys })
966
+ return this._privateMessage.yell({
967
+ senderSigner: this.userSigner,
968
+ imkcSigner: this.contentKeySigner,
969
+ privateChannelSigner: channel.signer,
970
+ privateChannelReaderPubkey: channel.readerPubkey,
971
+ receiverPubkeys,
972
+ ...routing,
973
+ expirationSeconds: this.offlineRecoverySeconds,
974
+ temporaryStorageArea: this.temporaryStorageArea,
975
+ code: SEEDER_PRESENCE_CODE,
976
+ payload: {},
977
+ _getIykcProofs: this.contentKeyLookup()
978
+ })
979
+ }
980
+
981
+ async startPresencePublisher (channelPubkey) {
982
+ if (this.presenceTimers.has(channelPubkey)) return
983
+ try {
984
+ await this.publishSeederPresence(channelPubkey)
985
+ } catch (err) {
986
+ console.warn('private-messenger seeder presence failed', err?.message ?? err)
987
+ }
988
+ const timer = this._setInterval(() => {
989
+ return this.publishSeederPresence(channelPubkey).catch(err => {
990
+ console.warn('private-messenger seeder presence failed', err?.message ?? err)
991
+ })
992
+ }, this.seederPresenceIntervalMs)
993
+ timer?.unref?.()
994
+ this.presenceTimers.set(channelPubkey, timer)
995
+ }
996
+
997
+ stopPresencePublisher (channelPubkey) {
998
+ const timer = this.presenceTimers.get(channelPubkey)
999
+ if (timer) this._clearInterval(timer)
1000
+ this.presenceTimers.delete(channelPubkey)
1001
+ }
1002
+
1003
+ async reconcilePresencePublishers () {
1004
+ const starts = []
1005
+ for (const pubkey of [...this.presenceTimers.keys()]) {
1006
+ if (!storesRecoverySeeds(this.channels.get(pubkey)?.mode)) this.stopPresencePublisher(pubkey)
1007
+ }
1008
+ for (const [pubkey, channel] of this.channels) {
1009
+ if (storesRecoverySeeds(channel.mode)) starts.push(this.startPresencePublisher(pubkey))
1010
+ else this.stopPresencePublisher(pubkey)
1011
+ }
1012
+ await Promise.all(starts)
1013
+ }
1014
+
1015
+ createMissingMessageReplyPacker (options) {
1016
+ return createMissingMessageReplyPacker({ messenger: this, ...options })
1017
+ }
1018
+
1019
+ createEventReplyPacker (options) {
1020
+ return createEventReplyPacker({ messenger: this, ...options })
1021
+ }
1022
+
1023
+ defaultChannelPubkey () {
1024
+ return this.channels.keys().next().value
1025
+ }
1026
+
1027
+ requireChannel (pubkey) {
1028
+ const channel = this.channels.get(pubkey)
1029
+ if (!channel) throw new Error('UNKNOWN_CHANNEL')
1030
+ return channel
1031
+ }
1032
+
1033
+ requireWritableChannel (pubkey) {
1034
+ const channel = this.requireChannel(pubkey)
1035
+ if (!channel.signer) throw new Error('PRIVATE_CHANNEL_WRITER_REQUIRED')
1036
+ return channel
1037
+ }
1038
+
1039
+ requireNymSigner (channel, override) {
1040
+ const signer = override || channel?.nymSigner || this.nymSigner
1041
+ if (!signer?.getPublicKey) throw new Error('NYM_SIGNER_REQUIRED')
1042
+ return signer
1043
+ }
1044
+
1045
+ contentKeyLookup () {
1046
+ return this.useContentKeys ? undefined : noContentKeys
1047
+ }
1048
+
1049
+ scheduleReloadGap (pubkey) {
1050
+ const current = this.readState().channels[pubkey]
1051
+ const start = current?.openOfflineStart || current?.lastSeenAt
1052
+ if (!start) return
1053
+ this._setTimeout(async () => {
1054
+ this.addOfflineRange(pubkey, Math.max(0, start - this.offlineSkewSeconds), nowSeconds())
1055
+ await this.recoverOfflineRanges([pubkey])
1056
+ }, this.reloadGapDelayMs)
1057
+ }
1058
+
1059
+ // Browser-offline recovery owns durable gaps. Stop only the child live reads;
1060
+ // unwatch() would also stop seeder-presence publishing and alter channel state.
1061
+ #pauseLiveWatches () {
1062
+ for (const stop of this.stopByChannel.values()) stop?.()
1063
+ this.stopByChannel.clear()
1064
+ }
1065
+
1066
+ async #resumeLiveWatches () {
1067
+ const channelPubkeys = [...this.channels.keys()]
1068
+ await this.watch(channelPubkeys, { scheduleReloadGap: false })
1069
+ return channelPubkeys
1070
+ }
1071
+
1072
+ ensureNetworkWatchers () {
1073
+ if (typeof window === 'undefined') return
1074
+ if (!this.stopOffline) {
1075
+ const offline = () => {
1076
+ const state = this.readState()
1077
+ const start = Math.max(0, nowSeconds() - this.offlineSkewSeconds)
1078
+ for (const pubkey of this.channels.keys()) {
1079
+ const current = state.channels[pubkey] || {}
1080
+ current.openOfflineStart ||= start
1081
+ state.channels[pubkey] = current
1082
+ }
1083
+ this.writeState(state)
1084
+ this.#pauseLiveWatches()
1085
+ }
1086
+ window.addEventListener('offline', offline)
1087
+ this.stopOffline = () => window.removeEventListener('offline', offline)
1088
+ }
1089
+ if (!this.stopOnline) {
1090
+ const online = async () => {
1091
+ this.closeOpenOfflineRanges()
1092
+ const channelPubkeys = await this.#resumeLiveWatches()
1093
+ await this.recoverOfflineRanges(channelPubkeys)
1094
+ }
1095
+ window.addEventListener('online', online)
1096
+ this.stopOnline = () => window.removeEventListener('online', online)
1097
+ }
1098
+ }
1099
+
1100
+ async askSeedersForMissingRange (channelPubkey, since, until) {
1101
+ if (!this.channels.get(channelPubkey)?.signer) return []
1102
+ const seeders = this.recoverySeeders(channelPubkey)
1103
+ if (!seeders.length || until < since) return []
1104
+
1105
+ const asks = []
1106
+ for (const seeder of seeders) {
1107
+ try {
1108
+ asks.push(await this.ask({
1109
+ channelPubkey,
1110
+ receiverPubkey: seeder,
1111
+ code: MISSING_MESSAGES_ASK_CODE,
1112
+ payload: { since, until }
1113
+ }))
1114
+ } catch (err) {
1115
+ console.warn('private-messenger seeder recovery ask failed', seeder, err?.message ?? err)
1116
+ }
1117
+ }
1118
+ return asks
1119
+ }
1120
+
1121
+ async askSeedersForRelayLeftEdge (channelPubkey, range, fetchedEvents) {
1122
+ const oldest = oldestCreatedAt(fetchedEvents)
1123
+ const until = oldest == null ? range.end : Math.min(range.end, oldest)
1124
+ if (until < range.start) return []
1125
+ return this.askSeedersForMissingRange(channelPubkey, range.start, until)
1126
+ }
1127
+
1128
+ async replyWithStoredSeeds (channelPubkey, message) {
1129
+ const payload = isPlainObject(message.payload?.payload) ? message.payload.payload : {}
1130
+ const since = Number.isFinite(payload.since) ? payload.since : undefined
1131
+ const until = Number.isFinite(payload.until) ? payload.until : undefined
1132
+ const packer = this.createMissingMessageReplyPacker({
1133
+ channelPubkey,
1134
+ question: message.event,
1135
+ receiverPubkey: message.event?.pubkey,
1136
+ since,
1137
+ until
1138
+ })
1139
+
1140
+ for await (const seed of this.seedQueue.storedItemsBy('byChannel', channelPubkey)) {
1141
+ await packer.update(seed)
1142
+ }
1143
+ await packer.finalize()
1144
+ }
1145
+
1146
+ async consumeMissingMessagesReply (channelPubkey, message) {
1147
+ const payload = message.payload?.payload
1148
+ const jsonl = typeof payload?.jsonl === 'string' ? payload.jsonl : ''
1149
+ if (!jsonl) return
1150
+
1151
+ for (const line of splitJsonl(jsonl)) {
1152
+ const record = parseJson(line, null)
1153
+ if (!record) continue
1154
+ const recovered = await this.messageFromBackfillRecord(channelPubkey, record)
1155
+ if (!recovered) continue
1156
+ await this.enqueueRumor(recovered.type, channelPubkey, {
1157
+ event: recovered.event,
1158
+ outer: recovered.outer,
1159
+ meta: { ...(recovered.meta || {}), channelPubkey, recoveredFromSeeder: message.event?.pubkey || '' },
1160
+ payload: recovered.payload
1161
+ })
1162
+ }
1163
+ }
1164
+
1165
+ async messageFromBackfillRecord (channelPubkey, record) {
1166
+ if (record?.recordType === NYM_CARRIER_SEED_RECORD_TYPE) {
1167
+ const event = this._privateChannel.eventFromNymCarriers(record.carriers)
1168
+ return {
1169
+ type: 'nym',
1170
+ event,
1171
+ outer: { id: '', created_at: nymCarrierRecordTime(record) },
1172
+ meta: { channelPubkey, carriers: record.carriers },
1173
+ payload: parseEventContent(event)
1174
+ }
1175
+ }
1176
+
1177
+ const routerRecord = record?.recordType === ROUTER_SEED_RECORD_TYPE ? record.router : null
1178
+ if (!isPrivateChannelRouter(routerRecord)) return null
1179
+ if (!this._privateChannel.unwrapEvent) throw new Error('PRIVATE_CHANNEL_UNWRAP_UNSUPPORTED')
1180
+
1181
+ const channel = this.requireChannel(channelPubkey)
1182
+ const router = {
1183
+ kind: privateChannel.ROUTER_KIND,
1184
+ pubkey: routerRecord.pubkey,
1185
+ created_at: routerRecord.created_at || nowSeconds(),
1186
+ tags: (routerRecord.tags || []).filter(tag => tag[0] !== 'c').concat([['c', '0', '1']]),
1187
+ content: routerRecord.content
1188
+ }
1189
+ const encryptSigner = channel.readerSigner && channel.readerSigner !== channel.signer ? channel.readerSigner : channel.signer
1190
+ const encryptPeerPubkey = encryptSigner === channel.signer ? channel.readerPubkey : channelPubkey
1191
+ const outer = {
1192
+ kind: privateChannel.PRIVATE_BROADCAST_KIND,
1193
+ pubkey: channelPubkey,
1194
+ created_at: router.created_at,
1195
+ tags: [],
1196
+ content: await encryptSigner.nip44v3Encrypt(
1197
+ encryptPeerPubkey,
1198
+ privateChannel.PRIVATE_BROADCAST_KIND,
1199
+ '',
1200
+ textToBase64(JSON.stringify(router))
1201
+ )
1202
+ }
1203
+ const event = await this._privateChannel.unwrapEvent({
1204
+ receiverSigner: this.userSigner,
1205
+ iykcSigner: this.contentKeySigner,
1206
+ privateChannelSigner: channel.signer,
1207
+ privateChannelReaderSigner: channel.readerSigner,
1208
+ privateChannelReaderPubkey: channel.readerPubkey,
1209
+ event: outer,
1210
+ receiverPubkey: this.userPubkey
1211
+ })
1212
+ if (!event) return null
1213
+ return {
1214
+ type: eventType(event),
1215
+ event,
1216
+ outer,
1217
+ meta: { channelPubkey },
1218
+ payload: parseEventContent(event)
1219
+ }
1220
+ }
1221
+
1222
+ async recoverOfflineRanges (channels = [...this.stopByChannel.keys()]) {
1223
+ const state = this.readState()
1224
+ const now = nowSeconds()
1225
+ const minStart = now - this.offlineRecoverySeconds
1226
+
1227
+ for (const pubkey of uniq(channels)) {
1228
+ const channel = this.channels.get(pubkey)
1229
+ const current = state.channels[pubkey]
1230
+ if (!channel || !current?.offlineRanges?.length) continue
1231
+
1232
+ const remaining = []
1233
+ for (const range of current.offlineRanges) {
1234
+ if (range.end < minStart) continue
1235
+ try {
1236
+ const fetchRelays = await this.resolveWatchRelays(channel)
1237
+ const fetchedEvents = await this._privateChannel.fetch({
1238
+ receiverSigner: this.userSigner,
1239
+ iykcSigner: this.contentKeySigner,
1240
+ privateChannelSigner: channel.signer,
1241
+ privateChannelReaderSigner: channel.readerSigner,
1242
+ privateChannelReaderPubkey: channel.readerPubkey,
1243
+ privateChannelPubkeys: [pubkey],
1244
+ receiverPubkey: this.userPubkey,
1245
+ relays: fetchRelays,
1246
+ since: Math.max(0, range.start),
1247
+ until: range.end,
1248
+ mode: channel.mode,
1249
+ modeByPubkey: { [pubkey]: channel.mode },
1250
+ receivedChunkTtlMs: this.offlineRecoverySeconds * 1000,
1251
+ onEvent: (event, outer, meta) => this.queueIncoming(() => this.enqueueRumor(eventType(event), pubkey, { event, outer, meta, payload: parseEventContent(event) })),
1252
+ onNymEvent: (event, outer, meta) => this.queueIncoming(() => this.enqueueRumor('nym', pubkey, { event, outer, meta, payload: parseEventContent(event) })),
1253
+ onSeedEvent: seed => this.queueIncoming(() => this.enqueueSeed(pubkey, seed)),
1254
+ onContentKeyUsage: usage => this.handleContentKeyUsage(pubkey, usage),
1255
+ onError: err => { throw err }
1256
+ }) || []
1257
+ await this.askSeedersForRelayLeftEdge(pubkey, range, fetchedEvents)
1258
+ } catch (err) {
1259
+ this.onError?.(err)
1260
+ remaining.push(range)
1261
+ }
1262
+ }
1263
+ const fresh = this.readState()
1264
+ fresh.channels[pubkey] = {
1265
+ ...(fresh.channels[pubkey] || {}),
1266
+ offlineRanges: remaining
1267
+ }
1268
+ this.writeState(fresh)
1269
+ }
1270
+ }
1271
+
1272
+ async clearChannel (pubkey) {
1273
+ return this.runQueueOperation(async () => {
1274
+ this.unwatch(pubkey)
1275
+ this._privateMessage.clearChannelState?.(pubkey)
1276
+ this.channels.delete(pubkey)
1277
+ this.removeChannelState(pubkey)
1278
+ await this.queue.removeBy('byChannel', pubkey)
1279
+ await this.seedQueue.removeBy('byChannel', pubkey)
1280
+ this.ensureRelayListWatcher()
1281
+ })
1282
+ }
1283
+
1284
+ async clearQueue () {
1285
+ return this.runQueueOperation(() => this.queue.clear())
1286
+ }
1287
+
1288
+ async cleanupStaleChannels () {
1289
+ if (!this.prefix) return
1290
+ return this.runQueueOperation(async () => {
1291
+ const state = this.readState()
1292
+ const cutoff = nowSeconds() - this.staleChannelSeconds
1293
+ for (const [pubkey, channel] of Object.entries(state.channels)) {
1294
+ if ((channel.lastWatchedAt || 0) >= cutoff) continue
1295
+ delete state.channels[pubkey]
1296
+ await this.queue?.removeBy('byChannel', pubkey)
1297
+ await this.seedQueue?.removeBy('byChannel', pubkey)
1298
+ }
1299
+ this.writeState(state)
1300
+ })
1301
+ }
1302
+
1303
+ async pruneStoredSeeds (channelPubkey) {
1304
+ if (!this.seedQueue) return
1305
+ const cutoff = nowSeconds() - this.offlineRecoverySeconds
1306
+ const keyRange = globalThis.IDBKeyRange
1307
+ if (channelPubkey && keyRange?.bound) {
1308
+ await this.seedQueue.removeBy('byChannelTime', keyRange.bound([channelPubkey, 0], [channelPubkey, cutoff], false, true))
1309
+ return
1310
+ }
1311
+ if (!channelPubkey && keyRange?.upperBound) {
1312
+ await this.seedQueue.removeBy('byTime', keyRange.upperBound(cutoff, true))
1313
+ return
1314
+ }
1315
+ await this.seedQueue.removeWhere(item => {
1316
+ if (channelPubkey && item.channelPubkey !== channelPubkey) return false
1317
+ return (seedRecordTime(item) || item.receivedAt || 0) < cutoff
1318
+ })
1319
+ }
1320
+
1321
+ close () {
1322
+ this.unwatch()
1323
+ for (const pubkey of [...this.presenceTimers.keys()]) this.stopPresencePublisher(pubkey)
1324
+ this.stopRelayListWatcher?.()
1325
+ this.stopRelayListWatcher = null
1326
+ this.relayListWatcherPubkey = ''
1327
+ this.stopOffline?.()
1328
+ this.stopOnline?.()
1329
+ this.stopOffline = null
1330
+ this.stopOnline = null
1331
+ }
1332
+ }
1333
+
1334
+ function mergeRanges (ranges) {
1335
+ const out = []
1336
+ for (const range of ranges) {
1337
+ const last = out[out.length - 1]
1338
+ if (!last || range.start > last.end + 1) out.push({ ...range })
1339
+ else last.end = Math.max(last.end, range.end)
1340
+ }
1341
+ return out
1342
+ }
1343
+
1344
+ function eventType (event) {
1345
+ if (event.kind === privateMessage.ASK_KIND) return 'ask'
1346
+ if (event.kind === privateMessage.REPLY_KIND) return 'reply'
1347
+ if (event.kind === privateMessage.TELL_KIND) return event.tags?.some(t => t[0] === 'r') ? 'tell' : 'yell'
1348
+ return 'message'
1349
+ }
1350
+
1351
+ function parseEventContent (event) {
1352
+ return privateMessage.parseRumorContent(event)
1353
+ }
1354
+
1355
+ function messageCode (message) {
1356
+ return isPlainObject(message.payload) && Object.prototype.hasOwnProperty.call(message.payload, 'code')
1357
+ ? message.payload.code
1358
+ : null
1359
+ }
1360
+
1361
+ function debugMessageInfo (type, channelPubkey, message) {
1362
+ return {
1363
+ type,
1364
+ code: messageCode(message) || '',
1365
+ channelPubkey,
1366
+ senderPubkey: message.event?.pubkey || '',
1367
+ eventId: message.event?.id || '',
1368
+ outerId: message.outer?.id || '',
1369
+ outerCreatedAt: message.outer?.created_at || message.event?.created_at || 0
1370
+ }
1371
+ }
1372
+
1373
+ function messageTime (message) {
1374
+ return message.outer?.created_at || message.event?.created_at || nowSeconds()
1375
+ }
1376
+
1377
+ function oldestCreatedAt (events) {
1378
+ let oldest = null
1379
+ for (const event of events || []) {
1380
+ if (!Number.isFinite(event?.created_at)) continue
1381
+ oldest = oldest == null ? event.created_at : Math.min(oldest, event.created_at)
1382
+ }
1383
+ return oldest
1384
+ }
1385
+
1386
+ function relayMapRelays (relayToReceivers) {
1387
+ if (!relayToReceivers) return []
1388
+ const entries = relayToReceivers instanceof Map ? relayToReceivers.entries() : Object.entries(relayToReceivers)
1389
+ return uniq([...entries].map(([relay]) => relay))
1390
+ }
1391
+
1392
+ function isPrivateChannelRouter (event) {
1393
+ return event?.kind === privateChannel.ROUTER_KIND &&
1394
+ typeof event.content === 'string' &&
1395
+ event.tags?.some(tag => tag[0] === 'c')
1396
+ }
1397
+
1398
+ function nymCarrierRecordTime (record) {
1399
+ return record?.carriers?.reduce((max, carrier) => Math.max(max, carrier.created_at || 0), 0) || 0
1400
+ }
1401
+
1402
+ function nymCarrierSeedKey (record) {
1403
+ const carriers = record?.carriers || []
1404
+ if (!carriers.length) return ''
1405
+ const ids = carriers.map(carrier => carrier.id || '').join(',')
1406
+ return `${record.channelPubkey || ''}:${carriers[0]?.pubkey || ''}:${ids}`
1407
+ }
1408
+
1409
+ function withoutQueueMetadata (item) {
1410
+ if (!item) return null
1411
+ const {
1412
+ [SEED_KEY]: ignoredSeedKey,
1413
+ [SEED_TIME]: ignoredSeedTime,
1414
+ ...value
1415
+ } = item
1416
+ return value
1417
+ }
1418
+
1419
+ function seedRecordTime (record) {
1420
+ if (record?.recordType === NYM_CARRIER_SEED_RECORD_TYPE || record?.carriers?.length) return nymCarrierRecordTime(record)
1421
+ if (record?.recordType === ROUTER_SEED_RECORD_TYPE) return record.lastSeenAt || record.router?.created_at || 0
1422
+ return record?.router?.created_at || 0
1423
+ }
1424
+
1425
+ function splitJsonl (jsonl) {
1426
+ return String(jsonl || '').split('\n').filter(Boolean)
1427
+ }
1428
+
1429
+ export async function createPrivateMessenger (options) {
1430
+ return new PrivateMessenger(options).init(options)
1431
+ }