libp2r2p 0.11.30 → 0.11.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md
CHANGED
|
@@ -1278,3 +1278,15 @@ circuit: explicit bans/refusals open immediately, repeated transport failures
|
|
|
1278
1278
|
open after three attempts, and a fully quarantined route reports
|
|
1279
1279
|
`RELAY_CIRCUIT_OPEN` with `retryAt` before another envelope is prepared. Reads and
|
|
1280
1280
|
receive routing are unaffected. See [session relay fallback](private-messenger/session/README.md#relay-fallback).
|
|
1281
|
+
|
|
1282
|
+
The 0.11.31 pending-decryption retry groups records by
|
|
1283
|
+
`(receiverPubkey, ownContentPubkey)` for one pass. A FIFO probe may skip the
|
|
1284
|
+
remaining records only when the local signer confirms `CONTENT_KEY_NOT_FOUND`
|
|
1285
|
+
with `hasOwnContentKey: false`. MAC, proof, parsing and operational failures are
|
|
1286
|
+
never generalized to the group, and every deferred record remains persisted.
|
|
1287
|
+
|
|
1288
|
+
The 0.11.32 local recovery-seed queue serializes incoming and outgoing writes
|
|
1289
|
+
within one PrivateMessenger. Duplicate `bySeedKey` races retry up to five times:
|
|
1290
|
+
NYM rows remain idempotent and router rows re-read the winning timestamps before
|
|
1291
|
+
replacement. External event-store seed adapters retain their independent batched
|
|
1292
|
+
writes.
|
package/package.json
CHANGED
|
@@ -57,7 +57,7 @@ import { assertMessagePublished, publicationFailureDiagnostic, sanitizePublicati
|
|
|
57
57
|
import { normalizeRelayUrl } from '../url/index.js'
|
|
58
58
|
import { deliveryInfo } from '../private-channel/helpers/rumor.js'
|
|
59
59
|
import { migrateMessageQueue } from './services/migrate-message-queue.js'
|
|
60
|
-
import { createPendingDecryption } from './services/pending-decryption.js'
|
|
60
|
+
import { createPendingDecryption, pendingContentKeyGroup, pendingMissingContentKeyGroup } from './services/pending-decryption.js'
|
|
61
61
|
import { createDecryptionScheduler } from './services/decryption-scheduler.js'
|
|
62
62
|
import { decryptionError, isDeferredDecryptionError } from '../private-channel/helpers/decryption-error.js'
|
|
63
63
|
import { bytesToBase64 } from '../base64/index.js'
|
|
@@ -108,6 +108,7 @@ export {
|
|
|
108
108
|
const DEFAULT_OFFLINE_RECOVERY_SECONDS = 7 * 24 * 60 * 60
|
|
109
109
|
const MAX_OFFLINE_RECOVERY_SECONDS = Math.floor(Number.MAX_SAFE_INTEGER / 1000)
|
|
110
110
|
const RECOVERY_STORAGE_BATCH_SIZE = 100
|
|
111
|
+
const SEED_QUEUE_UPSERT_ATTEMPTS = 5
|
|
111
112
|
const STORAGE_POLICY_BROADCAST_CHANNEL = 'libp2r2p:private-messenger:storage-policy'
|
|
112
113
|
const DEFAULT_OFFLINE_SKEW_SECONDS = 30
|
|
113
114
|
const DEFAULT_RELOAD_GAP_DELAY_MS = 500
|
|
@@ -374,6 +375,7 @@ export class PrivateMessenger {
|
|
|
374
375
|
this.watchRevisionByChannel = new Map()
|
|
375
376
|
this.presenceTimers = new Map()
|
|
376
377
|
this.presenceJobs = new Map()
|
|
378
|
+
this.seedQueueOperationTail = Promise.resolve()
|
|
377
379
|
this.stopRelayListWatcher = null
|
|
378
380
|
this.relayListWatcherPubkey = ''
|
|
379
381
|
this.relayListRefreshPromise = null
|
|
@@ -686,6 +688,13 @@ export class PrivateMessenger {
|
|
|
686
688
|
return this.runQueueOperation(operation)
|
|
687
689
|
}
|
|
688
690
|
|
|
691
|
+
runSeedQueueOperation (operation) {
|
|
692
|
+
if (this.closePromise) return Promise.reject(new Error('PRIVATE_MESSENGER_CLOSED'))
|
|
693
|
+
const run = this.seedQueueOperationTail.then(operation)
|
|
694
|
+
this.seedQueueOperationTail = run.catch(() => {})
|
|
695
|
+
return run
|
|
696
|
+
}
|
|
697
|
+
|
|
689
698
|
debug (action, detail = {}) {
|
|
690
699
|
try {
|
|
691
700
|
this.onDebug?.({ source: 'private-messenger', action, ...detail })
|
|
@@ -2140,8 +2149,8 @@ export class PrivateMessenger {
|
|
|
2140
2149
|
|
|
2141
2150
|
async enqueueSeed (channelPubkey, seed) {
|
|
2142
2151
|
if (!this.offlineRecoverySecondsFor(channelPubkey)) return
|
|
2143
|
-
const receivedAt = nowSeconds()
|
|
2144
2152
|
if (this.seedStorage) {
|
|
2153
|
+
const receivedAt = nowSeconds()
|
|
2145
2154
|
const rows = seed.recordType === NYM_CARRIER_SEED_RECORD_TYPE || seed.carriers?.length
|
|
2146
2155
|
? [{ recordType: NYM_CARRIER_SEED_RECORD_TYPE, carriers: compactSeedNymCarriers(seed.carriers) }]
|
|
2147
2156
|
: compactSeedRouterRows(seed)
|
|
@@ -2155,22 +2164,37 @@ export class PrivateMessenger {
|
|
|
2155
2164
|
}
|
|
2156
2165
|
return
|
|
2157
2166
|
}
|
|
2167
|
+
return this.runSeedQueueOperation(() => this.enqueueQueueSeed(channelPubkey, seed))
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
async enqueueQueueSeed (channelPubkey, seed) {
|
|
2171
|
+
this.assertOpen()
|
|
2172
|
+
if (!this.channels.has(channelPubkey) || !this.offlineRecoverySecondsFor(channelPubkey)) return
|
|
2173
|
+
const receivedAt = nowSeconds()
|
|
2158
2174
|
if (seed.recordType === NYM_CARRIER_SEED_RECORD_TYPE || seed.carriers?.length) {
|
|
2159
2175
|
const carriers = compactSeedNymCarriers(seed.carriers)
|
|
2160
2176
|
const recordTime = nymCarrierRecordTime({ carriers }) || seed.outer?.created_at || receivedAt
|
|
2161
2177
|
const key = nymCarrierSeedKey({ channelPubkey, carriers })
|
|
2162
2178
|
const seedKey = key ? `nym:${key}` : ''
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2179
|
+
for (let attempt = 0; attempt < SEED_QUEUE_UPSERT_ATTEMPTS; attempt++) {
|
|
2180
|
+
if (seedKey && await this.seedQueue.someBy('bySeedKey', seedKey)) break
|
|
2181
|
+
try {
|
|
2182
|
+
await this.seedQueue.enqueue({
|
|
2183
|
+
type: 'seed',
|
|
2184
|
+
recordType: NYM_CARRIER_SEED_RECORD_TYPE,
|
|
2185
|
+
channelPubkey,
|
|
2186
|
+
receivedAt,
|
|
2187
|
+
carriers,
|
|
2188
|
+
meta: { channelPubkey: seed.channelPubkey },
|
|
2189
|
+
[SEED_KEY]: seedKey || undefined,
|
|
2190
|
+
[SEED_TIME]: recordTime
|
|
2191
|
+
})
|
|
2192
|
+
break
|
|
2193
|
+
} catch (error) {
|
|
2194
|
+
if (error?.name !== 'ConstraintError') throw error
|
|
2195
|
+
if (attempt === SEED_QUEUE_UPSERT_ATTEMPTS - 1) throw error
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2174
2198
|
await this.pruneStoredSeeds(channelPubkey)
|
|
2175
2199
|
return
|
|
2176
2200
|
}
|
|
@@ -2180,21 +2204,29 @@ export class PrivateMessenger {
|
|
|
2180
2204
|
const rowTime = row.lastSeenAt || row.router?.created_at || receivedAt
|
|
2181
2205
|
const rowKey = routerSeedRowKey({ ...row, channelPubkey })
|
|
2182
2206
|
const seedKey = `router:${rowKey}`
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2207
|
+
for (let attempt = 0; attempt < SEED_QUEUE_UPSERT_ATTEMPTS; attempt++) {
|
|
2208
|
+
const [previous] = await this.seedQueue.removeBy('bySeedKey', seedKey)
|
|
2209
|
+
const firstSeenAt = Math.min(previous?.firstSeenAt ?? rowTime, row.firstSeenAt ?? rowTime)
|
|
2210
|
+
const lastSeenAt = Math.max(previous?.lastSeenAt ?? rowTime, row.lastSeenAt ?? rowTime)
|
|
2211
|
+
try {
|
|
2212
|
+
await this.seedQueue.enqueue({
|
|
2213
|
+
...row,
|
|
2214
|
+
type: 'seed',
|
|
2215
|
+
recordType: ROUTER_SEED_RECORD_TYPE,
|
|
2216
|
+
channelPubkey,
|
|
2217
|
+
receivedAt,
|
|
2218
|
+
firstSeenAt,
|
|
2219
|
+
lastSeenAt,
|
|
2220
|
+
meta: { channelPubkey: seed.channelPubkey },
|
|
2221
|
+
[SEED_KEY]: seedKey,
|
|
2222
|
+
[SEED_TIME]: lastSeenAt || rowTime
|
|
2223
|
+
})
|
|
2224
|
+
break
|
|
2225
|
+
} catch (error) {
|
|
2226
|
+
if (error?.name !== 'ConstraintError') throw error
|
|
2227
|
+
if (attempt === SEED_QUEUE_UPSERT_ATTEMPTS - 1) throw error
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2198
2230
|
}
|
|
2199
2231
|
await this.pruneStoredSeeds(channelPubkey)
|
|
2200
2232
|
}
|
|
@@ -3259,12 +3291,7 @@ export class PrivateMessenger {
|
|
|
3259
3291
|
job.started = true
|
|
3260
3292
|
const force = job.force
|
|
3261
3293
|
job.force = false
|
|
3262
|
-
const
|
|
3263
|
-
for (const record of await this.pendingDecryption.records({ force })) {
|
|
3264
|
-
if (!groups.has(record.channelPubkey)) groups.set(record.channelPubkey, [])
|
|
3265
|
-
groups.get(record.channelPubkey).push(record)
|
|
3266
|
-
}
|
|
3267
|
-
const results = await Promise.allSettled([...groups].map(async ([channelPubkey, records]) => {
|
|
3294
|
+
const replay = async (channelPubkey, records, callbacks = {}) => {
|
|
3268
3295
|
const channel = this.channels.get(channelPubkey)
|
|
3269
3296
|
if (!channel || !this.desiredChannels.has(channelPubkey)) return
|
|
3270
3297
|
const current = () => {
|
|
@@ -3302,15 +3329,78 @@ export class PrivateMessenger {
|
|
|
3302
3329
|
return this.receive(channelPubkey, () => this.enqueueRumor('nym', channelPubkey, { event, outer, meta, payload: parseEventContent(event) }), { event, outer }, { trackActivity: false })
|
|
3303
3330
|
},
|
|
3304
3331
|
onSeedEvent: seed => { current(); return this.receive(channelPubkey, () => this.enqueueSeed(channelPubkey, seed), seed, { trackActivity: false }) },
|
|
3305
|
-
onRecovered: record => {
|
|
3306
|
-
|
|
3332
|
+
onRecovered: async record => {
|
|
3333
|
+
current()
|
|
3334
|
+
await callbacks.onRecovered?.(record)
|
|
3335
|
+
return this.pendingDecryption.recovered(record)
|
|
3336
|
+
},
|
|
3337
|
+
onFailure: async (record, error) => {
|
|
3338
|
+
current()
|
|
3339
|
+
if (await callbacks.onFailure?.(record, error) === false) return
|
|
3340
|
+
return this.pendingDecryption.failed(record, error)
|
|
3341
|
+
}
|
|
3307
3342
|
})
|
|
3308
3343
|
} catch (error) {
|
|
3309
3344
|
if (this.channels.get(channelPubkey) === channel && this.desiredChannels.has(channelPubkey)) throw error
|
|
3310
3345
|
} finally { this.decryptionScheduler.finish(channelPubkey) }
|
|
3311
|
-
}
|
|
3346
|
+
}
|
|
3347
|
+
|
|
3348
|
+
const records = await this.pendingDecryption.records({ force })
|
|
3349
|
+
const probes = new Map()
|
|
3350
|
+
for (const record of records) {
|
|
3351
|
+
const channelPubkey = record.channelPubkey
|
|
3352
|
+
if (!this.channels.has(channelPubkey) || !this.desiredChannels.has(channelPubkey)) continue
|
|
3353
|
+
const missingGroup = pendingMissingContentKeyGroup(record)
|
|
3354
|
+
if (missingGroup && !probes.has(missingGroup)) probes.set(missingGroup, record)
|
|
3355
|
+
}
|
|
3356
|
+
|
|
3357
|
+
const unavailableGroups = new Set()
|
|
3358
|
+
const probed = new Set()
|
|
3359
|
+
let probeError
|
|
3360
|
+
for (const [groupKey, candidate] of probes) {
|
|
3361
|
+
signal.throwIfAborted()
|
|
3362
|
+
const current = await this.pendingDecryption.current(candidate, { force })
|
|
3363
|
+
if (!current || pendingMissingContentKeyGroup(current) !== groupKey) continue
|
|
3364
|
+
probed.add(current.id)
|
|
3365
|
+
let missing = false
|
|
3366
|
+
await replay(current.channelPubkey, [current], {
|
|
3367
|
+
onFailure: async (_record, error) => {
|
|
3368
|
+
const failedGroup = pendingMissingContentKeyGroup({
|
|
3369
|
+
code: error.code,
|
|
3370
|
+
receiverPubkey: error.receiverPubkey,
|
|
3371
|
+
ownContentPubkey: error.ownContentPubkey,
|
|
3372
|
+
hasOwnContentKey: error.hasOwnContentKey
|
|
3373
|
+
})
|
|
3374
|
+
if (failedGroup === groupKey) missing = true
|
|
3375
|
+
if (isDeferredDecryptionError(error)) {
|
|
3376
|
+
await this.pendingDecryption.failed(current, error)
|
|
3377
|
+
return false
|
|
3378
|
+
}
|
|
3379
|
+
probeError ||= error
|
|
3380
|
+
return false
|
|
3381
|
+
}
|
|
3382
|
+
})
|
|
3383
|
+
if (missing) unavailableGroups.add(groupKey)
|
|
3384
|
+
}
|
|
3385
|
+
|
|
3386
|
+
const remaining = []
|
|
3387
|
+
for (const record of records) {
|
|
3388
|
+
if (!this.channels.has(record.channelPubkey) || !this.desiredChannels.has(record.channelPubkey)) continue
|
|
3389
|
+
if (probed.has(record.id)) continue
|
|
3390
|
+
const groupKey = pendingContentKeyGroup(record)
|
|
3391
|
+
if (groupKey && unavailableGroups.has(groupKey)) continue
|
|
3392
|
+
remaining.push(record)
|
|
3393
|
+
}
|
|
3394
|
+
const groups = new Map()
|
|
3395
|
+
for (const record of remaining) {
|
|
3396
|
+
if (!groups.has(record.channelPubkey)) groups.set(record.channelPubkey, [])
|
|
3397
|
+
groups.get(record.channelPubkey).push(record)
|
|
3398
|
+
}
|
|
3399
|
+
const results = await Promise.allSettled([...groups].map(([channelPubkey, channelRecords]) =>
|
|
3400
|
+
replay(channelPubkey, channelRecords)))
|
|
3312
3401
|
const failed = results.find(result => result.status === 'rejected')
|
|
3313
3402
|
if (failed) throw failed.reason
|
|
3403
|
+
if (probeError) throw probeError
|
|
3314
3404
|
job.started = false
|
|
3315
3405
|
} while (job.again)
|
|
3316
3406
|
}
|
|
@@ -6,6 +6,8 @@ import { isDeferredDecryptionError } from '../../private-channel/helpers/decrypt
|
|
|
6
6
|
|
|
7
7
|
const encoder = new TextEncoder()
|
|
8
8
|
const decoder = new TextDecoder()
|
|
9
|
+
const HEX64 = /^[0-9a-f]{64}$/i
|
|
10
|
+
const CONTENT_KEY_FIELDS = ['receiverPubkey', 'ownContentPubkey', 'peerContentPubkey']
|
|
9
11
|
export const PENDING_DECRYPTION_MAX_BYTES = 16 * 1024 * 1024
|
|
10
12
|
|
|
11
13
|
function storedBytes (item) {
|
|
@@ -30,6 +32,34 @@ function packetSnapshot (packet) {
|
|
|
30
32
|
return { format: packet.format, channelPubkey: packet.channelPubkey, outer: event(packet.outer), ...(packet.router ? { router: event(packet.router, true) } : {}) }
|
|
31
33
|
}
|
|
32
34
|
|
|
35
|
+
function errorMetadata (error) {
|
|
36
|
+
const metadata = {}
|
|
37
|
+
for (const key of CONTENT_KEY_FIELDS) {
|
|
38
|
+
const value = error?.[key]
|
|
39
|
+
if (typeof value === 'string' && HEX64.test(value)) metadata[key] = value.toLowerCase()
|
|
40
|
+
}
|
|
41
|
+
if (typeof error?.hasOwnContentKey === 'boolean') metadata.hasOwnContentKey = error.hasOwnContentKey
|
|
42
|
+
return metadata
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function replaceErrorMetadata (item, error) {
|
|
46
|
+
const next = { ...item, code: error?.code }
|
|
47
|
+
for (const key of [...CONTENT_KEY_FIELDS, 'hasOwnContentKey']) delete next[key]
|
|
48
|
+
return Object.assign(next, errorMetadata(error))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function pendingContentKeyGroup (item) {
|
|
52
|
+
const receiver = item?.receiverPubkey
|
|
53
|
+
const own = item?.ownContentPubkey
|
|
54
|
+
if (typeof receiver !== 'string' || typeof own !== 'string' || !HEX64.test(receiver) || !HEX64.test(own)) return ''
|
|
55
|
+
return `${receiver.toLowerCase()}:${own.toLowerCase()}`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function pendingMissingContentKeyGroup (item) {
|
|
59
|
+
if (item?.code !== 'CONTENT_KEY_NOT_FOUND' || item?.hasOwnContentKey !== false) return ''
|
|
60
|
+
return pendingContentKeyGroup(item)
|
|
61
|
+
}
|
|
62
|
+
|
|
33
63
|
// Stable across live, history and synthesized recovery wrappers.
|
|
34
64
|
function packetId (record) {
|
|
35
65
|
const rows = record.format === 'routerRow_v1'
|
|
@@ -72,8 +102,19 @@ export async function createPendingDecryption ({ prefix, indexedDB, maxBytes = P
|
|
|
72
102
|
record = packetSnapshot(record)
|
|
73
103
|
return serial(async () => {
|
|
74
104
|
const id = packetId(record)
|
|
75
|
-
|
|
76
|
-
|
|
105
|
+
const previous = await queue.getBy('byId', id)
|
|
106
|
+
if (previous) {
|
|
107
|
+
const updated = replaceErrorMetadata(previous, error)
|
|
108
|
+
if (JSON.stringify(updated) !== JSON.stringify(previous)) await queue.putBy('byId', updated, { existingOnly: true })
|
|
109
|
+
return true
|
|
110
|
+
}
|
|
111
|
+
const item = {
|
|
112
|
+
id,
|
|
113
|
+
channelPubkey: record.channelPubkey,
|
|
114
|
+
packet: structuredClone(record),
|
|
115
|
+
macAttempts: error.code === 'INVALID_MAC' ? 1 : 0,
|
|
116
|
+
...replaceErrorMetadata({}, error)
|
|
117
|
+
}
|
|
77
118
|
const requiredBytes = storedBytes(item)
|
|
78
119
|
if (requiredBytes > maxBytes) {
|
|
79
120
|
notify('discarded', { reason: 'oversized', channelPubkey: item.channelPubkey, id }); return true
|
|
@@ -115,7 +156,10 @@ export async function createPendingDecryption ({ prefix, indexedDB, maxBytes = P
|
|
|
115
156
|
if (!isDeferredDecryptionError(error)) throw error
|
|
116
157
|
const current = await queue.getBy('byId', item.id)
|
|
117
158
|
if (!current) return
|
|
118
|
-
const updated = {
|
|
159
|
+
const updated = {
|
|
160
|
+
...replaceErrorMetadata(current, error),
|
|
161
|
+
macAttempts: current.macAttempts + (error.code === 'INVALID_MAC' ? 1 : 0)
|
|
162
|
+
}
|
|
119
163
|
await queue.putBy('byId', updated, { existingOnly: true })
|
|
120
164
|
if (updated.macAttempts >= 3 && item.macAttempts < 3) notify('suspended', { id: item.id, code: error.code, channelPubkey: item.channelPubkey })
|
|
121
165
|
})
|
|
@@ -9,6 +9,13 @@ the account messenger while available. Call after committed content-key changes;
|
|
|
9
9
|
ordinary availability recovery already requests a pass. Ciphertext stays in the
|
|
10
10
|
separate bounded FIFO until admitted to the normal inbox, evicted or explicitly
|
|
11
11
|
cleared. This does not retry application storage/permission failures as crypto.
|
|
12
|
+
Within one pass, records whose last failure proved the same
|
|
13
|
+
`(receiverPubkey, ownContentPubkey)` is missing share one FIFO probe across all
|
|
14
|
+
channels. An unchanged `CONTENT_KEY_NOT_FOUND` skips only that group's remaining
|
|
15
|
+
records for the pass; none are removed or suspended. If the probe advances past
|
|
16
|
+
the key lookup, including with `INVALID_MAC`, or fails with another error, all
|
|
17
|
+
other records in the group are still attempted. Missing-key suppression never
|
|
18
|
+
survives into another pass.
|
|
12
19
|
|
|
13
20
|
Construction accepts `pendingDecryptionReady` (default `true`). Pass `false` to
|
|
14
21
|
hold pending queue reads/replay until `setPendingDecryptionReady(true)` releases
|