libp2r2p 0.11.8 → 0.11.10

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.
@@ -2,11 +2,13 @@ import { CONTENT_KEY_KIND, parseContentKeyEvent } from '../event/index.js'
2
2
  import { pickRelaysForPubkeys, getRelaysByPubkey, relayPool } from '../../relay/index.js'
3
3
 
4
4
  const QUERY_CACHE_MS = 40 * 60 * 1000
5
+ const NEGATIVE_CACHE_MS = 5 * 60 * 1000
5
6
  const IYKC_CACHE_MAX_ITEMS = 10000
6
7
  const HEX_PUBKEY = /^[0-9a-f]{64}$/i
7
8
  const contentKeysByPubkey = Object.create(null)
8
9
  const iykcCacheTimersByPubkey = Object.create(null)
9
10
  const iykcCacheAddedAtByPubkey = Object.create(null)
11
+ const inFlightByPubkey = new Map()
10
12
 
11
13
  const getEvents = (...args) => relayPool.getEvents(...args)
12
14
 
@@ -68,42 +70,30 @@ export function clearContentKeyCache () {
68
70
  for (const key of Object.keys(contentKeysByPubkey)) delete contentKeysByPubkey[key]
69
71
  for (const key of Object.keys(iykcCacheTimersByPubkey)) delete iykcCacheTimersByPubkey[key]
70
72
  for (const key of Object.keys(iykcCacheAddedAtByPubkey)) delete iykcCacheAddedAtByPubkey[key]
73
+ inFlightByPubkey.clear()
71
74
  }
72
75
 
73
- export async function getIykcProofs (pubkeys, {
74
- _getEvents = getEvents,
75
- _getRelaysByPubkey = getRelaysByPubkey,
76
- cacheMs = QUERY_CACHE_MS
77
- } = {}) {
78
- const pubkeyList = uniquePubkeys(pubkeys, { requireHex: _getEvents === getEvents })
79
- if (!pubkeyList.length) return {}
80
-
81
- const out = {}
82
- const missingPubkeys = []
83
- for (const pubkey of pubkeyList) {
84
- if (!hasCachedKey(contentKeysByPubkey, pubkey)) {
85
- missingPubkeys.push(pubkey)
86
- continue
87
- }
88
- const cached = cloneContentKey(contentKeysByPubkey[pubkey])
89
- if (cached) out[pubkey] = cached
90
- }
91
- if (!missingPubkeys.length) return out
92
-
93
- const relaysByPubkey = await _getRelaysByPubkey(missingPubkeys, { _getEvents, cacheMs })
94
- const relayToAuthors = pickRelaysForPubkeys(missingPubkeys, relaysByPubkey)
76
+ async function queryContentKeyProofs (pubkeyList, { _getEvents, _getRelaysByPubkey, cacheMs, negativeCacheMs }) {
77
+ const relaysByPubkey = await _getRelaysByPubkey(pubkeyList, { _getEvents, cacheMs })
78
+ const relayToAuthors = pickRelaysForPubkeys(pubkeyList, relaysByPubkey)
95
79
  const eventGroups = await Promise.all(
96
80
  [...relayToAuthors.entries()]
97
81
  .map(async ([relay, authors]) => {
98
- const { result } = await _getEvents({
99
- kinds: [CONTENT_KEY_KIND],
100
- authors,
101
- limit: authors.length
102
- }, [relay], {
103
- timeout: 5000,
104
- timeoutAfterFirstEose: null
105
- })
106
- return result.map(({ event }) => event)
82
+ try {
83
+ const { result } = await _getEvents({
84
+ kinds: [CONTENT_KEY_KIND],
85
+ authors,
86
+ limit: authors.length
87
+ }, [relay], {
88
+ timeout: 5000,
89
+ timeoutAfterFirstEose: null
90
+ })
91
+ return result.map(({ event }) => event)
92
+ } catch {
93
+ // Discovery is best-effort: a failing relay group must not fail an
94
+ // otherwise publishable message.
95
+ return []
96
+ }
107
97
  })
108
98
  )
109
99
 
@@ -116,14 +106,67 @@ export async function getIykcProofs (pubkeys, {
116
106
  }
117
107
  }
118
108
 
119
- for (const pubkey of missingPubkeys) {
109
+ const out = {}
110
+ for (const pubkey of pubkeyList) {
120
111
  const entry = latestByPubkey[pubkey]
121
112
  const proof = entry
122
113
  ? { iykcPubkey: entry.iykcPubkey, iykcProof: entry.iykcProof }
123
114
  : null
124
- setCachedValue(contentKeysByPubkey, iykcCacheTimersByPubkey, iykcCacheAddedAtByPubkey, pubkey, cloneContentKey(proof), cacheMs)
125
- if (proof) out[pubkey] = proof
115
+ if (proof) {
116
+ setCachedValue(contentKeysByPubkey, iykcCacheTimersByPubkey, iykcCacheAddedAtByPubkey, pubkey, cloneContentKey(proof), cacheMs)
117
+ out[pubkey] = proof
118
+ } else if (negativeCacheMs > 0) {
119
+ // Misses expire quickly so a peer that publishes a key right after a
120
+ // prefetch is picked up without waiting for the positive cache window.
121
+ setCachedValue(contentKeysByPubkey, iykcCacheTimersByPubkey, iykcCacheAddedAtByPubkey, pubkey, null, negativeCacheMs)
122
+ }
126
123
  }
127
124
  pruneCache(contentKeysByPubkey, iykcCacheTimersByPubkey, iykcCacheAddedAtByPubkey, IYKC_CACHE_MAX_ITEMS)
128
125
  return out
129
126
  }
127
+
128
+ export async function getIykcProofs (pubkeys, {
129
+ _getEvents = getEvents,
130
+ _getRelaysByPubkey = getRelaysByPubkey,
131
+ cacheMs = QUERY_CACHE_MS,
132
+ negativeCacheMs = NEGATIVE_CACHE_MS
133
+ } = {}) {
134
+ const pubkeyList = uniquePubkeys(pubkeys, { requireHex: _getEvents === getEvents })
135
+ if (!pubkeyList.length) return {}
136
+
137
+ const out = {}
138
+ const waiting = []
139
+ const toQuery = []
140
+ for (const pubkey of pubkeyList) {
141
+ if (hasCachedKey(contentKeysByPubkey, pubkey)) {
142
+ const cached = cloneContentKey(contentKeysByPubkey[pubkey])
143
+ if (cached) out[pubkey] = cached
144
+ continue
145
+ }
146
+ const pending = inFlightByPubkey.get(pubkey)
147
+ if (pending) waiting.push([pubkey, pending])
148
+ else toQuery.push(pubkey)
149
+ }
150
+
151
+ if (toQuery.length) {
152
+ const query = queryContentKeyProofs(toQuery, { _getEvents, _getRelaysByPubkey, cacheMs, negativeCacheMs })
153
+ for (const pubkey of toQuery) inFlightByPubkey.set(pubkey, query)
154
+ try {
155
+ Object.assign(out, await query)
156
+ } finally {
157
+ for (const pubkey of toQuery) {
158
+ if (inFlightByPubkey.get(pubkey) === query) inFlightByPubkey.delete(pubkey)
159
+ }
160
+ }
161
+ }
162
+
163
+ for (const [pubkey, pending] of waiting) {
164
+ const found = await pending.catch(() => null)
165
+ if (found?.[pubkey]) out[pubkey] = found[pubkey]
166
+ else {
167
+ const cached = cloneContentKey(contentKeysByPubkey[pubkey])
168
+ if (cached) out[pubkey] = cached
169
+ }
170
+ }
171
+ return out
172
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.11.8",
3
+ "version": "0.11.10",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -0,0 +1,29 @@
1
+ export const LOOKUP_WARNING_INTERVAL_MS = 30_000
2
+
3
+ // Best-effort discovery can fail on every message while a relay or the
4
+ // network is unavailable. Keep the first failure visible, then debounce
5
+ // repeats and report how many were suppressed on the next log.
6
+ export function createLookupWarningLogger ({
7
+ intervalMs = LOOKUP_WARNING_INTERVAL_MS,
8
+ label = 'private-messenger content-key lookup failed',
9
+ warn = (...args) => console.warn(...args),
10
+ now = Date.now
11
+ } = {}) {
12
+ let warnedAt = null
13
+ let suppressed = 0
14
+ return (error, context = '') => {
15
+ const current = now()
16
+ if (warnedAt !== null && current - warnedAt < intervalMs) {
17
+ suppressed++
18
+ return false
19
+ }
20
+ const count = suppressed
21
+ warnedAt = current
22
+ suppressed = 0
23
+ warn(
24
+ `${label}${context ? ` (${context})` : ''}${count ? ` [${count} repeated failures suppressed]` : ''}`,
25
+ error?.message ?? error
26
+ )
27
+ return true
28
+ }
29
+ }
@@ -9,6 +9,7 @@ import { normalizeRumor, deliveryInfo } from './helpers/rumor.js'
9
9
  import { incompleteFetchError } from './helpers/fetch-error.js'
10
10
  import { readHistory } from './helpers/history.js'
11
11
  import { subscriptionError } from './helpers/subscription-error.js'
12
+ import { createLookupWarningLogger } from './helpers/lookup-warning.js'
12
13
  import * as nip44v3 from '../nip44-v3/index.js'
13
14
  import { relayPool } from '../relay/index.js'
14
15
  import { JSONL_CHUNK_BYTES, NYM_CARRIER_CHUNK_CHARS } from './helpers/chunk-size.js'
@@ -35,6 +36,7 @@ const HEX_SECKEY = /^[0-9a-f]{64}$/i
35
36
  const HEX_PUBKEY = /^[0-9a-f]{64}$/i
36
37
  const encoder = new TextEncoder()
37
38
  const decoder = new TextDecoder()
39
+ const warnLookupFailure = createLookupWarningLogger()
38
40
  const NIP44_V3_SCOPE = ''
39
41
 
40
42
  const sendToRelays = (event, relays) => relayPool.sendEvent(event, relays)
@@ -110,7 +112,19 @@ async function prepareRoutedMessage ({ senderSigner, imkcSigner, privateChannelS
110
112
  const useDoubleDh = typeof senderSigner.nip44EncryptDoubleDH === 'function'
111
113
  const channelPubkey = await privateChannelSigner.getPublicKey()
112
114
  const channelReaderPubkey = privateChannelReaderPubkey || channelPubkey
113
- const receiverContentKeys = useDoubleDh ? await _getIykcProofs(receiverPubkeysWithoutContentKeys(receivers)) : {}
115
+ let receiverContentKeys = {}
116
+ if (useDoubleDh) {
117
+ const receiversWithoutContentKeys = receiverPubkeysWithoutContentKeys(receivers)
118
+ try {
119
+ receiverContentKeys = await _getIykcProofs(receiversWithoutContentKeys) || {}
120
+ } catch (error) {
121
+ // Content-key discovery is best-effort; without a proof the row still
122
+ // works in sender-content mode instead of failing the send. Keep
123
+ // failures visible without logging on every message during an outage.
124
+ warnLookupFailure(error, `recipients: ${receiversWithoutContentKeys.length}`)
125
+ receiverContentKeys = {}
126
+ }
127
+ }
114
128
  const preparedRows = await prepareEnvelopeRows({
115
129
  senderSigner,
116
130
  imkcSigner: useDoubleDh ? imkcSigner : null,
@@ -390,13 +404,18 @@ async function unwrapRecipientEnvelope ({ payloadCiphertext, envelope, receiverS
390
404
  if (envelope.iykcPubkey) {
391
405
  assertValidEnvelopeIykcProof(envelope)
392
406
  }
407
+ // In self-encryption the advertised sender content key is also our own.
408
+ // Rows written without a content-key lookup (useContentKeys: false) carry
409
+ // no iykc pubkey, so hand imkc to the signer as the receiver's own key.
410
+ const ownContentPubkey = envelope.iykcPubkey ||
411
+ (senderPubkey && receiverPubkey && senderPubkey === receiverPubkey ? imkcPubkey : '')
393
412
  messageSeckey = base64ToText(await receiverSigner.nip44DecryptDoubleDH(
394
413
  senderPubkey,
395
414
  ROUTER_KIND,
396
415
  rowScope,
397
416
  envelope.ciphertext,
398
417
  imkcPubkey,
399
- envelope.iykcPubkey || ''
418
+ ownContentPubkey
400
419
  ))
401
420
  } else {
402
421
  if (!receiverSigner?.nip44v3Decrypt) throw new ValidationError('RECEIVER_SIGNER_NIP44V3_DECRYPT_UNSUPPORTED')
@@ -243,6 +243,16 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
243
243
  const requestedSeeders = new Set()
244
244
  const validSeeders = new Set([file.peerPubkey, ...messenger.recoverySeeders(file.controlChannelPubkey)])
245
245
  let terminalError
246
+ let lastError = null
247
+ const reportError = error => {
248
+ lastError = {
249
+ name: error?.name || 'Error',
250
+ message: error?.message ?? String(error),
251
+ code: error?.code || '',
252
+ at: Date.now()
253
+ }
254
+ onError(error)
255
+ }
246
256
  const fail = error => { terminalError ||= error }
247
257
  async function accept (event, index, { save = true } = {}) {
248
258
  signal.throwIfAborted()
@@ -297,7 +307,7 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
297
307
  })
298
308
  // Malformed remote records do not poison the ingestion chain. Operational
299
309
  // persistence failures stop the job so completion cannot outrun storage.
300
- ingest = task.catch(error => { if (error instanceof ValidationError) onError(error); else if (!signal.aborted) fail(error) })
310
+ ingest = task.catch(error => { if (error instanceof ValidationError) reportError(error); else if (!signal.aborted) fail(error) })
301
311
  return ingest
302
312
  }
303
313
  try {
@@ -312,7 +322,7 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
312
322
  if (total !== undefined && persisted.size === total) return
313
323
  const parent = messenger.requireWritableChannel(file.controlChannelPubkey)
314
324
  const relays = await messenger.resolveWatchRelays(parent)
315
- const options = { receiverSigner: messenger.userSigner, privateChannelSigner: file.signer, privateChannelPubkey: file.fileChannelPubkey, receiverPubkey: messenger.userPubkey, relays, receivedChunkIndexedDB: messenger._indexedDB, receivedChunkScope: `${messenger.userPubkey}:file:${file.fileChannelPubkey}`, onEvent: consume, onSeedEvent: seed => saveSeed(file, seed), mode: messenger.channels.get(file.controlChannelPubkey)?.mode === 'watchtower' ? 'watchtower' : 'leecher', onError }
325
+ const options = { receiverSigner: messenger.userSigner, privateChannelSigner: file.signer, privateChannelPubkey: file.fileChannelPubkey, receiverPubkey: messenger.userPubkey, relays, receivedChunkIndexedDB: messenger._indexedDB, receivedChunkScope: `${messenger.userPubkey}:file:${file.fileChannelPubkey}`, onEvent: consume, onSeedEvent: seed => saveSeed(file, seed), mode: messenger.channels.get(file.controlChannelPubkey)?.mode === 'watchtower' ? 'watchtower' : 'leecher', onError: reportError }
316
326
  sub = _transport.subscribe({ ...options, liveOnly: true })
317
327
  const stop = () => { sub?.close() }
318
328
  signal.addEventListener('abort', stop, { once: true })
@@ -323,7 +333,7 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
323
333
  const history = _transport.fetch({ ...options, signal: AbortSignal.any([signal, historyController.signal]), since: 0 }).catch(error => {
324
334
  // Completion aborts the remaining history read in finally. Only its
325
335
  // own cancellation is expected; genuine history failures stay visible.
326
- if (!signal.aborted && !(historyController.signal.aborted && error === historyController.signal.reason)) onError(error)
336
+ if (!signal.aborted && !(historyController.signal.aborted && error === historyController.signal.reason)) reportError(error)
327
337
  })
328
338
  work.add(history); history.finally(() => work.delete(history))
329
339
  const seeders = [...validSeeders].sort((a, b) => (successes.get(b) || 0) - (successes.get(a) || 0) || Math.random() - 0.5)
@@ -340,9 +350,33 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
340
350
  requestedSeeders.add(peer)
341
351
  requestRoundAt = Date.now() + _hedgeMs
342
352
  // The request is on dm; its compact reply is on the file channel.
343
- await messenger.ask({ channelPubkey: file.controlChannelPubkey, receiverPubkey: peer, code: FILE_CHUNKS_REQUEST_CODE, payload: { fileChannelPubkey: file.fileChannelPubkey, missingRanges: rangesFor(missing) } }).catch(onError)
353
+ await messenger.ask({ channelPubkey: file.controlChannelPubkey, receiverPubkey: peer, code: FILE_CHUNKS_REQUEST_CODE, payload: { fileChannelPubkey: file.fileChannelPubkey, missingRanges: rangesFor(missing) } }).catch(reportError)
354
+ }
355
+ if (!remaining.length && Date.now() - Math.max(lastProgress, requestRoundAt - _hedgeMs) >= _idleMs) {
356
+ const details = {
357
+ operation: 'file-download-stall',
358
+ ownerPubkey: messenger.userPubkey,
359
+ root: file.root,
360
+ peerPubkey: file.peerPubkey,
361
+ total,
362
+ persisted: persisted.size,
363
+ missingRanges: rangesFor(missing),
364
+ missingCount: total === undefined ? null : total - persisted.size,
365
+ seeders: [...validSeeders],
366
+ requestedSeeders: [...requestedSeeders],
367
+ lastProgressAt: new Date(lastProgress).toISOString(),
368
+ lastProgressAgoMs: Date.now() - lastProgress,
369
+ elapsedMs: Date.now() - Math.max(lastProgress, requestRoundAt - _hedgeMs),
370
+ lastError
371
+ }
372
+ const error = new Error('FILE_DOWNLOAD_STALLED')
373
+ error.code = 'FILE_DOWNLOAD_STALLED'
374
+ error.operation = details.operation
375
+ error.elapsedMs = details.elapsedMs
376
+ error.details = details
377
+ console.warn('private-messenger file download stalled', details)
378
+ throw error
344
379
  }
345
- if (!remaining.length && Date.now() - Math.max(lastProgress, requestRoundAt - _hedgeMs) >= _idleMs) throw new Error('FILE_DOWNLOAD_STALLED')
346
380
  // Start a fresh batch when all positions of the previous batch arrived.
347
381
  if (job.batch?.every(index => persisted.has(index))) { requestedSeeders.clear(); requestRoundAt = Date.now(); job.batch = null }
348
382
  job.batch ||= missing
@@ -9,11 +9,18 @@ Provide `owner`, a byte-based NIP-44 v3 `signer` with `withSharedKey`, and eithe
9
9
  Optional `chunkStorage` and `recoveryStorage` (`seeds`, `authorizations`) select
10
10
  persistence; absent file storage uses the library cache. `mode` defaults to
11
11
  `seeder`, `seedersForPeer(peer)` to `[peer]`, and `allowedKinds` to
12
- `[5, 9, 1063, 34601]`. The DM context is `dm`; file contexts are `dm:media:<root>`.
12
+ `[5, 9, 1063, 34601]`. `useContentKeys` defaults to `true`: the coordinator looks
13
+ up recipients' published content-key proofs (kind 18716) while sending and falls
14
+ back to sender-content whenever a proof is missing or the lookup fails. Misses
15
+ are cached for 5 minutes (found proofs for 40), so a later prefetch picks up a
16
+ key published after an earlier miss. Set it to `false` when the signer owns that discovery. The DM context is `dm`; file contexts are `dm:media:<root>`.
13
17
  `createMessengerSigner` adapts this byte signer to the transport's Base64 API.
14
18
 
15
- Methods: `setPeers`, `setAvailable(boolean)`, `enqueue`, `retry`, `cancel`,
16
- `download`, `cancelDownload`, and `close`. Callbacks are `onOutbox`, `onMedia`,
19
+ Methods: `setPeers`, `setAvailable(boolean)`, `prefetchContentKeys(peers?)`,
20
+ `enqueue`, `retry`, `cancel`, `download`, `cancelDownload`, and `close`.
21
+ `prefetchContentKeys` warms the content-key lookup cache for the owner and the
22
+ given peers (default: current peers); it is best-effort and never rejects.
23
+ Callbacks are `onOutbox`, `onMedia`,
17
24
  `onError`, and optional `onSendError(error, { id, peer })`. The caller maps its
18
25
  account permissions/connectivity/lock state to availability and owns view subscriptions. There are no DOM or UI imports.
19
26
 
@@ -3,11 +3,21 @@ import { base64ToBytes, bytesToBase64 } from '../../../base64/index.js'
3
3
  // The library transport uses its Base64 signer contract. Conversion stays at
4
4
  // this adapter; the injected launcher/vault channel always carries raw bytes.
5
5
  export function messengerSigner (signer) {
6
- return {
6
+ const adapter = {
7
7
  getPublicKey: () => signer.getPublicKey(),
8
8
  getRelays: () => signer.getRelays(),
9
9
  signEvent: event => signer.signEvent(event),
10
10
  nip44v3Encrypt: (peer, kind, scope, text) => signer.nip44v3.encrypt(peer, kind, scope, base64ToBytes(text).buffer),
11
11
  nip44v3Decrypt: async (peer, kind, scope, ciphertext) => bytesToBase64(new Uint8Array(await signer.nip44v3.decrypt(peer, kind, scope, ciphertext)))
12
12
  }
13
+ // Double DH stays opt-in by capability: a signer without both methods keeps
14
+ // the identity-only fallback that the private channel selects by itself.
15
+ if (typeof signer?.nip44v3?.encryptDoubleDH === 'function' &&
16
+ typeof signer?.nip44v3?.decryptDoubleDH === 'function') {
17
+ adapter.nip44EncryptDoubleDH = (peer, kind, scope, text, peerContentPubkey = '') =>
18
+ signer.nip44v3.encryptDoubleDH(peer, kind, scope, base64ToBytes(text).buffer, peerContentPubkey)
19
+ adapter.nip44DecryptDoubleDH = async (peer, kind, scope, ciphertext, peerContentPubkey = '', ownContentPubkey = '') =>
20
+ bytesToBase64(new Uint8Array(await signer.nip44v3.decryptDoubleDH(peer, kind, scope, ciphertext, peerContentPubkey, ownContentPubkey)))
21
+ }
22
+ return adapter
13
23
  }
@@ -3,6 +3,7 @@ import { createPrivateFileTransfer } from '../file/index.js'
3
3
  import { createPrivateMessenger } from '../index.js'
4
4
  import { getEventHash, isValidEvent, isSerializableEvent } from '../../event/index.js'
5
5
  import { decodeIrfsChunk, IRFS_CHUNK_BYTES } from '../../irfs/index.js'
6
+ import { getIykcProofs } from '../../content-key/index.js'
6
7
  import { onOnline } from '../../network/index.js'
7
8
  import { decodeFileMetadata } from '../../nip94/index.js'
8
9
  import { createChatOutbox } from './helpers/work-storage.js'
@@ -18,7 +19,7 @@ export function wireEvent (value, owner) {
18
19
  // Relay rejection text must not be interpreted as a local signer denial.
19
20
  const retryable = error => error?.code === 'MESSAGE_NOT_PUBLISHED' || !/DENIED|PERMISSION|REVOKED|READ_ONLY|INVALID|BLOCKED|EXPIRED|NOT_IN_PERSONA/i.test(`${error?.code || ''} ${error?.message || ''}`)
20
21
 
21
- export function createPrivateMessageSession ({ owner, signer, eventStore, messageStorage, chunkStorage, recoveryStorage, fallbackRelays = [], mode = 'seeder', seedersForPeer = peer => [peer], allowedKinds = [5, 9, 1063, 34601], onMedia = () => {}, onOutbox = () => {}, onError = () => {}, onSendError = () => {}, Messenger = createPrivateMessenger, openOutbox = createChatOutbox, FileTransfer = createPrivateFileTransfer, _onOnline = onOnline, openDownloads = options => createChatOutbox({ ...options, namespace: 'downloads' }) }) {
22
+ export function createPrivateMessageSession ({ owner, signer, eventStore, messageStorage, chunkStorage, recoveryStorage, fallbackRelays = [], mode = 'seeder', seedersForPeer = peer => [peer], allowedKinds = [5, 9, 1063, 34601], useContentKeys = true, onMedia = () => {}, onOutbox = () => {}, onError = () => {}, onSendError = () => {}, Messenger = createPrivateMessenger, openOutbox = createChatOutbox, FileTransfer = createPrivateFileTransfer, _onOnline = onOnline, _getIykcProofs = getIykcProofs, openDownloads = options => createChatOutbox({ ...options, namespace: 'downloads' }) }) {
22
23
  fallbackRelays = normalizeFallbackRelays(fallbackRelays)
23
24
  const userSigner = messengerSigner(signer)
24
25
  messageStorage ||= createEventStoreMessageStorage({ eventStore })
@@ -76,7 +77,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
76
77
  if (closed || !available || version !== lifecycle) return
77
78
  values.push(channel)
78
79
  }
79
- if (!messenger) messenger = await Messenger({ fallbackRelays, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys: false, onMessageQueued: () => drain(), onError })
80
+ if (!messenger) messenger = await Messenger({ fallbackRelays, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys, onMessageQueued: () => drain(), onError })
80
81
  if (closed || !available || version !== lifecycle) { await messenger.pause('signer'); return }
81
82
  await messenger.update({ channels: values })
82
83
  await messenger.resume('signer')
@@ -284,6 +285,24 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
284
285
  }
285
286
  return {
286
287
  download, cancelDownload,
288
+ // Peers-based so callers can prefetch as soon as a chat route opens, before
289
+ // the shared-key channel exists. When group chats get real membership, add
290
+ // a thin prefetchChannelContentKeys(channelPubkey) wrapper that resolves
291
+ // the channel's receivers and delegates here.
292
+ async prefetchContentKeys (values = [...peers]) {
293
+ if (closed || !useContentKeys) return {}
294
+ const requested = [...new Set([owner, ...(Array.isArray(values) ? values : [values])])]
295
+ .filter(value => typeof value === 'string' && value)
296
+ if (!requested.length) return {}
297
+ try {
298
+ return await _getIykcProofs(requested)
299
+ } catch (error) {
300
+ // Prefetch only warms the lookup cache; callers must keep working with
301
+ // sender-content until a proof is available. Keep the failure visible.
302
+ console.warn('private-messenger content-key prefetch failed', error?.message ?? error)
303
+ return {}
304
+ }
305
+ },
287
306
  async setPeers (values) {
288
307
  peers.clear(); for (const peer of values) if (peer !== owner) peers.add(peer)
289
308
  for (const peer of deniedPeers) if (!peers.has(peer)) deniedPeers.delete(peer)