libp2r2p 0.11.20 → 0.11.22

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,49 @@ 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. Private-channel consumers may opt in
268
+ with `onDecryptionFailure({ error, record })`, returning true only after durable
269
+ handling, and replay records with `replayDeferredDecryptions`. Without the hook,
270
+ standalone readers retain their existing error behavior.
271
+ The optional `isDecryptionPending(record)` hook skips an already persisted
272
+ packet before decryption; PrivateMessenger uses it for live/history/seeder
273
+ duplicates so retransmission cannot bypass the MAC attempt budget.
274
+
275
+ For standalone replay, pass `records: [{ id, channelPubkey, packet }]`, where
276
+ `packet` is the ciphertext record received by the failure hook, together with
277
+ the current receiver/channel signers and receiver pubkey. `onEvent` and
278
+ `onNymEvent` admit normal deliveries; `onRecovered(record)` removes durable
279
+ pending ciphertext only after admission, and `onFailure(record, error)` retains
280
+ it and records the failed attempt. Both settlement callbacks are required.
281
+ An optional AbortSignal fences processing; one batch shares a fragment processor.
282
+
283
+ `rememberContentKeyEvent(event)` from `libp2r2p/content-key` validates and remembers
284
+ a confirmed local announcement. Newest timestamp, then lowest ID, wins. Pending
285
+ discovery queries cannot overwrite or return an older result; normal positive
286
+ and negative cache windows remain unchanged.
287
+
245
288
  The main API is `createPrivateMessenger` from `libp2r2p/private-messenger`.
246
289
  It coordinates private-channel wrapping, relay watching, recovery state, and
247
290
  content-key lookup for direct or group-style private app messages.
@@ -692,6 +735,13 @@ Blossom server normalization accepts root `http:` origins for local
692
735
  development. Public Blossom validation additionally requires `https:` and a
693
736
  public host, and rejects credentials, paths, query strings and fragments.
694
737
 
738
+ Starting with 0.11.21, import `CONTENT_KEY_ANNOUNCEMENT` (18716) from
739
+ `libp2r2p/kind`. It is also included in `eventKinds` and used by content-key
740
+ builders, proof validation and relay discovery. The announcement publishes a
741
+ public content key for private communication and can be shared across apps.
742
+ The former `CONTENT_KEY_KIND` export from `libp2r2p/content-key/event` has been
743
+ removed; consumers must update that import.
744
+
695
745
  `classifyEvent()` from `libp2r2p/event` combines the exact NIP-01 kind
696
746
  ranges with tag-defined behavior. The first `d` tag may add `replaceable` or
697
747
  `addressable`, while an `expiration` tag equal to `created_at` adds
@@ -1,7 +1,6 @@
1
1
  import { ValidationError } from '../../error/index.js'
2
2
  import { getEventHash, isValidEvent } from '../../event/index.js'
3
-
4
- export const CONTENT_KEY_KIND = 18716
3
+ import { CONTENT_KEY_ANNOUNCEMENT } from '../../kind/index.js'
5
4
 
6
5
  const HEX_PUBKEY = /^[0-9a-f]{64}$/
7
6
  const HEX_SIG = /^[0-9a-f]{128}$/i
@@ -15,7 +14,7 @@ export async function makeContentKeyEventForPubkey ({ userSigner, contentPubkey,
15
14
  if (!HEX_PUBKEY.test(contentPubkey || '')) throw new ValidationError('CONTENT_PUBKEY_REQUIRED')
16
15
 
17
16
  return userSigner.signEvent({
18
- kind: CONTENT_KEY_KIND,
17
+ kind: CONTENT_KEY_ANNOUNCEMENT,
19
18
  created_at: createdAt,
20
19
  tags: [['cp', contentPubkey]],
21
20
  content: ''
@@ -32,7 +31,7 @@ export async function makeContentKeyEvent ({ userSigner, contentKeySigner, creat
32
31
  }
33
32
 
34
33
  export function parseContentKeyEvent (event) {
35
- if (!event || event.kind !== CONTENT_KEY_KIND || event.content !== '') return null
34
+ if (!event || event.kind !== CONTENT_KEY_ANNOUNCEMENT || event.content !== '') return null
36
35
  if (!HEX_PUBKEY.test(event.pubkey) || !Number.isSafeInteger(event.created_at)) return null
37
36
  if (!Array.isArray(event.tags) || event.tags.length !== 1) return null
38
37
  if (!isValidEvent(event)) return null
@@ -67,7 +66,7 @@ function contentKeyProofError ({ ownerPubkey, contentPubkey, proof }) {
67
66
  if (!parsed) return 'INVALID_CONTENT_KEY_PROOF'
68
67
 
69
68
  const event = {
70
- kind: CONTENT_KEY_KIND,
69
+ kind: CONTENT_KEY_ANNOUNCEMENT,
71
70
  pubkey: ownerPubkey,
72
71
  created_at: parsed.created_at,
73
72
  tags: [['cp', contentPubkey]],
@@ -1 +1 @@
1
- export { getIykcProofs } from './services/iykc-proof.js'
1
+ export { getIykcProofs, rememberContentKeyEvent } from './services/iykc-proof.js'
@@ -1,5 +1,7 @@
1
- import { CONTENT_KEY_KIND, parseContentKeyEvent } from '../event/index.js'
1
+ import { parseContentKeyEvent } from '../event/index.js'
2
+ import { CONTENT_KEY_ANNOUNCEMENT } from '../../kind/index.js'
2
3
  import { pickRelaysForPubkeys, getRelaysByPubkey, relayPool } from '../../relay/index.js'
4
+ import { ValidationError } from '../../error/index.js'
3
5
 
4
6
  const QUERY_CACHE_MS = 40 * 60 * 1000
5
7
  const NEGATIVE_CACHE_MS = 5 * 60 * 1000
@@ -9,6 +11,7 @@ const contentKeysByPubkey = Object.create(null)
9
11
  const iykcCacheTimersByPubkey = Object.create(null)
10
12
  const iykcCacheAddedAtByPubkey = Object.create(null)
11
13
  const inFlightByPubkey = new Map()
14
+ const announcementOrder = new Map()
12
15
 
13
16
  const getEvents = (...args) => relayPool.getEvents(...args)
14
17
 
@@ -36,6 +39,7 @@ function cloneContentKey (contentKey) {
36
39
  }
37
40
 
38
41
  function deleteCachedValue (cache, timers, addedAt, key) {
42
+ if (cache === contentKeysByPubkey) announcementOrder.delete(key)
39
43
  clearTimeout(timers[key])
40
44
  delete cache[key]
41
45
  delete timers[key]
@@ -71,6 +75,23 @@ export function clearContentKeyCache () {
71
75
  for (const key of Object.keys(iykcCacheTimersByPubkey)) delete iykcCacheTimersByPubkey[key]
72
76
  for (const key of Object.keys(iykcCacheAddedAtByPubkey)) delete iykcCacheAddedAtByPubkey[key]
73
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
74
95
  }
75
96
 
76
97
  async function queryContentKeyProofs (pubkeyList, { _getEvents, _getRelaysByPubkey, cacheMs, negativeCacheMs }) {
@@ -81,7 +102,7 @@ async function queryContentKeyProofs (pubkeyList, { _getEvents, _getRelaysByPubk
81
102
  .map(async ([relay, authors]) => {
82
103
  try {
83
104
  const { result } = await _getEvents({
84
- kinds: [CONTENT_KEY_KIND],
105
+ kinds: [CONTENT_KEY_ANNOUNCEMENT],
85
106
  authors,
86
107
  limit: authors.length
87
108
  }, [relay], {
@@ -101,18 +122,28 @@ async function queryContentKeyProofs (pubkeyList, { _getEvents, _getRelaysByPubk
101
122
  for (const event of eventGroups.flat()) {
102
123
  const parsed = parseContentKeyEvent(event)
103
124
  if (!parsed) continue
104
- if (!latestByPubkey[event.pubkey] || event.created_at > latestByPubkey[event.pubkey].created_at) {
105
- 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 }
106
129
  }
107
130
  }
108
131
 
109
132
  const out = {}
110
133
  for (const pubkey of pubkeyList) {
111
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
+ }
112
142
  const proof = entry
113
143
  ? { iykcPubkey: entry.iykcPubkey, iykcProof: entry.iykcProof }
114
144
  : null
115
145
  if (proof) {
146
+ announcementOrder.set(pubkey, { createdAt: entry.created_at, id: entry.id })
116
147
  setCachedValue(contentKeysByPubkey, iykcCacheTimersByPubkey, iykcCacheAddedAtByPubkey, pubkey, cloneContentKey(proof), cacheMs)
117
148
  out[pubkey] = proof
118
149
  } else if (negativeCacheMs > 0) {
@@ -122,6 +153,7 @@ async function queryContentKeyProofs (pubkeyList, { _getEvents, _getRelaysByPubk
122
153
  }
123
154
  }
124
155
  pruneCache(contentKeysByPubkey, iykcCacheTimersByPubkey, iykcCacheAddedAtByPubkey, IYKC_CACHE_MAX_ITEMS)
156
+ for (const key of announcementOrder.keys()) if (!hasCachedKey(contentKeysByPubkey, key)) announcementOrder.delete(key)
125
157
  return out
126
158
  }
127
159
 
@@ -168,5 +200,13 @@ export async function getIykcProofs (pubkeys, {
168
200
  if (cached) out[pubkey] = cached
169
201
  }
170
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
+ }
171
211
  return out
172
212
  }
package/kind/index.js CHANGED
@@ -58,6 +58,7 @@ export const FILE_SERVER_PREFERENCE = 10096
58
58
  export const GOOD_WIKI_AUTHORS = 10101
59
59
  export const GOOD_WIKI_RELAYS = 10102
60
60
  export const NWC_WALLET_INFO = 13194
61
+ export const CONTENT_KEY_ANNOUNCEMENT = 18716
61
62
  export const LIGHTNING_PUB_RPC = 21000
62
63
  export const AUTH = 22242
63
64
  export const NWC_WALLET_REQUEST = 23194
@@ -198,6 +199,7 @@ export const eventKinds = /* @__PURE__ */ Object.freeze({
198
199
  GOOD_WIKI_AUTHORS,
199
200
  GOOD_WIKI_RELAYS,
200
201
  NWC_WALLET_INFO,
202
+ CONTENT_KEY_ANNOUNCEMENT,
201
203
  LIGHTNING_PUB_RPC,
202
204
  AUTH,
203
205
  NWC_WALLET_REQUEST,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.11.20",
3
+ "version": "0.11.22",
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,
711
721
  onError,
712
722
  receivedChunkScope = '',
713
723
  receivedChunkTtlMs = DEFAULT_RECEIVED_CHUNK_TTL_MS,
@@ -733,6 +743,7 @@ function createProcessor ({
733
743
  async function processOuterEvent (outer) {
734
744
  await storeReady
735
745
  let groupKey = ''
746
+ let routerDecrypted = false
736
747
  try {
737
748
  const channelPubkey = outer.pubkey || await privateChannelSigner?.getPublicKey?.()
738
749
  const channelSigner = readSignerFromMap(privateChannelSignersByPubkey, channelPubkey) || privateChannelSigner
@@ -742,6 +753,7 @@ function createProcessor ({
742
753
  const channelReceivedChunkTtlMs = readValueFromMap(receivedChunkTtlMsByPubkey, channelPubkey) ?? receivedChunkTtlMs
743
754
  if (!channelPubkey) throw new ValidationError('PRIVATE_CHANNEL_PUBKEY_REQUIRED')
744
755
 
756
+ if (await isDecryptionPending?.({ format: 'outer_v1', channelPubkey, outer })) return
745
757
  const decrypted = await decryptRouter({
746
758
  content: outer.content,
747
759
  channelPubkey,
@@ -749,6 +761,7 @@ function createProcessor ({
749
761
  channelReaderSigner,
750
762
  channelReaderPubkey
751
763
  })
764
+ routerDecrypted = true
752
765
  if (decrypted.kind === NYM_CARRIER_KIND) {
753
766
  const carrier = assertValidNymCarrierEvent(decrypted)
754
767
  const { index, total } = readChunkTag(carrier)
@@ -834,6 +847,7 @@ function createProcessor ({
834
847
  // regular leechers can stop as soon as their envelope is decrypted.
835
848
  const mustScanWholeBundle = shouldSeed || sentByReceiver
836
849
  let event = null
850
+ let deferred = false
837
851
  const innerEventIdsByRowIndex = {}
838
852
 
839
853
  const drained = await receivedChunks.drainAvailable(groupKey, {
@@ -858,15 +872,36 @@ function createProcessor ({
858
872
  onContentKeyUsage
859
873
  })
860
874
  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
- })
875
+ const record = () => {
876
+ const row = JSON.stringify([envelope.receiverPubkey, envelope.ciphertext, envelope.iykcPubkey, envelope.iykcProof])
877
+ return {
878
+ format: 'routerRow_v1', channelPubkey,
879
+ outer: { id: outer.id, kind: outer.kind, pubkey: outer.pubkey, created_at: outer.created_at },
880
+ router: joinedRouter(router, bytesToBase64(encoder.encode(JSON.stringify([groupMeta.payloadCiphertext]) + '\n' + row + '\n')))
881
+ }
882
+ }
883
+ if (await isDecryptionPending?.(record())) {
884
+ deferred = true
885
+ if (!mustScanWholeBundle) return { stop: true }
886
+ return
887
+ }
888
+ try {
889
+ event = await unwrapRecipientEnvelope({
890
+ payloadCiphertext: groupMeta.payloadCiphertext,
891
+ envelope,
892
+ receiverSigner,
893
+ receiverPubkey,
894
+ senderPubkey,
895
+ imkcPubkey,
896
+ rowScope: channelPubkey
897
+ })
898
+ } catch (cause) {
899
+ const error = decryptionError(cause, { eventId: outer.id, eventKind: outer.kind, channelPubkey })
900
+ if (!onDecryptionFailure || !isDeferredDecryptionError(error)) throw error
901
+ if (!await onDecryptionFailure({ error, record: record() })) throw error
902
+ deferred = true
903
+ if (!mustScanWholeBundle) return { stop: true }
904
+ }
870
905
  if (event) {
871
906
  innerEventIdsByRowIndex[rowIndex] = event.id
872
907
  if (!mustScanWholeBundle) return { stop: true }
@@ -876,8 +911,8 @@ function createProcessor ({
876
911
  }
877
912
  })
878
913
 
879
- if (event && !mustScanWholeBundle) {
880
- await onEvent?.(event, outer, { router: joinedRouter(router), channelPubkey, ...deliveryInfo(event, senderPubkey) })
914
+ if ((event || deferred) && !mustScanWholeBundle) {
915
+ if (event) await onEvent?.(event, outer, { router: joinedRouter(router), channelPubkey, ...deliveryInfo(event, senderPubkey) })
881
916
  ignoredGroups.add(groupKey)
882
917
  await receivedChunks.removeGroup(groupKey)
883
918
  return
@@ -902,7 +937,13 @@ function createProcessor ({
902
937
  if (event) await onEvent?.(event, outer, { router: completeRouter, channelPubkey, jsonl, ...deliveryInfo(event, senderPubkey) })
903
938
 
904
939
  await receivedChunks.removeGroup(groupKey)
905
- } catch (err) {
940
+ } catch (cause) {
941
+ let err = cause
942
+ if (!routerDecrypted) {
943
+ err = decryptionError(err, { stage: 'outer-envelope', channelPubkey: outer.pubkey, eventId: outer.id, eventKind: outer.kind })
944
+ if (onDecryptionFailure && isDeferredDecryptionError(err) &&
945
+ await onDecryptionFailure({ error: err, record: { format: 'outer_v1', channelPubkey: outer.pubkey, outer } })) return
946
+ }
906
947
  if (shouldIgnoreGroupError(err) && groupKey) {
907
948
  ignoredGroups.add(groupKey)
908
949
  await receivedChunks.removeGroup(groupKey).catch(() => {})
@@ -939,7 +980,81 @@ function shouldIgnoreGroupError (err) {
939
980
  ].includes(err?.message)
940
981
  }
941
982
 
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 }) {
983
+ // Replay ciphertext only. Keep one fragment processor for the entire batch;
984
+ // incomplete outer groups remain pending until a later key/channel transition.
985
+ export async function replayDeferredDecryptions ({ records, onRecovered, onFailure, ...options }) {
986
+ if (!Array.isArray(records) || records.some(record => typeof record?.id !== 'string' ||
987
+ typeof record.channelPubkey !== 'string' || !['outer_v1', 'routerRow_v1'].includes(record.packet?.format))) throw new ValidationError('INVALID_DEFERRED_RECORDS')
988
+ if (typeof onRecovered !== 'function' || typeof onFailure !== 'function') throw new ValidationError('DEFERRED_CALLBACKS_REQUIRED')
989
+ const groups = new Map()
990
+ const completed = new Set()
991
+ let current
992
+ let chunked = false
993
+ const processor = createProcessor({
994
+ ...options, onDecryptionFailure: undefined, isDecryptionPending: undefined,
995
+ onError: error => { throw error },
996
+ onChunk: chunk => {
997
+ chunked = true
998
+ const key = chunk.router?.pubkey || nymCarrierGroupId(chunk.nymCarrier)
999
+ if (!groups.has(key)) groups.set(key, new Set())
1000
+ groups.get(key).add(current.id)
1001
+ },
1002
+ onEvent: async (event, outer, meta) => {
1003
+ options.signal?.throwIfAborted()
1004
+ await options.onEvent?.(event, outer, meta)
1005
+ completed.add(meta.router?.pubkey)
1006
+ },
1007
+ onNymEvent: async (event, outer, meta) => {
1008
+ options.signal?.throwIfAborted()
1009
+ await options.onNymEvent?.(event, outer, meta)
1010
+ completed.add(nymCarrierGroupId(meta.carrier))
1011
+ },
1012
+ onSeedEvent: async seed => {
1013
+ options.signal?.throwIfAborted()
1014
+ await options.onSeedEvent?.(seed)
1015
+ completed.add(seed.router?.pubkey || nymCarrierGroupId(seed.carrier))
1016
+ }
1017
+ })
1018
+ try {
1019
+ for (const record of records) {
1020
+ options.signal?.throwIfAborted()
1021
+ current = record
1022
+ chunked = false
1023
+ try {
1024
+ if (record.packet.format === 'outer_v1') {
1025
+ await processor(record.packet.outer)
1026
+ if (!chunked) await onRecovered(record)
1027
+ } else {
1028
+ const event = await unwrapRouterEvent({
1029
+ receiverSigner: options.receiverSigner, receiverPubkey: options.receiverPubkey,
1030
+ channelPubkey: record.channelPubkey, router: record.packet.router
1031
+ })
1032
+ options.signal?.throwIfAborted()
1033
+ if (event) {
1034
+ await options.onEvent?.(event, record.packet.outer, {
1035
+ router: record.packet.router, channelPubkey: record.channelPubkey,
1036
+ ...deliveryInfo(event, readSenderTag(record.packet.router))
1037
+ })
1038
+ }
1039
+ await onRecovered(record)
1040
+ }
1041
+ } catch (error) {
1042
+ options.signal?.throwIfAborted()
1043
+ await onFailure(record, decryptionError(error, { channelPubkey: record.channelPubkey, eventId: record.packet.outer?.id }))
1044
+ }
1045
+ // Let live ingestion and foreground signer work run between records.
1046
+ await new Promise(resolve => setTimeout(resolve, 0))
1047
+ }
1048
+ for (const key of completed) {
1049
+ for (const id of groups.get(key) || []) {
1050
+ const record = records.find(record => record.id === id)
1051
+ if (record) await onRecovered(record)
1052
+ }
1053
+ }
1054
+ } finally { processor.close() }
1055
+ }
1056
+
1057
+ 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, 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
1058
  if (!relays?.length) throw new ValidationError('NO_RELAYS')
944
1059
  const authors = privateChannelPubkeyList({ privateChannelPubkey, privateChannelPubkeys })
945
1060
  const filter = { kinds: [PRIVATE_BROADCAST_KIND] }
@@ -958,7 +1073,7 @@ export async function fetch ({ signal, receiverSigner, iykcSigner, privateChanne
958
1073
  const elapsedMs = Math.round(performance.now() - startedAt)
959
1074
  const events = result.map(({ event }) => event)
960
1075
  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 })
1076
+ const processOuterEvent = createProcessor({ receiverSigner, iykcSigner, privateChannelSigner, privateChannelSignersByPubkey, privateChannelReaderSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, receiverPubkey, mode, modeByPubkey, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onDecryptionFailure, isDecryptionPending, onError, receivedChunkScope, receivedChunkTtlMs, receivedChunkTtlMsByPubkey, receivedChunkMaxBytes, receivedChunkIndexedDB, ignoredGroupTtlMs, ignoredGroupMaxEntries })
962
1077
  try {
963
1078
  for (const event of events) {
964
1079
  signal?.throwIfAborted()
@@ -988,7 +1103,7 @@ export async function fetchHistory ({ receiverSigner, privateChannelSigner = rec
988
1103
  } finally { processOuterEvent.close() }
989
1104
  }
990
1105
 
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 }) {
1106
+ export function subscribe ({ receiverSigner, iykcSigner, privateChannelSigner = receiverSigner, privateChannelSignersByPubkey, privateChannelReaderSigner = privateChannelSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, privateChannelPubkey, privateChannelPubkeys, receiverPubkey, relays, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onDecryptionFailure, isDecryptionPending, 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
1107
  if (!relays?.length) throw new ValidationError('NO_RELAYS')
993
1108
  if (receiverSigner && !receiverSigner?.nip44DecryptDoubleDH && !receiverSigner?.nip44v3Decrypt) throw new ValidationError('RECEIVER_SIGNER_NIP44V3_DECRYPT_UNSUPPORTED')
994
1109
  if (!privateChannelReaderSigner && !privateChannelReaderSignersByPubkey && !privateChannelSigner && !privateChannelSignersByPubkey) throw new ValidationError('PRIVATE_CHANNEL_READER_REQUIRED')
@@ -997,7 +1112,7 @@ export function subscribe ({ receiverSigner, iykcSigner, privateChannelSigner =
997
1112
  const filter = { kinds: [PRIVATE_BROADCAST_KIND], since }
998
1113
  if (authors.length) filter.authors = authors
999
1114
  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 })
1115
+ const processOuterEvent = createProcessor({ receiverSigner, iykcSigner, privateChannelSigner, privateChannelSignersByPubkey, privateChannelReaderSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, receiverPubkey, mode, modeByPubkey, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onDecryptionFailure, isDecryptionPending, onError, receivedChunkScope, receivedChunkTtlMs, receivedChunkTtlMsByPubkey, receivedChunkMaxBytes, receivedChunkIndexedDB, ignoredGroupTtlMs, ignoredGroupMaxEntries })
1001
1116
  const controller = new AbortController()
1002
1117
  const events = liveOnly
1003
1118
  ? _liveEventsGenerator(filter, relays, {
@@ -477,6 +477,8 @@ export function createPrivateMessageSession ({ _setTimeout = setTimeout, _clearT
477
477
  return dispatchSeedEvent(seed)
478
478
  },
479
479
  onContentKeyUsage: dispatchContentKeyUsage,
480
+ isDecryptionPending: record => watchCallbacks(record.channelPubkey).isDecryptionPending?.(record),
481
+ onDecryptionFailure: failure => watchCallbacks(failure.record.channelPubkey).onDecryptionFailure?.(failure),
480
482
  onError: err => reportSubscriptionError(channels, err)
481
483
  })
482
484
 
@@ -522,6 +524,7 @@ export function createPrivateMessageSession ({ _setTimeout = setTimeout, _clearT
522
524
  onSeed,
523
525
  onChunk,
524
526
  onContentKeyUsage,
527
+ onDecryptionFailure, isDecryptionPending,
525
528
  onSubscriptionState,
526
529
  onError,
527
530
  receivedChunkTtlMs,
@@ -542,7 +545,7 @@ export function createPrivateMessageSession ({ _setTimeout = setTimeout, _clearT
542
545
  const ownPubkey = receiverPubkey || await receiverSigner?.getPublicKey?.()
543
546
  if (identity !== undefined && identity !== ownPubkey) throw new ValidationError('PRIVATE_MESSAGE_IDENTITY_MISMATCH')
544
547
  identity = ownPubkey
545
- const callbacks = { onAsk, onReply, onTell, onYell, onNym, onMessage, onSeed, onChunk, onContentKeyUsage, onSubscriptionState, onError }
548
+ const callbacks = { onAsk, onReply, onTell, onYell, onNym, onMessage, onSeed, onChunk, onContentKeyUsage, onDecryptionFailure, isDecryptionPending, onSubscriptionState, onError }
546
549
 
547
550
  for (const channel of channelList) {
548
551
  if (watchRequests.get(channel) !== request) continue
@@ -54,6 +54,9 @@ import { incompleteFetchError } from '../private-channel/helpers/fetch-error.js'
54
54
  import { recoveryRetryDelay, isPermanentRecoveryError } from './helpers/recovery-retry.js'
55
55
  import { normalizeRelayUrl } from '../url/index.js'
56
56
  import { deliveryInfo } from '../private-channel/helpers/rumor.js'
57
+ import { migrateMessageQueue } from './services/migrate-message-queue.js'
58
+ import { createPendingDecryption } from './services/pending-decryption.js'
59
+ import { decryptionError, isDeferredDecryptionError } from '../private-channel/helpers/decryption-error.js'
57
60
  import { bytesToBase64 } from '../base64/index.js'
58
61
  import { ValidationError } from '../error/index.js'
59
62
  import { getRelaysByPubkey, pickRelaysForPubkeys, subscribeRelayListUpdates, relayPool } from '../relay/index.js'
@@ -419,6 +422,7 @@ export class PrivateMessenger {
419
422
  this.assertOpen()
420
423
  this.contentKeyPubkey = await this.contentKeySigner?.getPublicKey?.() || ''
421
424
  this.assertOpen()
425
+ await migrateMessageQueue(this._indexedDB, this.prefix)
422
426
  this.queue = await createQueue({
423
427
  prefix: this.prefix,
424
428
  indexes: MESSAGE_QUEUE_INDEXES,
@@ -427,6 +431,10 @@ export class PrivateMessenger {
427
431
  indexedDB: this._indexedDB
428
432
  })
429
433
  this.assertOpen()
434
+ this.pendingDecryption = await createPendingDecryption({
435
+ prefix: this.prefix, indexedDB: this._indexedDB,
436
+ report: detail => this.debug('pending-decryption', detail)
437
+ })
430
438
  this.seedQueue = this.seedStorage
431
439
  ? null
432
440
  : await createQueue({
@@ -446,16 +454,20 @@ export class PrivateMessenger {
446
454
  this.state = { channels: await this.stateStore.load() }
447
455
  await this.update({ userSigner, contentKeySigner, nymSigner: this.nymSigner, channels, relays, mode })
448
456
  this.initialized = true
457
+ this.retryPendingDecryptions().catch(error => this.onError?.(error))
449
458
  await this.refreshAndApplyStoragePolicy()
450
459
  this.broadcastStoragePolicyChange()
451
460
  return this
452
461
  } catch (err) {
462
+ this.decryptionRetry?.controller.abort(err)
463
+ await Promise.allSettled([this.decryptionRetry?.work])
453
464
  this.stopStorageMaintenance()
454
465
  this.stopStoragePolicyBroadcast()
455
466
  try { await this.queueOperationTail } catch {}
456
467
  try { await this.stateWriteTail } catch {}
457
468
  await Promise.allSettled([
458
469
  this.queue?.close?.(),
470
+ this.pendingDecryption?.close(),
459
471
  this.seedQueue?.close?.(),
460
472
  this.stateStore?.close?.()
461
473
  ])
@@ -1209,6 +1221,8 @@ export class PrivateMessenger {
1209
1221
  onMessage: message => this.receive(pubkey, () => this.handleMessage(pubkey, message), message),
1210
1222
  onSeed: seed => this.receive(pubkey, () => this.enqueueSeed(pubkey, seed), seed),
1211
1223
  onContentKeyUsage: usage => this.handleContentKeyUsage(pubkey, usage),
1224
+ onDecryptionFailure: failure => this.deferDecryption(failure),
1225
+ isDecryptionPending: record => this.pendingDecryption.has(record),
1212
1226
  receivedChunkTtlMs: this.receivedChunkTtlMsFor(channel),
1213
1227
  receivedChunkIndexedDB: this._indexedDB,
1214
1228
  onSubscriptionState: state => this.handleSubscriptionState(pubkey, state),
@@ -1241,6 +1255,11 @@ export class PrivateMessenger {
1241
1255
  continue
1242
1256
  }
1243
1257
  this.stopByChannel.set(pubkey, stop)
1258
+ this.decryptionReadyChannels ??= new Set()
1259
+ if (!this.decryptionReadyChannels.has(pubkey)) {
1260
+ this.decryptionReadyChannels.add(pubkey)
1261
+ this.retryPendingDecryptions().catch(error => this.onError?.(error))
1262
+ }
1244
1263
  this.watchRevisionByChannel.set(pubkey, revision)
1245
1264
  this.updateChannelState(pubkey, {
1246
1265
  lastWatchedAt: nowSeconds(),
@@ -1829,6 +1848,7 @@ export class PrivateMessenger {
1829
1848
  if (typeof reason !== 'string' || !reason.trim()) throw new ValidationError('PAUSE_REASON_REQUIRED')
1830
1849
  this.assertOpen()
1831
1850
  this.pauseReasons.add(reason)
1851
+ this.decryptionRetry?.controller.abort(this.pausedError())
1832
1852
  this.pauseRevisions.set(reason, (this.pauseRevisions.get(reason) || 0) + 1)
1833
1853
  this.sendRoutingPause.abort(this.pausedError())
1834
1854
  this.notifyStatus()
@@ -1865,6 +1885,7 @@ export class PrivateMessenger {
1865
1885
  }
1866
1886
  if (this.pauseReasons.size || this.closePromise) return
1867
1887
  const jobs = removed ? this.startChannelBackground(channels, { recover: true }) : [...this.channelBackground.values()].map(job => job.work)
1888
+ if (removed) this.retryPendingDecryptions().catch(error => this.onError?.(error))
1868
1889
  if (removed) {
1869
1890
  for (const extension of this.extensions) {
1870
1891
  const work = Promise.resolve().then(() => { if (!this.pauseReasons.size && !this.closePromise) return extension.resume?.() })
@@ -2739,7 +2760,19 @@ export class PrivateMessenger {
2739
2760
  for (const line of splitJsonl(jsonl)) {
2740
2761
  const record = parseJson(line, null)
2741
2762
  if (!record) continue
2742
- const recovered = await this.messageFromBackfillRecord(channelPubkey, record)
2763
+ const pendingRecord = record.recordType === ROUTER_SEED_RECORD_TYPE && isPrivateChannelRouter(record.router)
2764
+ ? { format: 'routerRow_v1', channelPubkey, router: record.router, outer: { id: '', created_at: record.lastSeenAt || record.router.created_at } }
2765
+ : null
2766
+ if (pendingRecord && await this.pendingDecryption.has(pendingRecord)) continue
2767
+ let recovered
2768
+ try { recovered = await this.messageFromBackfillRecord(channelPubkey, record) } catch (cause) {
2769
+ const error = decryptionError(cause, { channelPubkey })
2770
+ if (!isDeferredDecryptionError(error) || record.recordType !== ROUTER_SEED_RECORD_TYPE) throw error
2771
+ await this.deferDecryption({
2772
+ error, record: pendingRecord
2773
+ })
2774
+ continue
2775
+ }
2743
2776
  if (!recovered) continue
2744
2777
  await this.enqueueRumor(recovered.type, channelPubkey, {
2745
2778
  event: recovered.event,
@@ -2847,6 +2880,8 @@ export class PrivateMessenger {
2847
2880
  onNymEvent: (event, outer, meta) => this.receive(channelPubkey, () => this.enqueueRumor('nym', channelPubkey, { event, outer, meta, payload: parseEventContent(event) }), { event, outer }),
2848
2881
  onSeedEvent: seed => this.receive(channelPubkey, () => this.enqueueSeed(channelPubkey, seed), seed),
2849
2882
  onContentKeyUsage: usage => this.handleContentKeyUsage(channelPubkey, usage),
2883
+ onDecryptionFailure: failure => this.deferDecryption(failure),
2884
+ isDecryptionPending: record => this.pendingDecryption.has(record),
2850
2885
  onError: err => { throw err }
2851
2886
  }
2852
2887
  }
@@ -3085,9 +3120,11 @@ export class PrivateMessenger {
3085
3120
  this.dropRecoveryState(pubkey)
3086
3121
  return this.runQueueOperation(async () => {
3087
3122
  this.channels.delete(pubkey)
3123
+ this.decryptionReadyChannels?.delete(pubkey)
3088
3124
  this.removeChannelState(pubkey)
3089
3125
  await this.flushStateWrites()
3090
3126
  await this.queue.removeBy('byChannel', pubkey)
3127
+ await this.pendingDecryption?.clearChannel(pubkey)
3091
3128
  await (this.seedStorage ? this.seedStorage.removeLocal({ channelPubkey: pubkey }) : this.seedQueue.removeBy('byChannel', pubkey))
3092
3129
  await this.touchStorageActivity({ force: true })
3093
3130
  this.ensureRelayListWatcher()
@@ -3098,6 +3135,74 @@ export class PrivateMessenger {
3098
3135
  return this.runQueueOperation(() => this.queue.clear())
3099
3136
  }
3100
3137
 
3138
+ async deferDecryption (failure) {
3139
+ this.assertOpen()
3140
+ const pubkey = failure.record.channelPubkey
3141
+ if (this.pauseReasons.size || !this.channels.has(pubkey)) throw this.pausedError()
3142
+ try { return await this.pendingDecryption.save(failure) } catch (error) {
3143
+ this.recordInterruption([pubkey], failure.record.outer?.created_at)
3144
+ if (isRecoverableStorageFailure(error)) this.pauseInternally('storage').catch(err => this.onError?.(err))
3145
+ throw error
3146
+ }
3147
+ }
3148
+
3149
+ retryPendingDecryptions ({ force = false } = {}) {
3150
+ if (typeof force !== 'boolean') throw new ValidationError('INVALID_FORCE')
3151
+ if (this.closePromise || this.pauseReasons.size || !this.initialized) return Promise.resolve()
3152
+ if (this.decryptionRetry && !this.decryptionRetry.controller.signal.aborted) {
3153
+ this.decryptionRetry.again = true
3154
+ this.decryptionRetry.force ||= force
3155
+ return this.decryptionRetry.work
3156
+ }
3157
+ const job = { controller: new AbortController(), again: false, force, work: null }
3158
+ const previous = this.decryptionRetry?.work
3159
+ this.decryptionRetry = job
3160
+ const { signal } = job.controller
3161
+ const run = async () => {
3162
+ do {
3163
+ signal.throwIfAborted()
3164
+ job.again = false
3165
+ const force = job.force
3166
+ job.force = false
3167
+ const groups = new Map()
3168
+ for (const record of await this.pendingDecryption.records({ force })) {
3169
+ if (!groups.has(record.channelPubkey)) groups.set(record.channelPubkey, [])
3170
+ groups.get(record.channelPubkey).push(record)
3171
+ }
3172
+ for (const [channelPubkey, records] of groups) {
3173
+ signal.throwIfAborted()
3174
+ const channel = this.channels.get(channelPubkey)
3175
+ if (!channel || !this.desiredChannels.has(channelPubkey)) continue
3176
+ const current = () => { signal.throwIfAborted(); if (this.channels.get(channelPubkey) !== channel) throw new Error('PRIVATE_CHANNEL_CHANGED') }
3177
+ await this._privateChannel.replayDeferredDecryptions({
3178
+ records, signal, receiverSigner: this.userSigner, receiverPubkey: this.userPubkey,
3179
+ privateChannelSigner: channel.signer, privateChannelReaderSigner: channel.readerSigner,
3180
+ privateChannelReaderPubkey: channel.readerPubkey, mode: channel.mode,
3181
+ receivedChunkScope: `${this.prefix}:pending`, receivedChunkIndexedDB: this._indexedDB,
3182
+ onEvent: (event, outer, meta) => {
3183
+ current()
3184
+ return this.receive(channelPubkey, () => this.enqueueRumor(eventType(event), channelPubkey, { event, outer, meta, payload: parseEventContent(event) }), { event, outer })
3185
+ },
3186
+ onNymEvent: (event, outer, meta) => {
3187
+ current()
3188
+ return this.receive(channelPubkey, () => this.enqueueRumor('nym', channelPubkey, { event, outer, meta, payload: parseEventContent(event) }), { event, outer })
3189
+ },
3190
+ onSeedEvent: seed => { current(); return this.receive(channelPubkey, () => this.enqueueSeed(channelPubkey, seed), seed) },
3191
+ onRecovered: record => { current(); return this.pendingDecryption.recovered(record) },
3192
+ onFailure: (record, error) => { current(); return this.pendingDecryption.failed(record, error) }
3193
+ })
3194
+ }
3195
+ } while (job.again)
3196
+ }
3197
+ job.work = Promise.resolve(previous).catch(() => {}).then(() => {
3198
+ const locks = globalThis.navigator?.locks
3199
+ return locks?.request ? locks.request(`${this.prefix}:pending-decryption:retry`, { signal }, run) : run()
3200
+ }).catch(error => { if (!signal.aborted) throw error }).finally(() => {
3201
+ if (this.decryptionRetry === job) this.decryptionRetry = null
3202
+ })
3203
+ return job.work
3204
+ }
3205
+
3101
3206
  async cleanupStaleChannels ({ storageSnapshot } = {}) {
3102
3207
  if (!this.prefix) return
3103
3208
  storageSnapshot ||= await this.readStoragePolicySnapshot()
@@ -3150,6 +3255,7 @@ export class PrivateMessenger {
3150
3255
  }
3151
3256
 
3152
3257
  close () {
3258
+ this.decryptionRetry?.controller.abort(new Error('PRIVATE_MESSENGER_CLOSED'))
3153
3259
  if (this.closePromise) return this.closePromise
3154
3260
  const initSettledPromise = this.initSettledPromise
3155
3261
  this.sendRoutingLifetime.abort()
@@ -3189,11 +3295,13 @@ export class PrivateMessenger {
3189
3295
  await Promise.allSettled([...this.deliveryReads])
3190
3296
  await Promise.all([...this.deliveries].map(delivery => delivery.nack()))
3191
3297
  await this.stateWriteTail
3298
+ await Promise.allSettled([this.decryptionRetry?.work])
3192
3299
  try { await this.storageTouchPromise } catch {}
3193
3300
  await this.storageMaintenancePromise
3194
3301
  try { await this.storagePolicyRefreshTail } catch {}
3195
3302
  await Promise.all([
3196
3303
  this.queue?.close?.(),
3304
+ this.pendingDecryption?.close(),
3197
3305
  this.seedQueue?.close?.(),
3198
3306
  this.stateStore?.close?.()
3199
3307
  ])
@@ -0,0 +1,95 @@
1
+ import { deliveryInfo } from '../../private-channel/helpers/rumor.js'
2
+
3
+ const encoder = new TextEncoder()
4
+ const indexName = 'byChannelTypeEventId'
5
+ const oldPath = ['item.channelPubkey', 'item.type', 'item.event.id']
6
+ const newPath = [...oldPath, 'item.provenance']
7
+ const equal = (a, b) => JSON.stringify(a) === JSON.stringify(b)
8
+
9
+ function open (indexedDB, name, version, upgrade) {
10
+ return new Promise((resolve, reject) => {
11
+ const request = indexedDB.open(name, version)
12
+ let rejected = false
13
+ request.onblocked = () => { rejected = true; reject(new Error('IDB_DATABASE_BLOCKED')) }
14
+ request.onerror = () => reject(request.error)
15
+ request.onupgradeneeded = () => {
16
+ if (rejected) { request.transaction.abort(); return }
17
+ try { upgrade?.(request.result, request.transaction) } catch (error) {
18
+ request.transaction.abort(); reject(error)
19
+ }
20
+ }
21
+ request.onsuccess = () => {
22
+ const db = request.result
23
+ db.onversionchange = () => db.close()
24
+ if (rejected) db.close()
25
+ else resolve(db)
26
+ }
27
+ })
28
+ }
29
+
30
+ function matchesLegacy (items) {
31
+ if (!items.indexNames.contains(indexName)) return false
32
+ const index = items.index(indexName)
33
+ return equal(items.keyPath, 'position') && equal(index.keyPath, oldPath) && index.unique && !index.multiEntry &&
34
+ items.indexNames.contains('byChannel') && items.index('byChannel').keyPath === 'item.channelPubkey' &&
35
+ !items.index('byChannel').unique && !items.index('byChannel').multiEntry
36
+ }
37
+
38
+ // The generic queue remains strict. Only this historical messenger schema is
39
+ // migrated, atomically with its records and capacity accounting.
40
+ export async function migrateMessageQueue (indexedDB, prefix) {
41
+ if (!indexedDB?.open) throw new Error('IDB_UNAVAILABLE')
42
+ const name = `${prefix}:idb-queue`
43
+ for (let attempt = 0; attempt < 3; attempt++) {
44
+ const db = await open(indexedDB, name)
45
+ if (!db.objectStoreNames.contains('items') || !db.objectStoreNames.contains('state')) { db.close(); return }
46
+ const tx = db.transaction(['items', 'state'], 'readonly')
47
+ const legacy = matchesLegacy(tx.objectStore('items')) && tx.objectStore('state').keyPath === 'key'
48
+ const version = db.version + 1
49
+ await new Promise((resolve, reject) => { tx.oncomplete = resolve; tx.onabort = () => reject(tx.error) })
50
+ db.close()
51
+ if (!legacy) return
52
+ try {
53
+ const upgraded = await open(indexedDB, name, version, (_db, tx) => {
54
+ const items = tx.objectStore('items')
55
+ if (!matchesLegacy(items)) return
56
+ items.deleteIndex(indexName)
57
+ items.createIndex(indexName, newPath, { unique: true })
58
+ let delta = 0
59
+ const cursor = items.openCursor()
60
+ cursor.onsuccess = () => {
61
+ try {
62
+ const row = cursor.result
63
+ if (!row) {
64
+ const state = tx.objectStore('state')
65
+ const request = state.get('queue')
66
+ request.onsuccess = () => {
67
+ if (request.result) state.put({ ...request.result, usedBytes: request.result.usedBytes + delta })
68
+ }
69
+ return
70
+ }
71
+ const record = row.value
72
+ const item = { ...record.item }
73
+ const sender = item.senderPubkey || item.meta?.senderPubkey || item.meta?.router?.tags?.find(tag => tag[0] === 'f')?.[1] || ''
74
+ const info = deliveryInfo(item.event, sender)
75
+ item.senderPubkey = sender
76
+ item.provenance = item.provenance === 'hearsay' ? 'hearsay' : info.provenance
77
+ let byteSize = 0
78
+ while (true) {
79
+ const size = encoder.encode(JSON.stringify({ byteSize, item })).length
80
+ if (size === byteSize) break
81
+ byteSize = size
82
+ }
83
+ delta += byteSize - record.byteSize
84
+ row.update({ ...record, item, byteSize })
85
+ row.continue()
86
+ } catch { tx.abort() }
87
+ }
88
+ })
89
+ upgraded.close()
90
+ return
91
+ } catch (error) {
92
+ if (error?.name !== 'VersionError' || attempt === 2) throw error
93
+ }
94
+ }
95
+ }
@@ -0,0 +1,120 @@
1
+ import { sha256 } from '@noble/hashes/sha2.js'
2
+ import { bytesToHex } from '../../base16/index.js'
3
+ import { base64ToBytes } from '../../base64/index.js'
4
+ import { createQueue } from '../../idb-queue/index.js'
5
+ import { isDeferredDecryptionError } from '../../private-channel/helpers/decryption-error.js'
6
+
7
+ const encoder = new TextEncoder()
8
+ const decoder = new TextDecoder()
9
+ export const PENDING_DECRYPTION_MAX_BYTES = 16 * 1024 * 1024
10
+
11
+ function storedBytes (item) {
12
+ let byteSize = 0
13
+ while (true) {
14
+ const size = encoder.encode(JSON.stringify({ byteSize, item })).length
15
+ if (size === byteSize) return size
16
+ byteSize = size
17
+ }
18
+ }
19
+
20
+ function packetSnapshot (packet) {
21
+ const event = (value, router = false) => {
22
+ const copy = Object.fromEntries(['id', 'kind', 'pubkey', 'created_at', 'content', 'sig'].filter(key => value?.[key] !== undefined).map(key => [key, value[key]]))
23
+ if (value?.tags) {
24
+ copy.tags = router
25
+ ? value.tags.filter(tag => ['f', 'p', 'imkc', 'c', 'i'].includes(tag[0])).map(tag => tag.slice(0, tag[0] === 'imkc' || tag[0] === 'c' ? 3 : 2))
26
+ : structuredClone(value.tags)
27
+ }
28
+ return copy
29
+ }
30
+ return { format: packet.format, channelPubkey: packet.channelPubkey, outer: event(packet.outer), ...(packet.router ? { router: event(packet.router, true) } : {}) }
31
+ }
32
+
33
+ // Stable across live, history and synthesized recovery wrappers.
34
+ function packetId (record) {
35
+ const rows = record.format === 'routerRow_v1'
36
+ ? decoder.decode(base64ToBytes(record.router.content)).split('\n').filter(Boolean).map(line => {
37
+ const row = JSON.parse(line)
38
+ return row.length === 1 ? row : [row[0], row[1], row[2] || '', row[3] || '']
39
+ })
40
+ : null
41
+ const value = rows ? [record.channelPubkey, record.router.pubkey, record.router.tags.filter(tag => tag[0] !== 'c'), rows] : [record.channelPubkey, record.outer.id]
42
+ return bytesToHex(sha256(encoder.encode(JSON.stringify(value))))
43
+ }
44
+
45
+ export async function createPendingDecryption ({ prefix, indexedDB, maxBytes = PENDING_DECRYPTION_MAX_BYTES, report = () => {} }) {
46
+ const queue = await createQueue({
47
+ prefix: `${prefix}:pending-decryption`, indexedDB, maxBytes, evictionPolicy: 'fifo',
48
+ indexes: { byId: { keyPath: 'id', unique: true }, byChannel: 'channelPubkey' }
49
+ })
50
+ let tail = Promise.resolve()
51
+ let timer
52
+ let counts = {}
53
+ const flush = () => {
54
+ clearTimeout(timer); timer = null
55
+ if (Object.keys(counts).length) {
56
+ const summary = counts; counts = {}
57
+ report({ action: 'summary', counts: summary })
58
+ console.debug('private-messenger pending decryption', JSON.stringify(summary))
59
+ }
60
+ }
61
+ const serial = work => { const result = tail.then(work); tail = result.catch(() => {}); return result }
62
+ const notify = (action, detail) => {
63
+ report({ action, ...detail })
64
+ const key = `${action}:${detail.code || detail.reason || ''}`
65
+ counts[key] = { count: (counts[key]?.count || 0) + 1, example: detail }
66
+ if (!timer) { timer = setTimeout(flush, 1000); timer.unref?.() }
67
+ }
68
+ return {
69
+ queue,
70
+ has: record => serial(() => queue.someBy('byId', packetId(packetSnapshot(record)))),
71
+ save ({ error, record }) {
72
+ record = packetSnapshot(record)
73
+ return serial(async () => {
74
+ const id = packetId(record)
75
+ if (await queue.someBy('byId', id)) return true
76
+ const item = { id, channelPubkey: record.channelPubkey, packet: structuredClone(record), code: error.code, macAttempts: error.code === 'INVALID_MAC' ? 1 : 0 }
77
+ const requiredBytes = storedBytes(item)
78
+ if (requiredBytes > maxBytes) {
79
+ notify('discarded', { reason: 'oversized', channelPubkey: item.channelPubkey, id }); return true
80
+ }
81
+ const before = await queue.getCapacity()
82
+ try { await queue.enqueue(item) } catch (error) {
83
+ if (error.name === 'ConstraintError') return true
84
+ throw error
85
+ }
86
+ const after = await queue.getCapacity()
87
+ const evictedBytes = Math.max(0, before.usedBytes + requiredBytes - after.usedBytes)
88
+ if (evictedBytes) notify('discarded', { reason: 'capacity', bytes: evictedBytes })
89
+ const coordinates = Object.fromEntries(['eventId', 'eventKind', 'senderPubkey', 'receiverPubkey', 'ownContentPubkey', 'peerContentPubkey', 'hasOwnContentKey']
90
+ .filter(key => ['string', 'boolean', 'number'].includes(typeof error[key])).map(key => [key, error[key]]))
91
+ notify('deferred', { ...coordinates, code: error.code, stage: error.stage, id, channelPubkey: item.channelPubkey, capacityBytesBefore: before.usedBytes, capacityBytesAfter: after.usedBytes })
92
+ return true
93
+ })
94
+ },
95
+ async records ({ force = false } = {}) {
96
+ await tail
97
+ const records = []
98
+ for await (const item of queue.storedItems()) if (force || item.macAttempts < 3) records.push(item)
99
+ return records
100
+ },
101
+ recovered (item) {
102
+ return serial(async () => {
103
+ await queue.removeBy('byId', item.id)
104
+ notify('recovered', { id: item.id, channelPubkey: item.channelPubkey })
105
+ })
106
+ },
107
+ failed (item, error) {
108
+ return serial(async () => {
109
+ if (!isDeferredDecryptionError(error)) throw error
110
+ const current = await queue.getBy('byId', item.id)
111
+ if (!current) return
112
+ const updated = { ...current, code: error.code, macAttempts: current.macAttempts + (error.code === 'INVALID_MAC' ? 1 : 0) }
113
+ await queue.putBy('byId', updated, { existingOnly: true })
114
+ if (updated.macAttempts >= 3 && item.macAttempts < 3) notify('suspended', { id: item.id, code: error.code, channelPubkey: item.channelPubkey })
115
+ })
116
+ },
117
+ clearChannel: channel => serial(() => queue.removeBy('byChannel', channel)),
118
+ async close () { await tail; flush(); await queue.close() }
119
+ }
120
+ }
@@ -185,6 +185,7 @@ function storageDatabaseNames (userPubkey) {
185
185
  const prefix = `libp2r2p:private-messenger:${userPubkey}`
186
186
  return [
187
187
  `${prefix}:idb-queue`,
188
+ `${prefix}:pending-decryption:idb-queue`,
188
189
  `${prefix}:seeds:idb-queue`,
189
190
  `${prefix}:file-seeds:idb-queue`,
190
191
  `${prefix}:file-authorizations:idb-queue`,
@@ -4,6 +4,12 @@
4
4
  outbox and IRFS attachments without presentation state. It builds on
5
5
  PrivateMessenger and the file coordinator, not another recovery protocol.
6
6
 
7
+ `retryPendingDecryptions({ force = false } = {})` forwards a background pass to
8
+ the account messenger while available. Call after committed content-key changes;
9
+ ordinary availability recovery already requests a pass. Ciphertext stays in the
10
+ separate bounded FIFO until admitted to the normal inbox, evicted or explicitly
11
+ cleared. This does not retry application storage/permission failures as crypto.
12
+
7
13
  Provide `owner`, a byte-based NIP-44 v3 `signer` with `withSharedKey`, and either
8
14
  `eventStore` or a `messageStorage.save(event, { peerPubkey, hearsay })` adapter.
9
15
  Optional `chunkStorage` and `recoveryStorage` (`seeds`, `authorizations`) select
@@ -558,6 +558,11 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
558
558
  await writeDownloadIntent(() => downloads?.remove(id))
559
559
  }
560
560
  return {
561
+ async retryPendingDecryptions (options) {
562
+ if (closed || !available) return
563
+ await ensureMessenger()
564
+ if (!closed && available) return messenger?.retryPendingDecryptions(options)
565
+ },
561
566
  download, cancelDownload, preparePeer,
562
567
  // Peers-based so callers can prefetch as soon as a chat route opens, before
563
568
  // the shared-key channel exists. When group chats get real membership, add