libp2r2p 0.11.21 → 0.11.23

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 CHANGED
@@ -242,6 +242,78 @@ stream according to the existing partial-failure rules.
242
242
 
243
243
  ## Private Messenger
244
244
 
245
+ ### Pending ciphertext recovery (0.11.22)
246
+
247
+ The known three-field message deduplication index is migrated automatically to
248
+ include provenance, preserving positions, records and byte accounting. Other
249
+ incompatible queue definitions still fail explicitly; no database reset is needed.
250
+
251
+ PrivateMessenger defers `CONTENT_KEY_NOT_FOUND` and `INVALID_MAC` packets to its
252
+ separate `${prefix}:pending-decryption:idb-queue`: 16 MiB FIFO, ciphertext only,
253
+ no per-item TTL. Duplicate packets and attempts preserve their queue positions.
254
+ Explicit channel removal and identity storage maintenance remove their pending
255
+ records. Oversized records and capacity eviction are reported as discards.
256
+ Stale channel metadata cleanup does not expire pending ciphertext.
257
+ History continues only after deferral commits; storage/permission failures are
258
+ not converted into successful coverage. Deferral is not message delivery.
259
+
260
+ Call `retryPendingDecryptions()` after new content keys become available. Initial
261
+ readiness and resume also request a pass. A single background worker coalesces
262
+ requests, uses an origin/identity Web Lock when available, and stops on pause or
263
+ close. Missing-key retries have no polling timer. MAC failures permit three total
264
+ attempts, persisted across reloads; exhausted packets remain until FIFO eviction.
265
+ `retryPendingDecryptions({ force: true })` explicitly retries exhausted packets.
266
+ Success enters the normal acknowledged inbox before removing the pending item.
267
+ The session coordinator forwards this API.
268
+
269
+ Pending recovery waits for 250 ms without channel setup, live/history processing
270
+ or publication activity. Session foreground persistence/publication also delays
271
+ it. After at most five seconds of eligible waiting, a busy channel may advance
272
+ one packet, then receives a fresh deadline. Idle channels take batches of up to
273
+ four records, yielding a task between every record. A busy channel does not hold
274
+ up other channels; overdue channels precede the preferred foreground channel.
275
+ Only requested passes run: a missing key never starts a polling loop. Timer
276
+ throttling or an in-flight signer/storage call can extend actual elapsed time.
277
+
278
+ `pendingDecryptionReady` defaults to `true` on messenger/session construction.
279
+ Apps may pass `false` and call `setPendingDecryptionReady(true)` after their
280
+ initial interface is presented; `false` holds only pending recovery, not live
281
+ reception, publication or channel setup. The idle/deadline windows start after
282
+ release. `setPendingDecryptionPriority(pubkey | null)` selects a channel on the
283
+ messenger or a peer on the session; self-chat/`null` clear the session preference.
284
+ The session retains a peer preference until preparation finishes and clears it
285
+ on peer removal. Keep startup release and account lifecycle fencing in the app.
286
+
287
+ `retryPendingDecryptions()` resolves after the requested scheduled pass (including
288
+ an aggregated follow-up). `force: true` only overrides the MAC attempt ceiling;
289
+ it does not bypass availability or admission. Requests before the first snapshot
290
+ share that pass; requests during it aggregate into one following pass. Queue
291
+ reads wait for initial release. Each packet is revalidated under the identity
292
+ Web Lock when available; waits hold neither that lock nor the normal inbox/IDB
293
+ queues. Fragment assembly survives scheduling boundaries. Pause/close cancel
294
+ waits; channel removal fences its queued and in-flight results.
295
+
296
+ Private-channel consumers may opt in
297
+ with `onDecryptionFailure({ error, record })`, returning true only after durable
298
+ handling, and replay records with `replayDeferredDecryptions`. Without the hook,
299
+ standalone readers retain their existing error behavior.
300
+ The optional `isDecryptionPending(record)` hook skips an already persisted
301
+ packet before decryption; PrivateMessenger uses it for live/history/seeder
302
+ duplicates so retransmission cannot bypass the MAC attempt budget.
303
+
304
+ For standalone replay, pass `records: [{ id, channelPubkey, packet }]`, where
305
+ `packet` is the ciphertext record received by the failure hook, together with
306
+ the current receiver/channel signers and receiver pubkey. `onEvent` and
307
+ `onNymEvent` admit normal deliveries; `onRecovered(record)` removes durable
308
+ pending ciphertext only after admission, and `onFailure(record, error)` retains
309
+ it and records the failed attempt. Both settlement callbacks are required.
310
+ An optional AbortSignal fences processing; one batch shares a fragment processor.
311
+
312
+ `rememberContentKeyEvent(event)` from `libp2r2p/content-key` validates and remembers
313
+ a confirmed local announcement. Newest timestamp, then lowest ID, wins. Pending
314
+ discovery queries cannot overwrite or return an older result; normal positive
315
+ and negative cache windows remain unchanged.
316
+
245
317
  The main API is `createPrivateMessenger` from `libp2r2p/private-messenger`.
246
318
  It coordinates private-channel wrapping, relay watching, recovery state, and
247
319
  content-key lookup for direct or group-style private app messages.
@@ -1 +1 @@
1
- export { getIykcProofs } from './services/iykc-proof.js'
1
+ export { getIykcProofs, rememberContentKeyEvent } from './services/iykc-proof.js'
@@ -1,6 +1,7 @@
1
1
  import { parseContentKeyEvent } from '../event/index.js'
2
2
  import { CONTENT_KEY_ANNOUNCEMENT } from '../../kind/index.js'
3
3
  import { pickRelaysForPubkeys, getRelaysByPubkey, relayPool } from '../../relay/index.js'
4
+ import { ValidationError } from '../../error/index.js'
4
5
 
5
6
  const QUERY_CACHE_MS = 40 * 60 * 1000
6
7
  const NEGATIVE_CACHE_MS = 5 * 60 * 1000
@@ -10,6 +11,7 @@ const contentKeysByPubkey = Object.create(null)
10
11
  const iykcCacheTimersByPubkey = Object.create(null)
11
12
  const iykcCacheAddedAtByPubkey = Object.create(null)
12
13
  const inFlightByPubkey = new Map()
14
+ const announcementOrder = new Map()
13
15
 
14
16
  const getEvents = (...args) => relayPool.getEvents(...args)
15
17
 
@@ -37,6 +39,7 @@ function cloneContentKey (contentKey) {
37
39
  }
38
40
 
39
41
  function deleteCachedValue (cache, timers, addedAt, key) {
42
+ if (cache === contentKeysByPubkey) announcementOrder.delete(key)
40
43
  clearTimeout(timers[key])
41
44
  delete cache[key]
42
45
  delete timers[key]
@@ -72,6 +75,23 @@ export function clearContentKeyCache () {
72
75
  for (const key of Object.keys(iykcCacheTimersByPubkey)) delete iykcCacheTimersByPubkey[key]
73
76
  for (const key of Object.keys(iykcCacheAddedAtByPubkey)) delete iykcCacheAddedAtByPubkey[key]
74
77
  inFlightByPubkey.clear()
78
+ announcementOrder.clear()
79
+ }
80
+
81
+ // Remember only authenticated announcements, including those just published
82
+ // locally. An older query may still finish, but cannot undo this selection.
83
+ export function rememberContentKeyEvent (event) {
84
+ const proof = parseContentKeyEvent(event)
85
+ if (!proof) throw new ValidationError('INVALID_CONTENT_KEY_EVENT')
86
+ const previous = announcementOrder.get(event.pubkey)
87
+ if (previous && (previous.createdAt > event.created_at ||
88
+ (previous.createdAt === event.created_at && previous.id <= event.id))) return false
89
+ announcementOrder.set(event.pubkey, { createdAt: event.created_at, id: event.id })
90
+ setCachedValue(contentKeysByPubkey, iykcCacheTimersByPubkey, iykcCacheAddedAtByPubkey,
91
+ event.pubkey, cloneContentKey(proof), QUERY_CACHE_MS)
92
+ pruneCache(contentKeysByPubkey, iykcCacheTimersByPubkey, iykcCacheAddedAtByPubkey, IYKC_CACHE_MAX_ITEMS)
93
+ for (const key of announcementOrder.keys()) if (!hasCachedKey(contentKeysByPubkey, key)) announcementOrder.delete(key)
94
+ return true
75
95
  }
76
96
 
77
97
  async function queryContentKeyProofs (pubkeyList, { _getEvents, _getRelaysByPubkey, cacheMs, negativeCacheMs }) {
@@ -102,18 +122,28 @@ async function queryContentKeyProofs (pubkeyList, { _getEvents, _getRelaysByPubk
102
122
  for (const event of eventGroups.flat()) {
103
123
  const parsed = parseContentKeyEvent(event)
104
124
  if (!parsed) continue
105
- if (!latestByPubkey[event.pubkey] || event.created_at > latestByPubkey[event.pubkey].created_at) {
106
- latestByPubkey[event.pubkey] = { created_at: event.created_at, ...parsed }
125
+ const previous = latestByPubkey[event.pubkey]
126
+ if (!previous || event.created_at > previous.created_at ||
127
+ (event.created_at === previous.created_at && event.id < previous.id)) {
128
+ latestByPubkey[event.pubkey] = { created_at: event.created_at, id: event.id, ...parsed }
107
129
  }
108
130
  }
109
131
 
110
132
  const out = {}
111
133
  for (const pubkey of pubkeyList) {
112
134
  const entry = latestByPubkey[pubkey]
135
+ const selected = announcementOrder.get(pubkey)
136
+ if (selected && (!entry || selected.createdAt > entry.created_at ||
137
+ (selected.createdAt === entry.created_at && selected.id <= entry.id))) {
138
+ const cached = cloneContentKey(contentKeysByPubkey[pubkey])
139
+ if (cached) out[pubkey] = cached
140
+ continue
141
+ }
113
142
  const proof = entry
114
143
  ? { iykcPubkey: entry.iykcPubkey, iykcProof: entry.iykcProof }
115
144
  : null
116
145
  if (proof) {
146
+ announcementOrder.set(pubkey, { createdAt: entry.created_at, id: entry.id })
117
147
  setCachedValue(contentKeysByPubkey, iykcCacheTimersByPubkey, iykcCacheAddedAtByPubkey, pubkey, cloneContentKey(proof), cacheMs)
118
148
  out[pubkey] = proof
119
149
  } else if (negativeCacheMs > 0) {
@@ -123,6 +153,7 @@ async function queryContentKeyProofs (pubkeyList, { _getEvents, _getRelaysByPubk
123
153
  }
124
154
  }
125
155
  pruneCache(contentKeysByPubkey, iykcCacheTimersByPubkey, iykcCacheAddedAtByPubkey, IYKC_CACHE_MAX_ITEMS)
156
+ for (const key of announcementOrder.keys()) if (!hasCachedKey(contentKeysByPubkey, key)) announcementOrder.delete(key)
126
157
  return out
127
158
  }
128
159
 
@@ -169,5 +200,13 @@ export async function getIykcProofs (pubkeys, {
169
200
  if (cached) out[pubkey] = cached
170
201
  }
171
202
  }
203
+ // A batch may wait for another author after reading an already cached proof.
204
+ // Reconcile again before returning so a publication during that wait wins too.
205
+ for (const pubkey of pubkeyList) {
206
+ if (!hasCachedKey(contentKeysByPubkey, pubkey)) continue
207
+ const cached = cloneContentKey(contentKeysByPubkey[pubkey])
208
+ if (cached) out[pubkey] = cached
209
+ else delete out[pubkey]
210
+ }
172
211
  return out
173
212
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.11.21",
3
+ "version": "0.11.23",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -0,0 +1,15 @@
1
+ // Select only operationally useful public coordinates. Never attach a packet,
2
+ // signer, plaintext, scope or message key to an error that can reach a logger.
3
+ export function decryptionError (cause, context) {
4
+ const error = new Error(cause.message, { cause })
5
+ error.name = cause.name
6
+ const code = cause.code || (cause.message === 'CONTENT_KEY_NOT_FOUND' ? 'CONTENT_KEY_NOT_FOUND' : cause.message === 'invalid MAC' ? 'INVALID_MAC' : undefined)
7
+ if (code) error.code = code
8
+ for (const key of ['stage', 'channelPubkey', 'eventId', 'eventKind', 'senderPubkey', 'receiverPubkey', 'ownContentPubkey', 'peerContentPubkey', 'hasOwnContentKey']) {
9
+ const value = context?.[key] ?? cause[key]
10
+ if (['string', 'number', 'boolean'].includes(typeof value)) error[key] = value
11
+ }
12
+ return error
13
+ }
14
+
15
+ export const isDeferredDecryptionError = error => ['CONTENT_KEY_NOT_FOUND', 'INVALID_MAC'].includes(error?.code)
@@ -5,6 +5,7 @@ import { hexToBytes } from '../base16/index.js'
5
5
  import { isValidContentKeyProof, isValidIykcProof, makeContentKeyEventForPubkey, parseContentKeyEvent } from '../content-key/event/index.js'
6
6
  import { getIykcProofs } from '../content-key/index.js'
7
7
  import { ValidationError } from '../error/index.js'
8
+ import { decryptionError, isDeferredDecryptionError } from './helpers/decryption-error.js'
8
9
  import { normalizeRumor, deliveryInfo } from './helpers/rumor.js'
9
10
  import { incompleteFetchError } from './helpers/fetch-error.js'
10
11
  import { readHistory } from './helpers/history.js'
@@ -416,19 +417,27 @@ async function unwrapRecipientEnvelope ({ payloadCiphertext, envelope, receiverS
416
417
  // no iykc pubkey, so hand imkc to the signer as the receiver's own key.
417
418
  const ownContentPubkey = envelope.iykcPubkey ||
418
419
  (senderPubkey && receiverPubkey && senderPubkey === receiverPubkey ? imkcPubkey : '')
419
- messageSeckey = base64ToText(await receiverSigner.nip44DecryptDoubleDH(
420
- senderPubkey,
421
- ROUTER_KIND,
422
- rowScope,
423
- envelope.ciphertext,
424
- imkcPubkey,
425
- ownContentPubkey
426
- ))
420
+ try {
421
+ messageSeckey = base64ToText(await receiverSigner.nip44DecryptDoubleDH(
422
+ senderPubkey,
423
+ ROUTER_KIND,
424
+ rowScope,
425
+ envelope.ciphertext,
426
+ imkcPubkey,
427
+ ownContentPubkey
428
+ ))
429
+ } catch (cause) {
430
+ throw decryptionError(cause, { stage: 'recipient-envelope', channelPubkey: rowScope, senderPubkey, receiverPubkey, ownContentPubkey, peerContentPubkey: imkcPubkey })
431
+ }
427
432
  } else {
428
433
  if (!receiverSigner?.nip44v3Decrypt) throw new ValidationError('RECEIVER_SIGNER_NIP44V3_DECRYPT_UNSUPPORTED')
429
- messageSeckey = base64ToText(await receiverSigner.nip44v3Decrypt(senderPubkey, ROUTER_KIND, rowScope, envelope.ciphertext))
434
+ try {
435
+ messageSeckey = base64ToText(await receiverSigner.nip44v3Decrypt(senderPubkey, ROUTER_KIND, rowScope, envelope.ciphertext))
436
+ } catch (cause) { throw decryptionError(cause, { stage: 'recipient-envelope', channelPubkey: rowScope, senderPubkey, receiverPubkey }) }
437
+ }
438
+ try { return eventFromPayload({ payloadCiphertext, messageSeckey, senderPubkey }) } catch (cause) {
439
+ throw decryptionError(cause, { stage: 'message-payload', channelPubkey: rowScope, senderPubkey, receiverPubkey })
430
440
  }
431
- return eventFromPayload({ payloadCiphertext, messageSeckey, senderPubkey })
432
441
  }
433
442
 
434
443
  export async function unwrapEvent ({ receiverSigner, privateChannelSigner = receiverSigner, privateChannelReaderSigner = privateChannelSigner, privateChannelReaderPubkey, event, receiverPubkey }) {
@@ -708,6 +717,7 @@ function createProcessor ({
708
717
  onNymEvent,
709
718
  onSeedEvent,
710
719
  onContentKeyUsage,
720
+ onDecryptionFailure, isDecryptionPending, _onProcessing,
711
721
  onError,
712
722
  receivedChunkScope = '',
713
723
  receivedChunkTtlMs = DEFAULT_RECEIVED_CHUNK_TTL_MS,
@@ -731,8 +741,16 @@ function createProcessor ({
731
741
  storeReady.catch(() => {})
732
742
 
733
743
  async function processOuterEvent (outer) {
734
- await storeReady
744
+ const release = _onProcessing?.(outer.pubkey)
745
+ try {
746
+ await storeReady
747
+ return await processReadyOuterEvent(outer)
748
+ } finally { release?.() }
749
+ }
750
+
751
+ async function processReadyOuterEvent (outer) {
735
752
  let groupKey = ''
753
+ let routerDecrypted = false
736
754
  try {
737
755
  const channelPubkey = outer.pubkey || await privateChannelSigner?.getPublicKey?.()
738
756
  const channelSigner = readSignerFromMap(privateChannelSignersByPubkey, channelPubkey) || privateChannelSigner
@@ -742,6 +760,7 @@ function createProcessor ({
742
760
  const channelReceivedChunkTtlMs = readValueFromMap(receivedChunkTtlMsByPubkey, channelPubkey) ?? receivedChunkTtlMs
743
761
  if (!channelPubkey) throw new ValidationError('PRIVATE_CHANNEL_PUBKEY_REQUIRED')
744
762
 
763
+ if (await isDecryptionPending?.({ format: 'outer_v1', channelPubkey, outer })) return
745
764
  const decrypted = await decryptRouter({
746
765
  content: outer.content,
747
766
  channelPubkey,
@@ -749,6 +768,7 @@ function createProcessor ({
749
768
  channelReaderSigner,
750
769
  channelReaderPubkey
751
770
  })
771
+ routerDecrypted = true
752
772
  if (decrypted.kind === NYM_CARRIER_KIND) {
753
773
  const carrier = assertValidNymCarrierEvent(decrypted)
754
774
  const { index, total } = readChunkTag(carrier)
@@ -834,6 +854,7 @@ function createProcessor ({
834
854
  // regular leechers can stop as soon as their envelope is decrypted.
835
855
  const mustScanWholeBundle = shouldSeed || sentByReceiver
836
856
  let event = null
857
+ let deferred = false
837
858
  const innerEventIdsByRowIndex = {}
838
859
 
839
860
  const drained = await receivedChunks.drainAvailable(groupKey, {
@@ -858,15 +879,36 @@ function createProcessor ({
858
879
  onContentKeyUsage
859
880
  })
860
881
  if (receiverSigner && !event) {
861
- event = await unwrapRecipientEnvelope({
862
- payloadCiphertext: groupMeta.payloadCiphertext,
863
- envelope,
864
- receiverSigner,
865
- receiverPubkey,
866
- senderPubkey,
867
- imkcPubkey,
868
- rowScope: channelPubkey
869
- })
882
+ const record = () => {
883
+ const row = JSON.stringify([envelope.receiverPubkey, envelope.ciphertext, envelope.iykcPubkey, envelope.iykcProof])
884
+ return {
885
+ format: 'routerRow_v1', channelPubkey,
886
+ outer: { id: outer.id, kind: outer.kind, pubkey: outer.pubkey, created_at: outer.created_at },
887
+ router: joinedRouter(router, bytesToBase64(encoder.encode(JSON.stringify([groupMeta.payloadCiphertext]) + '\n' + row + '\n')))
888
+ }
889
+ }
890
+ if (await isDecryptionPending?.(record())) {
891
+ deferred = true
892
+ if (!mustScanWholeBundle) return { stop: true }
893
+ return
894
+ }
895
+ try {
896
+ event = await unwrapRecipientEnvelope({
897
+ payloadCiphertext: groupMeta.payloadCiphertext,
898
+ envelope,
899
+ receiverSigner,
900
+ receiverPubkey,
901
+ senderPubkey,
902
+ imkcPubkey,
903
+ rowScope: channelPubkey
904
+ })
905
+ } catch (cause) {
906
+ const error = decryptionError(cause, { eventId: outer.id, eventKind: outer.kind, channelPubkey })
907
+ if (!onDecryptionFailure || !isDeferredDecryptionError(error)) throw error
908
+ if (!await onDecryptionFailure({ error, record: record() })) throw error
909
+ deferred = true
910
+ if (!mustScanWholeBundle) return { stop: true }
911
+ }
870
912
  if (event) {
871
913
  innerEventIdsByRowIndex[rowIndex] = event.id
872
914
  if (!mustScanWholeBundle) return { stop: true }
@@ -876,8 +918,8 @@ function createProcessor ({
876
918
  }
877
919
  })
878
920
 
879
- if (event && !mustScanWholeBundle) {
880
- await onEvent?.(event, outer, { router: joinedRouter(router), channelPubkey, ...deliveryInfo(event, senderPubkey) })
921
+ if ((event || deferred) && !mustScanWholeBundle) {
922
+ if (event) await onEvent?.(event, outer, { router: joinedRouter(router), channelPubkey, ...deliveryInfo(event, senderPubkey) })
881
923
  ignoredGroups.add(groupKey)
882
924
  await receivedChunks.removeGroup(groupKey)
883
925
  return
@@ -902,7 +944,13 @@ function createProcessor ({
902
944
  if (event) await onEvent?.(event, outer, { router: completeRouter, channelPubkey, jsonl, ...deliveryInfo(event, senderPubkey) })
903
945
 
904
946
  await receivedChunks.removeGroup(groupKey)
905
- } catch (err) {
947
+ } catch (cause) {
948
+ let err = cause
949
+ if (!routerDecrypted) {
950
+ err = decryptionError(err, { stage: 'outer-envelope', channelPubkey: outer.pubkey, eventId: outer.id, eventKind: outer.kind })
951
+ if (onDecryptionFailure && isDeferredDecryptionError(err) &&
952
+ await onDecryptionFailure({ error: err, record: { format: 'outer_v1', channelPubkey: outer.pubkey, outer } })) return
953
+ }
906
954
  if (shouldIgnoreGroupError(err) && groupKey) {
907
955
  ignoredGroups.add(groupKey)
908
956
  await receivedChunks.removeGroup(groupKey).catch(() => {})
@@ -939,7 +987,85 @@ function shouldIgnoreGroupError (err) {
939
987
  ].includes(err?.message)
940
988
  }
941
989
 
942
- export async function fetch ({ signal, receiverSigner, iykcSigner, privateChannelSigner = receiverSigner, privateChannelSignersByPubkey, privateChannelReaderSigner = privateChannelSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, privateChannelPubkey, privateChannelPubkeys, receiverPubkey, relays, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onError, since, until, limit, mode = 'leecher', modeByPubkey, receivedChunkScope = '', receivedChunkTtlMs = DEFAULT_RECEIVED_CHUNK_TTL_MS, receivedChunkTtlMsByPubkey, receivedChunkMaxBytes = DEFAULT_RECEIVED_CHUNK_MAX_BYTES, receivedChunkIndexedDB = globalThis.indexedDB, ignoredGroupTtlMs = DEFAULT_IGNORED_GROUP_TTL_MS, ignoredGroupMaxEntries = DEFAULT_IGNORED_GROUP_MAX_ENTRIES, _getEvents = getEvents }) {
990
+ // Replay ciphertext only. Keep one fragment processor for the entire batch;
991
+ // incomplete outer groups remain pending until a later key/channel transition.
992
+ export async function replayDeferredDecryptions ({ records, onRecovered, onFailure, _runRecord = (_record, work) => work(), ...options }) {
993
+ if (!Array.isArray(records) || records.some(record => typeof record?.id !== 'string' ||
994
+ typeof record.channelPubkey !== 'string' || !['outer_v1', 'routerRow_v1'].includes(record.packet?.format))) throw new ValidationError('INVALID_DEFERRED_RECORDS')
995
+ if (typeof onRecovered !== 'function' || typeof onFailure !== 'function') throw new ValidationError('DEFERRED_CALLBACKS_REQUIRED')
996
+ const groups = new Map()
997
+ const completed = new Set()
998
+ let current
999
+ let chunked = false
1000
+ const processor = createProcessor({
1001
+ ...options, onDecryptionFailure: undefined, isDecryptionPending: undefined,
1002
+ onError: error => { throw error },
1003
+ onChunk: chunk => {
1004
+ chunked = true
1005
+ const key = chunk.router?.pubkey || nymCarrierGroupId(chunk.nymCarrier)
1006
+ if (!groups.has(key)) groups.set(key, new Set())
1007
+ groups.get(key).add(current.id)
1008
+ },
1009
+ onEvent: async (event, outer, meta) => {
1010
+ options.signal?.throwIfAborted()
1011
+ await options.onEvent?.(event, outer, meta)
1012
+ completed.add(meta.router?.pubkey)
1013
+ },
1014
+ onNymEvent: async (event, outer, meta) => {
1015
+ options.signal?.throwIfAborted()
1016
+ await options.onNymEvent?.(event, outer, meta)
1017
+ completed.add(nymCarrierGroupId(meta.carrier))
1018
+ },
1019
+ onSeedEvent: async seed => {
1020
+ options.signal?.throwIfAborted()
1021
+ await options.onSeedEvent?.(seed)
1022
+ completed.add(seed.router?.pubkey || nymCarrierGroupId(seed.carrier))
1023
+ }
1024
+ })
1025
+ try {
1026
+ for (const record of records) {
1027
+ await _runRecord(record, async () => {
1028
+ options.signal?.throwIfAborted()
1029
+ current = record
1030
+ chunked = false
1031
+ try {
1032
+ if (record.packet.format === 'outer_v1') {
1033
+ await processor(record.packet.outer)
1034
+ if (!chunked) await onRecovered(record)
1035
+ } else {
1036
+ const event = await unwrapRouterEvent({
1037
+ receiverSigner: options.receiverSigner, receiverPubkey: options.receiverPubkey,
1038
+ channelPubkey: record.channelPubkey, router: record.packet.router
1039
+ })
1040
+ options.signal?.throwIfAborted()
1041
+ if (event) {
1042
+ await options.onEvent?.(event, record.packet.outer, {
1043
+ router: record.packet.router, channelPubkey: record.channelPubkey,
1044
+ ...deliveryInfo(event, readSenderTag(record.packet.router))
1045
+ })
1046
+ }
1047
+ await onRecovered(record)
1048
+ }
1049
+ } catch (error) {
1050
+ options.signal?.throwIfAborted()
1051
+ await onFailure(record, decryptionError(error, { channelPubkey: record.channelPubkey, eventId: record.packet.outer?.id }))
1052
+ }
1053
+ for (const key of completed) {
1054
+ for (const id of groups.get(key) || []) {
1055
+ const recovered = records.find(record => record.id === id)
1056
+ if (recovered) await onRecovered(recovered)
1057
+ }
1058
+ groups.delete(key)
1059
+ }
1060
+ completed.clear()
1061
+ })
1062
+ // Let live ingestion and foreground signer work run between records.
1063
+ await new Promise(resolve => setTimeout(resolve, 0))
1064
+ }
1065
+ } finally { processor.close() }
1066
+ }
1067
+
1068
+ export async function fetch ({ signal, receiverSigner, iykcSigner, privateChannelSigner = receiverSigner, privateChannelSignersByPubkey, privateChannelReaderSigner = privateChannelSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, privateChannelPubkey, privateChannelPubkeys, receiverPubkey, relays, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onDecryptionFailure, isDecryptionPending, _onProcessing, onError, since, until, limit, mode = 'leecher', modeByPubkey, receivedChunkScope = '', receivedChunkTtlMs = DEFAULT_RECEIVED_CHUNK_TTL_MS, receivedChunkTtlMsByPubkey, receivedChunkMaxBytes = DEFAULT_RECEIVED_CHUNK_MAX_BYTES, receivedChunkIndexedDB = globalThis.indexedDB, ignoredGroupTtlMs = DEFAULT_IGNORED_GROUP_TTL_MS, ignoredGroupMaxEntries = DEFAULT_IGNORED_GROUP_MAX_ENTRIES, _getEvents = getEvents }) {
943
1069
  if (!relays?.length) throw new ValidationError('NO_RELAYS')
944
1070
  const authors = privateChannelPubkeyList({ privateChannelPubkey, privateChannelPubkeys })
945
1071
  const filter = { kinds: [PRIVATE_BROADCAST_KIND] }
@@ -958,7 +1084,7 @@ export async function fetch ({ signal, receiverSigner, iykcSigner, privateChanne
958
1084
  const elapsedMs = Math.round(performance.now() - startedAt)
959
1085
  const events = result.map(({ event }) => event)
960
1086
  events.sort((a, b) => a.created_at - b.created_at)
961
- const processOuterEvent = createProcessor({ receiverSigner, iykcSigner, privateChannelSigner, privateChannelSignersByPubkey, privateChannelReaderSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, receiverPubkey, mode, modeByPubkey, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onError, receivedChunkScope, receivedChunkTtlMs, receivedChunkTtlMsByPubkey, receivedChunkMaxBytes, receivedChunkIndexedDB, ignoredGroupTtlMs, ignoredGroupMaxEntries })
1087
+ const processOuterEvent = createProcessor({ receiverSigner, iykcSigner, privateChannelSigner, privateChannelSignersByPubkey, privateChannelReaderSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, receiverPubkey, mode, modeByPubkey, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onDecryptionFailure, isDecryptionPending, _onProcessing, onError, receivedChunkScope, receivedChunkTtlMs, receivedChunkTtlMsByPubkey, receivedChunkMaxBytes, receivedChunkIndexedDB, ignoredGroupTtlMs, ignoredGroupMaxEntries })
962
1088
  try {
963
1089
  for (const event of events) {
964
1090
  signal?.throwIfAborted()
@@ -988,7 +1114,7 @@ export async function fetchHistory ({ receiverSigner, privateChannelSigner = rec
988
1114
  } finally { processOuterEvent.close() }
989
1115
  }
990
1116
 
991
- export function subscribe ({ receiverSigner, iykcSigner, privateChannelSigner = receiverSigner, privateChannelSignersByPubkey, privateChannelReaderSigner = privateChannelSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, privateChannelPubkey, privateChannelPubkeys, receiverPubkey, relays, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onError, since = nowSeconds() - 5, limit, liveOnly = false, mode = 'leecher', modeByPubkey, receivedChunkScope = '', receivedChunkTtlMs = DEFAULT_RECEIVED_CHUNK_TTL_MS, receivedChunkTtlMsByPubkey, receivedChunkMaxBytes = DEFAULT_RECEIVED_CHUNK_MAX_BYTES, receivedChunkIndexedDB = globalThis.indexedDB, ignoredGroupTtlMs = DEFAULT_IGNORED_GROUP_TTL_MS, ignoredGroupMaxEntries = DEFAULT_IGNORED_GROUP_MAX_ENTRIES, _liveEventsGenerator = getLiveEventsGenerator, _eventsFeedGenerator = getEventsFeedGenerator }) {
1117
+ export function subscribe ({ receiverSigner, iykcSigner, privateChannelSigner = receiverSigner, privateChannelSignersByPubkey, privateChannelReaderSigner = privateChannelSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, privateChannelPubkey, privateChannelPubkeys, receiverPubkey, relays, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onDecryptionFailure, isDecryptionPending, _onProcessing, onError, since = nowSeconds() - 5, limit, liveOnly = false, mode = 'leecher', modeByPubkey, receivedChunkScope = '', receivedChunkTtlMs = DEFAULT_RECEIVED_CHUNK_TTL_MS, receivedChunkTtlMsByPubkey, receivedChunkMaxBytes = DEFAULT_RECEIVED_CHUNK_MAX_BYTES, receivedChunkIndexedDB = globalThis.indexedDB, ignoredGroupTtlMs = DEFAULT_IGNORED_GROUP_TTL_MS, ignoredGroupMaxEntries = DEFAULT_IGNORED_GROUP_MAX_ENTRIES, _liveEventsGenerator = getLiveEventsGenerator, _eventsFeedGenerator = getEventsFeedGenerator }) {
992
1118
  if (!relays?.length) throw new ValidationError('NO_RELAYS')
993
1119
  if (receiverSigner && !receiverSigner?.nip44DecryptDoubleDH && !receiverSigner?.nip44v3Decrypt) throw new ValidationError('RECEIVER_SIGNER_NIP44V3_DECRYPT_UNSUPPORTED')
994
1120
  if (!privateChannelReaderSigner && !privateChannelReaderSignersByPubkey && !privateChannelSigner && !privateChannelSignersByPubkey) throw new ValidationError('PRIVATE_CHANNEL_READER_REQUIRED')
@@ -997,7 +1123,7 @@ export function subscribe ({ receiverSigner, iykcSigner, privateChannelSigner =
997
1123
  const filter = { kinds: [PRIVATE_BROADCAST_KIND], since }
998
1124
  if (authors.length) filter.authors = authors
999
1125
  if (limit != null) filter.limit = limit
1000
- const processOuterEvent = createProcessor({ receiverSigner, iykcSigner, privateChannelSigner, privateChannelSignersByPubkey, privateChannelReaderSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, receiverPubkey, mode, modeByPubkey, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onError, receivedChunkScope, receivedChunkTtlMs, receivedChunkTtlMsByPubkey, receivedChunkMaxBytes, receivedChunkIndexedDB, ignoredGroupTtlMs, ignoredGroupMaxEntries })
1126
+ const processOuterEvent = createProcessor({ receiverSigner, iykcSigner, privateChannelSigner, privateChannelSignersByPubkey, privateChannelReaderSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, receiverPubkey, mode, modeByPubkey, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onDecryptionFailure, isDecryptionPending, _onProcessing, onError, receivedChunkScope, receivedChunkTtlMs, receivedChunkTtlMsByPubkey, receivedChunkMaxBytes, receivedChunkIndexedDB, ignoredGroupTtlMs, ignoredGroupMaxEntries })
1001
1127
  const controller = new AbortController()
1002
1128
  const events = liveOnly
1003
1129
  ? _liveEventsGenerator(filter, relays, {
@@ -477,6 +477,9 @@ export function createPrivateMessageSession ({ _setTimeout = setTimeout, _clearT
477
477
  return dispatchSeedEvent(seed)
478
478
  },
479
479
  onContentKeyUsage: dispatchContentKeyUsage,
480
+ _onProcessing: pubkey => watchCallbacks(pubkey)._onProcessing?.(pubkey),
481
+ isDecryptionPending: record => watchCallbacks(record.channelPubkey).isDecryptionPending?.(record),
482
+ onDecryptionFailure: failure => watchCallbacks(failure.record.channelPubkey).onDecryptionFailure?.(failure),
480
483
  onError: err => reportSubscriptionError(channels, err)
481
484
  })
482
485
 
@@ -522,6 +525,7 @@ export function createPrivateMessageSession ({ _setTimeout = setTimeout, _clearT
522
525
  onSeed,
523
526
  onChunk,
524
527
  onContentKeyUsage,
528
+ onDecryptionFailure, isDecryptionPending, _onProcessing,
525
529
  onSubscriptionState,
526
530
  onError,
527
531
  receivedChunkTtlMs,
@@ -542,7 +546,7 @@ export function createPrivateMessageSession ({ _setTimeout = setTimeout, _clearT
542
546
  const ownPubkey = receiverPubkey || await receiverSigner?.getPublicKey?.()
543
547
  if (identity !== undefined && identity !== ownPubkey) throw new ValidationError('PRIVATE_MESSAGE_IDENTITY_MISMATCH')
544
548
  identity = ownPubkey
545
- const callbacks = { onAsk, onReply, onTell, onYell, onNym, onMessage, onSeed, onChunk, onContentKeyUsage, onSubscriptionState, onError }
549
+ const callbacks = { onAsk, onReply, onTell, onYell, onNym, onMessage, onSeed, onChunk, onContentKeyUsage, onDecryptionFailure, isDecryptionPending, _onProcessing, onSubscriptionState, onError }
546
550
 
547
551
  for (const channel of channelList) {
548
552
  if (watchRequests.get(channel) !== request) continue