libp2r2p 0.10.27 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -534,6 +534,14 @@ returned events; received messages may already have been queued. This is a
534
534
  history completeness failure, not evidence that an outgoing message failed.
535
535
  Existing rewatch/resume and relay-list refresh paths retry pending intervals;
536
536
  there is no dedicated periodic retry timer for a failed history fetch.
537
+ Subscription relay errors forwarded to `onError` include `relay` when known
538
+ and `operation: 'private-channel.subscribe'`. A per-notification wrapper retains
539
+ the native message, name, code, category and WebSocket close fields; `cause`
540
+ references the untouched original error, including its stack and causes.
541
+ Aggregate errors keep their nested errors. This also works with frozen/shared
542
+ errors without leaking attribution between subscribers. Missing relay metadata
543
+ is not inferred from the selected relay list. This does not change reconnection,
544
+ delivery or error classification; processing errors keep their existing path.
537
545
  `lastSeenAt` tracks messages persisted in the incoming queue, not app commits.
538
546
  The default recovery window is seven days; recovery still depends on available
539
547
  relay/seeder data, and retention limits cannot guarantee indefinite delivery.
@@ -882,3 +890,20 @@ complete per-relay report without delaying the first-success return. Connection
882
890
  establishment retains its separate 3-second deadline. PrivateMessenger inherits
883
891
  this publication policy, including seeder/recovery messages. A timeout indicates
884
892
  missing acknowledgement and cannot prove that the recipient did not receive it.
893
+
894
+ ## Private file channels (0.11)
895
+
896
+ `libp2r2p/private-messenger/file` provides `createPrivateFileTransfer` for
897
+ separate per-root media channels, bounded indexed recovery and persisted-byte
898
+ progress. [The file API and protocol](private-messenger/file/README.md) document
899
+ signer/storage adapters and lifecycle. All original and thumbnail chunks belong
900
+ to `dm:media:<root>`; small authenticated recovery requests stay on `dm`.
901
+ NIP-94 supports `thumbnail` metadata with unordered `mark`/`size` root extensions.
902
+
903
+ This release changes the encrypted router recipient tag from `r` to `p` without
904
+ legacy fallback. Upgrade communicating consumers together. Inner ask/reply/tell
905
+ `r` tags, file-reference `r` tags, and carrier 26400 are unchanged. Seeders now
906
+ capture outgoing ordinary-message ciphertext before publishing, even without relay
907
+ echo. For files, seeders instead keep expiring recipient authorizations and serve
908
+ verified local chunks as `irfsChunk_v1`; watchtowers retain ciphertext seeds.
909
+ No group derivation or multi-device seed replication is introduced.
package/nip27/index.js CHANGED
@@ -201,6 +201,7 @@ const NIP94_TAGS = {
201
201
  magnet: ['magnet'],
202
202
  i: ['i'],
203
203
  blurhash: ['blurhash'],
204
+ thumbhash: ['thumbhash'],
204
205
  thumb: ['thumb'],
205
206
  image: ['image'],
206
207
  summary: ['summary'],
package/nip94/README.md CHANGED
@@ -38,3 +38,16 @@ A bare/empty/duplicate/invalid value throws `INVALID_MEDIA_METADATA_DOWNLOAD`;
38
38
  `extractMedia()` carries the string flag for ordinary URLs and nfile URLs,
39
39
  including long nfile entities and `?localOnly=1`. Metadata fragments do not
40
40
  change the bytes or root of a file; clients strip them from download routes.
41
+
42
+ ## Thumbnails
43
+
44
+ `thumbnail: { url, root?, size?, sha256? }` emits a `thumb` tag and, when a root
45
+ is supplied, `['r', root, 'mark thumb', 'size <bytes>']`. `sha256` is the optional
46
+ third thumb-tag value. Original `r` stays unmarked. Decode validates nfile/root
47
+ agreement separately for original and thumbnail.
48
+
49
+ Only `r[1]` has a fixed position. `parseRootReference(tag)` parses the variadic
50
+ fields by their names; `mark` and `size` may appear in either order. Unknown
51
+ extensions are ignored. Duplicate recognized fields, ambiguous roots and unsafe
52
+ sizes are rejected. ThumbHash remains unchanged Base64, including through
53
+ percent-encoded NIP-27 `thumbhash` fragments; legacy `blurhash` still parses.
package/nip94/index.js CHANGED
@@ -19,10 +19,36 @@ export function decodeFileMetadata (event) {
19
19
  try { url = new URL(result.url) } catch { throw new ValidationError('INVALID_FILE_METADATA_URL') }
20
20
  if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password) throw new ValidationError('INVALID_FILE_METADATA_URL')
21
21
  if (typeof result.mime !== 'string' || !/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/.test(result.mime)) throw new ValidationError('INVALID_FILE_METADATA_MIME')
22
- for (const [tag, key] of [['r', 'root'], ['service', 'service'], ['thumbhash', 'thumbhash'], ['x', 'sha256'], ['ox', 'originalSha256'], ['alt', 'alt']]) {
22
+ for (const [tag, key] of [['service', 'service'], ['thumbhash', 'thumbhash'], ['x', 'sha256'], ['ox', 'originalSha256'], ['alt', 'alt']]) {
23
23
  const value = field(tag)
24
24
  if (value !== undefined) result[key] = value
25
25
  }
26
+ const roots = event.tags.filter(tag => Array.isArray(tag) && tag[0] === 'r').map(parseRootReference)
27
+ const originals = roots.filter(ref => ref.mark === undefined)
28
+ const thumbnails = roots.filter(ref => ref.mark === 'thumb')
29
+ if (originals.length > 1 || thumbnails.length > 1) throw new ValidationError('INVALID_FILE_METADATA_ROOT_REFERENCE')
30
+ if (originals.length) result.root = originals[0].root
31
+ const thumbUrl = field('thumb')
32
+ if (thumbUrl !== undefined) {
33
+ const thumbnail = { url: thumbUrl }
34
+ let parsed
35
+ try { parsed = new URL(thumbUrl) } catch { throw new ValidationError('INVALID_FILE_METADATA_THUMBNAIL') }
36
+ if (!['https:', 'http:'].includes(parsed.protocol) || parsed.username || parsed.password) throw new ValidationError('INVALID_FILE_METADATA_THUMBNAIL')
37
+ if (thumbnails.length) {
38
+ thumbnail.root = thumbnails[0].root
39
+ if (thumbnails[0].size !== undefined) thumbnail.size = thumbnails[0].size
40
+ }
41
+ const hash = event.tags.find(tag => tag[0] === 'thumb')?.[2]
42
+ if (hash !== undefined) {
43
+ if (!/^[0-9a-f]{64}$/.test(hash)) throw new ValidationError('INVALID_FILE_METADATA_HASH')
44
+ thumbnail.sha256 = hash
45
+ }
46
+ if (parsed.origin === 'https://nostr.alt') {
47
+ const file = nfileDecode(parsed.pathname.slice(1))
48
+ if (thumbnail.root && thumbnail.root !== file.root) throw new ValidationError('FILE_METADATA_NFILE_MISMATCH')
49
+ }
50
+ result.thumbnail = thumbnail
51
+ } else if (thumbnails.length) throw new ValidationError('INVALID_FILE_METADATA_THUMBNAIL')
26
52
  for (const key of ['root', 'sha256', 'originalSha256']) if (result[key] !== undefined && !/^[0-9a-f]{64}$/.test(result[key])) throw new ValidationError('INVALID_FILE_METADATA_HASH')
27
53
  const size = field('size')
28
54
  if (size !== undefined) {
@@ -49,7 +75,35 @@ export function createFileMetadata ({ caption = '', created_at: createdAt = Math
49
75
  if (metadata.download !== undefined && !['0', '1'].includes(metadata.download)) throw new ValidationError('INVALID_FILE_METADATA_DOWNLOAD')
50
76
  const fields = [['download', metadata.download], ['url', metadata.url], ['m', metadata.mime], ['r', metadata.root], ['size', metadata.size], ['service', metadata.service], ['thumbhash', metadata.thumbhash], ['x', metadata.sha256], ['ox', metadata.originalSha256], ['alt', metadata.alt]]
51
77
  if (metadata.width !== undefined || metadata.height !== undefined) fields.push(['dim', `${metadata.width}x${metadata.height}`])
52
- const event = { kind: FILE_METADATA, created_at: createdAt, content: caption, tags: [...fields.filter(([, value]) => value !== undefined).map(([key, value]) => [key, String(value)]), ...tags.map(tag => [...tag])] }
78
+ if (metadata.thumbnail !== undefined) {
79
+ const thumb = metadata.thumbnail
80
+ if (!thumb || typeof thumb !== 'object') throw new ValidationError('INVALID_FILE_METADATA_THUMBNAIL')
81
+ fields.push(thumb.sha256 === undefined ? ['thumb', thumb.url] : ['thumb', thumb.url, thumb.sha256])
82
+ if (thumb.root !== undefined) fields.push(['r', thumb.root, 'mark thumb', ...(thumb.size === undefined ? [] : [`size ${thumb.size}`])])
83
+ }
84
+ const event = { kind: FILE_METADATA, created_at: createdAt, content: caption, tags: [...fields.filter(([, value]) => value !== undefined).map(([key, ...values]) => [key, ...values.map(String)]), ...tags.map(tag => [...tag])] }
53
85
  decodeFileMetadata(event)
54
86
  return event
55
87
  }
88
+
89
+ // Only the root has a fixed position; extensions are unordered key/value fields.
90
+ export function parseRootReference (tag) {
91
+ if (!Array.isArray(tag) || tag[0] !== 'r' || typeof tag[1] !== 'string' || !/^[0-9a-f]{64}$/.test(tag[1])) throw new ValidationError('INVALID_FILE_METADATA_ROOT_REFERENCE')
92
+ const result = { root: tag[1] }
93
+ for (const field of tag.slice(2)) {
94
+ if (typeof field !== 'string') throw new ValidationError('INVALID_FILE_METADATA_ROOT_REFERENCE')
95
+ const separator = field.indexOf(' ')
96
+ const key = separator < 0 ? field : field.slice(0, separator)
97
+ if (!['mark', 'size'].includes(key)) continue
98
+ if (Object.hasOwn(result, key) || separator < 0) throw new ValidationError('INVALID_FILE_METADATA_ROOT_REFERENCE')
99
+ const value = field.slice(separator + 1)
100
+ if (key === 'mark') {
101
+ if (!value) throw new ValidationError('INVALID_FILE_METADATA_ROOT_REFERENCE')
102
+ result.mark = value
103
+ } else {
104
+ if (!/^(0|[1-9][0-9]*)$/.test(value) || !Number.isSafeInteger(Number(value))) throw new ValidationError('INVALID_FILE_METADATA_SIZE')
105
+ result.size = Number(value)
106
+ }
107
+ }
108
+ return result
109
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.10.27",
3
+ "version": "0.11.0",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -86,6 +86,7 @@
86
86
  "./private-channel": "./private-channel/index.js",
87
87
  "./private-message": "./private-message/index.js",
88
88
  "./private-messenger": "./private-messenger/index.js",
89
+ "./private-messenger/file": "./private-messenger/file/index.js",
89
90
  "./private-messenger/recovery": "./private-messenger/recovery/index.js",
90
91
  "./relay": "./relay/index.js",
91
92
  "./temporary-storage": "./temporary-storage/index.js",
@@ -16,7 +16,7 @@ function routerPlaintextByteLengthForChunk (jsonlByteLength) {
16
16
  kind: ROUTER_KIND,
17
17
  pubkey: SAMPLE_PUBKEY,
18
18
  created_at: MAX_TIME_SECONDS,
19
- tags: [['f', SAMPLE_PUBKEY], ['imkc', SAMPLE_PUBKEY, `${MAX_TIME_SECONDS}:${SAMPLE_SIGNATURE}`], ['c', MAX_CHUNK_TAG_VALUE, MAX_CHUNK_TAG_VALUE], ['r', SAMPLE_PUBKEY]],
19
+ tags: [['f', SAMPLE_PUBKEY], ['imkc', SAMPLE_PUBKEY, `${MAX_TIME_SECONDS}:${SAMPLE_SIGNATURE}`], ['c', MAX_CHUNK_TAG_VALUE, MAX_CHUNK_TAG_VALUE], ['p', SAMPLE_PUBKEY], ['i', String(Number.MAX_SAFE_INTEGER)]],
20
20
  content: 'A'.repeat(base64EncodedByteLength(jsonlByteLength)),
21
21
  id: SAMPLE_PUBKEY,
22
22
  sig: SAMPLE_SIGNATURE
@@ -156,7 +156,7 @@ function setPreparedRow (id, index, row, temporaryStorage) {
156
156
  storageFor(temporaryStorage).setItem(rowTempKey(id, index), row)
157
157
  }
158
158
 
159
- function readPreparedRow (preparedRows, index) {
159
+ export function readPreparedRow (preparedRows, index) {
160
160
  const row = storageFor(preparedRows.temporaryStorage).getItem(rowTempKey(preparedRows.id, index))
161
161
  if (typeof row !== 'string') throw new ValidationError('MISSING_PREPARED_ROW')
162
162
  return row
@@ -12,7 +12,7 @@ export function eventByteLength (event) {
12
12
  }
13
13
 
14
14
  export function readReceiverTag (event) {
15
- return event.tags?.find(t => t[0] === 'r')?.[1] || ''
15
+ return event.tags?.find(t => t[0] === 'p')?.[1] || ''
16
16
  }
17
17
 
18
18
  export function readSenderTag (event) {
@@ -47,14 +47,18 @@ export function readChunkTag (event) {
47
47
  return { index, total }
48
48
  }
49
49
 
50
- export function makeRouterEvent ({ pubkey, senderPubkey, imkcPubkey, imkcProof, receiverPubkey, chunkIndex, chunkTotal, content }) {
50
+ export function makeRouterEvent ({ pubkey, senderPubkey, imkcPubkey, imkcProof, receiverPubkey, chunkIndex, chunkTotal, fileChunkIndex, content }) {
51
51
  const tags = [['f', senderPubkey]]
52
52
  if (imkcPubkey) {
53
53
  if (!imkcProof) throw new ValidationError('INVALID_IMKC_PROOF')
54
54
  tags.push(['imkc', imkcPubkey, imkcProof])
55
55
  }
56
+ if (fileChunkIndex !== undefined) {
57
+ if (!Number.isSafeInteger(fileChunkIndex) || fileChunkIndex < 0) throw new ValidationError('INVALID_FILE_CHUNK_INDEX')
58
+ tags.push(['i', String(fileChunkIndex)])
59
+ }
56
60
  tags.push(['c', String(chunkIndex), String(chunkTotal)])
57
- if (receiverPubkey) tags.push(['r', receiverPubkey])
61
+ if (receiverPubkey) tags.push(['p', receiverPubkey])
58
62
  return { kind: ROUTER_KIND, pubkey, created_at: nowSeconds(), tags, content }
59
63
  }
60
64
 
@@ -67,3 +71,10 @@ export function makeNymCarrierEvent ({ innerId, chunkIndex, chunkTotal, content,
67
71
  content
68
72
  }
69
73
  }
74
+
75
+ export function readFileChunkIndex (event) {
76
+ const tags = event.tags?.filter(tag => tag[0] === 'i') || []
77
+ if (!tags.length) return undefined
78
+ if (tags.length !== 1 || tags[0].length !== 2 || !/^(0|[1-9][0-9]*)$/.test(tags[0][1]) || !Number.isSafeInteger(Number(tags[0][1]))) throw new ValidationError('INVALID_FILE_CHUNK_INDEX')
79
+ return Number(tags[0][1])
80
+ }
@@ -0,0 +1,14 @@
1
+ // Relay errors can be shared by multiple subscriptions or be frozen. Attach
2
+ // consumer context to a new error and retain the untouched original as cause.
3
+ export function subscriptionError (reason, relay) {
4
+ const message = reason?.message ?? String(reason)
5
+ const error = reason instanceof AggregateError
6
+ ? new AggregateError(reason.errors, message, { cause: reason })
7
+ : new Error(message, { cause: reason })
8
+ for (const key of ['name', 'code', 'category', 'closeCode', 'closeReason', 'wasClean']) {
9
+ if (reason?.[key] !== undefined) error[key] = reason[key]
10
+ }
11
+ error.operation = 'private-channel.subscribe'
12
+ if (typeof relay === 'string') error.relay = relay
13
+ return error
14
+ }
@@ -7,6 +7,7 @@ import { getIykcProofs } from '../content-key/index.js'
7
7
  import { ValidationError } from '../error/index.js'
8
8
  import { normalizeRumor, deliveryInfo } from './helpers/rumor.js'
9
9
  import { incompleteFetchError } from './helpers/fetch-error.js'
10
+ import { subscriptionError } from './helpers/subscription-error.js'
10
11
  import * as nip44v3 from '../nip44-v3/index.js'
11
12
  import { relayPool } from '../relay/index.js'
12
13
  import { JSONL_CHUNK_BYTES, NYM_CARRIER_CHUNK_CHARS } from './helpers/chunk-size.js'
@@ -17,12 +18,13 @@ import {
17
18
  prepareEnvelopeRows,
18
19
  preparedRowIndexesForReceivers,
19
20
  readChunkContent,
21
+ readPreparedRow,
20
22
  receiverPubkeys,
21
23
  receiverPubkeysWithoutContentKeys,
22
24
  writeChunksFromPreparedRows
23
25
  } from './helpers/chunks.js'
24
26
  import { EXPIRATION_SECONDS, MAX_EVENT_BYTES, NYM_CARRIER_KIND, PRIVATE_BROADCAST_KIND, ROUTER_KIND } from './constants/index.js'
25
- import { eventByteLength, hasImkcTag, makeNymCarrierEvent, makeRouterEvent, nowSeconds, readChunkTag, readIdTag, readImkcProof, readImkcTag, readReceiverTag, readSenderTag } from './helpers/event.js'
27
+ import { eventByteLength, hasImkcTag, makeNymCarrierEvent, makeRouterEvent, nowSeconds, readChunkTag, readFileChunkIndex, readIdTag, readImkcProof, readImkcTag, readReceiverTag, readSenderTag } from './helpers/event.js'
26
28
  import { createReceivedChunkStore, DEFAULT_RECEIVED_CHUNK_MAX_BYTES, DEFAULT_RECEIVED_CHUNK_TTL_MS } from './services/received-chunks.js'
27
29
 
28
30
  export { EXPIRATION_SECONDS, MAX_EVENT_BYTES, NYM_CARRIER_KIND, PRIVATE_BROADCAST_KIND, ROUTER_KIND } from './constants/index.js'
@@ -130,7 +132,7 @@ async function prepareRoutedMessage ({ senderSigner, imkcSigner, privateChannelS
130
132
  }
131
133
  }
132
134
 
133
- async function * wrapPreparedEvents ({ privateChannelSigner, receivers, receiverTag, deletionPubkey, expirationSeconds = EXPIRATION_SECONDS, context }) {
135
+ async function * wrapPreparedEvents ({ privateChannelSigner, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey, expirationSeconds = EXPIRATION_SECONDS, context }) {
134
136
  const routerSeckey = generateSecretKey()
135
137
  const routerPubkey = getPublicKey(routerSeckey)
136
138
  const receiverPubkeyList = receiverPubkeys(receivers)
@@ -143,6 +145,13 @@ async function * wrapPreparedEvents ({ privateChannelSigner, receivers, receiver
143
145
  const temporaryStorage = context.preparedRows.temporaryStorage
144
146
 
145
147
  try {
148
+ if (onPreparedSeed) {
149
+ const router = makeRouterEvent({ pubkey: routerPubkey, senderPubkey: context.senderPubkey, imkcPubkey: context.imkcPubkey, imkcProof: context.imkcProof, receiverPubkey: routerReceiverTag, chunkIndex: 0, chunkTotal: 1, fileChunkIndex, content: '' })
150
+ const payloadRow = readPreparedRow(context.preparedRows, 0)
151
+ for (const rowIndex of rowIndexes) {
152
+ await onPreparedSeed({ channelPubkey: context.channelPubkey, router, jsonl: `${payloadRow}\n${readPreparedRow(context.preparedRows, rowIndex)}\n` })
153
+ }
154
+ }
146
155
  for (let index = 0; index < total; index++) {
147
156
  const content = readChunkContent(id, index, temporaryStorage)
148
157
  const router = finalizeEvent(makeRouterEvent({
@@ -151,6 +160,7 @@ async function * wrapPreparedEvents ({ privateChannelSigner, receivers, receiver
151
160
  imkcPubkey: context.imkcPubkey,
152
161
  imkcProof: context.imkcProof,
153
162
  receiverPubkey: routerReceiverTag,
163
+ fileChunkIndex,
154
164
  chunkIndex: index,
155
165
  chunkTotal: total,
156
166
  content
@@ -171,7 +181,7 @@ async function * wrapPreparedEvents ({ privateChannelSigner, receivers, receiver
171
181
  }
172
182
 
173
183
  // Streaming version of wrapEvent
174
- export async function * wrapEvents ({ senderSigner, imkcSigner, privateChannelSigner = senderSigner, privateChannelReaderPubkey, receivers, receiverTag, deletionPubkey, event, expirationSeconds = EXPIRATION_SECONDS, temporaryStorageArea, _getIykcProofs = getIykcProofs }) {
184
+ export async function * wrapEvents ({ senderSigner, imkcSigner, privateChannelSigner = senderSigner, privateChannelReaderPubkey, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey, event, expirationSeconds = EXPIRATION_SECONDS, temporaryStorageArea, _getIykcProofs = getIykcProofs }) {
175
185
  const normalizedDeletionPubkey = normalizeDeletionPubkey(deletionPubkey)
176
186
  const context = await prepareRoutedMessage({
177
187
  senderSigner,
@@ -184,7 +194,7 @@ export async function * wrapEvents ({ senderSigner, imkcSigner, privateChannelSi
184
194
  _getIykcProofs
185
195
  })
186
196
  try {
187
- yield * wrapPreparedEvents({ privateChannelSigner, receivers, receiverTag, deletionPubkey: normalizedDeletionPubkey, expirationSeconds, context })
197
+ yield * wrapPreparedEvents({ privateChannelSigner, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey: normalizedDeletionPubkey, expirationSeconds, context })
188
198
  } finally {
189
199
  cleanupEnvelopeRows(context.preparedRows)
190
200
  }
@@ -414,6 +424,12 @@ export async function unwrapEvent ({ receiverSigner, privateChannelSigner = rece
414
424
  channelReaderSigner,
415
425
  channelReaderPubkey: privateChannelReaderPubkey
416
426
  })
427
+ return unwrapRouterEvent({ router, receiverSigner, receiverPubkey, channelPubkey })
428
+ }
429
+
430
+ // Recovery records contain an encrypted recipient row, not the original outer
431
+ // signature. Authenticate the control response before invoking this decoder.
432
+ export async function unwrapRouterEvent ({ router, receiverSigner, receiverPubkey, channelPubkey }) {
417
433
  if (router.kind !== ROUTER_KIND) throw new ValidationError('INVALID_ROUTER_KIND')
418
434
  if (receiverPubkey && readReceiverTag(router) && readReceiverTag(router) !== receiverPubkey) return null
419
435
 
@@ -505,7 +521,7 @@ function withRecoveryRelays (relays, recoveryRelays) {
505
521
  return uniq([...(relays || []), ...(recoveryRelays || [])])
506
522
  }
507
523
 
508
- export async function publish ({ senderSigner, imkcSigner, privateChannelSigner = senderSigner, privateChannelReaderPubkey, receivers, receiverTag, deletionPubkey, event, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, _getIykcProofs = getIykcProofs, _publish = sendToRelays }) {
524
+ export async function publish ({ senderSigner, imkcSigner, privateChannelSigner = senderSigner, privateChannelReaderPubkey, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey, event, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, _getIykcProofs = getIykcProofs, _publish = sendToRelays }) {
509
525
  const normalizedDeletionPubkey = normalizeDeletionPubkey(deletionPubkey)
510
526
  const results = []
511
527
  // Relays are grouped only when they have the exact same recipient pubkey set
@@ -523,7 +539,7 @@ export async function publish ({ senderSigner, imkcSigner, privateChannelSigner
523
539
  })
524
540
  try {
525
541
  for (const group of groups) {
526
- for await (const wrappedEvent of wrapPreparedEvents({ privateChannelSigner, receivers: group.receivers, receiverTag, deletionPubkey: normalizedDeletionPubkey, expirationSeconds, context })) {
542
+ for await (const wrappedEvent of wrapPreparedEvents({ privateChannelSigner, receivers: group.receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey: normalizedDeletionPubkey, expirationSeconds, context })) {
527
543
  results.push(await _publish(wrappedEvent, withRecoveryRelays(group.relays, recoveryRelays)))
528
544
  }
529
545
  }
@@ -533,7 +549,7 @@ export async function publish ({ senderSigner, imkcSigner, privateChannelSigner
533
549
  return results
534
550
  }
535
551
 
536
- for await (const wrappedEvent of wrapEvents({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag, deletionPubkey: normalizedDeletionPubkey, event, expirationSeconds, temporaryStorageArea, _getIykcProofs })) {
552
+ for await (const wrappedEvent of wrapEvents({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey: normalizedDeletionPubkey, event, expirationSeconds, temporaryStorageArea, _getIykcProofs })) {
537
553
  results.push(await _publish(wrappedEvent, withRecoveryRelays(relays, recoveryRelays)))
538
554
  }
539
555
  return results
@@ -764,6 +780,7 @@ function createProcessor ({
764
780
  const senderPubkey = readSenderTag(router)
765
781
  if (receiverPubkey && readReceiverTag(router) && readReceiverTag(router) !== receiverPubkey && senderPubkey !== receiverPubkey) return
766
782
  const { index, total } = readChunkTag(router)
783
+ readFileChunkIndex(router)
767
784
  groupKey = receivedChunks.groupKeyFor(channelPubkey, router.pubkey)
768
785
  if (ignoredGroups.has(groupKey)) return
769
786
 
@@ -772,6 +789,7 @@ function createProcessor ({
772
789
  const meta = await receivedChunks.put({
773
790
  channelPubkey,
774
791
  routerPubkey: router.pubkey,
792
+ descriptor: JSON.stringify(router.tags.filter(tag => tag[0] !== 'c')),
775
793
  index,
776
794
  total,
777
795
  contentBytes: base64ToBytes(router.content),
@@ -962,7 +980,7 @@ export function subscribe ({ receiverSigner, iykcSigner, privateChannelSigner =
962
980
  try {
963
981
  for await (const item of events) {
964
982
  if (controller.signal.aborted) continue
965
- if (item.type === 'error') onError?.(item.error)
983
+ if (item.type === 'error') onError?.(subscriptionError(item.error, item.relay))
966
984
  else if (item.type === 'event') await processOuterEvent(item.event)
967
985
  }
968
986
  } catch (error) {
@@ -975,6 +993,7 @@ export function subscribe ({ receiverSigner, iykcSigner, privateChannelSigner =
975
993
  const consumePromise = consumeEvents()
976
994
 
977
995
  return {
996
+ ready: events.ready || Promise.resolve(),
978
997
  close () {
979
998
  controller.abort()
980
999
  return consumePromise
@@ -135,6 +135,7 @@ function normalizeMeta (meta, fallbackTtlMs = DEFAULT_RECEIVED_CHUNK_TTL_MS) {
135
135
  return {
136
136
  groupKey: String(meta.groupKey),
137
137
  channelPubkey: String(meta.channelPubkey || ''),
138
+ descriptor: meta.descriptor,
138
139
  routerPubkey: String(meta.routerPubkey || ''),
139
140
  total,
140
141
  received: normalizeReceived(meta.received),
@@ -274,7 +275,7 @@ export function createReceivedChunkStore ({
274
275
  return cleanupStaleRaw(nowMs)
275
276
  }
276
277
 
277
- async function putOnce ({ channelPubkey, routerPubkey, index, total, contentBytes, ttlMs }) {
278
+ async function putOnce ({ channelPubkey, routerPubkey, index, total, contentBytes, ttlMs, descriptor }) {
278
279
  const groupKey = groupKeyFor(channelPubkey, routerPubkey)
279
280
  const bytes = normalizeBytes(contentBytes)
280
281
  const now = Date.now()
@@ -287,6 +288,7 @@ export function createReceivedChunkStore ({
287
288
  await deleteGroupInTransaction(tx, groupKey, usage, meta)
288
289
  meta = null
289
290
  }
291
+ if (meta && meta.descriptor !== descriptor) throw new ValidationError('INCONSISTENT_ROUTER_DESCRIPTOR')
290
292
  if (meta && meta.total !== total) {
291
293
  await deleteGroupInTransaction(tx, groupKey, usage, meta)
292
294
  meta = null
@@ -296,6 +298,7 @@ export function createReceivedChunkStore ({
296
298
  groupKey,
297
299
  channelPubkey,
298
300
  routerPubkey,
301
+ descriptor,
299
302
  total,
300
303
  received: {},
301
304
  receivedCount: 0,
@@ -364,7 +367,7 @@ export function createReceivedChunkStore ({
364
367
  })
365
368
  }
366
369
 
367
- async function put ({ channelPubkey, routerPubkey, index, total, contentBytes, ttlMs }) {
370
+ async function put ({ channelPubkey, routerPubkey, index, total, contentBytes, ttlMs, descriptor }) {
368
371
  if (!channelPubkey || !routerPubkey) throw new ValidationError('RECEIVED_CHUNK_GROUP_REQUIRED')
369
372
  if (!Number.isSafeInteger(index) || !Number.isSafeInteger(total) || index < 0 || total < 1 || index >= total) {
370
373
  throw new ValidationError('INVALID_RECEIVED_CHUNK_INDEX')
@@ -373,7 +376,7 @@ export function createReceivedChunkStore ({
373
376
  await ready()
374
377
  while (true) {
375
378
  try {
376
- const result = await putOnce({ channelPubkey, routerPubkey, index, total, contentBytes: bytes, ttlMs })
379
+ const result = await putOnce({ channelPubkey, routerPubkey, index, total, contentBytes: bytes, ttlMs, descriptor })
377
380
  if (result.tooLarge) throw new Error('RECEIVED_CHUNK_GROUP_TOO_LARGE')
378
381
  return result.meta
379
382
  } catch (err) {
@@ -517,10 +517,12 @@ async function sendPrivateMessage ({
517
517
  temporaryStorageArea,
518
518
  deletionPubkey,
519
519
  _getIykcProofs,
520
+ fileChunkIndex,
521
+ onPreparedSeed,
520
522
  _publish = privateChannel.publish
521
523
  }) {
522
524
  if (!privateChannelSigner?.getPublicKey) throw new ValidationError('PRIVATE_CHANNEL_WRITER_REQUIRED')
523
- return _publish({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag, deletionPubkey, event, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, _getIykcProofs })
525
+ return _publish({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag, deletionPubkey, event, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, fileChunkIndex, onPreparedSeed, _getIykcProofs })
524
526
  }
525
527
 
526
528
  async function sendNymMessage ({
@@ -560,6 +562,8 @@ export async function ask ({
560
562
  deletionPubkey,
561
563
  deletionSeckey,
562
564
  autoDeletionCapability = true,
565
+ fileChunkIndex,
566
+ onPreparedSeed,
563
567
  _publish = privateChannel.publish,
564
568
  _assertWatching
565
569
  }) {
@@ -583,7 +587,7 @@ export async function ask ({
583
587
  })
584
588
  })
585
589
  const deletion = resolveDeletionCapability({ deletionPubkey, deletionSeckey, autoDeletionCapability })
586
- const reports = await sendPrivateMessage({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers: [receiverPubkey], receiverTag: receiverPubkey, deletionPubkey: deletion.deletionPubkey, event: wireEvent, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, _getIykcProofs, _publish })
590
+ const reports = await sendPrivateMessage({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers: [receiverPubkey], receiverTag: receiverPubkey, deletionPubkey: deletion.deletionPubkey, event: wireEvent, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, fileChunkIndex, onPreparedSeed, _getIykcProofs, _publish })
587
591
 
588
592
  return withDelivery({ question }, reports, deletion.deletionSeckey)
589
593
  }
@@ -609,6 +613,8 @@ export async function reply ({
609
613
  deletionPubkey,
610
614
  deletionSeckey,
611
615
  autoDeletionCapability = true,
616
+ fileChunkIndex,
617
+ onPreparedSeed,
612
618
  _publish = privateChannel.publish
613
619
  }) {
614
620
  if (!question?.id) throw new ValidationError('QUESTION_REQUIRED')
@@ -622,7 +628,7 @@ export async function reply ({
622
628
  })
623
629
  })
624
630
  const deletion = resolveDeletionCapability({ deletionPubkey, deletionSeckey, autoDeletionCapability })
625
- const reports = await sendPrivateMessage({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers: [receiverPubkey], receiverTag: receiverPubkey, deletionPubkey: deletion.deletionPubkey, event: wireEvent, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, _getIykcProofs, _publish })
631
+ const reports = await sendPrivateMessage({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers: [receiverPubkey], receiverTag: receiverPubkey, deletionPubkey: deletion.deletionPubkey, event: wireEvent, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, fileChunkIndex, onPreparedSeed, _getIykcProofs, _publish })
626
632
  return withDelivery({ reply: event }, reports, deletion.deletionSeckey)
627
633
  }
628
634
 
@@ -646,6 +652,8 @@ export async function tell ({
646
652
  deletionPubkey,
647
653
  deletionSeckey,
648
654
  autoDeletionCapability = true,
655
+ fileChunkIndex,
656
+ onPreparedSeed,
649
657
  _publish = privateChannel.publish
650
658
  }) {
651
659
  if (!receiverPubkey) throw new ValidationError('RECEIVER_PUBKEY_REQUIRED')
@@ -658,7 +666,7 @@ export async function tell ({
658
666
  })
659
667
  })
660
668
  const deletion = resolveDeletionCapability({ deletionPubkey, deletionSeckey, autoDeletionCapability })
661
- const reports = await sendPrivateMessage({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers: [receiverPubkey], receiverTag: receiverPubkey, deletionPubkey: deletion.deletionPubkey, event: wireEvent, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, _getIykcProofs, _publish })
669
+ const reports = await sendPrivateMessage({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers: [receiverPubkey], receiverTag: receiverPubkey, deletionPubkey: deletion.deletionPubkey, event: wireEvent, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, fileChunkIndex, onPreparedSeed, _getIykcProofs, _publish })
662
670
  return withDelivery({ tell: event }, reports, deletion.deletionSeckey)
663
671
  }
664
672
 
@@ -682,6 +690,8 @@ export async function yell ({
682
690
  deletionPubkey,
683
691
  deletionSeckey,
684
692
  autoDeletionCapability = true,
693
+ fileChunkIndex,
694
+ onPreparedSeed,
685
695
  _publish = privateChannel.publish
686
696
  }) {
687
697
  const receivers = uniq(receiverPubkeys)
@@ -695,7 +705,7 @@ export async function yell ({
695
705
  })
696
706
  })
697
707
  const deletion = resolveDeletionCapability({ deletionPubkey, deletionSeckey, autoDeletionCapability })
698
- const reports = await sendPrivateMessage({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag: '', deletionPubkey: deletion.deletionPubkey, event: wireEvent, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, _getIykcProofs, _publish })
708
+ const reports = await sendPrivateMessage({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag: '', deletionPubkey: deletion.deletionPubkey, event: wireEvent, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, fileChunkIndex, onPreparedSeed, _getIykcProofs, _publish })
699
709
  return withDelivery({ yell: event }, reports, deletion.deletionSeckey)
700
710
  }
701
711
 
@@ -715,13 +725,15 @@ export async function broadcastRumor ({
715
725
  deletionPubkey,
716
726
  deletionSeckey,
717
727
  autoDeletionCapability = true,
728
+ fileChunkIndex,
729
+ onPreparedSeed,
718
730
  _publish = privateChannel.publish
719
731
  }) {
720
732
  const receivers = uniq(receiverPubkeys)
721
733
  if (!receivers.length) throw new ValidationError('NO_RECEIVERS')
722
734
  const { event, wireEvent } = await makeOutgoingRumor({ senderSigner, rumor })
723
735
  const deletion = resolveDeletionCapability({ deletionPubkey, deletionSeckey, autoDeletionCapability })
724
- const reports = await sendPrivateMessage({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag: '', deletionPubkey: deletion.deletionPubkey, event: wireEvent, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, _getIykcProofs, _publish })
736
+ const reports = await sendPrivateMessage({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag: '', deletionPubkey: deletion.deletionPubkey, event: wireEvent, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, fileChunkIndex, onPreparedSeed, _getIykcProofs, _publish })
725
737
  return withDelivery({ rumor: event }, reports, deletion.deletionSeckey)
726
738
  }
727
739
 
@@ -741,13 +753,15 @@ export async function broadcastEvent ({
741
753
  deletionPubkey,
742
754
  deletionSeckey,
743
755
  autoDeletionCapability = true,
756
+ fileChunkIndex,
757
+ onPreparedSeed,
744
758
  _publish = privateChannel.publish
745
759
  }) {
746
760
  const receivers = uniq(receiverPubkeys)
747
761
  if (!receivers.length) throw new ValidationError('NO_RECEIVERS')
748
762
  const wireEvent = assertValidSignedEvent({ ...event, tags: cloneTags(event?.tags) })
749
763
  const deletion = resolveDeletionCapability({ deletionPubkey, deletionSeckey, autoDeletionCapability })
750
- const reports = await sendPrivateMessage({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag: '', deletionPubkey: deletion.deletionPubkey, event: wireEvent, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, _getIykcProofs, _publish })
764
+ const reports = await sendPrivateMessage({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag: '', deletionPubkey: deletion.deletionPubkey, event: wireEvent, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, fileChunkIndex, onPreparedSeed, _getIykcProofs, _publish })
751
765
  return withDelivery({ event: wireEvent }, reports, deletion.deletionSeckey)
752
766
  }
753
767
 
@@ -0,0 +1,127 @@
1
+ # Private file transfer
2
+
3
+ `createPrivateFileTransfer({ messenger, resolveChannel, storage, onError })`
4
+ creates one account coordinator attached to an initialized PrivateMessenger.
5
+ It does not create permanent watched channels for files. The application resolves
6
+ channel signers and supplies `storage.read(root, index, descriptor)` and
7
+ `storage.save(event, descriptor)`. The latter must resolve only after persistence.
8
+ The descriptor is `{ controlChannelPubkey, peerPubkey, root, size?, sharedAt? }`.
9
+ `sharedAt` is a stable sharing timestamp, never a read/retry timestamp.
10
+
11
+ For one-to-one chats, resolve the signer with
12
+ `withSharedKey(peerPubkey, fileChannelInfo(root))`: the canonical context is
13
+ `dm:media:<lowercase MMR root>`. Original and thumbnail roots use separate
14
+ channels under the same convention. Do not put this context in a public tag or
15
+ append it to the thumbnail URL. This is not a group-key derivation scheme.
16
+
17
+ - `register(descriptor)` resolves and checks the file channel without watching it.
18
+ - `authorizeSeeding(descriptor, { receiverPubkeys, sharedAt })` durably grants
19
+ the listed identities indexed recovery of this root on this control/data pair.
20
+ The application calls it for an explicit share, using its persisted message
21
+ timestamp (standalone 1063 uses its own timestamp). Registration, cache hits
22
+ and incoming requests never grant access. Missing router `p` is not a wildcard.
23
+ - `publishChunk(descriptor, event)` checks a 34601 proof and the descriptor,
24
+ captures ciphertext only in watchtower mode, and publishes to the recipient's relays.
25
+ Every wrapper must receive at least one relay acceptance. Yield between calls
26
+ so text/control sends can run. Publish all file chunks before announcing 1063/9.
27
+ - `download(descriptor, { manual, thumbnail, signal })` returns the verified,
28
+ persisted file descriptor. Automatic originals are limited to 1 MiB and
29
+ automatic thumbnails to 51,000 bytes. Larger/unknown originals require action;
30
+ already complete local files remain accessible. Unknown-size manual downloads
31
+ bootstrap their total from a verified chunk and learn size from the last one.
32
+ - `observe(callback)` returns an unsubscribe function. Updates include control
33
+ channel, root, status, persisted useful bytes (`completed`), total and errors.
34
+ - `cancel(controlChannelPubkey, root)` cancels the shared transfer; individual
35
+ caller signals only stop that caller waiting. Partial chunks are retained.
36
+ - `close()` releases subscriptions, timers and storage. Messenger pause/unwatch/
37
+ close also stop work; resume restarts paused transfers, never explicit cancels.
38
+
39
+ There are at most two active downloads per coordinator; thumbnails precede
40
+ manual originals, then small automatic originals. Open live input before reading
41
+ history or asking seeders. A failed historical read does not block peer recovery.
42
+ Deduplication and completion depend on verified, persisted chunk indices, not
43
+ publication ACKs or a terminal response. Ordinary DM left-edge temporal recovery
44
+ is unchanged.
45
+
46
+ ## Recovery protocol
47
+
48
+ The authenticated direct request travels on the conversation's `dm` channel:
49
+
50
+ ```js
51
+ { code: 'fileChunksRequest_p5cc', payload: {
52
+ fileChannelPubkey,
53
+ missingRanges: [[0, 15]] // inclusive, sorted, disjoint; at most 16 indices
54
+ } }
55
+ ```
56
+
57
+ A seeder replies from `storage.read`, skipping unavailable indices and validating
58
+ local proofs before sending. Each JSONL record is:
59
+
60
+ ```js
61
+ { recordType: 'irfsChunk_v1', index, total, proof, content }
62
+ ```
63
+
64
+ `proof` and `content` use Base93. The receiver reconstructs a 34601 template,
65
+ checks its proof against the expected root, and persists it without claiming the
66
+ original announcer authored the reconstructed event. The outer reply remains
67
+ individually encrypted for its recipient. Watchtowers continue replying with
68
+ `routerEnvelopeRow_v1`; they need not decrypt original bytes.
69
+
70
+ The reply travels on the **file** channel, with code `fileChunksReply_p5cc` and
71
+ the existing compact-record JSONL packer's `index`, `isLast`, and `jsonl` fields.
72
+ The inner reply's `q` references the request event. No new terminal availability
73
+ message is required and `isLast` never establishes file completeness. Late
74
+ responses from authorized seeders remain useful during an active download.
75
+ Seed replies run outside the DM dispatcher, with at most two reply workers and
76
+ sixteen queued requests; workers yield between records so control traffic can
77
+ continue. Requests discarded at the queue bound can be retried.
78
+
79
+ Request one seeder first and another after two seconds, recalculating missing
80
+ indices. A new persisted chunk resets the inactivity clock; duplicates do not.
81
+ After contacting all selected seeders, 30 seconds without progress makes the
82
+ transfer retryable. Each new attempt rechecks local storage and relay history.
83
+
84
+ Router 26300 uses `p` for its optional recipient, `f` for transport sender,
85
+ optional `imkc`, `c` for outer fragmentation, and `i` for the IRFS chunk index.
86
+ Repeat `i` across every fragment. It survives seed compaction, and must agree
87
+ with the validated 34601. It is encrypted within the router, never a public tag.
88
+ Carrier 26400 continues to use only `id`/`c`; inner control recipient `r` and
89
+ NIP-94 file-reference `r` are unchanged. There is no router `r` read fallback.
90
+ The existing 65,536-byte event ceiling includes the additional router overhead.
91
+
92
+ ## Storage and scope
93
+
94
+ Seeder authorizations live in
95
+ `libp2r2p:private-messenger:<owner>:file-authorizations:idb-queue`, with `key`
96
+ (unique control/data/recipient) and `channel` indexes. Records hold root, peer,
97
+ optional size, recipient, `sharedAt`, and absolute `expiresAt`; no ciphertext,
98
+ chunk bytes, signer or file-retention reference. The queue rejects capacity
99
+ failures instead of silently evicting grants. Grants survive restart and expire
100
+ at the original sharing time plus parent retention (default seven days), subject
101
+ to a subsequently shorter parent policy. Only a newer explicit share renews them.
102
+
103
+ Direct validated, persisted deliveries may authorize their actual local receiver
104
+ using the descriptor's `sharedAt`. Cache reads and recovery replies never renew
105
+ or create grants. Applications must not infer authorization for a third-party
106
+ announcer from its knowledge of a root. Group membership/revocation is not part
107
+ of this DM implementation; future group requests also need current membership.
108
+
109
+ Serving requires both live authorization and locally available chunks. The
110
+ catalog never pins NostrDB roots or recreates missing bytes. Checks run before
111
+ reads and again before each publication. Shared cleanup removes invalid grants
112
+ at startup and every minute while running; expired grants are immediately
113
+ unusable even before physical cleanup. Identity cleanup includes the catalog.
114
+
115
+ Watchtower ciphertext remains in
116
+ `libp2r2p:private-messenger:<owner>:file-seeds:idb-queue`, with its existing
117
+ 64 MiB FIFO budget and parent retention. The authorization catalog starts empty;
118
+ there is no migration or compatibility path for unpublished seeder file seeds.
119
+
120
+ Seed responses require the matching control/data channel pair and requester.
121
+ A channel holder can group traffic by its public channel author; no plaintext
122
+ root/index tag is exposed. This does not hide timing or repeated transfers of
123
+ the same pair/root.
124
+
125
+ Multi-device seed synchronization and group-channel derivation remain separate
126
+ follow-ups. Applications own durable manual-download intent, metadata retention
127
+ and UI policy. Removing one message must not delete a root retained by another.
@@ -0,0 +1,65 @@
1
+ import { ValidationError } from '../../../error/index.js'
2
+ import { createQueue } from '../../../idb-queue/index.js'
3
+
4
+ const localLocks = new WeakMap()
5
+ const keyFor = row => `${row.controlChannelPubkey}:${row.fileChannelPubkey}:${row.receiverPubkey}`
6
+
7
+ // A grant contains no payload, key material or retention reference in the file
8
+ // store. Serialize read/merge/write with other coordinators for this identity.
9
+ export function createFileCatalog ({ messenger, now }) {
10
+ const prefix = `${messenger.prefix}:file-authorizations`
11
+ let database
12
+ const db = () => (database ??= createQueue({ prefix, indexedDB: messenger._indexedDB, evictionPolicy: 'reject', indexes: { key: { keyPath: 'key', unique: true }, channel: 'fileChannelPubkey' } }))
13
+ function lock (work) {
14
+ if (globalThis.navigator?.locks?.request) return navigator.locks.request(prefix, work)
15
+ let locks = localLocks.get(messenger._indexedDB)
16
+ if (!locks) localLocks.set(messenger._indexedDB, (locks = new Map()))
17
+ const task = (locks.get(prefix) || Promise.resolve()).catch(() => {}).then(work)
18
+ locks.set(prefix, task.catch(() => {}))
19
+ return task
20
+ }
21
+ function isValid (row) {
22
+ const parent = messenger.channels.get(row.controlChannelPubkey)
23
+ return parent?.mode === 'seeder' && row.expiresAt > now() && row.sharedAt + messenger.offlineRecoverySecondsFor(parent) > now()
24
+ }
25
+ async function put (file, receiverPubkey, sharedAt, expiresAt) {
26
+ const { controlChannelPubkey, fileChannelPubkey, peerPubkey, root, size } = file
27
+ const row = { controlChannelPubkey, fileChannelPubkey, peerPubkey, root, size, receiverPubkey, sharedAt, expiresAt }
28
+ row.key = keyFor(row)
29
+ if (!isValid(row)) return
30
+ const queue = await db()
31
+ const prior = await queue.getBy('key', row.key)
32
+ if (prior && (prior.root !== root || prior.peerPubkey !== peerPubkey)) throw new ValidationError('FILE_AUTHORIZATION_CONFLICT')
33
+ // Replaying an older share cannot shorten or renew a newer grant.
34
+ if (prior && prior.sharedAt >= sharedAt && isValid(prior)) return
35
+ await queue.putBy('key', row)
36
+ }
37
+ async function pruneUnlocked () {
38
+ await (await db()).removeWhere(row => !isValid(row))
39
+ }
40
+ const ready = lock(pruneUnlocked)
41
+
42
+ return {
43
+ ready,
44
+ async authorize (file, receivers, sharedAt) {
45
+ await ready
46
+ return lock(async () => {
47
+ await pruneUnlocked()
48
+ const expiresAt = sharedAt + messenger.offlineRecoverySecondsFor(file.controlChannelPubkey)
49
+ for (const receiver of receivers) await put(file, receiver, sharedAt, expiresAt)
50
+ })
51
+ },
52
+ async find (controlChannelPubkey, fileChannelPubkey, receiverPubkey) {
53
+ await ready
54
+ return lock(async () => {
55
+ const queue = await db()
56
+ const key = keyFor({ controlChannelPubkey, fileChannelPubkey, receiverPubkey })
57
+ const row = await queue.getBy('key', key)
58
+ if (row && !isValid(row)) { await queue.removeBy('key', key); return null }
59
+ return row
60
+ })
61
+ },
62
+ async prune () { await ready; return lock(pruneUnlocked) },
63
+ async close () { await ready.catch(() => {}); await lock(async () => { if (database) await (await database.catch(() => null))?.close() }) }
64
+ }
65
+ }
@@ -0,0 +1,413 @@
1
+ import NMMR from 'nmmr'
2
+ import { createFileCatalog } from './helpers/catalog.js'
3
+ import { ValidationError } from '../../error/index.js'
4
+ import { IRFS_CHUNK_BYTES, decodeIrfsChunk } from '../../irfs/index.js'
5
+ import { createQueue } from '../../idb-queue/index.js'
6
+ import * as channelTransport from '../../private-channel/index.js'
7
+ import * as messages from '../../private-message/index.js'
8
+ import { readFileChunkIndex } from '../../private-channel/helpers/event.js'
9
+ import { compactSeedRouterRows, compactRecordsFromSeed, createEventReplyPacker, ROUTER_SEED_RECORD_TYPE } from '../recovery/index.js'
10
+
11
+ export const FILE_CHUNKS_REQUEST_CODE = 'fileChunksRequest_p5cc'
12
+ export const FILE_CHUNKS_REPLY_CODE = 'fileChunksReply_p5cc'
13
+ export const IRFS_CHUNK_RECORD_TYPE = 'irfsChunk_v1'
14
+ export const AUTO_DOWNLOAD_BYTES = 1024 * 1024
15
+ export const FILE_REQUEST_CHUNKS = 16
16
+ const HEX = /^[0-9a-f]{64}$/
17
+ const now = () => Math.floor(Date.now() / 1000)
18
+
19
+ export function fileChannelInfo (root) {
20
+ if (typeof root !== 'string' || !HEX.test(root)) throw new ValidationError('INVALID_FILE_ROOT')
21
+ return `dm:media:${root}`
22
+ }
23
+
24
+ export function decodeMissingRanges (ranges) {
25
+ if (!Array.isArray(ranges) || !ranges.length) throw new ValidationError('INVALID_FILE_CHUNK_RANGES')
26
+ const indices = []
27
+ let previous = -1
28
+ for (const range of ranges) {
29
+ if (!Array.isArray(range) || range.length !== 2) throw new ValidationError('INVALID_FILE_CHUNK_RANGES')
30
+ const [first, last] = range
31
+ if (![first, last].every(Number.isSafeInteger) || first < 0 || first <= previous || last < first || last - first + 1 > FILE_REQUEST_CHUNKS - indices.length) throw new ValidationError('INVALID_FILE_CHUNK_RANGES')
32
+ for (let index = first; index <= last; index++) indices.push(index)
33
+ previous = last
34
+ }
35
+ return indices
36
+ }
37
+
38
+ function rangesFor (indices) {
39
+ const ranges = []
40
+ for (const index of indices) {
41
+ const last = ranges.at(-1)
42
+ if (last && last[1] + 1 === index) last[1] = index
43
+ else ranges.push([index, index])
44
+ }
45
+ return ranges
46
+ }
47
+
48
+ function assertPublished (result) {
49
+ const reports = result?.delivery?.reports
50
+ if (!Array.isArray(reports) || !reports.length || !reports.every(report => report?.success === true)) throw Object.assign(new Error('FILE_NOT_PUBLISHED'), { code: 'MESSAGE_NOT_PUBLISHED' })
51
+ }
52
+
53
+ function waitForTransfer (promise, signal) {
54
+ signal?.throwIfAborted()
55
+ if (!signal) return promise
56
+ return new Promise((resolve, reject) => {
57
+ const aborted = () => reject(signal.reason)
58
+ signal.addEventListener('abort', aborted, { once: true })
59
+ promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', aborted))
60
+ })
61
+ }
62
+
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')
67
+ const registrations = new Map()
68
+ const jobs = new Map()
69
+ const listeners = new Set()
70
+ const successes = new Map()
71
+ const work = new Set()
72
+ const requests = []
73
+ let replying = 0
74
+ let active = 0
75
+ let closed = false
76
+ let queuePromise
77
+ 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 })
79
+ catalog.ready.catch(onError)
80
+ const keyFor = (control, root) => `${control}:${root}`
81
+ 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 }) }
82
+ const usable = file => {
83
+ if (closed) throw new Error('FILE_TRANSFER_CLOSED')
84
+ messenger.requireWritableChannel(file.controlChannelPubkey)
85
+ if (!messenger.desiredChannels.has(file.controlChannelPubkey)) throw new Error('FILE_TRANSFER_CHANNEL_UNWATCHED')
86
+ }
87
+ async function register ({ controlChannelPubkey, peerPubkey, root, size, sharedAt }) {
88
+ fileChannelInfo(root)
89
+ if (!HEX.test(controlChannelPubkey) || !HEX.test(peerPubkey) || (size !== undefined && (!Number.isSafeInteger(size) || size < 1))) throw new ValidationError('INVALID_FILE_TRANSFER_DESCRIPTOR')
90
+ if (sharedAt !== undefined && (!Number.isSafeInteger(sharedAt) || sharedAt < 0)) throw new ValidationError('INVALID_FILE_SHARED_AT')
91
+ const key = keyFor(controlChannelPubkey, root)
92
+ const previous = registrations.get(key)
93
+ if (previous && (previous.peerPubkey !== peerPubkey || (size !== undefined && previous.size !== undefined && previous.size !== size))) throw new ValidationError('CONFLICTING_FILE_TRANSFER_DESCRIPTOR')
94
+ const file = previous || { controlChannelPubkey, peerPubkey, root }
95
+ if (size !== undefined) file.size = size
96
+ if (sharedAt !== undefined) file.sharedAt = Math.max(file.sharedAt ?? 0, sharedAt)
97
+ registrations.set(key, file)
98
+ file.ready ||= (async () => {
99
+ file.signer = await resolveChannel({ controlChannelPubkey, peerPubkey, root, info: fileChannelInfo(root) })
100
+ file.fileChannelPubkey = await file.signer.getPublicKey()
101
+ if (!HEX.test(file.fileChannelPubkey)) throw new ValidationError('INVALID_FILE_CHANNEL_PUBKEY')
102
+ })().catch(error => { if (registrations.get(key) === file) registrations.delete(key); throw error })
103
+ await file.ready
104
+ return file
105
+ }
106
+ function seedEnabled (file) {
107
+ const parent = messenger.channels.get(file.controlChannelPubkey)
108
+ return ['seeder', 'watchtower'].includes(parent?.mode) && messenger.offlineRecoverySecondsFor(parent) > 0
109
+ }
110
+ async function authorizeSeeding (descriptor, { receiverPubkeys, sharedAt } = {}) {
111
+ if (!Array.isArray(receiverPubkeys) || !receiverPubkeys.length || !receiverPubkeys.every(key => typeof key === 'string' && HEX.test(key))) throw new ValidationError('INVALID_FILE_AUTHORIZATION_RECEIVERS')
112
+ if (!Number.isSafeInteger(sharedAt) || sharedAt < 0) throw new ValidationError('INVALID_FILE_SHARED_AT')
113
+ const file = await register(descriptor)
114
+ usable(file)
115
+ await catalog.authorize(file, [...new Set(receiverPubkeys)], sharedAt)
116
+ }
117
+ async function prune () {
118
+ await catalog.prune()
119
+ if (!queuePromise) return
120
+ await (await queue()).removeWhere(row => {
121
+ const parent = messenger.channels.get(row.controlChannelPubkey)
122
+ return parent?.mode !== 'watchtower' || !seedEnabled(row) || row.expiresAt <= _now() || row.receivedAt + messenger.offlineRecoverySecondsFor(parent) <= _now()
123
+ })
124
+ }
125
+ async function saveSeed (file, seed) {
126
+ if (messenger.channels.get(file.controlChannelPubkey)?.mode !== 'watchtower' || !seedEnabled(file)) return
127
+ const index = readFileChunkIndex(seed.router)
128
+ if (index === undefined) return
129
+ const db = await queue()
130
+ for (const row of compactSeedRouterRows(seed)) {
131
+ if (![file.peerPubkey, messenger.userPubkey].includes(row.receiverPubkey)) continue
132
+ 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) })
134
+ }
135
+ await prune()
136
+ }
137
+ function checked (file, event, index) {
138
+ const decoded = decodeIrfsChunk(event)
139
+ if (decoded.root !== file.root || (index !== undefined && decoded.index !== index) || (file.size !== undefined && (decoded.total !== Math.ceil(file.size / IRFS_CHUNK_BYTES) || (decoded.index === decoded.total - 1 && decoded.contentBytes.length !== file.size - decoded.index * IRFS_CHUNK_BYTES)))) throw new ValidationError('FILE_CHUNK_DESCRIPTOR_MISMATCH')
140
+ return decoded
141
+ }
142
+ async function routing (file, receiverPubkey) {
143
+ const parent = messenger.requireWritableChannel(file.controlChannelPubkey)
144
+ return messenger.resolveSendRouting({ channel: parent, receiverPubkeys: [receiverPubkey] })
145
+ }
146
+ function sendOptions (file, routes) {
147
+ return { senderSigner: messenger.userSigner, imkcSigner: messenger.contentKeySigner, privateChannelSigner: file.signer, ...routes, temporaryStorageArea: messenger.temporaryStorageArea, expirationSeconds: messenger.eventExpirationSecondsFor(file.controlChannelPubkey), _getIykcProofs: messenger.contentKeyLookup() }
148
+ }
149
+ async function publishChunk (descriptor, event) {
150
+ const file = await register(descriptor)
151
+ usable(file)
152
+ const decoded = checked(file, event)
153
+ 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)
157
+ // A scheduler turn between chunks lets queued text/control publications run.
158
+ await new Promise(resolve => setTimeout(resolve, 0))
159
+ return result
160
+ }
161
+ async function replyToRequest (controlChannelPubkey, message) {
162
+ if (message.payload?.code !== FILE_CHUNKS_REQUEST_CODE) return false
163
+ const question = message.question || message.event
164
+ if (message.provenance === 'hearsay' || message.senderPubkey !== question?.pubkey || question.tags?.find(tag => tag[0] === 'r')?.[1] !== messenger.userPubkey) return true
165
+ const payload = message.payload.payload
166
+ if (!HEX.test(payload?.fileChannelPubkey)) throw new ValidationError('INVALID_FILE_TRANSFER_DESCRIPTOR')
167
+ const indices = new Set(decodeMissingRanges(payload.missingRanges))
168
+ await prune()
169
+ const grant = await catalog.find(controlChannelPubkey, payload.fileChannelPubkey, question.pubkey)
170
+ if (grant) {
171
+ const file = await register(grant)
172
+ usable(file)
173
+ if (file.fileChannelPubkey !== payload.fileChannelPubkey) throw new ValidationError('FILE_CHANNEL_MISMATCH')
174
+ const routes = await routing(file, question.pubkey)
175
+ const packer = createEventReplyPacker({
176
+ messenger: {
177
+ reply: async options => {
178
+ usable(file)
179
+ if (!await catalog.find(controlChannelPubkey, file.fileChannelPubkey, question.pubkey)) return
180
+ return _messages.reply({ ...sendOptions(file, routes), ...options })
181
+ }
182
+ },
183
+ channelPubkey: file.fileChannelPubkey, question, receiverPubkey: question.pubkey,
184
+ code: FILE_CHUNKS_REPLY_CODE, eventsPerChunk: 1, recordsFromInput: record => [record]
185
+ })
186
+ for (const index of indices) {
187
+ usable(file)
188
+ if (!await catalog.find(controlChannelPubkey, file.fileChannelPubkey, question.pubkey)) break
189
+ const event = await storage.read(file.root, index, file)
190
+ if (!event) continue
191
+ let decoded
192
+ try { decoded = checked(file, event, index) } catch (error) { if (error instanceof ValidationError) { onError(error); continue } throw error }
193
+ const proof = event.tags.find(tag => tag[0] === 'mmr')[3]
194
+ await packer.update({ recordType: IRFS_CHUNK_RECORD_TYPE, index, total: decoded.total, proof, content: event.content })
195
+ await new Promise(resolve => setTimeout(resolve, 0))
196
+ }
197
+ await packer.finalize()
198
+ return true
199
+ }
200
+ if (messenger.channels.get(controlChannelPubkey)?.mode !== 'watchtower') return true
201
+ const db = await queue()
202
+ let packer
203
+ for await (const seed of db.storedItemsBy('channel', payload.fileChannelPubkey)) {
204
+ if (seed.controlChannelPubkey !== controlChannelPubkey || seed.receiverPubkey !== question.pubkey || !indices.has(seed.chunkIndex) || !seedEnabled(seed)) continue
205
+ const file = await register(seed)
206
+ usable(file)
207
+ if (file.fileChannelPubkey !== payload.fileChannelPubkey) throw new ValidationError('FILE_CHANNEL_MISMATCH')
208
+ if (!packer) {
209
+ const routes = await routing(file, question.pubkey)
210
+ packer = createEventReplyPacker({ messenger: { reply: async options => { usable(file); return _messages.reply({ ...sendOptions(file, routes), ...options }) } }, channelPubkey: file.fileChannelPubkey, question, receiverPubkey: question.pubkey, code: FILE_CHUNKS_REPLY_CODE, eventsPerChunk: 1, recordsFromInput: row => compactRecordsFromSeed(row, { receiverPubkey: question.pubkey }) })
211
+ }
212
+ await packer.update(seed)
213
+ await new Promise(resolve => setTimeout(resolve, 0))
214
+ }
215
+ await packer?.finalize()
216
+ return true
217
+ }
218
+ async function run (job) {
219
+ const file = job.file
220
+ const signal = job.controller.signal
221
+ let sub
222
+ const historyController = new AbortController()
223
+ let ingest = Promise.resolve()
224
+ let total = file.size === undefined ? undefined : Math.ceil(file.size / IRFS_CHUNK_BYTES)
225
+ const persisted = new Set()
226
+ let lastProgress = Date.now()
227
+ let requestRoundAt = Date.now()
228
+ const requestedSeeders = new Set()
229
+ const validSeeders = new Set([file.peerPubkey, ...messenger.recoverySeeders(file.controlChannelPubkey)])
230
+ let terminalError
231
+ const fail = error => { terminalError ||= error }
232
+ async function accept (event, index, { save = true } = {}) {
233
+ signal.throwIfAborted()
234
+ usable(file)
235
+ const chunk = checked(file, event, index)
236
+ if (total !== undefined && chunk.total !== total) throw new ValidationError('FILE_CHUNK_TOTAL_MISMATCH')
237
+ total = chunk.total
238
+ if (persisted.has(chunk.index)) return
239
+ if (save) await storage.save(event, file)
240
+ signal.throwIfAborted()
241
+ persisted.add(chunk.index)
242
+ job.completed += chunk.contentBytes.length
243
+ lastProgress = Date.now()
244
+ if (chunk.index === total - 1 && file.size === undefined) file.size = (total - 1) * IRFS_CHUNK_BYTES + chunk.contentBytes.length
245
+ emit(job)
246
+ }
247
+ const consume = (event, outer, meta) => {
248
+ const task = ingest.then(async () => {
249
+ if (signal.aborted) return
250
+ if (event.kind === messages.REPLY_KIND) {
251
+ const reply = messages.parseRumorContent(event)
252
+ if (reply.code !== FILE_CHUNKS_REPLY_CODE || meta.provenance === 'hearsay' || meta.senderPubkey !== event.pubkey || !validSeeders.has(event.pubkey) || event.tags?.find(tag => tag[0] === 'r')?.[1] !== messenger.userPubkey) return
253
+ const jsonl = reply.payload?.jsonl
254
+ if (typeof jsonl !== 'string') throw new ValidationError('INVALID_FILE_CHUNK_REPLY')
255
+ const rows = jsonl.split('\n').filter(Boolean)
256
+ if (rows.length > FILE_REQUEST_CHUNKS) throw new ValidationError('INVALID_FILE_CHUNK_REPLY')
257
+ for (const line of rows) {
258
+ let record
259
+ try { record = JSON.parse(line) } catch { throw new ValidationError('INVALID_FILE_CHUNK_REPLY') }
260
+ if (record.recordType === IRFS_CHUNK_RECORD_TYPE) {
261
+ if (!Number.isSafeInteger(record.index) || record.index < 0 || !Number.isSafeInteger(record.total) || record.total <= record.index || typeof record.content !== 'string' || record.content.length > 100000 || typeof record.proof !== 'string' || record.proof.length > 8192) throw new ValidationError('INVALID_FILE_CHUNK_REPLY')
262
+ const chunk = { kind: 34601, created_at: event.created_at, tags: [['d', NMMR.deriveChunkId(file.root, record.index)], ['mmr', String(record.index), String(record.total), record.proof]], content: record.content }
263
+ await accept(chunk, record.index)
264
+ successes.set(event.pubkey, Date.now())
265
+ continue
266
+ }
267
+ if (record.recordType !== ROUTER_SEED_RECORD_TYPE) continue
268
+ const index = readFileChunkIndex(record.router)
269
+ if (index === undefined) throw new ValidationError('INVALID_FILE_CHUNK_INDEX')
270
+ const original = await _transport.unwrapRouterEvent({ router: record.router, receiverSigner: messenger.userSigner, receiverPubkey: messenger.userPubkey, channelPubkey: file.fileChannelPubkey })
271
+ if (original) { await accept(original, index); successes.set(event.pubkey, Date.now()) }
272
+ }
273
+ } else {
274
+ if (meta.senderPubkey !== file.peerPubkey) return
275
+ const index = readFileChunkIndex(meta.router)
276
+ if (index === undefined) throw new ValidationError('INVALID_FILE_CHUNK_INDEX')
277
+ await accept(event, index)
278
+ // Only direct, validated delivery proves this identity was a recipient.
279
+ // Local cache hits and recovery replies never create/renew a grant.
280
+ if (file.sharedAt !== undefined) await authorizeSeeding(file, { receiverPubkeys: [messenger.userPubkey], sharedAt: file.sharedAt })
281
+ }
282
+ })
283
+ // Malformed remote records do not poison the ingestion chain. Operational
284
+ // persistence failures stop the job so completion cannot outrun storage.
285
+ ingest = task.catch(error => { if (error instanceof ValidationError) onError(error); else if (!signal.aborted) fail(error) })
286
+ return ingest
287
+ }
288
+ try {
289
+ job.status = 'downloading'; emit(job)
290
+ // Resume from independently verified local chunks, never from an ACK.
291
+ for (let index = 0; index < (total ?? 1); index++) {
292
+ signal.throwIfAborted()
293
+ const event = await storage.read(file.root, index, file)
294
+ if (event) await accept(event, index, { save: false })
295
+ }
296
+ if (total !== undefined && persisted.size === total) return
297
+ const parent = messenger.requireWritableChannel(file.controlChannelPubkey)
298
+ const relays = await messenger.resolveWatchRelays(parent)
299
+ const options = { receiverSigner: messenger.userSigner, privateChannelSigner: file.signer, privateChannelPubkey: file.fileChannelPubkey, receiverPubkey: messenger.userPubkey, relays, receivedChunkIndexedDB: messenger._indexedDB, receivedChunkScope: `${messenger.userPubkey}:file:${file.fileChannelPubkey}`, onEvent: consume, onSeedEvent: seed => saveSeed(file, seed), mode: messenger.channels.get(file.controlChannelPubkey)?.mode === 'watchtower' ? 'watchtower' : 'leecher', onError }
300
+ sub = _transport.subscribe({ ...options, liveOnly: true })
301
+ const stop = () => { sub?.close() }
302
+ signal.addEventListener('abort', stop, { once: true })
303
+ try {
304
+ await sub.ready
305
+ signal.throwIfAborted()
306
+ // Partial/failed history still permits recovery from peers.
307
+ const history = _transport.fetch({ ...options, signal: AbortSignal.any([signal, historyController.signal]), since: 0 }).catch(error => { if (!signal.aborted) onError(error) })
308
+ work.add(history); history.finally(() => work.delete(history))
309
+ const seeders = [...validSeeders].sort((a, b) => (successes.get(b) || 0) - (successes.get(a) || 0) || Math.random() - 0.5)
310
+ while (true) {
311
+ signal.throwIfAborted(); usable(file)
312
+ await ingest
313
+ if (terminalError) throw terminalError
314
+ if (total !== undefined && persisted.size === total) break
315
+ const missing = []
316
+ for (let index = 0; index < (total ?? 1) && missing.length < FILE_REQUEST_CHUNKS; index++) if (!persisted.has(index)) missing.push(index)
317
+ const remaining = seeders.filter(peer => !requestedSeeders.has(peer))
318
+ if (remaining.length && Date.now() >= requestRoundAt) {
319
+ const peer = remaining[0]
320
+ requestedSeeders.add(peer)
321
+ requestRoundAt = Date.now() + _hedgeMs
322
+ // The request is on dm; its compact reply is on the file channel.
323
+ await messenger.ask({ channelPubkey: file.controlChannelPubkey, receiverPubkey: peer, code: FILE_CHUNKS_REQUEST_CODE, payload: { fileChannelPubkey: file.fileChannelPubkey, missingRanges: rangesFor(missing) } }).catch(onError)
324
+ }
325
+ if (!remaining.length && Date.now() - Math.max(lastProgress, requestRoundAt - _hedgeMs) >= _idleMs) throw new Error('FILE_DOWNLOAD_STALLED')
326
+ // Start a fresh batch when all positions of the previous batch arrived.
327
+ if (job.batch?.every(index => persisted.has(index))) { requestedSeeders.clear(); requestRoundAt = Date.now(); job.batch = null }
328
+ job.batch ||= missing
329
+ 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
+ }
331
+ } finally { signal.removeEventListener('abort', stop) }
332
+ } finally { historyController.abort(); await sub?.close(); await ingest }
333
+ if (job.completed !== file.size) throw new ValidationError('FILE_SIZE_MISMATCH')
334
+ }
335
+ function pump () {
336
+ if (closed) return
337
+ for (const job of [...jobs.values()].filter(job => job.status === 'queued').sort((a, b) => b.priority - a.priority)) {
338
+ if (active >= 2) break
339
+ active++; job.status = 'starting'
340
+ const task = run(job).then(() => { job.status = 'complete'; emit(job); job.resolve(job.file) }, error => { job.status = job.pauseRequested ? 'paused' : job.controller.signal.aborted ? 'cancelled' : 'error'; job.error = error; emit(job); job.reject(error) }).finally(() => { active--; work.delete(task); pump() })
341
+ work.add(task)
342
+ }
343
+ }
344
+ async function download (descriptor, { manual = false, thumbnail = false, signal } = {}) {
345
+ const file = await register(descriptor)
346
+ usable(file)
347
+ const existing = jobs.get(keyFor(file.controlChannelPubkey, file.root))
348
+ if (existing && !['error', 'cancelled', 'paused'].includes(existing.status)) return waitForTransfer(existing.promise, signal)
349
+ if (!manual && (file.size === undefined || file.size > (thumbnail ? IRFS_CHUNK_BYTES : AUTO_DOWNLOAD_BYTES))) {
350
+ // Locally complete files need no network consent, including the sender's
351
+ // own originals and files already downloaded before a restart.
352
+ let total, completed = 0
353
+ for (let index = 0; index < (total ?? 1); index++) {
354
+ signal?.throwIfAborted(); usable(file)
355
+ const event = await storage.read(file.root, index, file)
356
+ if (!event) throw new Error('FILE_DOWNLOAD_REQUIRES_ACTION')
357
+ const chunk = checked(file, event, index)
358
+ if (total !== undefined && total !== chunk.total) throw new ValidationError('FILE_CHUNK_TOTAL_MISMATCH')
359
+ total = chunk.total; completed += chunk.contentBytes.length
360
+ }
361
+ file.size ??= completed
362
+ if (completed !== file.size) throw new ValidationError('FILE_SIZE_MISMATCH')
363
+ const job = { file, completed, status: 'complete', promise: Promise.resolve(file) }
364
+ jobs.set(keyFor(file.controlChannelPubkey, file.root), job); emit(job)
365
+ return file
366
+ }
367
+ const key = keyFor(file.controlChannelPubkey, file.root)
368
+ let job = jobs.get(key)
369
+ if (!job || ['error', 'cancelled', 'paused'].includes(job.status)) {
370
+ const controller = new AbortController()
371
+ const { promise, resolve, reject } = Promise.withResolvers()
372
+ job = { file, controller, resolve, reject, promise, completed: 0, manual, thumbnail, priority: thumbnail ? 2 : manual ? 1 : 0, status: 'queued' }
373
+ jobs.set(key, job); emit(job); pump()
374
+ }
375
+ // A view leaving does not cancel another consumer's shared download.
376
+ return waitForTransfer(job.promise, signal)
377
+ }
378
+ const cancelJob = job => {
379
+ if (['complete', 'error', 'cancelled'].includes(job.status)) return
380
+ const error = new DOMException('Download cancelled', 'AbortError')
381
+ job.controller.abort(error)
382
+ if (job.status === 'queued') { job.status = job.pauseRequested ? 'paused' : 'cancelled'; job.reject(error); emit(job) }
383
+ }
384
+ function drainRequests () {
385
+ if (closed) return
386
+ while (replying < 2 && requests.length) {
387
+ const [channel, message] = requests.shift()
388
+ replying++
389
+ const task = replyToRequest(channel, message).catch(error => { if (!(closed && error.message === 'FILE_TRANSFER_CLOSED')) onError(error) }).finally(() => { replying--; work.delete(task); drainRequests() })
390
+ work.add(task)
391
+ }
392
+ }
393
+ const extension = {
394
+ handleAsk (channel, message) {
395
+ if (message.payload?.code !== FILE_CHUNKS_REQUEST_CODE) return false
396
+ // Never hold the DM dispatcher while publishing a batch of file bytes.
397
+ // Bound both workers and queued requests; discarded excess is retryable.
398
+ if (!closed && requests.length < 16) { requests.push([channel, message]); drainRequests() }
399
+ return true
400
+ },
401
+ pause () { requests.length = 0; for (const job of jobs.values()) { if (!['complete', 'error', 'cancelled'].includes(job.status)) job.pauseRequested = true; cancelJob(job) } },
402
+ async resume () {
403
+ await Promise.allSettled([...work])
404
+ for (const job of jobs.values()) if (job.pauseRequested && job.status !== 'complete') download(job.file, { manual: job.manual, thumbnail: job.thumbnail }).catch(onError)
405
+ },
406
+ 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() }
408
+ }
409
+ const timer = setInterval(() => { prune().catch(onError) }, 60000)
410
+ timer.unref?.()
411
+ 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) } }
413
+ }
@@ -265,6 +265,7 @@ export class PrivateMessenger {
265
265
  this.state = { channels: {} }
266
266
  this.stateWriteTail = Promise.resolve()
267
267
  this.stateWriteError = null
268
+ this.extensions = new Set()
268
269
  this.channels = new Map()
269
270
  this.stopByChannel = new Map()
270
271
  this.desiredChannels = new Set()
@@ -1128,6 +1129,7 @@ export class PrivateMessenger {
1128
1129
 
1129
1130
  unwatch (channels) {
1130
1131
  const pubkeys = channels ? uniq(Array.isArray(channels) ? channels : [channels]) : [...this.desiredChannels]
1132
+ for (const extension of this.extensions) extension.unwatch?.(pubkeys)
1131
1133
  for (const pubkey of pubkeys) this.desiredChannels.delete(pubkey)
1132
1134
  this.recordInterruption(pubkeys)
1133
1135
  for (const controller of this.recoveryControllers) {
@@ -1140,6 +1142,7 @@ export class PrivateMessenger {
1140
1142
  if (typeof reason !== 'string' || !reason.trim()) throw new ValidationError('PAUSE_REASON_REQUIRED')
1141
1143
  this.assertOpen()
1142
1144
  this.pauseReasons.add(reason)
1145
+ for (const extension of this.extensions) extension.pause?.()
1143
1146
  this.recordInterruption(this.desiredChannels)
1144
1147
  for (const controller of this.recoveryControllers) controller.abort()
1145
1148
  return Promise.all([this.stopWatches([...this.desiredChannels]), this.flushStateWrites()])
@@ -1164,6 +1167,7 @@ export class PrivateMessenger {
1164
1167
  if (this.pauseReasons.size || this.closePromise) return
1165
1168
  await this.reconcilePresencePublishers()
1166
1169
  await this.recoverOfflineRanges(channels)
1170
+ for (const extension of this.extensions) await extension.resume?.()
1167
1171
  })()
1168
1172
  this.resumeWork = work
1169
1173
  try { await work } catch (err) {
@@ -1238,6 +1242,7 @@ export class PrivateMessenger {
1238
1242
  async handleAsk (channelPubkey, message) {
1239
1243
  if (message.provenance === 'hearsay') return this.enqueueRumor('message', channelPubkey, message)
1240
1244
  this.trackSeederActivity(channelPubkey, message)
1245
+ for (const extension of this.extensions) if (await extension.handleAsk?.(channelPubkey, message)) return
1241
1246
  if (doesModeStoreRecoverySeeds(this.channels.get(channelPubkey)?.mode) && messageCode(message) === MISSING_MESSAGES_ASK_CODE) {
1242
1247
  await this.replyWithStoredSeeds(channelPubkey, message)
1243
1248
  return
@@ -1329,6 +1334,11 @@ export class PrivateMessenger {
1329
1334
  this.onMessageQueued?.()
1330
1335
  }
1331
1336
 
1337
+ outgoingSeedHandler (channelPubkey) {
1338
+ if (!doesModeStoreRecoverySeeds(this.channels.get(channelPubkey)?.mode) || !this.offlineRecoverySecondsFor(channelPubkey)) return undefined
1339
+ return seed => this.enqueueSeed(channelPubkey, seed)
1340
+ }
1341
+
1332
1342
  async enqueueSeed (channelPubkey, seed) {
1333
1343
  if (!this.offlineRecoverySecondsFor(channelPubkey)) return
1334
1344
  const receivedAt = nowSeconds()
@@ -1494,6 +1504,7 @@ export class PrivateMessenger {
1494
1504
  payload,
1495
1505
  error,
1496
1506
  content,
1507
+ onPreparedSeed: this.outgoingSeedHandler(channelPubkey),
1497
1508
  _getIykcProofs: this.contentKeyLookup()
1498
1509
  })
1499
1510
  }
@@ -1520,6 +1531,7 @@ export class PrivateMessenger {
1520
1531
  payload,
1521
1532
  error,
1522
1533
  content,
1534
+ onPreparedSeed: this.outgoingSeedHandler(channelPubkey),
1523
1535
  _getIykcProofs: this.contentKeyLookup()
1524
1536
  })
1525
1537
  }
@@ -1544,6 +1556,7 @@ export class PrivateMessenger {
1544
1556
  payload,
1545
1557
  error,
1546
1558
  content,
1559
+ onPreparedSeed: this.outgoingSeedHandler(channelPubkey),
1547
1560
  _getIykcProofs: this.contentKeyLookup()
1548
1561
  })
1549
1562
  }
@@ -1568,6 +1581,7 @@ export class PrivateMessenger {
1568
1581
  payload,
1569
1582
  error,
1570
1583
  content,
1584
+ onPreparedSeed: this.outgoingSeedHandler(channelPubkey),
1571
1585
  _getIykcProofs: this.contentKeyLookup()
1572
1586
  })
1573
1587
  }
@@ -1588,6 +1602,7 @@ export class PrivateMessenger {
1588
1602
  deletionPubkey,
1589
1603
  autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
1590
1604
  rumor,
1605
+ onPreparedSeed: this.outgoingSeedHandler(channelPubkey),
1591
1606
  _getIykcProofs: this.contentKeyLookup()
1592
1607
  })
1593
1608
  }
@@ -1608,6 +1623,7 @@ export class PrivateMessenger {
1608
1623
  deletionPubkey,
1609
1624
  autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
1610
1625
  event,
1626
+ onPreparedSeed: this.outgoingSeedHandler(channelPubkey),
1611
1627
  _getIykcProofs: this.contentKeyLookup()
1612
1628
  })
1613
1629
  }
@@ -1664,6 +1680,7 @@ export class PrivateMessenger {
1664
1680
  autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
1665
1681
  code: SEEDER_PRESENCE_CODE,
1666
1682
  payload: {},
1683
+ onPreparedSeed: this.outgoingSeedHandler(channelPubkey),
1667
1684
  _getIykcProofs: this.contentKeyLookup()
1668
1685
  })
1669
1686
  }
@@ -2177,6 +2194,7 @@ export class PrivateMessenger {
2177
2194
  let unwatchError
2178
2195
  try { await unwatchPromise } catch (err) { unwatchError = err }
2179
2196
  await initSettledPromise
2197
+ await Promise.all([...this.extensions].map(extension => extension.close?.()))
2180
2198
  await this.stampActiveChannelActivity()
2181
2199
  await this.queueOperationTail
2182
2200
  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,8 @@ 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`,
189
191
  `${prefix}:state:idb`
190
192
  ]
191
193
  }