libp2r2p 0.12.0 → 0.12.2

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.
@@ -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(() => {})
@@ -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 routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers })
2423
- this.debugSend('yell', channelPubkey, { code, receiverPubkeys })
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
- onPreparedSeed: this.outgoingSeedHandler(channelPubkey),
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 routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers, signal, attemptStartedAt, onDebug })
2451
- this.debugSend('broadcastRumor', channelPubkey, { receiverPubkeys })
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
- preparedPublication,
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 routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers, signal, attemptStartedAt, onDebug })
2476
- this.debugSend('broadcastEvent', channelPubkey, { receiverPubkeys })
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
- preparedPublication,
2505
+ preparedPublications: prepared.length ? prepared : undefined,
2491
2506
  _getIykcProofs: this.contentKeyLookup()
2492
2507
  })
2493
2508
  } finally { releaseActivity() }
@@ -48,6 +48,11 @@ function replaceErrorMetadata (item, error) {
48
48
  return Object.assign(next, errorMetadata(error))
49
49
  }
50
50
 
51
+ function createdAtMetadata (item) {
52
+ const value = item?.packet?.outer?.created_at ?? item?.outer?.created_at
53
+ return Number.isSafeInteger(value) ? { outerCreatedAt: value } : {}
54
+ }
55
+
51
56
  export function pendingContentKeyGroup (item) {
52
57
  const receiver = item?.receiverPubkey
53
58
  const own = item?.ownContentPubkey
@@ -92,7 +97,7 @@ export async function createPendingDecryption ({ prefix, indexedDB, maxBytes = P
92
97
  const notify = (action, detail) => {
93
98
  report({ action, ...detail })
94
99
  const key = `${action}:${detail.code || detail.reason || ''}`
95
- counts[key] = { count: (counts[key]?.count || 0) + 1, example: detail }
100
+ counts[key] = { count: (counts[key]?.count || 0) + 1, sample: detail }
96
101
  if (!timer) { timer = setTimeout(flush, 1000); timer.unref?.() }
97
102
  }
98
103
  return {
@@ -129,7 +134,16 @@ export async function createPendingDecryption ({ prefix, indexedDB, maxBytes = P
129
134
  if (evictedBytes) notify('discarded', { reason: 'capacity', bytes: evictedBytes })
130
135
  const coordinates = Object.fromEntries(['eventId', 'eventKind', 'senderPubkey', 'receiverPubkey', 'ownContentPubkey', 'peerContentPubkey', 'hasOwnContentKey']
131
136
  .filter(key => ['string', 'boolean', 'number'].includes(typeof error[key])).map(key => [key, error[key]]))
132
- notify('deferred', { ...coordinates, code: error.code, stage: error.stage, id, channelPubkey: item.channelPubkey, capacityBytesBefore: before.usedBytes, capacityBytesAfter: after.usedBytes })
137
+ notify('deferred', {
138
+ ...coordinates,
139
+ ...createdAtMetadata(record),
140
+ code: error.code,
141
+ stage: error.stage,
142
+ id,
143
+ channelPubkey: item.channelPubkey,
144
+ capacityBytesBefore: before.usedBytes,
145
+ capacityBytesAfter: after.usedBytes
146
+ })
133
147
  return true
134
148
  })
135
149
  },
@@ -148,7 +162,7 @@ export async function createPendingDecryption ({ prefix, indexedDB, maxBytes = P
148
162
  recovered (item) {
149
163
  return serial(async () => {
150
164
  await queue.removeBy('byId', item.id)
151
- notify('recovered', { id: item.id, channelPubkey: item.channelPubkey })
165
+ notify('recovered', { id: item.id, channelPubkey: item.channelPubkey, ...createdAtMetadata(item) })
152
166
  })
153
167
  },
154
168
  failed (item, error) {
@@ -161,7 +175,9 @@ export async function createPendingDecryption ({ prefix, indexedDB, maxBytes = P
161
175
  macAttempts: current.macAttempts + (error.code === 'INVALID_MAC' ? 1 : 0)
162
176
  }
163
177
  await queue.putBy('byId', updated, { existingOnly: true })
164
- if (updated.macAttempts >= 3 && item.macAttempts < 3) notify('suspended', { id: item.id, code: error.code, channelPubkey: item.channelPubkey })
178
+ if (updated.macAttempts >= 3 && item.macAttempts < 3) {
179
+ notify('suspended', { id: item.id, code: error.code, channelPubkey: item.channelPubkey, ...createdAtMetadata(updated) })
180
+ }
165
181
  })
166
182
  },
167
183
  clearChannel: channel => serial(() => queue.removeBy('byChannel', channel)),
@@ -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
- The existing `onPreparedSeed` callback now also supplies `publication`. The
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: packet.publication.channelPubkey, peerPubkey: descriptor.peerPubkey,
35
+ fileChannelPubkey: publication.channelPubkey, peerPubkey: descriptor.peerPubkey,
36
36
  chunkIndex: packet.target.chunkIndex,
37
- key: `${descriptor.controlChannelPubkey}:${packet.publication.channelPubkey}:${row.receiverPubkey}:${packet.target.chunkIndex}`
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.publication.events[0].id })
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
- if (entry.prepared && entry.prepared.publication.events[0].id !== seed.publication.events[0].id) throw new Error('PUBLICATION_ARCHIVE_PENDING')
93
- entry.prepared ||= { publication: structuredClone(seed.publication), target, receivedAt: Math.floor(Date.now() / 1000), retentionSeconds, recover: retentionSeconds > 0, accepted: false, archived: retentionSeconds <= 0 }
94
- if (durable.has(entry.prepared)) return
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.prepared.publication.events[0].id,
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 seed of entry.prepared.publication.seeds) await fileTransfers.archiveSeed(entry.prepared.target.file, seed)
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 seed of entry.prepared.publication.seeds) await messenger.enqueueSeed(seed.channelPubkey, seed)
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.prepared.publication.events[0].id,
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
- preparedPublication: entry.prepared?.publication,
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, preparedPublication: entry.prepared?.publication,
596
- onPreparedSeed: async seed => { await journal.stage(entry, seed, target, recover); controller.signal.throwIfAborted() }
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)