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.
@@ -11,6 +11,7 @@ import { incompleteFetchError } from './helpers/fetch-error.js'
11
11
  import { readHistory } from './helpers/history.js'
12
12
  import { subscriptionError } from './helpers/subscription-error.js'
13
13
  import { createLookupWarningLogger } from './helpers/lookup-warning.js'
14
+ import { normalizePreparedPublications } from './helpers/prepared.js'
14
15
  import * as nip44v3 from '../nip44-v3/index.js'
15
16
  import { relayPool } from '../relay/index.js'
16
17
  import { JSONL_CHUNK_BYTES, NYM_CARRIER_CHUNK_CHARS } from './helpers/chunk-size.js'
@@ -40,7 +41,7 @@ const decoder = new TextDecoder()
40
41
  const warnLookupFailure = createLookupWarningLogger()
41
42
  const NIP44_V3_SCOPE = ''
42
43
 
43
- const sendToRelays = (event, relays) => relayPool.sendEvent(event, relays)
44
+ const sendToRelays = (event, relays, options) => relayPool.sendEvent(event, relays, options)
44
45
  const getEvents = (...args) => relayPool.getEvents(...args)
45
46
  const getLiveEventsGenerator = (...args) => relayPool.getLiveEventsGenerator(...args)
46
47
  const getEventsFeedGenerator = (...args) => relayPool.getEventsFeedGenerator(...args)
@@ -153,18 +154,29 @@ async function prepareRoutedMessage ({ senderSigner, imkcSigner, privateChannelS
153
154
  }
154
155
  }
155
156
 
156
- async function * wrapPreparedEvents ({ privateChannelSigner, senderSigner, event, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey, expirationSeconds = EXPIRATION_SECONDS, context, preparedPublication }) {
157
+ async function validatePreparedPublication ({ privateChannelSigner, senderSigner, event, receivers, preparedPublication }) {
158
+ const channelPubkey = await privateChannelSigner.getPublicKey()
159
+ const innerEventId = getEventHash({ ...event, pubkey: event.pubkey || await senderSigner.getPublicKey() })
160
+ const publicationReceivers = Array.isArray(preparedPublication?.receiverPubkeys)
161
+ ? [...new Set(preparedPublication.receiverPubkeys)]
162
+ : []
163
+ const expected = receivers ? receiverPubkeys(receivers).sort().join(',') : ''
164
+ if (preparedPublication?.innerEventId !== innerEventId || preparedPublication?.channelPubkey !== channelPubkey ||
165
+ !publicationReceivers.length || (receivers && publicationReceivers.slice().sort().join(',') !== expected) ||
166
+ !Array.isArray(preparedPublication.events) || !preparedPublication.events.length || !Array.isArray(preparedPublication.seeds) ||
167
+ preparedPublication.events.some(value => !isValidEvent(value) || value.pubkey !== channelPubkey || value.kind !== PRIVATE_BROADCAST_KIND)) {
168
+ throw new ValidationError('INVALID_PREPARED_PUBLICATION')
169
+ }
170
+ if (preparedPublication.events.some(value => Number(value.tags.find(tag => tag[0] === 'expiration')?.[1]) <= nowSeconds())) {
171
+ throw new ValidationError('PREPARED_PUBLICATION_EXPIRED')
172
+ }
173
+ return publicationReceivers
174
+ }
175
+
176
+ async function * wrapPreparedEvents ({ privateChannelSigner, senderSigner, event, receivers, receiverTag, fileChunkIndex, onPreparedSeed, onPreparedPublication, deletionPubkey, expirationSeconds = EXPIRATION_SECONDS, context, preparedPublication }) {
157
177
  let publication = preparedPublication
158
178
  if (publication) {
159
- const channelPubkey = await privateChannelSigner.getPublicKey()
160
- const expected = receiverPubkeys(receivers).sort().join(',')
161
- const innerEventId = getEventHash({ ...event, pubkey: event.pubkey || await senderSigner.getPublicKey() })
162
- if (publication.innerEventId !== innerEventId || publication.channelPubkey !== channelPubkey || !Array.isArray(publication.receiverPubkeys) || publication.receiverPubkeys.slice().sort().join(',') !== expected ||
163
- !Array.isArray(publication.events) || !publication.events.length || !Array.isArray(publication.seeds) ||
164
- publication.events.some(event => !isValidEvent(event) || event.pubkey !== channelPubkey || event.kind !== PRIVATE_BROADCAST_KIND)) {
165
- throw new ValidationError('INVALID_PREPARED_PUBLICATION')
166
- }
167
- if (publication.events.some(event => Number(event.tags.find(tag => tag[0] === 'expiration')?.[1]) <= nowSeconds())) throw new ValidationError('PREPARED_PUBLICATION_EXPIRED')
179
+ await validatePreparedPublication({ privateChannelSigner, senderSigner, event, receivers, preparedPublication: publication })
168
180
  } else {
169
181
  const routerSeckey = generateSecretKey()
170
182
  const routerPubkey = getPublicKey(routerSeckey)
@@ -194,6 +206,7 @@ async function * wrapPreparedEvents ({ privateChannelSigner, senderSigner, event
194
206
  }
195
207
  publication = { innerEventId: context.innerEventId, channelPubkey: context.channelPubkey, receiverPubkeys: receiverPubkeyList, events, seeds }
196
208
  }
209
+ onPreparedPublication?.(publication)
197
210
  // The callback commits recovery data before any bytes leave this publisher.
198
211
  // A session commits its encrypted journal; a direct messenger commits seeds.
199
212
  if (onPreparedSeed) {
@@ -203,9 +216,12 @@ async function * wrapPreparedEvents ({ privateChannelSigner, senderSigner, event
203
216
  }
204
217
 
205
218
  // Streaming version of wrapEvent
206
- export async function * wrapEvents ({ senderSigner, imkcSigner, privateChannelSigner = senderSigner, privateChannelReaderPubkey, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey, event, expirationSeconds = EXPIRATION_SECONDS, temporaryStorageArea, _getIykcProofs = getIykcProofs, preparedPublication }) {
219
+ export async function * wrapEvents ({ senderSigner, imkcSigner, privateChannelSigner = senderSigner, privateChannelReaderPubkey, receivers, receiverTag, fileChunkIndex, onPreparedSeed, onPreparedPublication, deletionPubkey, event, expirationSeconds = EXPIRATION_SECONDS, temporaryStorageArea, _getIykcProofs = getIykcProofs, preparedPublication, preparedPublications }) {
207
220
  const normalizedDeletionPubkey = normalizeDeletionPubkey(deletionPubkey)
208
- const context = preparedPublication
221
+ const prepared = normalizePreparedPublications({ preparedPublication, preparedPublications })
222
+ if (prepared.length > 1) throw new ValidationError('INVALID_PREPARED_PUBLICATIONS')
223
+ const publication = prepared[0]
224
+ const context = publication
209
225
  ? null
210
226
  : await prepareRoutedMessage({
211
227
  senderSigner,
@@ -218,7 +234,7 @@ export async function * wrapEvents ({ senderSigner, imkcSigner, privateChannelSi
218
234
  _getIykcProofs
219
235
  })
220
236
  try {
221
- yield * wrapPreparedEvents({ privateChannelSigner, senderSigner, event, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey: normalizedDeletionPubkey, expirationSeconds, context, preparedPublication })
237
+ yield * wrapPreparedEvents({ privateChannelSigner, senderSigner, event, receivers, receiverTag, fileChunkIndex, onPreparedSeed, onPreparedPublication, deletionPubkey: normalizedDeletionPubkey, expirationSeconds, context, preparedPublication: publication })
222
238
  } finally {
223
239
  if (context) cleanupEnvelopeRows(context.preparedRows)
224
240
  }
@@ -558,29 +574,208 @@ function withRecoveryRelays (relays, recoveryRelays) {
558
574
  return uniq([...(relays || []), ...(recoveryRelays || [])])
559
575
  }
560
576
 
561
- export async function publish ({ senderSigner, imkcSigner, privateChannelSigner = senderSigner, privateChannelReaderPubkey, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey, event, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, _getIykcProofs = getIykcProofs, _publish = sendToRelays, preparedPublication }) {
577
+ function withPublicationCoverage (report, event, receivers) {
578
+ if (!report || typeof report !== 'object' || Array.isArray(report)) return report
579
+ return {
580
+ ...report,
581
+ outerEventId: event.id,
582
+ receiverPubkeys: receiverPubkeys(receivers)
583
+ }
584
+ }
585
+
586
+ function expirationRetentionSeconds (publication, expirationSeconds) {
587
+ if (Number.isSafeInteger(expirationSeconds) && expirationSeconds >= 0) return expirationSeconds
588
+ for (const event of publication.events || []) {
589
+ const expiration = Number(event.tags?.find(tag => tag[0] === 'expiration')?.[1])
590
+ if (Number.isSafeInteger(expiration) && expiration >= event.created_at) return expiration - event.created_at
591
+ }
592
+ return null
593
+ }
594
+
595
+ async function refreshPublicationForRetry ({ privateChannelSigner, publication, expirationSeconds }) {
596
+ if (publication?.outerRefreshAttempted === true) throw new ValidationError('OUTER_REFRESH_ALREADY_ATTEMPTED')
597
+ if (!Array.isArray(publication?.events) || publication.events.length === 0) throw new ValidationError('INVALID_PREPARED_PUBLICATION')
598
+ const channelPubkey = await privateChannelSigner.getPublicKey()
599
+ if (publication.channelPubkey !== channelPubkey) throw new ValidationError('INVALID_PREPARED_PUBLICATION')
600
+ const currentTime = nowSeconds()
601
+ const createdAt = Math.max(currentTime, ...publication.events.map(event => event.created_at + 1))
602
+ const retention = expirationRetentionSeconds(publication, expirationSeconds)
603
+ const events = []
604
+ for (const previous of publication.events) {
605
+ if (!isValidEvent(previous) || previous.kind !== PRIVATE_BROADCAST_KIND || previous.pubkey !== channelPubkey) {
606
+ throw new ValidationError('INVALID_PREPARED_PUBLICATION')
607
+ }
608
+ const tags = previous.tags.map(tag => {
609
+ if (tag[0] !== 'expiration' || retention === null) return [...tag]
610
+ return ['expiration', String(createdAt + retention)]
611
+ })
612
+ const refreshed = await privateChannelSigner.signEvent({
613
+ kind: PRIVATE_BROADCAST_KIND,
614
+ created_at: createdAt,
615
+ tags,
616
+ content: previous.content
617
+ })
618
+ if (!isValidEvent(refreshed) || refreshed.id === previous.id || refreshed.kind !== PRIVATE_BROADCAST_KIND || refreshed.pubkey !== channelPubkey ||
619
+ refreshed.created_at !== createdAt || refreshed.content !== previous.content || JSON.stringify(refreshed.tags) !== JSON.stringify(tags)) {
620
+ throw new ValidationError('INVALID_PREPARED_PUBLICATION')
621
+ }
622
+ if (eventByteLength(refreshed) > MAX_EVENT_BYTES) throw new ValidationError('EVENT_TOO_LARGE')
623
+ events.push(refreshed)
624
+ }
625
+ return {
626
+ ...structuredClone(publication),
627
+ events,
628
+ outerRefreshAttempted: true
629
+ }
630
+ }
631
+
632
+ async function publishOuterEvents ({
633
+ publication,
634
+ publishRelays,
635
+ primaryRelays,
636
+ receivers,
637
+ privateChannelSigner,
638
+ expirationSeconds,
639
+ onPreparedSeed,
640
+ _publish,
641
+ includeCoverage = true,
642
+ includeRoutingContext = true
643
+ }) {
644
+ let current = publication
645
+ let refreshed = false
646
+ let refreshWork
647
+ const results = []
648
+ for (let index = 0; index < current.events.length; index++) {
649
+ const getRetryEvent = index === 0 && current.outerRefreshAttempted !== true
650
+ ? async ({ event }) => {
651
+ if (refreshed || refreshWork || event.id !== current.events[0].id) return null
652
+ refreshWork = (async () => {
653
+ const next = await refreshPublicationForRetry({ privateChannelSigner, publication: current, expirationSeconds })
654
+ const firstSeed = next.seeds?.[0]
655
+ if (firstSeed && onPreparedSeed) await onPreparedSeed({ ...firstSeed, publication: next }, { replace: true })
656
+ current = next
657
+ refreshed = true
658
+ return current.events[0]
659
+ })()
660
+ return await refreshWork
661
+ }
662
+ : undefined
663
+ const report = await _publish(current.events[index], publishRelays, includeRoutingContext
664
+ ? {
665
+ receiverPubkeys: receiverPubkeys(receivers),
666
+ primaryRelays,
667
+ getRetryEvent
668
+ }
669
+ : { getRetryEvent })
670
+ results.push(includeCoverage ? withPublicationCoverage(report, current.events[index], receivers) : report)
671
+ }
672
+ return { publication: current, results }
673
+ }
674
+
675
+ function relayMapForReceivers (relayToReceivers, receivers) {
676
+ if (!relayToReceivers) return null
677
+ const wanted = new Set(receivers)
678
+ const relays = []
679
+ for (const [relay, values] of relayReceiverEntries(relayToReceivers)) {
680
+ const offered = (Array.isArray(values) ? values : [values])
681
+ .map(value => typeof value === 'string' ? value : receiverPubkeys([value])[0])
682
+ if (!offered.some(pubkey => wanted.has(pubkey))) continue
683
+ relays.push(relay)
684
+ }
685
+ return relays
686
+ }
687
+
688
+ export async function publish ({ senderSigner, imkcSigner, privateChannelSigner = senderSigner, privateChannelReaderPubkey, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey, event, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, _getIykcProofs = getIykcProofs, _publish = sendToRelays, preparedPublication, preparedPublications }) {
562
689
  const normalizedDeletionPubkey = normalizeDeletionPubkey(deletionPubkey)
563
690
  const results = []
564
- // Relays are grouped only when they have the exact same recipient pubkey set
565
- const groups = groupedRelayReceivers({ relayToReceivers, receivers })
566
- if (groups) {
567
- const context = preparedPublication
568
- ? null
569
- : await prepareRoutedMessage({
570
- senderSigner,
571
- imkcSigner,
691
+ const requestedReceivers = receiverPubkeys(receivers)
692
+ const prepared = normalizePreparedPublications({ preparedPublication, preparedPublications })
693
+ if (prepared.length) {
694
+ const requested = new Set(requestedReceivers)
695
+ const covered = new Set()
696
+ for (let publicationIndex = 0; publicationIndex < prepared.length; publicationIndex++) {
697
+ const publication = prepared[publicationIndex]
698
+ const candidateReceivers = Array.isArray(publication?.receiverPubkeys)
699
+ ? [...new Set(publication.receiverPubkeys)]
700
+ : []
701
+ const candidateTargets = candidateReceivers.filter(pubkey => requested.has(pubkey))
702
+ if (!candidateTargets.length) continue
703
+ const publicationReceivers = await validatePreparedPublication({
572
704
  privateChannelSigner,
573
- privateChannelReaderPubkey,
574
- receivers,
705
+ senderSigner,
575
706
  event,
576
- temporaryStorageArea,
577
- _getIykcProofs
707
+ preparedPublication: publication
708
+ })
709
+ const targeted = publicationReceivers.filter(pubkey => requested.has(pubkey))
710
+ for (const pubkey of targeted) covered.add(pubkey)
711
+ if (onPreparedSeed) {
712
+ for (const seed of publication.seeds) await onPreparedSeed({ ...seed, publication })
713
+ }
714
+ const targetRelays = relayMapForReceivers(relayToReceivers, targeted) || uniq(relays)
715
+ const publishRelays = withRecoveryRelays(targetRelays, recoveryRelays)
716
+ if (!publishRelays.length) throw new ValidationError('NO_RELAYS')
717
+ const outcome = await publishOuterEvents({
718
+ publication,
719
+ publishRelays,
720
+ primaryRelays: targetRelays,
721
+ receivers: targeted.map(pubkey => ({ receiverPubkey: pubkey })),
722
+ privateChannelSigner,
723
+ expirationSeconds,
724
+ onPreparedSeed,
725
+ _publish
578
726
  })
727
+ prepared[publicationIndex] = outcome.publication
728
+ results.push(...outcome.results)
729
+ }
730
+ if ([...requested].some(pubkey => !covered.has(pubkey))) {
731
+ throw new ValidationError('PREPARED_PUBLICATION_RECEIVER_MISSING')
732
+ }
733
+ return results
734
+ }
735
+ // Relays are grouped only when they have the exact same recipient pubkey set
736
+ const groups = groupedRelayReceivers({ relayToReceivers, receivers })
737
+ if (groups) {
738
+ const context = await prepareRoutedMessage({
739
+ senderSigner,
740
+ imkcSigner,
741
+ privateChannelSigner,
742
+ privateChannelReaderPubkey,
743
+ receivers,
744
+ event,
745
+ temporaryStorageArea,
746
+ _getIykcProofs
747
+ })
579
748
  try {
580
749
  for (const group of groups) {
581
- for await (const wrappedEvent of wrapPreparedEvents({ privateChannelSigner, senderSigner, event, receivers: group.receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey: normalizedDeletionPubkey, expirationSeconds, context, preparedPublication })) {
582
- results.push(await _publish(wrappedEvent, withRecoveryRelays(group.relays, recoveryRelays), { receiverPubkeys: receiverPubkeys(group.receivers), primaryRelays: group.relays }))
750
+ let publication
751
+ const events = []
752
+ for await (const wrappedEvent of wrapPreparedEvents({
753
+ privateChannelSigner,
754
+ senderSigner,
755
+ event,
756
+ receivers: group.receivers,
757
+ receiverTag,
758
+ fileChunkIndex,
759
+ onPreparedSeed,
760
+ onPreparedPublication: value => { publication = value },
761
+ deletionPubkey: normalizedDeletionPubkey,
762
+ expirationSeconds,
763
+ context
764
+ })) {
765
+ events.push(wrappedEvent)
583
766
  }
767
+ const publishRelays = withRecoveryRelays(group.relays, recoveryRelays)
768
+ const outcome = await publishOuterEvents({
769
+ publication: publication || { events },
770
+ publishRelays,
771
+ primaryRelays: group.relays,
772
+ receivers: group.receivers,
773
+ privateChannelSigner,
774
+ expirationSeconds,
775
+ onPreparedSeed,
776
+ _publish
777
+ })
778
+ results.push(...outcome.results)
584
779
  }
585
780
  } finally {
586
781
  if (context) cleanupEnvelopeRows(context.preparedRows)
@@ -588,9 +783,37 @@ export async function publish ({ senderSigner, imkcSigner, privateChannelSigner
588
783
  return results
589
784
  }
590
785
 
591
- for await (const wrappedEvent of wrapEvents({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey: normalizedDeletionPubkey, event, expirationSeconds, temporaryStorageArea, _getIykcProofs, preparedPublication })) {
592
- results.push(await _publish(wrappedEvent, withRecoveryRelays(relays, recoveryRelays), { receiverPubkeys: receiverPubkeys(receivers), primaryRelays: relays }))
786
+ let publication
787
+ const events = []
788
+ for await (const wrappedEvent of wrapEvents({
789
+ senderSigner,
790
+ imkcSigner,
791
+ privateChannelSigner,
792
+ privateChannelReaderPubkey,
793
+ receivers,
794
+ receiverTag,
795
+ fileChunkIndex,
796
+ onPreparedSeed,
797
+ onPreparedPublication: value => { publication = value },
798
+ deletionPubkey: normalizedDeletionPubkey,
799
+ event,
800
+ expirationSeconds,
801
+ temporaryStorageArea,
802
+ _getIykcProofs
803
+ })) {
804
+ events.push(wrappedEvent)
593
805
  }
806
+ const outcome = await publishOuterEvents({
807
+ publication: publication || { events },
808
+ publishRelays: withRecoveryRelays(relays, recoveryRelays),
809
+ primaryRelays: relays,
810
+ receivers,
811
+ privateChannelSigner,
812
+ expirationSeconds,
813
+ onPreparedSeed,
814
+ _publish
815
+ })
816
+ results.push(...outcome.results)
594
817
  return results
595
818
  }
596
819
 
@@ -598,8 +821,24 @@ export async function publishNymEvent ({ nymSigner, privateChannelSigner, privat
598
821
  const normalizedDeletionPubkey = normalizeDeletionPubkey(deletionPubkey)
599
822
  const results = []
600
823
  const publishRelays = withRecoveryRelays(relayToReceivers ? relaysFromRelayReceivers(relayToReceivers) : relays, recoveryRelays)
824
+ const events = []
601
825
  for await (const wrappedEvent of wrapNymEvents({ nymSigner, privateChannelSigner, privateChannelReaderPubkey, deletionPubkey: normalizedDeletionPubkey, event, expirationSeconds })) {
602
- results.push(await _publish(wrappedEvent, publishRelays))
826
+ events.push(wrappedEvent)
827
+ }
828
+ if (events.length) {
829
+ const channelPubkey = await privateChannelSigner.getPublicKey()
830
+ const outcome = await publishOuterEvents({
831
+ publication: { channelPubkey, receiverPubkeys: [], events, seeds: [] },
832
+ publishRelays,
833
+ primaryRelays: publishRelays,
834
+ receivers: [],
835
+ privateChannelSigner,
836
+ expirationSeconds,
837
+ includeCoverage: false,
838
+ includeRoutingContext: false,
839
+ _publish
840
+ })
841
+ results.push(...outcome.results)
603
842
  }
604
843
  return results
605
844
  }
@@ -4,6 +4,7 @@ import { ValidationError } from '../error/index.js'
4
4
  import { generateKeypair, keypairFromSeckey } from '../key/index.js'
5
5
  import * as privateChannel from '../private-channel/index.js'
6
6
  import { normalizeRumor, deliveryInfo } from '../private-channel/helpers/rumor.js'
7
+ import { normalizePreparedPublications } from '../private-channel/helpers/prepared.js'
7
8
 
8
9
  export const ASK_KIND = 7329
9
10
  export const REPLY_KIND = 7330
@@ -41,17 +42,25 @@ function normalizeDeletionPubkey (deletionPubkey) {
41
42
  return deletionPubkey.toLowerCase()
42
43
  }
43
44
 
44
- function resolveDeletionCapability ({ deletionPubkey, deletionSeckey, autoDeletionCapability = true, preparedPublication }) {
45
+ function resolveDeletionCapability ({ deletionPubkey, deletionSeckey, autoDeletionCapability = true, preparedPublications = [] }) {
45
46
  if (deletionSeckey !== undefined) throw new ValidationError('DELETION_SECKEY_NOT_ACCEPTED')
46
47
  if (typeof autoDeletionCapability !== 'boolean') throw new ValidationError('AUTO_DELETION_CAPABILITY_BOOLEAN_REQUIRED')
47
48
 
48
49
  const normalizedDeletionPubkey = normalizeDeletionPubkey(deletionPubkey)
49
- if (preparedPublication) {
50
- const capability = preparedPublication.deletionCapability
51
- if (!capability || (capability.deletionPubkey !== undefined && normalizeDeletionPubkey(capability.deletionPubkey) !== capability.deletionPubkey) ||
52
- (normalizedDeletionPubkey && normalizedDeletionPubkey !== capability.deletionPubkey) ||
53
- (capability.deletionSeckey !== undefined && keypairFromSeckey(capability.deletionSeckey).pubkey !== capability.deletionPubkey) ||
54
- preparedPublication.events?.some(event => event.tags.find(tag => tag[0] === 's')?.[1] !== capability.deletionPubkey)) throw new ValidationError('INVALID_PREPARED_PUBLICATION')
50
+ if (preparedPublications.length) {
51
+ const capability = preparedPublications[0].deletionCapability
52
+ if (!capability) throw new ValidationError('INVALID_PREPARED_PUBLICATION')
53
+ for (const publication of preparedPublications) {
54
+ const current = publication.deletionCapability
55
+ if (!current || (current.deletionPubkey !== capability.deletionPubkey) ||
56
+ (current.deletionSeckey !== capability.deletionSeckey) ||
57
+ (current.deletionPubkey !== undefined && normalizeDeletionPubkey(current.deletionPubkey) !== current.deletionPubkey) ||
58
+ (normalizedDeletionPubkey && normalizedDeletionPubkey !== capability.deletionPubkey) ||
59
+ (capability.deletionSeckey !== undefined && keypairFromSeckey(capability.deletionSeckey).pubkey !== capability.deletionPubkey) ||
60
+ publication.events?.some(event => event.tags.find(tag => tag[0] === 's')?.[1] !== capability.deletionPubkey)) {
61
+ throw new ValidationError('INVALID_PREPARED_PUBLICATION')
62
+ }
63
+ }
55
64
  return { ...capability }
56
65
  }
57
66
  if (normalizedDeletionPubkey) return { deletionPubkey: normalizedDeletionPubkey }
@@ -63,9 +72,9 @@ function resolveDeletionCapability ({ deletionPubkey, deletionSeckey, autoDeleti
63
72
 
64
73
  function preparedSeedHandler (onPreparedSeed, deletion) {
65
74
  if (!onPreparedSeed) return undefined
66
- return seed => {
75
+ return (seed, options) => {
67
76
  seed.publication.deletionCapability = deletion
68
- return onPreparedSeed(seed)
77
+ return onPreparedSeed(seed, options)
69
78
  }
70
79
  }
71
80
 
@@ -668,11 +677,11 @@ async function sendPrivateMessage ({
668
677
  _getIykcProofs,
669
678
  fileChunkIndex,
670
679
  onPreparedSeed,
671
- preparedPublication,
680
+ preparedPublications,
672
681
  _publish = privateChannel.publish
673
682
  }) {
674
683
  if (!privateChannelSigner?.getPublicKey) throw new ValidationError('PRIVATE_CHANNEL_WRITER_REQUIRED')
675
- return _publish({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag, deletionPubkey, event, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, fileChunkIndex, onPreparedSeed, preparedPublication, _getIykcProofs })
684
+ return _publish({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag, deletionPubkey, event, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, fileChunkIndex, onPreparedSeed, preparedPublications: preparedPublications?.length ? preparedPublications : undefined, _getIykcProofs })
676
685
  }
677
686
 
678
687
  async function sendNymMessage ({
@@ -841,21 +850,29 @@ export async function yell ({
841
850
  deletionSeckey,
842
851
  autoDeletionCapability = true,
843
852
  fileChunkIndex,
853
+ createdAt,
844
854
  onPreparedSeed,
855
+ preparedPublication,
856
+ preparedPublications,
845
857
  _publish = privateChannel.publish
846
858
  }) {
847
- const receivers = uniq(receiverPubkeys)
859
+ const prepared = normalizePreparedPublications({ preparedPublication, preparedPublications })
860
+ const receivers = uniq(receiverPubkeys?.length
861
+ ? receiverPubkeys
862
+ : prepared.flatMap(publication => publication.receiverPubkeys || []))
848
863
  if (!receivers.length) throw new ValidationError('NO_RECEIVERS')
864
+ const rumor = makeMessageRumor({
865
+ kind: TELL_KIND,
866
+ tags: [],
867
+ message: message || { code, payload, error, content }
868
+ })
869
+ if (createdAt !== undefined) rumor.created_at = createdAt
849
870
  const { event, wireEvent } = await makeOutgoingRumor({
850
871
  senderSigner,
851
- rumor: makeMessageRumor({
852
- kind: TELL_KIND,
853
- tags: [],
854
- message: message || { code, payload, error, content }
855
- })
872
+ rumor
856
873
  })
857
- const deletion = resolveDeletionCapability({ deletionPubkey, deletionSeckey, autoDeletionCapability })
858
- const reports = await sendPrivateMessage({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag: '', deletionPubkey: deletion.deletionPubkey, event: wireEvent, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, fileChunkIndex, onPreparedSeed, _getIykcProofs, _publish })
874
+ const deletion = resolveDeletionCapability({ deletionPubkey, deletionSeckey, autoDeletionCapability, preparedPublications: prepared })
875
+ const reports = await sendPrivateMessage({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag: '', deletionPubkey: deletion.deletionPubkey, event: wireEvent, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, fileChunkIndex, onPreparedSeed: preparedSeedHandler(onPreparedSeed, deletion), preparedPublications: prepared, _getIykcProofs, _publish })
859
876
  return withDelivery({ yell: event }, reports, deletion.deletionSeckey)
860
877
  }
861
878
 
@@ -878,13 +895,15 @@ export async function broadcastRumor ({
878
895
  fileChunkIndex,
879
896
  onPreparedSeed,
880
897
  preparedPublication,
898
+ preparedPublications,
881
899
  _publish = privateChannel.publish
882
900
  }) {
883
901
  const receivers = uniq(receiverPubkeys)
884
902
  if (!receivers.length) throw new ValidationError('NO_RECEIVERS')
903
+ const prepared = normalizePreparedPublications({ preparedPublication, preparedPublications })
885
904
  const { event, wireEvent } = await makeOutgoingRumor({ senderSigner, rumor })
886
- const deletion = resolveDeletionCapability({ deletionPubkey, deletionSeckey, autoDeletionCapability, preparedPublication })
887
- const reports = await sendPrivateMessage({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag: '', deletionPubkey: deletion.deletionPubkey, event: wireEvent, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, fileChunkIndex, onPreparedSeed: preparedSeedHandler(onPreparedSeed, deletion), preparedPublication, _getIykcProofs, _publish })
905
+ const deletion = resolveDeletionCapability({ deletionPubkey, deletionSeckey, autoDeletionCapability, preparedPublications: prepared })
906
+ const reports = await sendPrivateMessage({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag: '', deletionPubkey: deletion.deletionPubkey, event: wireEvent, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, fileChunkIndex, onPreparedSeed: preparedSeedHandler(onPreparedSeed, deletion), preparedPublications: prepared, _getIykcProofs, _publish })
888
907
  return withDelivery({ rumor: event }, reports, deletion.deletionSeckey)
889
908
  }
890
909
 
@@ -907,13 +926,15 @@ export async function broadcastEvent ({
907
926
  fileChunkIndex,
908
927
  onPreparedSeed,
909
928
  preparedPublication,
929
+ preparedPublications,
910
930
  _publish = privateChannel.publish
911
931
  }) {
912
932
  const receivers = uniq(receiverPubkeys)
913
933
  if (!receivers.length) throw new ValidationError('NO_RECEIVERS')
934
+ const prepared = normalizePreparedPublications({ preparedPublication, preparedPublications })
914
935
  const wireEvent = assertValidSignedEvent({ ...event, tags: cloneTags(event?.tags) })
915
- const deletion = resolveDeletionCapability({ deletionPubkey, deletionSeckey, autoDeletionCapability, preparedPublication })
916
- const reports = await sendPrivateMessage({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag: '', deletionPubkey: deletion.deletionPubkey, event: wireEvent, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, fileChunkIndex, onPreparedSeed: preparedSeedHandler(onPreparedSeed, deletion), preparedPublication, _getIykcProofs, _publish })
936
+ const deletion = resolveDeletionCapability({ deletionPubkey, deletionSeckey, autoDeletionCapability, preparedPublications: prepared })
937
+ const reports = await sendPrivateMessage({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag: '', deletionPubkey: deletion.deletionPubkey, event: wireEvent, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, fileChunkIndex, onPreparedSeed: preparedSeedHandler(onPreparedSeed, deletion), preparedPublications: prepared, _getIykcProofs, _publish })
917
938
  return withDelivery({ event: wireEvent }, reports, deletion.deletionSeckey)
918
939
  }
919
940
 
@@ -4,6 +4,7 @@ import { createFileCache } from './helpers/cache.js'
4
4
  export { createFileCache, DEFAULT_FILE_CACHE_BYTES } from './helpers/cache.js'
5
5
  import { createFileCatalog } from './helpers/catalog.js'
6
6
  import { assertMessagePublished } from '../helpers/publication.js'
7
+ import { normalizePreparedPublications } from '../../private-channel/helpers/prepared.js'
7
8
  import { ValidationError } from '../../error/index.js'
8
9
  import { IRFS_CHUNK_BYTES, decodeIrfsChunk } from '../../irfs/index.js'
9
10
  import { createQueue } from '../../idb-queue/index.js'
@@ -151,9 +152,10 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
151
152
  function sendOptions (file, routes) {
152
153
  return { senderSigner: messenger.userSigner, imkcSigner: messenger.contentKeySigner, privateChannelSigner: file.signer, ...routes, temporaryStorageArea: messenger.temporaryStorageArea, expirationSeconds: messenger.eventExpirationSecondsFor(file.controlChannelPubkey), _getIykcProofs: messenger.contentKeyLookup() }
153
154
  }
154
- async function publishChunk (descriptor, event, { signal, attemptStartedAt, onPreparedSeed, preparedPublication, onDebug } = {}) {
155
+ async function publishChunk (descriptor, event, { signal, attemptStartedAt, onPreparedSeed, preparedPublication, preparedPublications, onDebug } = {}) {
155
156
  const file = await register(descriptor)
156
157
  usable(file)
158
+ const prepared = normalizePreparedPublications({ preparedPublication, preparedPublications })
157
159
  const decoded = checked(file, event)
158
160
  await catalog.ready
159
161
  if (ownsStorage) {
@@ -162,7 +164,7 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
162
164
  }
163
165
  let result
164
166
  try {
165
- const options = { ...sendOptions(file, await routing(file, file.peerPubkey, signal, attemptStartedAt, onDebug)), receiverPubkeys: [file.peerPubkey], fileChunkIndex: decoded.index, preparedPublication, ...(onPreparedSeed ? { onPreparedSeed } : messenger.channels.get(file.controlChannelPubkey)?.mode === 'watchtower' ? { onPreparedSeed: seed => saveSeed(file, seed) } : {}) }
167
+ const options = { ...sendOptions(file, await routing(file, file.peerPubkey, signal, attemptStartedAt, onDebug)), receiverPubkeys: [file.peerPubkey], fileChunkIndex: decoded.index, preparedPublications: prepared.length ? prepared : undefined, ...(onPreparedSeed ? { onPreparedSeed } : messenger.channels.get(file.controlChannelPubkey)?.mode === 'watchtower' ? { onPreparedSeed: seed => saveSeed(file, seed) } : {}) }
166
168
  result = event.sig ? await _messages.broadcastEvent({ ...options, event }) : await _messages.broadcastRumor({ ...options, rumor: event })
167
169
  await assertMessagePublished(result, result?.rumor || result?.event || { ...event, pubkey: event.pubkey || messenger.userPubkey })
168
170
  } catch (error) { await uploadLeases.get(file.root)?.(); uploadLeases.delete(file.root); throw error }
@@ -2,7 +2,7 @@ import { isReplaceableRelayFailure } from '../../relay/helpers/failure.js'
2
2
 
3
3
  // Each invocation owns one already signed outer event and its encrypted subset.
4
4
  // Lanes share coverage and the tried set; only started reports outlive the race.
5
- export function publishWithEarlyFallback ({ event, receivers, first, mirrors, tried, nextPrimary, nextFallback, sendEvent, isOnline, isCurrent, signal, pauseSignal, remember, rememberSuccess = () => {}, delay, attemptStartedAt, monotonicNow = () => performance.now(), now, setTimer, clearTimer }) {
5
+ export function publishWithEarlyFallback ({ event, receivers, first, mirrors, tried, getRetryEvent, nextPrimary, nextFallback, sendEvent, isOnline, isCurrent, signal, pauseSignal, remember, rememberSuccess = () => {}, delay, attemptStartedAt, monotonicNow = () => performance.now(), now, setTimer, clearTimer }) {
6
6
  const pending = new Set(receivers)
7
7
  const summaries = []
8
8
  const transportSignal = signal && pauseSignal ? AbortSignal.any([signal, pauseSignal]) : signal || pauseSignal
@@ -33,6 +33,13 @@ export function publishWithEarlyFallback ({ event, receivers, first, mirrors, tr
33
33
  ...(interruptedWork && !signal?.aborted ? { retryWhenAvailable: true, ...(offline ? { retryWhenOnline: true } : {}) } : {})
34
34
  })
35
35
  }
36
+ const fail = error => {
37
+ if (done) return
38
+ done = true
39
+ clearTimer(timer); timer = null
40
+ transportSignal?.removeEventListener('abort', interrupted)
41
+ outcome.reject(error)
42
+ }
36
43
  const check = () => {
37
44
  if (done) return
38
45
  if (!pending.size) finish(true)
@@ -59,7 +66,11 @@ export function publishWithEarlyFallback ({ event, receivers, first, mirrors, tr
59
66
  const slot = Promise.withResolvers()
60
67
  summaries.push(slot.promise)
61
68
  let result
62
- try { result = await sendEvent(event, relays, { signal: transportSignal }) } catch (reason) {
69
+ try { result = await sendEvent(event, relays, { signal: transportSignal, getRetryEvent }) } catch (reason) {
70
+ if (reason?.code === 'RETRY_EVENT_PREPARATION_FAILED') {
71
+ fail(reason)
72
+ return
73
+ }
63
74
  result = { success: false, promise: Promise.resolve({ fulfilled: 0, errors: [{ reason }] }) }
64
75
  }
65
76
  const settled = Promise.resolve(result.promise).catch(reason => ({ fulfilled: 0, errors: [{ reason }] }))
@@ -123,6 +123,7 @@ export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey,
123
123
  }
124
124
  const result = await publishWithEarlyFallback({
125
125
  event, receivers, first: first.length ? { receivers, relays: first } : null, mirrors, tried,
126
+ getRetryEvent: context.getRetryEvent,
126
127
  nextPrimary: pending => pick(pending, false), nextFallback: pending => pick(pending, true),
127
128
  sendEvent, isOnline, isCurrent, signal, pauseSignal, remember, rememberSuccess,
128
129
  delay: fallbackDelayMs, attemptStartedAt, monotonicNow, now, setTimer, clearTimer
@@ -158,7 +159,7 @@ export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey,
158
159
  for (const relay of relays) tried.add(normalized(relay))
159
160
  // Every relay in this batch serves the same pending recipient subset.
160
161
  // One ACK covers that subset, never unrelated members of the ciphertext.
161
- result = await sendEvent(event, relays, { signal: transportSignal })
162
+ result = await sendEvent(event, relays, { signal: transportSignal, getRetryEvent: context.getRetryEvent })
162
163
  const settled = Promise.resolve(result.promise)
163
164
  summaries.push(settled)
164
165
  settled.then(report => rememberSuccess(report?.succeededRelays || [])).catch(() => {})