libp2r2p 0.10.28 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -890,3 +890,26 @@ complete per-relay report without delaying the first-success return. Connection
890
890
  establishment retains its separate 3-second deadline. PrivateMessenger inherits
891
891
  this publication policy, including seeder/recovery messages. A timeout indicates
892
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.
910
+
911
+ Private-message consumers can now substitute recovery persistence through
912
+ [`private-messenger/event-store`](private-messenger/event-store/README.md), or use
913
+ local defaults, including a bounded streaming file cache. The optional
914
+ [`private-messenger/session`](private-messenger/session/README.md) coordinator
915
+ provides reusable DM inbox/outbox and attachment lifecycle without UI coupling.
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.28",
3
+ "version": "0.11.1",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -86,7 +86,10 @@
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/event-store": "./private-messenger/event-store/index.js",
90
+ "./private-messenger/file": "./private-messenger/file/index.js",
89
91
  "./private-messenger/recovery": "./private-messenger/recovery/index.js",
92
+ "./private-messenger/session": "./private-messenger/session/index.js",
90
93
  "./relay": "./relay/index.js",
91
94
  "./temporary-storage": "./temporary-storage/index.js",
92
95
  "./url": "./url/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
+ }
@@ -18,12 +18,13 @@ import {
18
18
  prepareEnvelopeRows,
19
19
  preparedRowIndexesForReceivers,
20
20
  readChunkContent,
21
+ readPreparedRow,
21
22
  receiverPubkeys,
22
23
  receiverPubkeysWithoutContentKeys,
23
24
  writeChunksFromPreparedRows
24
25
  } from './helpers/chunks.js'
25
26
  import { EXPIRATION_SECONDS, MAX_EVENT_BYTES, NYM_CARRIER_KIND, PRIVATE_BROADCAST_KIND, ROUTER_KIND } from './constants/index.js'
26
- 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'
27
28
  import { createReceivedChunkStore, DEFAULT_RECEIVED_CHUNK_MAX_BYTES, DEFAULT_RECEIVED_CHUNK_TTL_MS } from './services/received-chunks.js'
28
29
 
29
30
  export { EXPIRATION_SECONDS, MAX_EVENT_BYTES, NYM_CARRIER_KIND, PRIVATE_BROADCAST_KIND, ROUTER_KIND } from './constants/index.js'
@@ -131,7 +132,7 @@ async function prepareRoutedMessage ({ senderSigner, imkcSigner, privateChannelS
131
132
  }
132
133
  }
133
134
 
134
- 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 }) {
135
136
  const routerSeckey = generateSecretKey()
136
137
  const routerPubkey = getPublicKey(routerSeckey)
137
138
  const receiverPubkeyList = receiverPubkeys(receivers)
@@ -144,6 +145,13 @@ async function * wrapPreparedEvents ({ privateChannelSigner, receivers, receiver
144
145
  const temporaryStorage = context.preparedRows.temporaryStorage
145
146
 
146
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
+ }
147
155
  for (let index = 0; index < total; index++) {
148
156
  const content = readChunkContent(id, index, temporaryStorage)
149
157
  const router = finalizeEvent(makeRouterEvent({
@@ -152,6 +160,7 @@ async function * wrapPreparedEvents ({ privateChannelSigner, receivers, receiver
152
160
  imkcPubkey: context.imkcPubkey,
153
161
  imkcProof: context.imkcProof,
154
162
  receiverPubkey: routerReceiverTag,
163
+ fileChunkIndex,
155
164
  chunkIndex: index,
156
165
  chunkTotal: total,
157
166
  content
@@ -172,7 +181,7 @@ async function * wrapPreparedEvents ({ privateChannelSigner, receivers, receiver
172
181
  }
173
182
 
174
183
  // Streaming version of wrapEvent
175
- 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 }) {
176
185
  const normalizedDeletionPubkey = normalizeDeletionPubkey(deletionPubkey)
177
186
  const context = await prepareRoutedMessage({
178
187
  senderSigner,
@@ -185,7 +194,7 @@ export async function * wrapEvents ({ senderSigner, imkcSigner, privateChannelSi
185
194
  _getIykcProofs
186
195
  })
187
196
  try {
188
- yield * wrapPreparedEvents({ privateChannelSigner, receivers, receiverTag, deletionPubkey: normalizedDeletionPubkey, expirationSeconds, context })
197
+ yield * wrapPreparedEvents({ privateChannelSigner, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey: normalizedDeletionPubkey, expirationSeconds, context })
189
198
  } finally {
190
199
  cleanupEnvelopeRows(context.preparedRows)
191
200
  }
@@ -415,6 +424,12 @@ export async function unwrapEvent ({ receiverSigner, privateChannelSigner = rece
415
424
  channelReaderSigner,
416
425
  channelReaderPubkey: privateChannelReaderPubkey
417
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 }) {
418
433
  if (router.kind !== ROUTER_KIND) throw new ValidationError('INVALID_ROUTER_KIND')
419
434
  if (receiverPubkey && readReceiverTag(router) && readReceiverTag(router) !== receiverPubkey) return null
420
435
 
@@ -506,7 +521,7 @@ function withRecoveryRelays (relays, recoveryRelays) {
506
521
  return uniq([...(relays || []), ...(recoveryRelays || [])])
507
522
  }
508
523
 
509
- 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 }) {
510
525
  const normalizedDeletionPubkey = normalizeDeletionPubkey(deletionPubkey)
511
526
  const results = []
512
527
  // Relays are grouped only when they have the exact same recipient pubkey set
@@ -524,7 +539,7 @@ export async function publish ({ senderSigner, imkcSigner, privateChannelSigner
524
539
  })
525
540
  try {
526
541
  for (const group of groups) {
527
- 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 })) {
528
543
  results.push(await _publish(wrappedEvent, withRecoveryRelays(group.relays, recoveryRelays)))
529
544
  }
530
545
  }
@@ -534,7 +549,7 @@ export async function publish ({ senderSigner, imkcSigner, privateChannelSigner
534
549
  return results
535
550
  }
536
551
 
537
- 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 })) {
538
553
  results.push(await _publish(wrappedEvent, withRecoveryRelays(relays, recoveryRelays)))
539
554
  }
540
555
  return results
@@ -765,6 +780,7 @@ function createProcessor ({
765
780
  const senderPubkey = readSenderTag(router)
766
781
  if (receiverPubkey && readReceiverTag(router) && readReceiverTag(router) !== receiverPubkey && senderPubkey !== receiverPubkey) return
767
782
  const { index, total } = readChunkTag(router)
783
+ readFileChunkIndex(router)
768
784
  groupKey = receivedChunks.groupKeyFor(channelPubkey, router.pubkey)
769
785
  if (ignoredGroups.has(groupKey)) return
770
786
 
@@ -773,6 +789,7 @@ function createProcessor ({
773
789
  const meta = await receivedChunks.put({
774
790
  channelPubkey,
775
791
  routerPubkey: router.pubkey,
792
+ descriptor: JSON.stringify(router.tags.filter(tag => tag[0] !== 'c')),
776
793
  index,
777
794
  total,
778
795
  contentBytes: base64ToBytes(router.content),
@@ -976,6 +993,7 @@ export function subscribe ({ receiverSigner, iykcSigner, privateChannelSigner =
976
993
  const consumePromise = consumeEvents()
977
994
 
978
995
  return {
996
+ ready: events.ready || Promise.resolve(),
979
997
  close () {
980
998
  controller.abort()
981
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,110 @@
1
+ # Event-store adapters
2
+
3
+ `createPersonalCopyRecoveryStorage({ eventStore, signer })` returns `seeds`,
4
+ `authorizations`, and `close()`. Pass these to `PrivateMessenger` (`seedStorage`)
5
+ and `createPrivateFileTransfer` (`seedStorage`, `authorizationStorage`). The owner
6
+ signer and event store must address the same identity. There is no `window` access
7
+ and no launcher-specific private database API.
8
+
9
+ The signer exposes `getPublicKey`, `obfuscate(value, kind, scope)`, and byte-based
10
+ `nip44v3.decrypt(peer, kind, scope, ciphertext)`. The store exposes the existing
11
+ `query`, `addPersonalCopy`, and `remove` API. Queries require `&tags`, `!ids`,
12
+ `search:sort:desc`, and the personal-copy mirrors used by 44billion. Returned
13
+ wrappers remain encrypted until the adapter decrypts them. The adapter bounds memoized obfuscations (256) and decrypted grants (128); every
14
+ grant use still queries the store, so removal is immediately effective. Permission, lock,
15
+ read-only, quota and operational failures propagate; they are not empty results.
16
+ Expiry cleanup uses the signed wrapper expiration preserved by personal copies,
17
+ without decrypting the archive; the store may also purge expired wrappers itself.
18
+
19
+ ## Contracts and ownership
20
+
21
+ - Seeds: `put(record)`, `iterate(query)`, `has(record)`, `removeLocal(query)`,
22
+ `prune({ now })`, and `revoke(records)`.
23
+ - Authorizations: the same operations plus `find(query)`, choosing the newest
24
+ valid share, then the shorter expiry and lexicographically smaller record ID.
25
+ - `iterate` is an async generator. Queries can name `recordId`, `channelPubkey`,
26
+ `receiverPubkey`, `since`/`until`, or the file tuple `controlChannelPubkey`,
27
+ `fileChannelPubkey`, `receiverPubkey`, and `indices`. `signal` cancels reads.
28
+ - The messenger/coordinator never closes caller-owned adapters. Close their
29
+ owner factory after the consumers. No ciphertext is duplicated into a local
30
+ library queue. Limits and storage admission belong to the event store.
31
+ - `createEventStoreChunkStorage({ eventStore })` implements `read`/`save` for
32
+ IRFS chunks. Reconstruction saves an owner template, not a forged announcer
33
+ identity. NostrDB keeps the usual deduplicated payloads and root references.
34
+ - `createEventStoreMessageStorage({ eventStore })` implements
35
+ `save(event, { peerPubkey, hearsay })`, preserving the DM context/provenance.
36
+
37
+ Local defaults remain available when the corresponding adapter is omitted.
38
+ Registration, cache reads and recovery requests never grant file access. A grant
39
+ neither pins chunks nor contains signer material. Synced timestamps never renew
40
+ retention. Missing channel configuration is not a distributed revocation.
41
+
42
+ ## Immutable inner events
43
+
44
+ Recovery data uses self-authored kind 30078 templates, `content: ''`, personal
45
+ copy context `''`, and no hearsay. Coordinates are
46
+ `libp2r2p:recovery:seed:<id>` / `libp2r2p:recovery:grant:<id>` (no schema version
47
+ in `d`). Data changes create another snapshot. Imports preserve authored times;
48
+ identical writes query their coordinate and do not re-encrypt/revise it.
49
+
50
+ `id` is lowercase SHA-256 of UTF-8 `JSON.stringify([type, bodyTags])`, where
51
+ `type` is `seed` or `grant` and body tags follow the fixed encoder order below.
52
+ `d`, `t`, `s`, `D`, and CRDT decorations are derived, not hash inputs. The body
53
+ includes expiration. Original strings starting with `%` or `~` escape that first
54
+ character as `%25` or `%7E`; decoding reverses exactly one prefix. This protects
55
+ preserved strings from CRDT metadata interpretation. Numbers are canonical
56
+ nonnegative decimal strings. The decoder rebuilds and verifies all tags and the
57
+ coordinate, tolerating only the store's reserved CRDT decorations.
58
+
59
+ Grant body, in order: `c` (control channel), `channel` (data channel), `peer`,
60
+ `p` (recipient), `root`, optional `size`, `shared`, `expiration`. There is no `r`
61
+ root reference: a grant must not retain file bytes. Creation time is `shared`.
62
+
63
+ Router seed body: `c` (data/ordinary channel), `type=routerEnvelopeRow_v1`, `p`,
64
+ `router` (pubkey, original timestamp), numbered `router-tag` entries (original
65
+ transport `c` removed), `payload`, `key`, optional `iykc` (remaining envelope
66
+ fields), optional `event` (known inner ID), `range` (first/last), optional `file`
67
+ (control channel, peer, root) plus `received`, and `expiration`. Kind 26300 is
68
+ implied by the record type. The original router's `i` supplies the file index.
69
+ The wire reply still reconstructs the existing JSONL rows; no network codec was
70
+ replaced. File seed creation time is `received`; ordinary seed time is range end.
71
+
72
+ Carrier seed body: `c`, `type=nymCarrier_v1`, numbered `carrier` entries containing
73
+ kind/pubkey/timestamp/ID/signature/content, their numbered `carrier-tag` entries
74
+ with `carrierIndex:tagIndex` identities, `range`, `expiration`. Signatures and
75
+ original tag order are verified/preserved. Range/creation time uses the maximum
76
+ carrier timestamp, as the existing recovery codec does.
77
+
78
+ All snapshots have `t=libp2r2p:recovery:<type>` and selector `s`. Selector hashes
79
+ use the same UTF-8 JSON/SHA-256 operation on:
80
+
81
+ - `['grant', control, data, recipient]`
82
+ - `['seed', channel, recipientOrEmpty]`
83
+ - `['file', control, data, recipient, chunkIndex]`
84
+
85
+ Ordinary seeds also carry every UTC Unix day in their interval (`D`, 86400 seconds
86
+ per day). More than 32 days uses the literal `D='*'`. Queries include their days
87
+ and `*`, then verify exact interval overlap. Wide/unbounded queries skip the day
88
+ prefilter. They never filter wrapper `created_at` with the recovery interval.
89
+ Snapshots do not invent an interval between independent observations. The local
90
+ IDB implementation retains its existing logical min/max compaction.
91
+
92
+ ## Deletion and synchronization
93
+
94
+ `revoke(records)` writes owner-authored kind-5 personal copies in context `''`,
95
+ with `a` targets for the **inner** 30078 coordinates and advisory `k=30078`.
96
+ Batches contain at most 100 targets. Their timestamp covers stored versions and
97
+ expiration is the latest target expiry. The normal NostrDB private-deletion path
98
+ applies local removal and synced tombstones. There is no custom `state` tag.
99
+ Local eviction/removal and expiry do not publish a deletion. Once a tombstone
100
+ expires, its original targets are expired too. Do not reauthor synced snapshots.
101
+
102
+ Sync transports must keep **their own recovery seeds local**. Persisting seeds
103
+ of the transport that synchronizes seeds into that same event store creates a
104
+ recursive stream of new synchronization data. Consumer chat sessions may use
105
+ these adapters while the vault's synchronization messenger keeps its default.
106
+
107
+ No legacy seed migration or automatic local-to-event-store copying is performed.
108
+ A newly configured adapter records new captures/shares. Paired devices still
109
+ need the same channel configuration, identity keys, mode and locally available
110
+ chunks to serve a synced grant. Watchtowers need only their preserved envelopes.