libp2r2p 0.11.36 → 0.12.1
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 +96 -1
- package/THIRD_PARTY_NOTICES.md +46 -0
- package/event/helpers/core.js +120 -0
- package/event/helpers/serialize.js +56 -0
- package/event/index.js +81 -74
- package/event/js/index.js +13 -0
- package/helpers/secp256k1-wasm/backend.js +98 -0
- package/helpers/secp256k1-wasm/binding.js +229 -0
- package/helpers/secp256k1-wasm/generated.js +25 -0
- package/key/helpers/core.js +149 -0
- package/key/index.js +57 -123
- package/key/js/index.js +13 -0
- package/package.json +7 -1
- package/private-channel/helpers/prepared.js +12 -0
- package/private-channel/index.js +271 -32
- package/private-message/index.js +44 -23
- package/private-messenger/file/index.js +4 -2
- package/private-messenger/helpers/fallback-race.js +13 -2
- package/private-messenger/helpers/send-routing.js +2 -1
- package/private-messenger/index.js +30 -15
- package/private-messenger/session/README.md +17 -3
- package/private-messenger/session/helpers/prepared-state.js +17 -0
- package/private-messenger/session/helpers/publication-journal.js +38 -10
- package/private-messenger/session/index.js +21 -9
- package/relay/services/relay-pool.js +127 -58
|
@@ -56,6 +56,7 @@ import { recoveryRetryDelay, isPermanentRecoveryError } from './helpers/recovery
|
|
|
56
56
|
import { assertMessagePublished, publicationFailureDiagnostic, sanitizePublicationError } from './helpers/publication.js'
|
|
57
57
|
import { normalizeRelayUrl } from '../url/index.js'
|
|
58
58
|
import { deliveryInfo } from '../private-channel/helpers/rumor.js'
|
|
59
|
+
import { normalizePreparedPublications } from '../private-channel/helpers/prepared.js'
|
|
59
60
|
import { migrateMessageQueue } from './services/migrate-message-queue.js'
|
|
60
61
|
import { createPendingDecryption, pendingContentKeyGroup, pendingMissingContentKeyGroup } from './services/pending-decryption.js'
|
|
61
62
|
import { createDecryptionScheduler } from './services/decryption-scheduler.js'
|
|
@@ -2415,18 +2416,22 @@ export class PrivateMessenger {
|
|
|
2415
2416
|
} finally { releaseActivity() }
|
|
2416
2417
|
}
|
|
2417
2418
|
|
|
2418
|
-
async yell ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, message, code, payload, error, content, deletionPubkey }) {
|
|
2419
|
+
async yell ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, message, code, payload, error, content, deletionPubkey, createdAt, onPreparedSeed, preparedPublication, preparedPublications }) {
|
|
2419
2420
|
const channel = this.requireWritableChannel(channelPubkey)
|
|
2420
2421
|
const releaseActivity = this.decryptionScheduler.activity(channelPubkey)
|
|
2421
2422
|
try {
|
|
2422
|
-
const
|
|
2423
|
-
|
|
2423
|
+
const prepared = normalizePreparedPublications({ preparedPublication, preparedPublications })
|
|
2424
|
+
const receivers = uniq(receiverPubkeys?.length
|
|
2425
|
+
? receiverPubkeys
|
|
2426
|
+
: prepared.flatMap(publication => publication.receiverPubkeys || []))
|
|
2427
|
+
const routing = await this.resolveSendRouting({ channel, receiverPubkeys: receivers, relays, relayToReceivers })
|
|
2428
|
+
this.debugSend('yell', channelPubkey, { code, receiverPubkeys: receivers })
|
|
2424
2429
|
return await this._privateMessage.yell({
|
|
2425
2430
|
senderSigner: this.userSigner,
|
|
2426
2431
|
imkcSigner: this.contentKeySigner,
|
|
2427
2432
|
privateChannelSigner: channel.signer,
|
|
2428
2433
|
privateChannelReaderPubkey: channel.readerPubkey,
|
|
2429
|
-
receiverPubkeys,
|
|
2434
|
+
receiverPubkeys: receivers,
|
|
2430
2435
|
...routing,
|
|
2431
2436
|
expirationSeconds: this.eventExpirationSecondsFor(channel),
|
|
2432
2437
|
temporaryStorageArea: this.temporaryStorageArea,
|
|
@@ -2437,24 +2442,30 @@ export class PrivateMessenger {
|
|
|
2437
2442
|
payload,
|
|
2438
2443
|
error,
|
|
2439
2444
|
content,
|
|
2440
|
-
|
|
2445
|
+
createdAt,
|
|
2446
|
+
onPreparedSeed: onPreparedSeed || this.outgoingSeedHandler(channelPubkey),
|
|
2447
|
+
preparedPublications: prepared.length ? prepared : undefined,
|
|
2441
2448
|
_getIykcProofs: this.contentKeyLookup()
|
|
2442
2449
|
})
|
|
2443
2450
|
} finally { releaseActivity() }
|
|
2444
2451
|
}
|
|
2445
2452
|
|
|
2446
|
-
async broadcastRumor ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, rumor, deletionPubkey, signal, attemptStartedAt, onPreparedSeed, preparedPublication, onDebug }) {
|
|
2453
|
+
async broadcastRumor ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, rumor, deletionPubkey, signal, attemptStartedAt, onPreparedSeed, preparedPublication, preparedPublications, onDebug }) {
|
|
2447
2454
|
const channel = this.requireWritableChannel(channelPubkey)
|
|
2448
2455
|
const releaseActivity = this.decryptionScheduler.activity(channelPubkey)
|
|
2449
2456
|
try {
|
|
2450
|
-
const
|
|
2451
|
-
|
|
2457
|
+
const prepared = normalizePreparedPublications({ preparedPublication, preparedPublications })
|
|
2458
|
+
const receivers = uniq(receiverPubkeys?.length
|
|
2459
|
+
? receiverPubkeys
|
|
2460
|
+
: prepared.flatMap(publication => publication.receiverPubkeys || []))
|
|
2461
|
+
const routing = await this.resolveSendRouting({ channel, receiverPubkeys: receivers, relays, relayToReceivers, signal, attemptStartedAt, onDebug })
|
|
2462
|
+
this.debugSend('broadcastRumor', channelPubkey, { receiverPubkeys: receivers })
|
|
2452
2463
|
return await this._privateMessage.broadcastRumor({
|
|
2453
2464
|
senderSigner: this.userSigner,
|
|
2454
2465
|
imkcSigner: this.contentKeySigner,
|
|
2455
2466
|
privateChannelSigner: channel.signer,
|
|
2456
2467
|
privateChannelReaderPubkey: channel.readerPubkey,
|
|
2457
|
-
receiverPubkeys,
|
|
2468
|
+
receiverPubkeys: receivers,
|
|
2458
2469
|
...routing,
|
|
2459
2470
|
expirationSeconds: this.eventExpirationSecondsFor(channel),
|
|
2460
2471
|
temporaryStorageArea: this.temporaryStorageArea,
|
|
@@ -2462,24 +2473,28 @@ export class PrivateMessenger {
|
|
|
2462
2473
|
autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
|
|
2463
2474
|
rumor,
|
|
2464
2475
|
onPreparedSeed: onPreparedSeed || this.outgoingSeedHandler(channelPubkey),
|
|
2465
|
-
|
|
2476
|
+
preparedPublications: prepared.length ? prepared : undefined,
|
|
2466
2477
|
_getIykcProofs: this.contentKeyLookup()
|
|
2467
2478
|
})
|
|
2468
2479
|
} finally { releaseActivity() }
|
|
2469
2480
|
}
|
|
2470
2481
|
|
|
2471
|
-
async broadcastEvent ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, event, deletionPubkey, signal, attemptStartedAt, onPreparedSeed, preparedPublication, onDebug }) {
|
|
2482
|
+
async broadcastEvent ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, event, deletionPubkey, signal, attemptStartedAt, onPreparedSeed, preparedPublication, preparedPublications, onDebug }) {
|
|
2472
2483
|
const channel = this.requireWritableChannel(channelPubkey)
|
|
2473
2484
|
const releaseActivity = this.decryptionScheduler.activity(channelPubkey)
|
|
2474
2485
|
try {
|
|
2475
|
-
const
|
|
2476
|
-
|
|
2486
|
+
const prepared = normalizePreparedPublications({ preparedPublication, preparedPublications })
|
|
2487
|
+
const receivers = uniq(receiverPubkeys?.length
|
|
2488
|
+
? receiverPubkeys
|
|
2489
|
+
: prepared.flatMap(publication => publication.receiverPubkeys || []))
|
|
2490
|
+
const routing = await this.resolveSendRouting({ channel, receiverPubkeys: receivers, relays, relayToReceivers, signal, attemptStartedAt, onDebug })
|
|
2491
|
+
this.debugSend('broadcastEvent', channelPubkey, { receiverPubkeys: receivers })
|
|
2477
2492
|
return await this._privateMessage.broadcastEvent({
|
|
2478
2493
|
senderSigner: this.userSigner,
|
|
2479
2494
|
imkcSigner: this.contentKeySigner,
|
|
2480
2495
|
privateChannelSigner: channel.signer,
|
|
2481
2496
|
privateChannelReaderPubkey: channel.readerPubkey,
|
|
2482
|
-
receiverPubkeys,
|
|
2497
|
+
receiverPubkeys: receivers,
|
|
2483
2498
|
...routing,
|
|
2484
2499
|
expirationSeconds: this.eventExpirationSecondsFor(channel),
|
|
2485
2500
|
temporaryStorageArea: this.temporaryStorageArea,
|
|
@@ -2487,7 +2502,7 @@ export class PrivateMessenger {
|
|
|
2487
2502
|
autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
|
|
2488
2503
|
event,
|
|
2489
2504
|
onPreparedSeed: onPreparedSeed || this.outgoingSeedHandler(channelPubkey),
|
|
2490
|
-
|
|
2505
|
+
preparedPublications: prepared.length ? prepared : undefined,
|
|
2491
2506
|
_getIykcProofs: this.contentKeyLookup()
|
|
2492
2507
|
})
|
|
2493
2508
|
} finally { releaseActivity() }
|
|
@@ -289,9 +289,17 @@ recipient/channel and inner-event identities, event/chunk position, retention
|
|
|
289
289
|
and accepted/archived flags. Old entries without it retain `localSaved`, index
|
|
290
290
|
and file/chunk progress and enter the same preparation path; there is no database
|
|
291
291
|
migration or alternate publisher. Replays validate signatures and identity and
|
|
292
|
-
reuse the exact envelopes without repeating cryptography or extending expiry
|
|
293
|
-
|
|
294
|
-
|
|
292
|
+
reuse the exact envelopes without repeating cryptography or extending expiry,
|
|
293
|
+
except for one 3560 age-auth refresh. That refresh reuses router/ciphertext and
|
|
294
|
+
seeds but re-signs the whole publication with a new shared `created_at` and
|
|
295
|
+
recomputed `expiration`, writes the replacement generation before retrying, and
|
|
296
|
+
sets `outerRefreshAttempted` so reload cannot repeat it. A relay may report a
|
|
297
|
+
stale wrapper as `invalid:`; that prefix permits this refresh but does not
|
|
298
|
+
attempt NIP-42 authentication.
|
|
299
|
+
|
|
300
|
+
The existing `onPreparedSeed` callback now also supplies `publication`, plus an
|
|
301
|
+
optional `{ replace: true }` second argument when a refreshed publication must
|
|
302
|
+
replace the same inner-event/receiver group in place. The
|
|
295
303
|
session commits that minimal journal before transmitting; direct messengers
|
|
296
304
|
retain their local seed-store durability. Only after that commit can relay
|
|
297
305
|
publication proceed. One independent serial worker converts recovery seeds into
|
|
@@ -321,6 +329,12 @@ relay confirmation separately from `localSaved`. Old entries normalize from
|
|
|
321
329
|
saved delivery status, never from local progress; their event/file indices and
|
|
322
330
|
prepared envelopes remain unchanged. No database migration is needed.
|
|
323
331
|
|
|
332
|
+
Prepared envelopes are stored as the `prepared.publications` array. Older
|
|
333
|
+
snapshots containing `prepared.publication` are converted to a one-item array
|
|
334
|
+
and rewritten through the same encrypted outbox queue before publication or
|
|
335
|
+
archive work resumes. The singular key is never written by current versions and
|
|
336
|
+
is not accepted as a public replay argument.
|
|
337
|
+
|
|
324
338
|
`onOutbox` adds `localPersistence: { state, retryable }`, where state is pending,
|
|
325
339
|
saved or failed. It is a presentation summary with no native error objects.
|
|
326
340
|
A saved remote entry may still have pending/failed local work. Keep it visible
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { ValidationError } from '../../../error/index.js'
|
|
2
|
+
|
|
3
|
+
export function normalizePreparedState (prepared) {
|
|
4
|
+
if (!prepared) return { prepared, migrated: false }
|
|
5
|
+
const legacy = prepared.publication
|
|
6
|
+
const publications = prepared.publications !== undefined
|
|
7
|
+
? prepared.publications
|
|
8
|
+
: legacy !== undefined
|
|
9
|
+
? [legacy]
|
|
10
|
+
: null
|
|
11
|
+
if (!Array.isArray(publications) || publications.length === 0) {
|
|
12
|
+
throw new ValidationError('INVALID_PREPARED_PUBLICATIONS')
|
|
13
|
+
}
|
|
14
|
+
prepared.publications = publications
|
|
15
|
+
delete prepared.publication
|
|
16
|
+
return { prepared, migrated: legacy !== undefined }
|
|
17
|
+
}
|
|
@@ -24,7 +24,7 @@ export function createPublicationJournal ({ entries, store, archive, changed, on
|
|
|
24
24
|
function rows (entry) {
|
|
25
25
|
const packet = entry.prepared
|
|
26
26
|
if (!packet?.recover) return []
|
|
27
|
-
return packet.publication.seeds.flatMap(seed => compactSeedRouterRows(seed).map(row => {
|
|
27
|
+
return packet.publications.flatMap(publication => publication.seeds.flatMap(seed => compactSeedRouterRows(seed).map(row => {
|
|
28
28
|
const descriptor = packet.target.file
|
|
29
29
|
const value = {
|
|
30
30
|
...row, channelPubkey: seed.channelPubkey, receivedAt: packet.receivedAt,
|
|
@@ -32,15 +32,15 @@ export function createPublicationJournal ({ entries, store, archive, changed, on
|
|
|
32
32
|
...(descriptor
|
|
33
33
|
? {
|
|
34
34
|
root: descriptor.root, controlChannelPubkey: descriptor.controlChannelPubkey,
|
|
35
|
-
fileChannelPubkey:
|
|
35
|
+
fileChannelPubkey: publication.channelPubkey, peerPubkey: descriptor.peerPubkey,
|
|
36
36
|
chunkIndex: packet.target.chunkIndex,
|
|
37
|
-
key: `${descriptor.controlChannelPubkey}:${
|
|
37
|
+
key: `${descriptor.controlChannelPubkey}:${publication.channelPubkey}:${row.receiverPubkey}:${packet.target.chunkIndex}`
|
|
38
38
|
}
|
|
39
39
|
: {})
|
|
40
40
|
}
|
|
41
41
|
value.recordId = encodeRecord('seed', value).tags[0][1]
|
|
42
42
|
return value
|
|
43
|
-
}))
|
|
43
|
+
})))
|
|
44
44
|
}
|
|
45
45
|
function retryLater () {
|
|
46
46
|
if (stopped || !enabled || timer || !failed.size) return
|
|
@@ -60,7 +60,7 @@ export function createPublicationJournal ({ entries, store, archive, changed, on
|
|
|
60
60
|
try {
|
|
61
61
|
if (!durable.has(packet)) { await write(entry); durable.add(packet) }
|
|
62
62
|
if (!packet.archived) {
|
|
63
|
-
debug(entry, 'recovery-archive-start', queued.get(packet), { publicationId: packet.
|
|
63
|
+
debug(entry, 'recovery-archive-start', queued.get(packet), { publicationId: packet.publications[0].events[0].id })
|
|
64
64
|
if (packet.recover) await archive(entry, rows(entry))
|
|
65
65
|
packet.archived = true
|
|
66
66
|
debug(entry, 'recovery-archived', started)
|
|
@@ -87,15 +87,43 @@ export function createPublicationJournal ({ entries, store, archive, changed, on
|
|
|
87
87
|
write,
|
|
88
88
|
async flush (entry) { while (writes.has(entry.id)) await writes.get(entry.id) },
|
|
89
89
|
adopt (entry) { if (entry.prepared) { durable.add(entry.prepared); queued.set(entry.prepared, Date.now()) } },
|
|
90
|
-
async stage (entry, seed, target, retentionSeconds) {
|
|
90
|
+
async stage (entry, seed, target, retentionSeconds, { replace = false } = {}) {
|
|
91
91
|
const started = Date.now()
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
92
|
+
const publication = structuredClone(seed.publication)
|
|
93
|
+
const targetKey = JSON.stringify(target)
|
|
94
|
+
entry.prepared ||= { publications: [], target, receivedAt: Math.floor(Date.now() / 1000), retentionSeconds, recover: retentionSeconds > 0, accepted: false, archived: retentionSeconds <= 0 }
|
|
95
|
+
if (replace && targetKey !== JSON.stringify(entry.prepared.target)) throw new Error('PUBLICATION_ARCHIVE_TARGET_MISMATCH')
|
|
96
|
+
if (entry.prepared.publications.some(existing => existing.innerEventId !== publication.innerEventId)) {
|
|
97
|
+
throw new Error('PUBLICATION_ARCHIVE_PENDING')
|
|
98
|
+
}
|
|
99
|
+
const receiverKey = (publication.receiverPubkeys || []).slice().sort().join(',')
|
|
100
|
+
const eventKey = publication.events.map(event => event.id).join(',')
|
|
101
|
+
const matchingIndexes = entry.prepared.publications
|
|
102
|
+
.map((existing, index) => ({ existing, index }))
|
|
103
|
+
.filter(({ existing }) => existing.innerEventId === publication.innerEventId &&
|
|
104
|
+
(existing.receiverPubkeys || []).slice().sort().join(',') === receiverKey)
|
|
105
|
+
const existingIndex = matchingIndexes[0]?.index ?? -1
|
|
106
|
+
const existing = matchingIndexes[0]?.existing
|
|
107
|
+
const duplicate = Boolean(existing) &&
|
|
108
|
+
existing.events.map(event => event.id).join(',') === eventKey
|
|
109
|
+
const matches = existingIndex >= 0
|
|
110
|
+
if (replace) {
|
|
111
|
+
if (!matches) throw new Error('PUBLICATION_ARCHIVE_REPLACE_MISSING')
|
|
112
|
+
entry.prepared.publications = entry.prepared.publications
|
|
113
|
+
.filter((_publication, index) => index !== existingIndex)
|
|
114
|
+
.filter(candidate =>
|
|
115
|
+
candidate.innerEventId !== publication.innerEventId ||
|
|
116
|
+
(candidate.receiverPubkeys || []).slice().sort().join(',') !== receiverKey
|
|
117
|
+
)
|
|
118
|
+
entry.prepared.publications.splice(existingIndex, 0, publication)
|
|
119
|
+
} else {
|
|
120
|
+
if (!duplicate) entry.prepared.publications.push(publication)
|
|
121
|
+
if (duplicate && durable.has(entry.prepared)) return
|
|
122
|
+
}
|
|
95
123
|
queued.set(entry.prepared, Date.now())
|
|
96
124
|
await write(entry)
|
|
97
125
|
durable.add(entry.prepared)
|
|
98
|
-
debug(entry, 'publication-checkpoint', started, { publicationId: seed.publication.events[0].id })
|
|
126
|
+
debug(entry, replace ? 'publication-refresh' : 'publication-checkpoint', started, { publicationId: seed.publication.events[0].id })
|
|
99
127
|
for (const row of rows(entry)) debug(entry, 'recovery-pending', undefined, { seedRecordId: row.recordId, publicationId: seed.publication.events[0].id })
|
|
100
128
|
wake()
|
|
101
129
|
},
|
|
@@ -16,6 +16,7 @@ import { assertMessagePublished } from '../helpers/publication.js'
|
|
|
16
16
|
import { normalizeFallbackRelays, normalizeFallbackDelay } from '../helpers/send-routing.js'
|
|
17
17
|
import { createPublicationJournal, withPendingSeeds } from './helpers/publication-journal.js'
|
|
18
18
|
import { createPauseRecovery, isRecoverableStorageFailure } from '../helpers/pause-recovery.js'
|
|
19
|
+
import { normalizePreparedState } from './helpers/prepared-state.js'
|
|
19
20
|
|
|
20
21
|
export function wireEvent (value, owner) {
|
|
21
22
|
const event = { kind: value.kind, created_at: value.created_at, tags: structuredClone(value.tags), content: value.content, pubkey: value.pubkey || owner }
|
|
@@ -164,6 +165,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
164
165
|
const idleWaiters = new Set()
|
|
165
166
|
const settled = () => { if (!sending && !localSaving && !enqueuing && !draining && !cleanups.size) { for (const resolve of idleWaiters) resolve(); idleWaiters.clear() } }
|
|
166
167
|
const localComplete = entry => entry.localSaved.every(Boolean)
|
|
168
|
+
const preparedPublicationId = entry => entry.prepared?.publications?.[0]?.events?.[0]?.id
|
|
167
169
|
const completed = entry => !unadmitted.has(entry) && localComplete(entry) && (entry.peer === owner || entry.deliveryConfirmed)
|
|
168
170
|
const emit = () => onOutbox([...entries.values()].filter(entry => !unadmitted.has(entry) && !entry.deletion && !entry.cancelled).map(entry => ({
|
|
169
171
|
...entry,
|
|
@@ -188,7 +190,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
188
190
|
const onDiagnostic = details => {
|
|
189
191
|
diagnosticReported = true
|
|
190
192
|
debug(entry, 'recovery-archive-result', archiveStartedAt, {
|
|
191
|
-
publicationId: entry
|
|
193
|
+
publicationId: preparedPublicationId(entry),
|
|
192
194
|
...details
|
|
193
195
|
})
|
|
194
196
|
}
|
|
@@ -199,15 +201,19 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
199
201
|
for (const row of rows) archived.push(await recoveryStorage.seeds.put(row))
|
|
200
202
|
}
|
|
201
203
|
} else if (entry.prepared.target.file) {
|
|
202
|
-
for (const
|
|
204
|
+
for (const publication of entry.prepared.publications) {
|
|
205
|
+
for (const seed of publication.seeds) await fileTransfers.archiveSeed(entry.prepared.target.file, seed)
|
|
206
|
+
}
|
|
203
207
|
} else {
|
|
204
|
-
for (const
|
|
208
|
+
for (const publication of entry.prepared.publications) {
|
|
209
|
+
for (const seed of publication.seeds) await messenger.enqueueSeed(seed.channelPubkey, seed)
|
|
210
|
+
}
|
|
205
211
|
}
|
|
206
212
|
if (!diagnosticReported) {
|
|
207
213
|
const results = Array.isArray(archived) ? archived : []
|
|
208
214
|
const storedCount = results.filter(Boolean).length
|
|
209
215
|
debug(entry, 'recovery-archive-result', archiveStartedAt, {
|
|
210
|
-
publicationId: entry
|
|
216
|
+
publicationId: preparedPublicationId(entry),
|
|
211
217
|
rowCount: rows.length,
|
|
212
218
|
uniqueCount: new Set(rows.map(row => row.recordId).filter(Boolean)).size,
|
|
213
219
|
storedCount,
|
|
@@ -226,6 +232,9 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
226
232
|
const ready = () => (initialized ??= (async () => {
|
|
227
233
|
storage = await openOutbox({ owner, signer, onDebug })
|
|
228
234
|
for (const entry of await storage.list()) {
|
|
235
|
+
if (normalizePreparedState(entry.prepared).migrated) {
|
|
236
|
+
await storage.put(structuredClone(entry), { existing: true })
|
|
237
|
+
}
|
|
229
238
|
// Old encrypted entries retain progress and enter this same pipeline.
|
|
230
239
|
const legacyLocalFailure = entry.deliveryConfirmed === undefined && entry.failed && !entry.localSaved.every(Boolean)
|
|
231
240
|
if (legacyLocalFailure) {
|
|
@@ -471,10 +480,10 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
471
480
|
const options = {
|
|
472
481
|
channelPubkey, receiverPubkeys: [peer], signal, attemptStartedAt: attemptStarts.get(entry.id),
|
|
473
482
|
onDebug: onDebug ? value => debug(entry, value.stage, undefined, value) : undefined,
|
|
474
|
-
|
|
475
|
-
onPreparedSeed: async seed => {
|
|
483
|
+
preparedPublications: entry.prepared?.publications,
|
|
484
|
+
onPreparedSeed: async (seed, { replace = false } = {}) => {
|
|
476
485
|
debug(entry, 'envelope-prepared', preparationStartedAt, { publicationId: seed.publication.events[0].id })
|
|
477
|
-
await journal.stage(entry, seed, { index: entry.index }, retention)
|
|
486
|
+
await journal.stage(entry, seed, { index: entry.index }, retention, { replace })
|
|
478
487
|
signal.throwIfAborted()
|
|
479
488
|
}
|
|
480
489
|
}
|
|
@@ -592,8 +601,11 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
592
601
|
const recover = mode === 'watchtower' ? messenger.offlineRecoverySecondsFor?.(descriptor.controlChannelPubkey) ?? 0 : 0
|
|
593
602
|
const target = { file: descriptor, fileIndex: entry.fileIndex, chunkIndex: entry.chunkIndex }
|
|
594
603
|
await fileTransfers.publishChunk(descriptor, wireEvent(chunk, owner), {
|
|
595
|
-
signal: controller.signal, attemptStartedAt: chunkStartedAt, onDebug: onDebug ? value => debug(entry, value.stage, undefined, value) : undefined,
|
|
596
|
-
onPreparedSeed: async seed
|
|
604
|
+
signal: controller.signal, attemptStartedAt: chunkStartedAt, onDebug: onDebug ? value => debug(entry, value.stage, undefined, value) : undefined, preparedPublications: entry.prepared?.publications,
|
|
605
|
+
onPreparedSeed: async (seed, { replace = false } = {}) => {
|
|
606
|
+
await journal.stage(entry, seed, target, recover, { replace })
|
|
607
|
+
controller.signal.throwIfAborted()
|
|
608
|
+
}
|
|
597
609
|
})
|
|
598
610
|
entry.chunkIndex++
|
|
599
611
|
const completed = entry.files.slice(0, entry.fileIndex).reduce((sum, value) => sum + value.size, 0) + Math.min(file.size, entry.chunkIndex * IRFS_CHUNK_BYTES)
|
|
@@ -8,6 +8,7 @@ import { maybeUnref } from '../helpers/timer.js'
|
|
|
8
8
|
import { categorizeRelayError, relayTimeoutError } from '../helpers/error.js'
|
|
9
9
|
import { ReadRetry, isRetryableReadFailure, needsConnectivityCheck, waitUntil } from '../helpers/read-retry.js'
|
|
10
10
|
import { normalizeRelayUrl } from '../../url/index.js'
|
|
11
|
+
import { isValidEvent } from '../../event/index.js'
|
|
11
12
|
import { RelayConnection } from './relay-connection.js'
|
|
12
13
|
|
|
13
14
|
const CONNECTION_TIMEOUT_MS = 3000
|
|
@@ -69,7 +70,19 @@ function notifyRelayResult (onRelayResult, result) {
|
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
function requiresNip42Auth (reason) {
|
|
72
|
-
|
|
73
|
+
const message = reason?.message || reason?.code || ''
|
|
74
|
+
return message.startsWith('auth-required:') || message.startsWith('restricted:')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function requiresOuterRefresh (reason) {
|
|
78
|
+
return requiresNip42Auth(reason) || (reason?.message || reason?.code || '').startsWith('invalid:')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isDefinitiveOuterRejection (report) {
|
|
82
|
+
return report?.success === false &&
|
|
83
|
+
Array.isArray(report.errors) &&
|
|
84
|
+
report.errors.length > 0 &&
|
|
85
|
+
report.errors.every(({ reason }) => requiresOuterRefresh(reason))
|
|
73
86
|
}
|
|
74
87
|
|
|
75
88
|
function countResponseError () {
|
|
@@ -1199,15 +1212,19 @@ export class RelayPool {
|
|
|
1199
1212
|
// { relay, success, outcome, reason? } result per relay as it settles; outcome
|
|
1200
1213
|
// is published, duplicate, muted, failed, or timed-out. getAuthEvent is used
|
|
1201
1214
|
// only after auth-required or restricted publish failures, then retries once.
|
|
1215
|
+
// getRetryEvent may return one fresh signed event after every relay rejected
|
|
1216
|
+
// the original with auth-required/restricted/invalid; it shares the operation deadline.
|
|
1202
1217
|
// Await `promise` for the complete report, including every relay outcome.
|
|
1203
1218
|
async sendEvent (event, relays, {
|
|
1204
1219
|
timeout = SEND_TIMEOUT_MS,
|
|
1205
1220
|
timeoutUntilFirstFulfillment = null,
|
|
1206
1221
|
getAuthEvent,
|
|
1222
|
+
getRetryEvent,
|
|
1207
1223
|
onRelayResult,
|
|
1208
1224
|
signal
|
|
1209
1225
|
} = {}) {
|
|
1210
1226
|
if (signal != null && (typeof signal.aborted !== 'boolean' || typeof signal.addEventListener !== 'function' || typeof signal.removeEventListener !== 'function')) throw new ValidationError('INVALID_RELAY_SIGNAL')
|
|
1227
|
+
if (getRetryEvent != null && typeof getRetryEvent !== 'function') throw new ValidationError('INVALID_RETRY_EVENT_CALLBACK')
|
|
1211
1228
|
const urls = normalizedRelayUrls(relays)
|
|
1212
1229
|
if (!urls.length) {
|
|
1213
1230
|
const promise = Promise.resolve(publishSummary([], urls, {
|
|
@@ -1216,69 +1233,121 @@ export class RelayPool {
|
|
|
1216
1233
|
return { total: 0, success: false, promise }
|
|
1217
1234
|
}
|
|
1218
1235
|
|
|
1219
|
-
const
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1236
|
+
const deadline = timeout === null ? null : Date.now() + timeout
|
|
1237
|
+
const remainingTimeout = () => deadline === null ? null : Math.max(0, deadline - Date.now())
|
|
1238
|
+
const sendAttempt = async (eventToSend, { notify = true } = {}) => {
|
|
1239
|
+
const attemptTimeout = remainingTimeout()
|
|
1240
|
+
const eventForAttempt = eventToSend.meta ? { ...eventToSend } : eventToSend
|
|
1241
|
+
if (eventForAttempt.meta) delete eventForAttempt.meta
|
|
1242
|
+
const sendControllers = urls.map(() => new AbortController())
|
|
1243
|
+
const sendDeferreds = urls.map(() => Promise.withResolvers())
|
|
1244
|
+
const sendPromises = sendDeferreds.map(({ promise }) => promise)
|
|
1245
|
+
const bufferedResults = []
|
|
1246
|
+
const delivery = { notify }
|
|
1247
|
+
|
|
1248
|
+
// Starts before connection work so every relay shares one real deadline.
|
|
1249
|
+
const settlement = createPublishSettlements(sendPromises, attemptTimeout, {
|
|
1250
|
+
onSettled: (settlement, index) => {
|
|
1251
|
+
sendControllers[index].abort(settlement.reason)
|
|
1252
|
+
if (settlement.reason?.category === 'timeout' && !settlement.reason.cause) {
|
|
1253
|
+
const relay = this.#relays.get(normalizeRelayUrl(urls[index]))
|
|
1254
|
+
if (relay?.lastTransportError) settlement.reason.cause = relay.lastTransportError
|
|
1255
|
+
}
|
|
1256
|
+
const result = relayResultForSettlement(urls[index], settlement)
|
|
1257
|
+
if (delivery.notify) notifyRelayResult(onRelayResult, result)
|
|
1258
|
+
else bufferedResults.push(result)
|
|
1233
1259
|
}
|
|
1234
|
-
|
|
1235
|
-
}
|
|
1236
|
-
})
|
|
1237
|
-
|
|
1238
|
-
// Resolves after every relay settles (or reaches the operation timeout) as
|
|
1239
|
-
// { success, total, fulfilled, succeededRelays, errors },
|
|
1240
|
-
// where errors contains { relay, reason } entries for failed relays.
|
|
1241
|
-
const cancel = () => settlement.cancel(signal.reason)
|
|
1242
|
-
if (signal?.aborted) cancel()
|
|
1243
|
-
else signal?.addEventListener('abort', cancel, { once: true })
|
|
1244
|
-
const promise = settlement.promise
|
|
1245
|
-
.finally(() => signal?.removeEventListener('abort', cancel))
|
|
1246
|
-
.then(settlements => publishSummary(settlements, urls, {
|
|
1247
|
-
includeSucceededRelays: true
|
|
1248
|
-
}))
|
|
1260
|
+
})
|
|
1249
1261
|
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1262
|
+
// Resolves after every relay settles (or reaches the operation timeout) as
|
|
1263
|
+
// { success, total, fulfilled, succeededRelays, errors },
|
|
1264
|
+
// where errors contains { relay, reason } entries for failed relays.
|
|
1265
|
+
const cancel = () => settlement.cancel(signal.reason)
|
|
1266
|
+
if (signal?.aborted) cancel()
|
|
1267
|
+
else signal?.addEventListener('abort', cancel, { once: true })
|
|
1268
|
+
const promise = settlement.promise
|
|
1269
|
+
.finally(() => signal?.removeEventListener('abort', cancel))
|
|
1270
|
+
.then(settlements => publishSummary(settlements, urls, {
|
|
1271
|
+
includeSucceededRelays: true
|
|
1272
|
+
}))
|
|
1273
|
+
|
|
1274
|
+
urls.forEach((url, index) => {
|
|
1275
|
+
const deferred = sendDeferreds[index]
|
|
1276
|
+
;(async () => {
|
|
1277
|
+
try {
|
|
1278
|
+
const operationSignal = sendControllers[index].signal
|
|
1279
|
+
if (operationSignal.aborted) throw operationSignal.reason
|
|
1280
|
+
const relay = await this.#getRelay(url)
|
|
1281
|
+
if (operationSignal.aborted) throw operationSignal.reason
|
|
1282
|
+
return await this.#publishEvent(relay, eventForAttempt, getAuthEvent, operationSignal)
|
|
1283
|
+
} catch (err) {
|
|
1284
|
+
const reason = err instanceof Error ? err : new Error(String(err))
|
|
1285
|
+
if (reason instanceof Nip42AuthenticationError) throw reason
|
|
1286
|
+
if (reason.message.startsWith('duplicate:')) return 'duplicate'
|
|
1287
|
+
if (reason.message.startsWith('mute:')) {
|
|
1288
|
+
console.info([url, reason.message].filter(Boolean).join(' - '))
|
|
1289
|
+
return 'muted'
|
|
1290
|
+
}
|
|
1291
|
+
throw reason
|
|
1266
1292
|
}
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
})().then(deferred.resolve, deferred.reject)
|
|
1270
|
-
})
|
|
1293
|
+
})().then(deferred.resolve, deferred.reject)
|
|
1294
|
+
})
|
|
1271
1295
|
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1296
|
+
const earlyTimeout = timeoutUntilFirstFulfillment === null || attemptTimeout === null
|
|
1297
|
+
? timeoutUntilFirstFulfillment
|
|
1298
|
+
: Math.min(timeoutUntilFirstFulfillment, attemptTimeout)
|
|
1299
|
+
const success = await firstFulfillment(sendPromises, earlyTimeout, {
|
|
1300
|
+
fallback: promise.then(report => report.success)
|
|
1301
|
+
})
|
|
1302
|
+
if (!success) settlement.timeout()
|
|
1276
1303
|
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1304
|
+
const result = {
|
|
1305
|
+
total: urls.length,
|
|
1306
|
+
success,
|
|
1307
|
+
promise
|
|
1308
|
+
}
|
|
1309
|
+
Object.defineProperty(result, 'flushRelayResults', {
|
|
1310
|
+
value: () => {
|
|
1311
|
+
delivery.notify = true
|
|
1312
|
+
for (const result of bufferedResults.splice(0)) notifyRelayResult(onRelayResult, result)
|
|
1313
|
+
}
|
|
1314
|
+
})
|
|
1315
|
+
return result
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
const first = await sendAttempt(event, { notify: !getRetryEvent })
|
|
1319
|
+
if (first.success || !getRetryEvent) {
|
|
1320
|
+
first.flushRelayResults()
|
|
1321
|
+
return first
|
|
1322
|
+
}
|
|
1323
|
+
let report
|
|
1324
|
+
try {
|
|
1325
|
+
report = await first.promise
|
|
1326
|
+
} catch {
|
|
1327
|
+
first.flushRelayResults()
|
|
1328
|
+
return first
|
|
1329
|
+
}
|
|
1330
|
+
if (!isDefinitiveOuterRejection(report) || (deadline !== null && Date.now() >= deadline)) {
|
|
1331
|
+
first.flushRelayResults()
|
|
1332
|
+
return first
|
|
1333
|
+
}
|
|
1334
|
+
let retryEvent
|
|
1335
|
+
try {
|
|
1336
|
+
retryEvent = await getRetryEvent({ event, report })
|
|
1337
|
+
} catch (cause) {
|
|
1338
|
+
throw Object.assign(new Error('RETRY_EVENT_PREPARATION_FAILED', { cause }), {
|
|
1339
|
+
code: 'RETRY_EVENT_PREPARATION_FAILED',
|
|
1340
|
+
cause
|
|
1341
|
+
})
|
|
1342
|
+
}
|
|
1343
|
+
if ((deadline !== null && Date.now() >= deadline) ||
|
|
1344
|
+
!isValidEvent(retryEvent) || retryEvent.id === event.id || retryEvent.kind !== event.kind) {
|
|
1345
|
+
first.flushRelayResults()
|
|
1346
|
+
return first
|
|
1281
1347
|
}
|
|
1348
|
+
const retried = await sendAttempt(retryEvent, { notify: true })
|
|
1349
|
+
Object.defineProperty(retried, 'retriedEvent', { value: retryEvent })
|
|
1350
|
+
return retried
|
|
1282
1351
|
}
|
|
1283
1352
|
}
|
|
1284
1353
|
|