libp2r2p 0.11.0 → 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.
@@ -6,7 +6,29 @@ const keyFor = row => `${row.controlChannelPubkey}:${row.fileChannelPubkey}:${ro
6
6
 
7
7
  // A grant contains no payload, key material or retention reference in the file
8
8
  // store. Serialize read/merge/write with other coordinators for this identity.
9
- export function createFileCatalog ({ messenger, now }) {
9
+ export function createFileCatalog ({ messenger, now, storage }) {
10
+ if (storage) {
11
+ const valid = row => {
12
+ const parent = messenger.channels.get(row.controlChannelPubkey)
13
+ return parent?.mode === 'seeder' && row.expiresAt > now() && row.sharedAt + messenger.offlineRecoverySecondsFor(parent) > now()
14
+ }
15
+ return {
16
+ ready: Promise.resolve(),
17
+ async authorize (file, receivers, sharedAt) {
18
+ const { controlChannelPubkey, fileChannelPubkey, peerPubkey, root, size } = file
19
+ for (const receiverPubkey of receivers) {
20
+ const row = { controlChannelPubkey, fileChannelPubkey, peerPubkey, root, size, receiverPubkey, sharedAt, expiresAt: sharedAt + messenger.offlineRecoverySecondsFor(controlChannelPubkey) }
21
+ if (valid(row)) await storage.put(row)
22
+ }
23
+ },
24
+ async find (controlChannelPubkey, fileChannelPubkey, receiverPubkey) {
25
+ const row = await storage.find({ controlChannelPubkey, fileChannelPubkey, receiverPubkey })
26
+ return row && valid(row) ? row : null
27
+ },
28
+ prune: () => storage.prune({ now: now() }),
29
+ async close () {}
30
+ }
31
+ }
10
32
  const prefix = `${messenger.prefix}:file-authorizations`
11
33
  let database
12
34
  const db = () => (database ??= createQueue({ prefix, indexedDB: messenger._indexedDB, evictionPolicy: 'reject', indexes: { key: { keyPath: 'key', unique: true }, channel: 'fileChannelPubkey' } }))
@@ -0,0 +1,31 @@
1
+ import { decodeIrfsChunk } from '../../../irfs/index.js'
2
+ import { ValidationError } from '../../../error/index.js'
3
+
4
+ export function chunkStream ({ read, root, size, signal, release = async () => {} }) {
5
+ let index = 0, total, bytes = 0, settled = false, controller
6
+ const finish = async () => {
7
+ if (settled) return
8
+ settled = true; signal?.removeEventListener('abort', abort)
9
+ await release()
10
+ }
11
+ const abort = () => { if (settled) return; controller.error(signal.reason); finish().catch(() => {}) }
12
+ return new ReadableStream({
13
+ start (value) { controller = value; signal?.addEventListener('abort', abort, { once: true }); if (signal?.aborted) abort() },
14
+ async pull () {
15
+ try {
16
+ signal?.throwIfAborted()
17
+ const event = await read(root, index)
18
+ signal?.throwIfAborted()
19
+ if (settled) return
20
+ if (!event) throw new Error('FILE_UNAVAILABLE')
21
+ const chunk = decodeIrfsChunk(event)
22
+ if (chunk.root !== root || chunk.index !== index || (total !== undefined && total !== chunk.total)) throw new ValidationError('FILE_CHUNK_DESCRIPTOR_MISMATCH')
23
+ total = chunk.total; index++; bytes += chunk.contentBytes.length
24
+ if (index === total && size !== undefined && bytes !== size) throw new ValidationError('FILE_SIZE_MISMATCH')
25
+ controller.enqueue(chunk.contentBytes)
26
+ if (index === total) { await finish(); controller.close() }
27
+ } catch (error) { if (!settled) { await finish(); controller.error(error) } }
28
+ },
29
+ cancel: finish
30
+ }, { highWaterMark: 1 })
31
+ }
@@ -1,4 +1,7 @@
1
1
  import NMMR from 'nmmr'
2
+ import { chunkStream } from './helpers/stream.js'
3
+ import { createFileCache } from './helpers/cache.js'
4
+ export { createFileCache, DEFAULT_FILE_CACHE_BYTES } from './helpers/cache.js'
2
5
  import { createFileCatalog } from './helpers/catalog.js'
3
6
  import { ValidationError } from '../../error/index.js'
4
7
  import { IRFS_CHUNK_BYTES, decodeIrfsChunk } from '../../irfs/index.js'
@@ -60,10 +63,13 @@ function waitForTransfer (promise, signal) {
60
63
  })
61
64
  }
62
65
 
63
- // One coordinator per account. Signer derivation and the local IRFS database
64
- // belong to the caller; recovery, validation and scheduling belong here.
65
- export function createPrivateFileTransfer ({ messenger, resolveChannel, storage, onError = () => {}, _transport = channelTransport, _messages = messages, _hedgeMs = 2000, _idleMs = 30000, _now = now }) {
66
- if (!messenger?.extensions || typeof resolveChannel !== 'function' || !storage?.read || !storage?.save) throw new ValidationError('INVALID_FILE_TRANSFER_OPTIONS')
66
+ // One coordinator per account. The caller resolves signers and may supply
67
+ // storage; recovery, validation and scheduling belong here.
68
+ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage, authorizationStorage, seedStorage, cacheMaxBytes, onError = () => {}, _transport = channelTransport, _messages = messages, _hedgeMs = 2000, _idleMs = 30000, _now = now }) {
69
+ if (!messenger?.extensions || typeof resolveChannel !== 'function' || (storage && (!storage.read || !storage.save))) throw new ValidationError('INVALID_FILE_TRANSFER_OPTIONS')
70
+ const ownsStorage = !storage
71
+ storage ||= createFileCache({ prefix: messenger.prefix, indexedDB: messenger._indexedDB, maxBytes: cacheMaxBytes })
72
+ const uploadLeases = new Map()
67
73
  const registrations = new Map()
68
74
  const jobs = new Map()
69
75
  const listeners = new Set()
@@ -75,7 +81,7 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
75
81
  let closed = false
76
82
  let queuePromise
77
83
  const queue = () => (queuePromise ??= createQueue({ prefix: `${messenger.prefix}:file-seeds`, indexedDB: messenger._indexedDB, maxBytes: 64 * 1024 * 1024, evictionPolicy: 'fifo', indexes: { key: { keyPath: 'key', unique: true }, channel: 'fileChannelPubkey' } }))
78
- const catalog = createFileCatalog({ messenger, now: _now })
84
+ const catalog = createFileCatalog({ messenger, now: _now, storage: authorizationStorage })
79
85
  catalog.ready.catch(onError)
80
86
  const keyFor = (control, root) => `${control}:${root}`
81
87
  const emit = job => { for (const listener of listeners) listener({ root: job.file.root, controlChannelPubkey: job.file.controlChannelPubkey, status: job.status, completed: job.completed, total: job.file.size, error: job.error }) }
@@ -116,6 +122,7 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
116
122
  }
117
123
  async function prune () {
118
124
  await catalog.prune()
125
+ if (seedStorage) { await seedStorage.prune({ now: _now() }); return }
119
126
  if (!queuePromise) return
120
127
  await (await queue()).removeWhere(row => {
121
128
  const parent = messenger.channels.get(row.controlChannelPubkey)
@@ -126,11 +133,13 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
126
133
  if (messenger.channels.get(file.controlChannelPubkey)?.mode !== 'watchtower' || !seedEnabled(file)) return
127
134
  const index = readFileChunkIndex(seed.router)
128
135
  if (index === undefined) return
129
- const db = await queue()
136
+ const db = seedStorage ? null : await queue()
130
137
  for (const row of compactSeedRouterRows(seed)) {
131
138
  if (![file.peerPubkey, messenger.userPubkey].includes(row.receiverPubkey)) continue
132
139
  const key = `${file.controlChannelPubkey}:${file.fileChannelPubkey}:${row.receiverPubkey}:${index}`
133
- await db.putBy('key', { ...row, key, root: file.root, controlChannelPubkey: file.controlChannelPubkey, fileChannelPubkey: file.fileChannelPubkey, peerPubkey: file.peerPubkey, chunkIndex: index, receivedAt: _now(), expiresAt: _now() + messenger.offlineRecoverySecondsFor(file.controlChannelPubkey) })
140
+ const record = { ...row, key, root: file.root, controlChannelPubkey: file.controlChannelPubkey, fileChannelPubkey: file.fileChannelPubkey, peerPubkey: file.peerPubkey, chunkIndex: index, receivedAt: _now(), expiresAt: _now() + messenger.offlineRecoverySecondsFor(file.controlChannelPubkey) }
141
+ if (seedStorage) await seedStorage.put(record)
142
+ else await db.putBy('key', record)
134
143
  }
135
144
  await prune()
136
145
  }
@@ -151,9 +160,17 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
151
160
  usable(file)
152
161
  const decoded = checked(file, event)
153
162
  await catalog.ready
154
- const options = { ...sendOptions(file, await routing(file, file.peerPubkey)), receiverPubkeys: [file.peerPubkey], fileChunkIndex: decoded.index, ...(messenger.channels.get(file.controlChannelPubkey)?.mode === 'watchtower' ? { onPreparedSeed: seed => saveSeed(file, seed) } : {}) }
155
- const result = event.sig ? await _messages.broadcastEvent({ ...options, event }) : await _messages.broadcastRumor({ ...options, rumor: event })
156
- assertPublished(result)
163
+ if (ownsStorage) {
164
+ if (!uploadLeases.has(file.root)) uploadLeases.set(file.root, await storage.reserve(file.root, file.size))
165
+ try { await storage.save(event, file) } catch (error) { await uploadLeases.get(file.root)?.(); uploadLeases.delete(file.root); throw error }
166
+ }
167
+ let result
168
+ try {
169
+ const options = { ...sendOptions(file, await routing(file, file.peerPubkey)), receiverPubkeys: [file.peerPubkey], fileChunkIndex: decoded.index, ...(messenger.channels.get(file.controlChannelPubkey)?.mode === 'watchtower' ? { onPreparedSeed: seed => saveSeed(file, seed) } : {}) }
170
+ result = event.sig ? await _messages.broadcastEvent({ ...options, event }) : await _messages.broadcastRumor({ ...options, rumor: event })
171
+ assertPublished(result)
172
+ } catch (error) { await uploadLeases.get(file.root)?.(); uploadLeases.delete(file.root); throw error }
173
+ if (decoded.index === decoded.total - 1) { await uploadLeases.get(file.root)?.(); uploadLeases.delete(file.root) }
157
174
  // A scheduler turn between chunks lets queued text/control publications run.
158
175
  await new Promise(resolve => setTimeout(resolve, 0))
159
176
  return result
@@ -198,9 +215,11 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
198
215
  return true
199
216
  }
200
217
  if (messenger.channels.get(controlChannelPubkey)?.mode !== 'watchtower') return true
201
- const db = await queue()
218
+ const db = seedStorage ? null : await queue()
202
219
  let packer
203
- for await (const seed of db.storedItemsBy('channel', payload.fileChannelPubkey)) {
220
+ const candidates = seedStorage ? seedStorage.iterate({ controlChannelPubkey, fileChannelPubkey: payload.fileChannelPubkey, receiverPubkey: question.pubkey, indices: [...indices] }) : db.storedItemsBy('channel', payload.fileChannelPubkey)
221
+ for await (const seed of candidates) {
222
+ if (seed.expiresAt <= _now() || seed.receivedAt + messenger.offlineRecoverySecondsFor(controlChannelPubkey) <= _now() || (seedStorage && !await seedStorage.has(seed))) continue
204
223
  if (seed.controlChannelPubkey !== controlChannelPubkey || seed.receiverPubkey !== question.pubkey || !indices.has(seed.chunkIndex) || !seedEnabled(seed)) continue
205
224
  const file = await register(seed)
206
225
  usable(file)
@@ -218,7 +237,7 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
218
237
  async function run (job) {
219
238
  const file = job.file
220
239
  const signal = job.controller.signal
221
- let sub
240
+ let sub, release
222
241
  const historyController = new AbortController()
223
242
  let ingest = Promise.resolve()
224
243
  let total = file.size === undefined ? undefined : Math.ceil(file.size / IRFS_CHUNK_BYTES)
@@ -286,6 +305,7 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
286
305
  return ingest
287
306
  }
288
307
  try {
308
+ release = await storage.reserve?.(file.root, file.size, { signal })
289
309
  job.status = 'downloading'; emit(job)
290
310
  // Resume from independently verified local chunks, never from an ACK.
291
311
  for (let index = 0; index < (total ?? 1); index++) {
@@ -329,7 +349,7 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
329
349
  await new Promise(resolve => { const timer = setTimeout(done, Math.min(100, _hedgeMs)); function done () { clearTimeout(timer); signal.removeEventListener('abort', done); resolve() } signal.addEventListener('abort', done, { once: true }) })
330
350
  }
331
351
  } finally { signal.removeEventListener('abort', stop) }
332
- } finally { historyController.abort(); await sub?.close(); await ingest }
352
+ } finally { historyController.abort(); await sub?.close(); await ingest; await release?.() }
333
353
  if (job.completed !== file.size) throw new ValidationError('FILE_SIZE_MISMATCH')
334
354
  }
335
355
  function pump () {
@@ -366,7 +386,7 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
366
386
  }
367
387
  const key = keyFor(file.controlChannelPubkey, file.root)
368
388
  let job = jobs.get(key)
369
- if (!job || ['error', 'cancelled', 'paused'].includes(job.status)) {
389
+ if (!job || ['error', 'cancelled', 'paused', ...(ownsStorage ? ['complete'] : [])].includes(job.status)) {
370
390
  const controller = new AbortController()
371
391
  const { promise, resolve, reject } = Promise.withResolvers()
372
392
  job = { file, controller, resolve, reject, promise, completed: 0, manual, thumbnail, priority: thumbnail ? 2 : manual ? 1 : 0, status: 'queued' }
@@ -404,10 +424,10 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
404
424
  for (const job of jobs.values()) if (job.pauseRequested && job.status !== 'complete') download(job.file, { manual: job.manual, thumbnail: job.thumbnail }).catch(onError)
405
425
  },
406
426
  unwatch (channels) { for (const job of jobs.values()) if (channels.includes(job.file.controlChannelPubkey)) cancelJob(job) },
407
- async close () { if (closed) return; closed = true; extension.pause(); clearInterval(timer); await Promise.allSettled([...work]); await catalog.close(); if (queuePromise) await (await queuePromise).close(); messenger.extensions.delete(extension); listeners.clear() }
427
+ async close () { if (closed) return; closed = true; extension.pause(); clearInterval(timer); await Promise.allSettled([...work]); await catalog.close(); for (const release of uploadLeases.values()) await release(); uploadLeases.clear(); if (ownsStorage) await storage.close(); if (queuePromise) await (await queuePromise).close(); messenger.extensions.delete(extension); listeners.clear() }
408
428
  }
409
429
  const timer = setInterval(() => { prune().catch(onError) }, 60000)
410
430
  timer.unref?.()
411
431
  messenger.extensions.add(extension)
412
- return { register, authorizeSeeding, publishChunk, download, close: extension.close, cancel (control, root) { const job = jobs.get(keyFor(control, root)); if (job) { job.pauseRequested = false; cancelJob(job) } }, observe (listener) { listeners.add(listener); for (const job of jobs.values()) emit(job); return () => listeners.delete(listener) } }
432
+ return { register, authorizeSeeding, publishChunk, download, readChunk: (root, index) => storage.read(root, index), stream: (descriptor, options = {}) => storage.stream ? storage.stream(descriptor, options) : chunkStream({ read: storage.read.bind(storage), ...descriptor, ...options }), close: extension.close, cancel (control, root) { const job = jobs.get(keyFor(control, root)); if (job) { job.pauseRequested = false; cancelJob(job) } }, observe (listener) { listeners.add(listener); for (const job of jobs.values()) emit(job); return () => listeners.delete(listener) } }
413
433
  }
@@ -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
@@ -348,13 +350,15 @@ export class PrivateMessenger {
348
350
  indexedDB: this._indexedDB
349
351
  })
350
352
  this.assertOpen()
351
- this.seedQueue = await createQueue({
352
- prefix: `${this.prefix}:seeds`,
353
- indexes: SEED_QUEUE_INDEXES,
354
- maxBytes: this.seedQueueMaxBytes,
355
- evictionPolicy: 'fifo',
356
- indexedDB: this._indexedDB
357
- })
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
+ })
358
362
  this.assertOpen()
359
363
  this.stateStore = await createChannelStateStore({
360
364
  prefix: this.prefix,
@@ -1342,6 +1346,16 @@ export class PrivateMessenger {
1342
1346
  async enqueueSeed (channelPubkey, seed) {
1343
1347
  if (!this.offlineRecoverySecondsFor(channelPubkey)) return
1344
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
+ }
1345
1359
  if (seed.recordType === NYM_CARRIER_SEED_RECORD_TYPE || seed.carriers?.length) {
1346
1360
  const carriers = compactSeedNymCarriers(seed.carriers)
1347
1361
  const recordTime = nymCarrierRecordTime({ carriers }) || seed.outer?.created_at || receivedAt
@@ -1932,7 +1946,9 @@ export class PrivateMessenger {
1932
1946
  })
1933
1947
 
1934
1948
  if (this.offlineRecoverySecondsFor(channelPubkey)) {
1935
- 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
1936
1952
  await packer.update(seed)
1937
1953
  }
1938
1954
  }
@@ -2108,7 +2124,7 @@ export class PrivateMessenger {
2108
2124
  this.removeChannelState(pubkey)
2109
2125
  await this.flushStateWrites()
2110
2126
  await this.queue.removeBy('byChannel', pubkey)
2111
- await this.seedQueue.removeBy('byChannel', pubkey)
2127
+ await (this.seedStorage ? this.seedStorage.removeLocal({ channelPubkey: pubkey }) : this.seedQueue.removeBy('byChannel', pubkey))
2112
2128
  await this.touchStorageActivity({ force: true })
2113
2129
  this.ensureRelayListWatcher()
2114
2130
  })
@@ -2134,7 +2150,7 @@ export class PrivateMessenger {
2134
2150
  delete state.channels[pubkey]
2135
2151
  stalePubkeys.push(pubkey)
2136
2152
  await this.queue?.removeBy('byChannel', pubkey)
2137
- await this.seedQueue?.removeBy('byChannel', pubkey)
2153
+ await (this.seedStorage ? this.seedStorage.removeLocal({ channelPubkey: pubkey }) : this.seedQueue?.removeBy('byChannel', pubkey))
2138
2154
  }
2139
2155
  this.state = state
2140
2156
  if (stalePubkeys.length) await this.removeChannelStates(stalePubkeys)
@@ -2142,6 +2158,7 @@ export class PrivateMessenger {
2142
2158
  }
2143
2159
 
2144
2160
  async pruneStoredSeeds (channelPubkey) {
2161
+ if (this.seedStorage) { await this.seedStorage.prune({ now: nowSeconds() }); return }
2145
2162
  if (!this.seedQueue) return
2146
2163
  const keyRange = globalThis.IDBKeyRange
2147
2164
  if (!channelPubkey) {
@@ -188,6 +188,7 @@ function storageDatabaseNames (userPubkey) {
188
188
  `${prefix}:seeds:idb-queue`,
189
189
  `${prefix}:file-seeds:idb-queue`,
190
190
  `${prefix}:file-authorizations:idb-queue`,
191
+ `${prefix}:file-chunks:idb`,
191
192
  `${prefix}:state:idb`
192
193
  ]
193
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
+ }