libp2r2p 0.10.28 → 0.11.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.
@@ -201,6 +201,7 @@ export class PrivateMessenger {
201
201
  maxDynamicRecoverySeeders = DEFAULT_MAX_DYNAMIC_RECOVERY_SEEDERS,
202
202
  messageQueueMaxBytes = DEFAULT_MESSAGE_QUEUE_MAX_BYTES,
203
203
  seedQueueMaxBytes = DEFAULT_SEED_QUEUE_MAX_BYTES,
204
+ seedStorage,
204
205
  temporaryStorageArea = globalThis.sessionStorage,
205
206
  autoDeletionCapability = true,
206
207
  _indexedDB = globalThis.indexedDB,
@@ -232,6 +233,7 @@ export class PrivateMessenger {
232
233
  this.maxDynamicRecoverySeeders = maxDynamicRecoverySeeders
233
234
  this.messageQueueMaxBytes = messageQueueMaxBytes
234
235
  this.seedQueueMaxBytes = seedQueueMaxBytes
236
+ this.seedStorage = seedStorage
235
237
  this.temporaryStorageArea = temporaryStorageArea
236
238
  this.autoDeletionCapability = normalizeAutoDeletionCapability(autoDeletionCapability)
237
239
  this._indexedDB = _indexedDB
@@ -265,6 +267,7 @@ export class PrivateMessenger {
265
267
  this.state = { channels: {} }
266
268
  this.stateWriteTail = Promise.resolve()
267
269
  this.stateWriteError = null
270
+ this.extensions = new Set()
268
271
  this.channels = new Map()
269
272
  this.stopByChannel = new Map()
270
273
  this.desiredChannels = new Set()
@@ -347,13 +350,15 @@ export class PrivateMessenger {
347
350
  indexedDB: this._indexedDB
348
351
  })
349
352
  this.assertOpen()
350
- this.seedQueue = await createQueue({
351
- prefix: `${this.prefix}:seeds`,
352
- indexes: SEED_QUEUE_INDEXES,
353
- maxBytes: this.seedQueueMaxBytes,
354
- evictionPolicy: 'fifo',
355
- indexedDB: this._indexedDB
356
- })
353
+ this.seedQueue = this.seedStorage
354
+ ? null
355
+ : await createQueue({
356
+ prefix: `${this.prefix}:seeds`,
357
+ indexes: SEED_QUEUE_INDEXES,
358
+ maxBytes: this.seedQueueMaxBytes,
359
+ evictionPolicy: 'fifo',
360
+ indexedDB: this._indexedDB
361
+ })
357
362
  this.assertOpen()
358
363
  this.stateStore = await createChannelStateStore({
359
364
  prefix: this.prefix,
@@ -1128,6 +1133,7 @@ export class PrivateMessenger {
1128
1133
 
1129
1134
  unwatch (channels) {
1130
1135
  const pubkeys = channels ? uniq(Array.isArray(channels) ? channels : [channels]) : [...this.desiredChannels]
1136
+ for (const extension of this.extensions) extension.unwatch?.(pubkeys)
1131
1137
  for (const pubkey of pubkeys) this.desiredChannels.delete(pubkey)
1132
1138
  this.recordInterruption(pubkeys)
1133
1139
  for (const controller of this.recoveryControllers) {
@@ -1140,6 +1146,7 @@ export class PrivateMessenger {
1140
1146
  if (typeof reason !== 'string' || !reason.trim()) throw new ValidationError('PAUSE_REASON_REQUIRED')
1141
1147
  this.assertOpen()
1142
1148
  this.pauseReasons.add(reason)
1149
+ for (const extension of this.extensions) extension.pause?.()
1143
1150
  this.recordInterruption(this.desiredChannels)
1144
1151
  for (const controller of this.recoveryControllers) controller.abort()
1145
1152
  return Promise.all([this.stopWatches([...this.desiredChannels]), this.flushStateWrites()])
@@ -1164,6 +1171,7 @@ export class PrivateMessenger {
1164
1171
  if (this.pauseReasons.size || this.closePromise) return
1165
1172
  await this.reconcilePresencePublishers()
1166
1173
  await this.recoverOfflineRanges(channels)
1174
+ for (const extension of this.extensions) await extension.resume?.()
1167
1175
  })()
1168
1176
  this.resumeWork = work
1169
1177
  try { await work } catch (err) {
@@ -1238,6 +1246,7 @@ export class PrivateMessenger {
1238
1246
  async handleAsk (channelPubkey, message) {
1239
1247
  if (message.provenance === 'hearsay') return this.enqueueRumor('message', channelPubkey, message)
1240
1248
  this.trackSeederActivity(channelPubkey, message)
1249
+ for (const extension of this.extensions) if (await extension.handleAsk?.(channelPubkey, message)) return
1241
1250
  if (doesModeStoreRecoverySeeds(this.channels.get(channelPubkey)?.mode) && messageCode(message) === MISSING_MESSAGES_ASK_CODE) {
1242
1251
  await this.replyWithStoredSeeds(channelPubkey, message)
1243
1252
  return
@@ -1329,9 +1338,24 @@ export class PrivateMessenger {
1329
1338
  this.onMessageQueued?.()
1330
1339
  }
1331
1340
 
1341
+ outgoingSeedHandler (channelPubkey) {
1342
+ if (!doesModeStoreRecoverySeeds(this.channels.get(channelPubkey)?.mode) || !this.offlineRecoverySecondsFor(channelPubkey)) return undefined
1343
+ return seed => this.enqueueSeed(channelPubkey, seed)
1344
+ }
1345
+
1332
1346
  async enqueueSeed (channelPubkey, seed) {
1333
1347
  if (!this.offlineRecoverySecondsFor(channelPubkey)) return
1334
1348
  const receivedAt = nowSeconds()
1349
+ if (this.seedStorage) {
1350
+ const rows = seed.recordType === NYM_CARRIER_SEED_RECORD_TYPE || seed.carriers?.length
1351
+ ? [{ recordType: NYM_CARRIER_SEED_RECORD_TYPE, carriers: compactSeedNymCarriers(seed.carriers) }]
1352
+ : compactSeedRouterRows(seed)
1353
+ for (const row of rows) {
1354
+ const time = seedRecordTime(row) || receivedAt
1355
+ await this.seedStorage.put({ ...row, channelPubkey, receivedAt, expiresAt: time + this.offlineRecoverySecondsFor(channelPubkey) })
1356
+ }
1357
+ return
1358
+ }
1335
1359
  if (seed.recordType === NYM_CARRIER_SEED_RECORD_TYPE || seed.carriers?.length) {
1336
1360
  const carriers = compactSeedNymCarriers(seed.carriers)
1337
1361
  const recordTime = nymCarrierRecordTime({ carriers }) || seed.outer?.created_at || receivedAt
@@ -1494,6 +1518,7 @@ export class PrivateMessenger {
1494
1518
  payload,
1495
1519
  error,
1496
1520
  content,
1521
+ onPreparedSeed: this.outgoingSeedHandler(channelPubkey),
1497
1522
  _getIykcProofs: this.contentKeyLookup()
1498
1523
  })
1499
1524
  }
@@ -1520,6 +1545,7 @@ export class PrivateMessenger {
1520
1545
  payload,
1521
1546
  error,
1522
1547
  content,
1548
+ onPreparedSeed: this.outgoingSeedHandler(channelPubkey),
1523
1549
  _getIykcProofs: this.contentKeyLookup()
1524
1550
  })
1525
1551
  }
@@ -1544,6 +1570,7 @@ export class PrivateMessenger {
1544
1570
  payload,
1545
1571
  error,
1546
1572
  content,
1573
+ onPreparedSeed: this.outgoingSeedHandler(channelPubkey),
1547
1574
  _getIykcProofs: this.contentKeyLookup()
1548
1575
  })
1549
1576
  }
@@ -1568,6 +1595,7 @@ export class PrivateMessenger {
1568
1595
  payload,
1569
1596
  error,
1570
1597
  content,
1598
+ onPreparedSeed: this.outgoingSeedHandler(channelPubkey),
1571
1599
  _getIykcProofs: this.contentKeyLookup()
1572
1600
  })
1573
1601
  }
@@ -1588,6 +1616,7 @@ export class PrivateMessenger {
1588
1616
  deletionPubkey,
1589
1617
  autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
1590
1618
  rumor,
1619
+ onPreparedSeed: this.outgoingSeedHandler(channelPubkey),
1591
1620
  _getIykcProofs: this.contentKeyLookup()
1592
1621
  })
1593
1622
  }
@@ -1608,6 +1637,7 @@ export class PrivateMessenger {
1608
1637
  deletionPubkey,
1609
1638
  autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
1610
1639
  event,
1640
+ onPreparedSeed: this.outgoingSeedHandler(channelPubkey),
1611
1641
  _getIykcProofs: this.contentKeyLookup()
1612
1642
  })
1613
1643
  }
@@ -1664,6 +1694,7 @@ export class PrivateMessenger {
1664
1694
  autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
1665
1695
  code: SEEDER_PRESENCE_CODE,
1666
1696
  payload: {},
1697
+ onPreparedSeed: this.outgoingSeedHandler(channelPubkey),
1667
1698
  _getIykcProofs: this.contentKeyLookup()
1668
1699
  })
1669
1700
  }
@@ -1915,7 +1946,9 @@ export class PrivateMessenger {
1915
1946
  })
1916
1947
 
1917
1948
  if (this.offlineRecoverySecondsFor(channelPubkey)) {
1918
- for await (const seed of this.seedQueue.storedItemsBy('byChannel', channelPubkey)) {
1949
+ for await (const seed of this.seedStorage ? this.seedStorage.iterate({ channelPubkey, receiverPubkey: message.event?.pubkey, since, until }) : this.seedQueue.storedItemsBy('byChannel', channelPubkey)) {
1950
+ if (seedRecordTime(seed) < nowSeconds() - this.offlineRecoverySecondsFor(channelPubkey)) continue
1951
+ if (this.seedStorage && !await this.seedStorage.has(seed)) continue
1919
1952
  await packer.update(seed)
1920
1953
  }
1921
1954
  }
@@ -2091,7 +2124,7 @@ export class PrivateMessenger {
2091
2124
  this.removeChannelState(pubkey)
2092
2125
  await this.flushStateWrites()
2093
2126
  await this.queue.removeBy('byChannel', pubkey)
2094
- await this.seedQueue.removeBy('byChannel', pubkey)
2127
+ await (this.seedStorage ? this.seedStorage.removeLocal({ channelPubkey: pubkey }) : this.seedQueue.removeBy('byChannel', pubkey))
2095
2128
  await this.touchStorageActivity({ force: true })
2096
2129
  this.ensureRelayListWatcher()
2097
2130
  })
@@ -2117,7 +2150,7 @@ export class PrivateMessenger {
2117
2150
  delete state.channels[pubkey]
2118
2151
  stalePubkeys.push(pubkey)
2119
2152
  await this.queue?.removeBy('byChannel', pubkey)
2120
- await this.seedQueue?.removeBy('byChannel', pubkey)
2153
+ await (this.seedStorage ? this.seedStorage.removeLocal({ channelPubkey: pubkey }) : this.seedQueue?.removeBy('byChannel', pubkey))
2121
2154
  }
2122
2155
  this.state = state
2123
2156
  if (stalePubkeys.length) await this.removeChannelStates(stalePubkeys)
@@ -2125,6 +2158,7 @@ export class PrivateMessenger {
2125
2158
  }
2126
2159
 
2127
2160
  async pruneStoredSeeds (channelPubkey) {
2161
+ if (this.seedStorage) { await this.seedStorage.prune({ now: nowSeconds() }); return }
2128
2162
  if (!this.seedQueue) return
2129
2163
  const keyRange = globalThis.IDBKeyRange
2130
2164
  if (!channelPubkey) {
@@ -2177,6 +2211,7 @@ export class PrivateMessenger {
2177
2211
  let unwatchError
2178
2212
  try { await unwatchPromise } catch (err) { unwatchError = err }
2179
2213
  await initSettledPromise
2214
+ await Promise.all([...this.extensions].map(extension => extension.close?.()))
2180
2215
  await this.stampActiveChannelActivity()
2181
2216
  await this.queueOperationTail
2182
2217
  await Promise.allSettled([...this.recoveries.values()])
@@ -193,7 +193,7 @@ function compactNymCarriersFromSeed (seed, { since, until }) {
193
193
  }]
194
194
  }
195
195
 
196
- function compactRecordsFromSeed (seed, { receiverPubkey, since, until }) {
196
+ export function compactRecordsFromSeed (seed, { receiverPubkey, since, until }) {
197
197
  if (seed?.recordType === NYM_CARRIER_SEED_RECORD_TYPE) {
198
198
  return compactNymCarriersFromSeed(seed, { since, until })
199
199
  }
@@ -186,6 +186,9 @@ function storageDatabaseNames (userPubkey) {
186
186
  return [
187
187
  `${prefix}:idb-queue`,
188
188
  `${prefix}:seeds:idb-queue`,
189
+ `${prefix}:file-seeds:idb-queue`,
190
+ `${prefix}:file-authorizations:idb-queue`,
191
+ `${prefix}:file-chunks:idb`,
189
192
  `${prefix}:state:idb`
190
193
  ]
191
194
  }
@@ -0,0 +1,39 @@
1
+ # Private message sessions
2
+
3
+ `createPrivateMessageSession(options)` coordinates one identity's DM inbox,
4
+ outbox and IRFS attachments without presentation state. It builds on
5
+ PrivateMessenger and the file coordinator, not another recovery protocol.
6
+
7
+ Provide `owner`, a byte-based NIP-44 v3 `signer` with `withSharedKey`, and either
8
+ `eventStore` or a `messageStorage.save(event, { peerPubkey, hearsay })` adapter.
9
+ Optional `chunkStorage` and `recoveryStorage` (`seeds`, `authorizations`) select
10
+ persistence; absent file storage uses the library cache. `mode` defaults to
11
+ `seeder`, `seedersForPeer(peer)` to `[peer]`, and `allowedKinds` to
12
+ `[5, 9, 1063, 34601]`. The DM context is `dm`; file contexts are `dm:media:<root>`.
13
+ `createMessengerSigner` adapts this byte signer to the transport's Base64 API.
14
+
15
+ Methods: `setPeers`, `setAvailable(boolean)`, `enqueue`, `retry`, `cancel`,
16
+ `download`, `cancelDownload`, and `close`. Callbacks are `onOutbox`, `onMedia`,
17
+ and `onError`. The caller maps its account permissions/connectivity/lock state
18
+ to availability and owns view subscriptions. There are no DOM or UI imports.
19
+
20
+ `enqueue({ peer, event, context, requiredFiles, deletion })` preserves prepared
21
+ identities. Local message commits and remote stages have independent checkpoints.
22
+ It sends original/thumbnail chunks before announcements, yields for small messages,
23
+ and never interprets relay rejection text as signer permission denial. Inbox ACK
24
+ follows durable storage; failed storage nacks and pauses ingestion. Hearsay cannot
25
+ execute an authoritative deletion. Completion of file recovery means verified,
26
+ persisted indices, not a relay ACK.
27
+
28
+ `openOutbox` and `openDownloads` inject work storage. Their default is exported as
29
+ `createPrivateSessionStorage({ owner, signer, indexedDB, namespace, prefix })`:
30
+ IDB queue with unique IDs, encrypted payloads, reject-on-capacity and atomic
31
+ existing-only checkpoints. Default prefix is `libp2r2p:private-messenger:session`;
32
+ namespace is `outbox` or `downloads`. These contain pending user work, not a cache;
33
+ no automatic age eviction or synchronization is performed. `close()` releases
34
+ connections without deleting pending work. Apps can preserve existing storage by
35
+ passing their established factories, as Zillion does.
36
+
37
+ External recovery factories are owned by their caller and must be closed after
38
+ the session. Work storage opened by the session is closed by the session.
39
+ This is a DM profile; group membership/key derivation is not implemented.
@@ -0,0 +1,40 @@
1
+ import { getEventHash } from '../../../event/index.js'
2
+
3
+ // A report describes one outer event, not one relay. Each outer event needs
4
+ // at least one relay acknowledgement. Keep the successful fast path immediate.
5
+ export async function assertMessagePublished (result, event) {
6
+ const reports = result?.delivery?.reports
7
+ const present = Array.isArray(reports) && reports.length > 0
8
+ if (present && reports.every(report => report?.success === true)) return
9
+
10
+ const failures = present
11
+ ? await Promise.all(reports.map(async (report, index) => {
12
+ if (report?.success === true) return null
13
+ let settled
14
+ try { settled = await report?.promise } catch (reason) { settled = { errors: [{ reason }] } }
15
+ // Never retain the broadcast result: it includes plaintext and may carry
16
+ // delivery.deletionSeckey. Native relay errors retain transport causes.
17
+ return {
18
+ index,
19
+ total: settled?.total ?? report?.total,
20
+ fulfilled: settled?.fulfilled,
21
+ errors: (settled?.errors || []).map(({ relay, reason }) => ({ relay, reason }))
22
+ }
23
+ })).then(values => values.filter(Boolean))
24
+ : []
25
+ const reason = present ? 'RELAY_PUBLICATION_FAILED' : 'NO_DELIVERY_REPORTS'
26
+ const details = failures.map(report => {
27
+ const errors = report.errors.map(({ relay, reason }) => {
28
+ const category = reason?.category ? ` [${reason.category}]` : ''
29
+ return `${relay || 'unknown relay'}${category}: ${reason?.message || String(reason)}`
30
+ })
31
+ return `report ${report.index + 1} (${report.total ?? '?'} relays): ${errors.join('; ') || (report.total === 0 ? 'NO_RELAYS' : 'NO_RELAY_ACKNOWLEDGEMENT')}`
32
+ })
33
+ throw Object.assign(new Error(`MESSAGE_NOT_PUBLISHED: ${details.join(' | ') || reason}`), {
34
+ code: 'MESSAGE_NOT_PUBLISHED',
35
+ reason,
36
+ eventId: getEventHash(event),
37
+ eventKind: event.kind,
38
+ reports: failures
39
+ })
40
+ }
@@ -0,0 +1,13 @@
1
+ import { base64ToBytes, bytesToBase64 } from '../../../base64/index.js'
2
+
3
+ // The library transport uses its Base64 signer contract. Conversion stays at
4
+ // this adapter; the injected launcher/vault channel always carries raw bytes.
5
+ export function messengerSigner (signer) {
6
+ return {
7
+ getPublicKey: () => signer.getPublicKey(),
8
+ getRelays: () => signer.getRelays(),
9
+ signEvent: event => signer.signEvent(event),
10
+ nip44v3Encrypt: (peer, kind, scope, text) => signer.nip44v3.encrypt(peer, kind, scope, base64ToBytes(text).buffer),
11
+ nip44v3Decrypt: async (peer, kind, scope, ciphertext) => bytesToBase64(new Uint8Array(await signer.nip44v3.decrypt(peer, kind, scope, ciphertext)))
12
+ }
13
+ }
@@ -0,0 +1,32 @@
1
+ import { createQueue } from '../../../idb-queue/index.js'
2
+
3
+ // Only ciphertext and opaque event IDs are persisted here. Chunks are committed
4
+ // to the configured file store before an entry is accepted.
5
+ export async function createChatOutbox ({ owner, signer, indexedDB = globalThis.indexedDB, namespace = 'outbox', prefix = 'libp2r2p:private-messenger:session' }) {
6
+ const queue = await createQueue({
7
+ prefix: `${prefix}:${namespace}:${owner}`,
8
+ indexes: { byId: { keyPath: 'id', unique: true } },
9
+ evictionPolicy: 'reject',
10
+ indexedDB
11
+ })
12
+ return {
13
+ async put (entry, { existing = false } = {}) {
14
+ const bytes = new TextEncoder().encode(JSON.stringify(entry)).buffer
15
+ const ciphertext = await signer.nip44v3.encrypt(owner, 9, `${prefix}:${namespace}`, bytes)
16
+ return queue.putBy('byId', { id: entry.id, ciphertext }, { existingOnly: existing })
17
+ },
18
+ async list () {
19
+ const entries = []
20
+ for await (const row of queue.storedItemsBy('byId')) {
21
+ const bytes = await signer.nip44v3.decrypt(owner, 9, `${prefix}:${namespace}`, row.ciphertext)
22
+ const entry = JSON.parse(new TextDecoder().decode(bytes))
23
+ if (entry.id !== row.id) throw new Error('INVALID_OUTBOX_ENTRY')
24
+ entries.push(entry)
25
+ }
26
+ return entries
27
+ },
28
+ has: id => queue.someBy('byId', id),
29
+ remove: id => queue.removeBy('byId', id),
30
+ close: () => queue.close()
31
+ }
32
+ }
@@ -0,0 +1,307 @@
1
+ import { createEventStoreChunkStorage, createEventStoreMessageStorage } from '../event-store/index.js'
2
+ import { createPrivateFileTransfer } from '../file/index.js'
3
+ import { createPrivateMessenger } from '../index.js'
4
+ import { getEventHash, isValidEvent, isSerializableEvent } from '../../event/index.js'
5
+ import { decodeIrfsChunk, IRFS_CHUNK_BYTES } from '../../irfs/index.js'
6
+ import { decodeFileMetadata } from '../../nip94/index.js'
7
+ import { createChatOutbox } from './helpers/work-storage.js'
8
+ import { messengerSigner } from './helpers/signer.js'
9
+ import { assertMessagePublished } from './helpers/publication.js'
10
+
11
+ export function wireEvent (value, owner) {
12
+ const event = { kind: value.kind, created_at: value.created_at, tags: structuredClone(value.tags), content: value.content, pubkey: value.pubkey || owner }
13
+ if (value.sig) return { ...event, id: value.id, sig: value.sig }
14
+ return event
15
+ }
16
+ // Relay rejection text must not be interpreted as a local signer denial.
17
+ const retryable = error => error?.code === 'MESSAGE_NOT_PUBLISHED' || !/DENIED|PERMISSION|REVOKED|READ_ONLY|INVALID|BLOCKED|EXPIRED|NOT_IN_PERSONA/i.test(`${error?.code || ''} ${error?.message || ''}`)
18
+
19
+ export function createPrivateMessageSession ({ owner, signer, eventStore, messageStorage, chunkStorage, recoveryStorage, mode = 'seeder', seedersForPeer = peer => [peer], allowedKinds = [5, 9, 1063, 34601], onMedia = () => {}, onOutbox = () => {}, onError = () => {}, Messenger = createPrivateMessenger, openOutbox = createChatOutbox, FileTransfer = createPrivateFileTransfer, openDownloads = options => createChatOutbox({ ...options, namespace: 'downloads' }) }) {
20
+ const userSigner = messengerSigner(signer)
21
+ messageStorage ||= createEventStoreMessageStorage({ eventStore })
22
+ chunkStorage ||= eventStore ? createEventStoreChunkStorage({ eventStore }) : undefined
23
+ const peers = new Set()
24
+ const channels = new Map()
25
+ const deniedPeers = new Set()
26
+ const entries = new Map()
27
+ const cancelled = new Set()
28
+ let messenger
29
+ let fileTransfers
30
+ let downloads
31
+ const downloadIntents = new Map()
32
+ const downloadEpochs = new Map()
33
+ let downloadWriteTail = Promise.resolve()
34
+ const writeDownloadIntent = operation => {
35
+ const work = downloadWriteTail.catch(() => {}).then(operation)
36
+ downloadWriteTail = work
37
+ return work
38
+ }
39
+ let storage
40
+ let initialized
41
+ let closed = false
42
+ let available = false
43
+ let lifecycle = 0
44
+ let configuring = Promise.resolve()
45
+ let sending
46
+ let draining
47
+ const idleWaiters = new Set()
48
+ const settled = () => { if (!sending && !draining) { for (const resolve of idleWaiters) resolve(); idleWaiters.clear() } }
49
+ const emit = () => onOutbox([...entries.values()].filter(entry => !entry.deletion))
50
+ const ready = () => (initialized ??= (async () => {
51
+ storage = await openOutbox({ owner, signer })
52
+ for (const entry of await storage.list()) entries.set(entry.id, entry)
53
+ emit()
54
+ })().catch(async error => { await storage?.close(); storage = null; initialized = null; throw error }))
55
+ async function configure () {
56
+ if (closed || !available) return
57
+ await ready()
58
+ const version = lifecycle
59
+ const values = []
60
+ for (const peer of peers) {
61
+ if (deniedPeers.has(peer)) continue
62
+ let channel = channels.get(peer)
63
+ if (!channel) {
64
+ try {
65
+ const scoped = messengerSigner(signer.withSharedKey(peer, 'dm'))
66
+ channel = { signer: scoped, pubkey: await scoped.getPublicKey(), mode, seeders: seedersForPeer(peer) }
67
+ channels.set(peer, channel)
68
+ } catch (error) { if (!retryable(error)) deniedPeers.add(peer); onError(error); continue }
69
+ }
70
+ if (closed || !available || version !== lifecycle) return
71
+ values.push(channel)
72
+ }
73
+ if (!messenger) messenger = await Messenger({ seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys: false, onMessageQueued: () => drain(), onError })
74
+ if (closed || !available || version !== lifecycle) { await messenger.pause('signer'); return }
75
+ await messenger.update({ channels: values })
76
+ await messenger.resume('signer')
77
+ if (!fileTransfers) {
78
+ fileTransfers = FileTransfer({
79
+ messenger, onError,
80
+ resolveChannel: ({ peerPubkey, info }) => messengerSigner(signer.withSharedKey(peerPubkey, info)),
81
+ storage: chunkStorage,
82
+ seedStorage: recoveryStorage?.seeds,
83
+ authorizationStorage: recoveryStorage?.authorizations
84
+ })
85
+ fileTransfers.observe(state => {
86
+ const peer = [...channels].find(([, value]) => value.pubkey === state.controlChannelPubkey)?.[0]
87
+ if (peer) {
88
+ onMedia({ ...state, peer })
89
+ if (state.status === 'complete') {
90
+ const id = intentId({ peer, root: state.root })
91
+ if (downloadIntents.delete(id)) writeDownloadIntent(() => downloads?.remove(id)).catch(onError)
92
+ }
93
+ }
94
+ })
95
+ downloads = await openDownloads({ owner, signer })
96
+ for (const entry of await downloads.list()) downloadIntents.set(entry.id, entry)
97
+ }
98
+ for (const entry of downloadIntents.values()) if (peers.has(entry.file.peer)) download(entry.file, { manual: true }).catch(onError)
99
+ drain(); pump()
100
+ }
101
+ function schedule () {
102
+ const work = configuring.catch(() => {}).then(configure)
103
+ configuring = work
104
+ work.catch(onError)
105
+ return work
106
+ }
107
+ async function drain () {
108
+ if (draining || closed || !available || !messenger) return
109
+ draining = true
110
+ const deferred = []
111
+ try {
112
+ while (true) {
113
+ if (closed || !available) break
114
+ const delivery = await messenger.nextMessage()
115
+ if (!delivery) break
116
+ const { message, ack, nack } = delivery
117
+ try {
118
+ const peer = [...channels].find(([, value]) => value.pubkey === message.channelPubkey)?.[0]
119
+ if (!peer) { deferred.push(nack); continue }
120
+ if (message.senderPubkey !== peer) { await ack(); continue }
121
+ if (!peers.has(peer)) { deferred.push(nack); continue }
122
+ const event = wireEvent(message.event, peer)
123
+ const valid = event.pubkey === peer || message.provenance === 'hearsay' || message.provenance === 'signed'
124
+ if (!valid || !isSerializableEvent(event) || !allowedKinds.includes(event.kind) || (event.sig && !isValidEvent(event)) || getEventHash(event) !== message.event.id) { await ack(); continue }
125
+ if (event.kind === 34601) {
126
+ try { decodeIrfsChunk(event) } catch (error) { onError(error); await ack(); continue }
127
+ }
128
+ if (event.kind === 5 && (message.provenance === 'hearsay' || event.pubkey !== peer)) { await ack(); continue }
129
+ if (!available || closed) { await nack(); break }
130
+ const saved = await messageStorage.save(event, { peerPubkey: peer, hearsay: message.provenance === 'hearsay' })
131
+ if (!saved?.result?.ok && !['blocked', 'expired'].includes(saved?.result?.code)) throw Object.assign(new Error('INBOX_STORAGE_FAILED'), { code: saved?.result?.code?.toUpperCase() })
132
+ await ack()
133
+ } catch (error) {
134
+ await nack()
135
+ onError(error)
136
+ // Retain the failed record and suspend network ingestion until recovery.
137
+ await messenger.pause('storage')
138
+ break
139
+ }
140
+ }
141
+ } finally { await Promise.all(deferred.map(nack => nack())); draining = false; settled() }
142
+ }
143
+ async function publish (peer, event) {
144
+ if (deniedPeers.has(peer)) throw new Error('PERMISSION_DENIED')
145
+ if (!available || !peers.has(peer) || !channels.has(peer)) throw new Error('CHAT_UNAVAILABLE')
146
+ const options = { channelPubkey: channels.get(peer)?.pubkey, receiverPubkeys: [peer] }
147
+ const report = event.sig ? await messenger.broadcastEvent({ ...options, event }) : await messenger.broadcastRumor({ ...options, rumor: event })
148
+ // Published is relay acceptance, never a peer receipt.
149
+ await assertMessagePublished(report, event)
150
+ }
151
+ async function sendEntry (entry) {
152
+ entry.status = 'pending'; emit()
153
+ try {
154
+ const active = async () => {
155
+ if (storage.has && !await storage.has(entry.id)) cancelled.add(entry.id)
156
+ if (closed || !available || cancelled.has(entry.id) || (entry.peer !== owner && !peers.has(entry.peer))) throw new Error('CHAT_UNAVAILABLE')
157
+ }
158
+ // Personal copies must commit even if publishing attachment bytes
159
+ // fails offline. Remote progress remains independently retryable.
160
+ for (let index = 0; index < entry.events.length; index++) {
161
+ await active()
162
+ const event = entry.events[index]
163
+ if (!entry.localSaved[index]) {
164
+ const hearsay = !event.sig && event.pubkey !== owner
165
+ const result = await messageStorage.save(event, { peerPubkey: entry.peer, hearsay })
166
+ if (result?.result?.code === 'blocked') { cancelled.add(entry.id); await active() }
167
+ if (!result?.result?.ok) throw Object.assign(new Error('MESSAGE_STORAGE_FAILED'), { code: result?.result?.code?.toUpperCase() })
168
+ entry.localSaved[index] = true
169
+ await storage.put(entry, { existing: true })
170
+ }
171
+ }
172
+ // Read/publish one chunk at a time. Progress is durable after each
173
+ // accepted chunk; replay after interruption keeps its original identity.
174
+ for (; entry.peer !== owner && entry.fileIndex < entry.files.length; entry.fileIndex++, entry.chunkIndex = 0) {
175
+ const file = entry.files[entry.fileIndex]
176
+ await active()
177
+ await fileTransfers.authorizeSeeding({ controlChannelPubkey: channels.get(entry.peer).pubkey, peerPubkey: entry.peer, root: file.root, size: file.size }, { receiverPubkeys: [entry.peer], sharedAt: entry.event.created_at })
178
+ for (; entry.chunkIndex < Math.ceil(file.size / IRFS_CHUNK_BYTES); entry.chunkIndex++) {
179
+ await active()
180
+ const chunk = chunkStorage ? await chunkStorage.read(file.root, entry.chunkIndex, file) : await fileTransfers.readChunk(file.root, entry.chunkIndex)
181
+ if (!chunk || decodeIrfsChunk(chunk).root !== file.root) { if (file.optional) break; throw new Error('FILE_UNAVAILABLE') }
182
+ if (!fileTransfers) throw new Error('FILE_TRANSFER_UNAVAILABLE')
183
+ await fileTransfers.publishChunk({ controlChannelPubkey: channels.get(entry.peer).pubkey, peerPubkey: entry.peer, root: file.root, size: file.size }, wireEvent(chunk, owner))
184
+ const completed = entry.files.slice(0, entry.fileIndex).reduce((sum, value) => sum + value.size, 0) + Math.min(file.size, (entry.chunkIndex + 1) * IRFS_CHUNK_BYTES)
185
+ entry.uploadProgress = { completed, total: entry.files.reduce((sum, value) => sum + value.size, 0) }
186
+ emit()
187
+ await active()
188
+ await storage.put({ ...entry, chunkIndex: entry.chunkIndex + 1 }, { existing: true })
189
+ // Newly queued text and deletion controls can pass an in-flight file.
190
+ let admitted = 0
191
+ for (const pending of entries.values()) {
192
+ if (closed || !available || admitted >= 8) break
193
+ if (pending.files.length || pending.failed || cancelled.has(pending.id) || (pending.peer !== owner && !peers.has(pending.peer))) continue
194
+ admitted++
195
+ await sendEntry(pending)
196
+ }
197
+ }
198
+ }
199
+ for (; entry.index < entry.events.length; entry.index++) {
200
+ await active()
201
+ const event = entry.events[entry.index]
202
+ if (entry.peer !== owner) await publish(entry.peer, event)
203
+ await active()
204
+ await storage.put({ ...entry, index: entry.index + 1 }, { existing: true })
205
+ }
206
+ await storage.remove(entry.id)
207
+ entries.delete(entry.id)
208
+ emit()
209
+ } catch (error) {
210
+ if (cancelled.has(entry.id)) { await storage.remove(entry.id); entries.delete(entry.id) } else { entry.status = 'error'; entry.failed = true; entry.retryable = retryable(error); await storage.put(entry, { existing: true }).catch(onError); onError(error) }
211
+ emit()
212
+ }
213
+ }
214
+ async function pump () {
215
+ if (sending || !available || closed || !messenger || !storage) return
216
+ sending = true
217
+ try {
218
+ for (const entry of entries.values()) {
219
+ if (closed || !available) break
220
+ if ((entry.peer !== owner && !peers.has(entry.peer)) || entry.failed || cancelled.has(entry.id)) continue
221
+ await sendEntry(entry)
222
+ }
223
+ } finally { sending = false; settled() }
224
+ }
225
+ const intentId = file => getEventHash({ kind: 0, pubkey: owner, created_at: 0, tags: [], content: `${file.peer}:${file.root}` })
226
+ async function download (file, options = {}) {
227
+ if (file.peer === owner) return file
228
+ if (!available || closed || !peers.has(file.peer) || !fileTransfers) throw new Error('CHAT_UNAVAILABLE')
229
+ const id = intentId(file)
230
+ if (options.manual) {
231
+ const epoch = downloadEpochs.get(id) || 0
232
+ const cancelled = () => { if ((downloadEpochs.get(id) || 0) !== epoch) throw new DOMException('Download cancelled', 'AbortError') }
233
+ await writeDownloadIntent(async () => {
234
+ cancelled()
235
+ await downloads.put({ id, file })
236
+ cancelled()
237
+ downloadIntents.set(id, { id, file })
238
+ })
239
+ }
240
+ if (!available || closed || !peers.has(file.peer)) throw new Error('CHAT_UNAVAILABLE')
241
+ const result = await fileTransfers.download({ controlChannelPubkey: channels.get(file.peer).pubkey, peerPubkey: file.peer, root: file.root, size: file.size, sharedAt: file.sharedAt }, options)
242
+ if (downloadIntents.delete(id)) await writeDownloadIntent(() => downloads.remove(id))
243
+ return result
244
+ }
245
+ async function cancelDownload (file) {
246
+ fileTransfers?.cancel(channels.get(file.peer)?.pubkey, file.root)
247
+ const id = intentId(file)
248
+ downloadEpochs.set(id, (downloadEpochs.get(id) || 0) + 1)
249
+ downloadIntents.delete(id)
250
+ await writeDownloadIntent(() => downloads?.remove(id))
251
+ }
252
+ return {
253
+ download, cancelDownload,
254
+ async setPeers (values) {
255
+ peers.clear(); for (const peer of values) if (peer !== owner) peers.add(peer)
256
+ for (const peer of deniedPeers) if (!peers.has(peer)) deniedPeers.delete(peer)
257
+ lifecycle++
258
+ return schedule()
259
+ },
260
+ async setAvailable (value) {
261
+ available = value === true
262
+ lifecycle++
263
+ if (!available) { await messenger?.pause('signer'); return }
264
+ await ready()
265
+ for (const entry of entries.values()) if (entry.retryable) entry.failed = false
266
+ await messenger?.resume('storage')
267
+ return schedule()
268
+ },
269
+ async enqueue ({ peer, event, context = [], requiredFiles = [], deletion = false }) {
270
+ if (closed || !available || (peer !== owner && !peers.has(peer))) throw new Error('CHAT_UNAVAILABLE')
271
+ await ready()
272
+ const main = wireEvent(event, owner)
273
+ const id = getEventHash(main)
274
+ const events = [...context.map(value => wireEvent(value, owner)), main]
275
+ const files = events.filter(value => value.kind === 1063).map(value => ({ ...decodeFileMetadata(value), optional: !requiredFiles.includes(getEventHash(value)) })).filter(file => file.service === 'irfs').flatMap(file => [file, ...(file.thumbnail?.root ? [{ ...file.thumbnail, optional: file.optional }] : [])])
276
+ const entry = { id, peer, event: { ...main, id }, events, files, index: 0, fileIndex: 0, chunkIndex: 0, localSaved: context.map(() => true).concat(false), status: 'pending', deletion }
277
+ await storage.put(entry)
278
+ entries.set(id, entry); emit(); pump()
279
+ return id
280
+ },
281
+ async retry (id) {
282
+ await ready()
283
+ const entry = entries.get(id)
284
+ if (entry) { entry.failed = false; if (deniedPeers.delete(entry.peer)) await schedule(); return pump() }
285
+ },
286
+ async cancel (id) {
287
+ cancelled.add(id)
288
+ await ready()
289
+ entries.delete(id)
290
+ await storage.remove(id)
291
+ emit()
292
+ },
293
+ async close () {
294
+ closed = true; available = false; lifecycle++
295
+ await Promise.allSettled([configuring, initialized])
296
+ await messenger?.pause('closed')
297
+ if (sending || draining) await new Promise(resolve => idleWaiters.add(resolve))
298
+ await messenger?.close()
299
+ await storage?.close()
300
+ await downloadWriteTail.catch(onError)
301
+ await downloads?.close()
302
+ }
303
+ }
304
+ }
305
+
306
+ export { createChatOutbox as createPrivateSessionStorage } from './helpers/work-storage.js'
307
+ export { messengerSigner as createMessengerSigner } from './helpers/signer.js'