libp2r2p 0.11.21 → 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 +43 -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 +141 -26
- package/private-message/index.js +4 -1
- package/private-messenger/index.js +109 -1
- package/private-messenger/services/migrate-message-queue.js +95 -0
- package/private-messenger/services/pending-decryption.js +120 -0
- package/private-messenger/services/storage-maintenance.js +1 -0
- package/private-messenger/session/README.md +6 -0
- package/private-messenger/session/index.js +5 -0
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.
|
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,
|
|
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
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
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 (
|
|
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
|
-
|
|
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, {
|
package/private-message/index.js
CHANGED
|
@@ -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
|
|
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
|