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 +72 -0
- package/content-key/index.js +1 -1
- package/content-key/services/iykc-proof.js +41 -2
- package/package.json +1 -1
- package/private-channel/helpers/decryption-error.js +15 -0
- package/private-channel/index.js +153 -27
- package/private-message/index.js +5 -1
- package/private-messenger/index.js +338 -150
- package/private-messenger/services/decryption-scheduler.js +135 -0
- package/private-messenger/services/migrate-message-queue.js +95 -0
- package/private-messenger/services/pending-decryption.js +126 -0
- package/private-messenger/services/storage-maintenance.js +1 -0
- package/private-messenger/session/README.md +19 -0
- package/private-messenger/session/helpers/foreground-work.js +6 -1
- package/private-messenger/session/index.js +26 -2
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.
|
package/content-key/index.js
CHANGED
|
@@ -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
|
-
|
|
106
|
-
|
|
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
|
@@ -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)
|
package/private-channel/index.js
CHANGED
|
@@ -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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
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 (
|
|
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
|
-
|
|
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, {
|
package/private-message/index.js
CHANGED
|
@@ -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
|