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.
@@ -0,0 +1,136 @@
1
+ import { sha256 } from '@noble/hashes/sha2.js'
2
+ import { bytesToHex } from '../../../base16/index.js'
3
+ import { ValidationError } from '../../../error/index.js'
4
+ import { isValidEvent } from '../../../event/index.js'
5
+
6
+ const encoder = new TextEncoder()
7
+ const HEX = /^[0-9a-f]{64}$/
8
+ const DAY = 86400
9
+ export const PREFIX = 'libp2r2p:recovery:'
10
+ export const digest = value => bytesToHex(sha256(encoder.encode(JSON.stringify(value))))
11
+ const fail = () => { throw new ValidationError('INVALID_RECOVERY_RECORD') }
12
+ const hex = value => { if (!HEX.test(value || '')) fail(); return value }
13
+ const integer = value => { const n = Number(value); if (!Number.isSafeInteger(n) || n < 0 || String(n) !== String(value)) fail(); return n }
14
+ // Protect original values from NostrDB's reserved CRDT metadata syntax.
15
+ const escape = value => String(value).replace(/^%/, '%25').replace(/^~/, '%7E')
16
+ const unescape = value => value.startsWith('%25') ? `%${value.slice(3)}` : value.startsWith('%7E') ? `~${value.slice(3)}` : value
17
+ export function days (since, until) {
18
+ const first = Math.floor(since / DAY), last = Math.floor(until / DAY)
19
+ return last - first >= 32 ? ['*'] : Array.from({ length: Math.max(0, last - first + 1) }, (_, i) => String(first + i))
20
+ }
21
+ export function selector (type, row) {
22
+ return digest(type === 'grant'
23
+ ? ['grant', row.controlChannelPubkey, row.fileChannelPubkey, row.receiverPubkey]
24
+ : row.fileChannelPubkey
25
+ ? ['file', row.controlChannelPubkey, row.fileChannelPubkey, row.receiverPubkey, row.chunkIndex]
26
+ : ['seed', row.channelPubkey, row.receiverPubkey || ''])
27
+ }
28
+ function body (type, row) {
29
+ const tags = []
30
+ const add = (name, ...values) => tags.push([name, ...values.map(escape)])
31
+ const expires = integer(row.expiresAt)
32
+ if (type === 'grant') {
33
+ add('c', hex(row.controlChannelPubkey)); add('channel', hex(row.fileChannelPubkey))
34
+ add('peer', hex(row.peerPubkey)); add('p', hex(row.receiverPubkey)); add('root', hex(row.root))
35
+ if (row.size !== undefined) { if (!integer(row.size)) fail(); add('size', row.size) }
36
+ add('shared', integer(row.sharedAt))
37
+ if (expires <= row.sharedAt) fail()
38
+ } else if (type === 'seed') {
39
+ add('c', hex(row.fileChannelPubkey || row.channelPubkey))
40
+ add('type', row.recordType)
41
+ if (row.recordType === 'routerEnvelopeRow_v1') {
42
+ const payload = JSON.parse(row.payloadRow), envelope = JSON.parse(row.row)
43
+ if (!Array.isArray(payload) || payload.length !== 1 || typeof payload[0] !== 'string' || !Array.isArray(envelope) || envelope.length < 2 || envelope.length > 4 || !envelope.every(v => typeof v === 'string') || envelope[0] !== row.receiverPubkey) fail()
44
+ add('p', hex(row.receiverPubkey))
45
+ if (row.router?.kind !== 26300) fail()
46
+ add('router', hex(row.router.pubkey), integer(row.router.created_at))
47
+ if (!Array.isArray(row.router.tags)) fail()
48
+ row.router.tags.filter(tag => tag[0] !== 'c').forEach((tag, i) => {
49
+ if (!Array.isArray(tag) || !tag.length || !tag.every(v => typeof v === 'string')) fail()
50
+ add('router-tag', i, ...tag)
51
+ })
52
+ add('payload', payload[0]); add('key', envelope[1])
53
+ if (envelope.length > 2) add('iykc', ...envelope.slice(2))
54
+ if (row.innerEventId) add('event', hex(row.innerEventId))
55
+ const first = integer(row.firstSeenAt ?? row.router.created_at), last = integer(row.lastSeenAt ?? row.router.created_at)
56
+ if (first > last) fail()
57
+ add('range', first, last)
58
+ if (row.fileChannelPubkey) {
59
+ add('file', hex(row.controlChannelPubkey), hex(row.peerPubkey), hex(row.root))
60
+ add('received', integer(row.receivedAt))
61
+ const index = row.router.tags.find(tag => tag[0] === 'i')?.[1]
62
+ if (integer(index) !== row.chunkIndex) fail()
63
+ }
64
+ } else if (row.recordType === 'nymCarrier_v1') {
65
+ if (!row.carriers?.length) fail()
66
+ row.carriers.forEach((carrier, i) => {
67
+ if (!isValidEvent(carrier) || carrier.kind !== 26400) fail()
68
+ add('carrier', i, carrier.kind, carrier.pubkey, carrier.created_at, carrier.id, carrier.sig, carrier.content)
69
+ carrier.tags.forEach((tag, j) => add('carrier-tag', `${i}:${j}`, ...tag))
70
+ })
71
+ const at = Math.max(...row.carriers.map(c => c.created_at))
72
+ add('range', at, at)
73
+ } else fail()
74
+ } else fail()
75
+ add('expiration', expires)
76
+ return tags
77
+ }
78
+ export function encodeRecord (type, row) {
79
+ try {
80
+ const tags = body(type, row)
81
+ const id = digest([type, tags])
82
+ const range = tags.find(t => t[0] === 'range')
83
+ const recordedAt = type === 'grant' ? row.sharedAt : row.fileChannelPubkey ? row.receivedAt : Number(range[2])
84
+ return { kind: 30078, created_at: recordedAt, tags: [['d', `${PREFIX}${type}:${id}`], ['t', `${PREFIX}${type}`], ['s', selector(type, row)], ...tags, ...(type === 'seed' && !row.fileChannelPubkey ? days(Number(range[1]), Number(range[2])).map(day => ['D', day]) : [])], content: '' }
85
+ } catch (error) { if (error instanceof ValidationError) throw error; fail() }
86
+ }
87
+ export function decodeRecord (event) {
88
+ try {
89
+ if (event?.kind !== 30078 || event.content !== '' || !Array.isArray(event.tags)) fail()
90
+ const tags = event.tags.filter(t => !['~', 'z', 'zz'].includes(t[0])).map(t => {
91
+ if (!Array.isArray(t) || !t.every(v => typeof v === 'string')) fail()
92
+ return t.filter((v, i) => i === 0 || !v.startsWith('~')).map((v, i) => i ? unescape(v) : v)
93
+ })
94
+ const one = (name, required = true) => {
95
+ const matches = tags.filter(t => t[0] === name)
96
+ if (matches.length > 1 || (required && !matches.length)) fail()
97
+ return matches[0]?.slice(1)
98
+ }
99
+ const coordinate = one('d')[0]
100
+ const type = coordinate?.startsWith(`${PREFIX}grant:`) ? 'grant' : coordinate?.startsWith(`${PREFIX}seed:`) ? 'seed' : fail()
101
+ const row = { expiresAt: integer(one('expiration')[0]), recordId: coordinate }
102
+ if (type === 'grant') {
103
+ Object.assign(row, { controlChannelPubkey: one('c')[0], fileChannelPubkey: one('channel')[0], peerPubkey: one('peer')[0], receiverPubkey: one('p')[0], root: one('root')[0], sharedAt: integer(one('shared')[0]) })
104
+ const size = one('size', false); if (size) row.size = integer(size[0])
105
+ } else {
106
+ row.channelPubkey = one('c')[0]; row.recordType = one('type')[0]
107
+ const range = one('range'); row.firstSeenAt = integer(range[0]); row.lastSeenAt = integer(range[1])
108
+ if (row.recordType === 'routerEnvelopeRow_v1') {
109
+ row.receiverPubkey = one('p')[0]
110
+ const router = one('router')
111
+ const list = tags.filter(t => t[0] === 'router-tag').sort((a, b) => Number(a[1]) - Number(b[1]))
112
+ list.forEach((t, i) => { if (t[1] !== String(i)) fail() })
113
+ row.router = { kind: 26300, pubkey: router[0], created_at: integer(router[1]), tags: list.map(t => t.slice(2)) }
114
+ row.payloadRow = JSON.stringify(one('payload'))
115
+ const envelope = [row.receiverPubkey, ...one('key'), ...(one('iykc', false) || [])]
116
+ row.row = JSON.stringify(envelope); row.iykcPubkey = envelope[2] || ''
117
+ row.innerEventId = one('event', false)?.[0] || ''
118
+ const file = one('file', false)
119
+ if (file) Object.assign(row, { controlChannelPubkey: file[0], peerPubkey: file[1], root: file[2], fileChannelPubkey: row.channelPubkey, chunkIndex: integer(row.router.tags.find(t => t[0] === 'i')?.[1]), receivedAt: integer(one('received')[0]) })
120
+ } else if (row.recordType === 'nymCarrier_v1') {
121
+ row.carriers = tags.filter(t => t[0] === 'carrier').sort((a, b) => Number(a[1]) - Number(b[1])).map((t, i) => {
122
+ if (t[1] !== String(i)) fail()
123
+ const original = tags.filter(v => v[0] === 'carrier-tag' && v[1].startsWith(`${i}:`)).sort((a, b) => Number(a[1].split(':')[1]) - Number(b[1].split(':')[1]))
124
+ original.forEach((v, j) => { if (v[1] !== `${i}:${j}`) fail() })
125
+ return { kind: integer(t[2]), pubkey: t[3], created_at: integer(t[4]), id: t[5], sig: t[6], content: t[7], tags: original.map(v => v.slice(2)) }
126
+ })
127
+ } else fail()
128
+ }
129
+ const encoded = encodeRecord(type, row)
130
+ // Compare the complete semantic tag multiset, including derived indices.
131
+ const canonical = list => list.map(t => JSON.stringify(t)).sort()
132
+ const cleanEncoded = encoded.tags.map(t => t.map((v, i) => i ? unescape(v) : v))
133
+ if (JSON.stringify(canonical(tags)) !== JSON.stringify(canonical(cleanEncoded))) fail()
134
+ return { type, row }
135
+ } catch (error) { if (error instanceof ValidationError) throw error; fail() }
136
+ }
@@ -0,0 +1,176 @@
1
+ import NMMR from 'nmmr'
2
+ import { chunkStream } from '../file/helpers/stream.js'
3
+ import { ValidationError } from '../../error/index.js'
4
+ import { decodeIrfsChunk } from '../../irfs/index.js'
5
+ import { encodeRecord, decodeRecord, selector, days, PREFIX } from './helpers/codec.js'
6
+ export { encodeRecord as encodeRecoveryRecord, decodeRecord as decodeRecoveryRecord } from './helpers/codec.js'
7
+
8
+ const nowSeconds = () => Math.floor(Date.now() / 1000)
9
+ function assertOptions (eventStore, signer) {
10
+ if (!eventStore?.query || !eventStore?.addPersonalCopy || !signer?.getPublicKey || !signer?.obfuscate || !signer?.nip44v3?.decrypt) throw new ValidationError('INVALID_EVENT_STORE_OPTIONS')
11
+ }
12
+ function committed (value) {
13
+ if (!value?.result?.ok) throw Object.assign(new Error(value?.result?.message || 'EVENT_STORE_WRITE_FAILED'), { code: value?.result?.code || 'EVENT_STORE_WRITE_FAILED' })
14
+ return value
15
+ }
16
+ // Bound each page and retain only the IDs at the timestamp boundary.
17
+ async function * pages (eventStore, filter, signal) {
18
+ let until = filter.until, excluded = []
19
+ while (true) {
20
+ signal?.throwIfAborted()
21
+ const { results } = await eventStore.query({ ...filter, ...(until === undefined ? {} : { until }), ...(excluded.length ? { '!ids': excluded } : {}), limit: 128, search: 'sort:desc' })
22
+ if (!results.length) return
23
+ for (const event of results) { signal?.throwIfAborted(); yield event }
24
+ const boundary = results.at(-1).created_at
25
+ const ids = results.filter(e => e.created_at === boundary).map(e => e.id)
26
+ excluded = boundary === until ? [...excluded, ...ids] : ids
27
+ until = boundary
28
+ if (results.length < 128) return
29
+ }
30
+ }
31
+ export function createPersonalCopyRecoveryStorage ({ eventStore, signer, now = nowSeconds }) {
32
+ assertOptions(eventStore, signer)
33
+ let closed = false
34
+ let opening
35
+ const ready = () => (opening ??= Promise.all([signer.getPublicKey(), signer.obfuscate('', '1006', '')]).catch(error => { opening = undefined; throw error }))
36
+ const check = () => { if (closed) throw new Error('RECOVERY_STORAGE_CLOSED') }
37
+ const mirrors = new Map(), grants = new Map()
38
+ const mirror = (name, value) => {
39
+ const key = `${name}:${value}`
40
+ if (!mirrors.has(key)) {
41
+ if (mirrors.size >= 256) mirrors.delete(mirrors.keys().next().value)
42
+ const promise = Promise.resolve().then(() => signer.obfuscate(value, '1006', `#${name}`)).catch(error => { if (mirrors.get(key) === promise) mirrors.delete(key); throw error })
43
+ mirrors.set(key, promise)
44
+ }
45
+ return mirrors.get(key)
46
+ }
47
+ async function filterFor (type) {
48
+ check()
49
+ const [owner, context] = await ready()
50
+ return { kinds: [1006], authors: [owner], '#k': ['30078'], '#c': [context], '#v': ['0', '1'], '&o': [await mirror('t', `${PREFIX}${type}`)] }
51
+ }
52
+ async function read (wrapper) {
53
+ check()
54
+ // Query the store before every use; only decryption of an immutable wrapper
55
+ // is cached. A removed wrapper therefore cannot authorize another reply.
56
+ const cached = grants.get(wrapper.id)
57
+ if (cached) return { ...cached, row: { ...cached.row }, wrapper }
58
+ const [owner] = await ready()
59
+ const bytes = await signer.nip44v3.decrypt(owner, 30078, '', wrapper.content)
60
+ const decoded = decodeRecord(JSON.parse(new TextDecoder().decode(bytes)))
61
+ if (decoded.type === 'grant') {
62
+ if (grants.size >= 128) grants.delete(grants.keys().next().value)
63
+ grants.set(wrapper.id, { ...decoded, row: { ...decoded.row } })
64
+ }
65
+ return { ...decoded, wrapper }
66
+ }
67
+ async function * records (type, query = {}) {
68
+ const filter = await filterFor(type)
69
+ if (query.recordId) filter['&o'].push(await mirror('d', query.recordId))
70
+ else if (query.fileChannelPubkey && query.receiverPubkey) {
71
+ const indices = type === 'seed' ? query.indices : [undefined]
72
+ if (indices?.length) filter['#o'] = await Promise.all(indices.map(chunkIndex => mirror('s', selector(type, { ...query, chunkIndex }))))
73
+ } else if (query.channelPubkey) {
74
+ filter['&o'].push(await mirror('c', query.channelPubkey))
75
+ if (Number.isFinite(query.since) && Number.isFinite(query.until)) {
76
+ const buckets = days(query.since, query.until)
77
+ // A wide query must include daily records too, so skip the day prefilter.
78
+ if (!buckets.includes('*')) filter['#o'] = await Promise.all([...buckets, '*'].map(d => mirror('D', d)))
79
+ }
80
+ }
81
+ for await (const wrapper of pages(eventStore, filter, query.signal)) {
82
+ const { row } = await read(wrapper)
83
+ if (query.channelPubkey && row.channelPubkey !== query.channelPubkey) continue
84
+ if (query.controlChannelPubkey && row.controlChannelPubkey !== query.controlChannelPubkey) continue
85
+ if (query.fileChannelPubkey && row.fileChannelPubkey !== query.fileChannelPubkey) continue
86
+ if (query.receiverPubkey && row.receiverPubkey && row.receiverPubkey !== query.receiverPubkey) continue
87
+ if (query.indices && !query.indices.includes(row.chunkIndex)) continue
88
+ if (query.since !== undefined && row.lastSeenAt < query.since) continue
89
+ if (query.until !== undefined && row.firstSeenAt > query.until) continue
90
+ if (!query.includeExpired && row.expiresAt <= now()) continue
91
+ yield { row, wrapper }
92
+ }
93
+ }
94
+ function store (type) {
95
+ return {
96
+ async put (row) {
97
+ check()
98
+ const event = encodeRecord(type, row)
99
+ if (row.expiresAt <= now()) return
100
+ const recordId = event.tags[0][1]
101
+ const existing = records(type, { recordId, includeExpired: true })
102
+ try { const first = await existing.next(); if (!first.done) return first.value.row } finally { await existing.return() }
103
+ const saved = committed(await eventStore.addPersonalCopy(event, { context: '' }))
104
+ // A pending kind-5 tombstone can reject the insertion; do not cache it.
105
+ if (saved.result.stored === false && saved.result.code === 'blocked') return
106
+ return { ...row, recordId }
107
+ },
108
+ async * iterate (query = {}) { for await (const { row } of records(type, query)) yield row },
109
+ async has (row) {
110
+ const iterator = records(type, { recordId: row.recordId || encodeRecord(type, row).tags[0][1] })
111
+ try { return !(await iterator.next()).done } finally { await iterator.return() }
112
+ },
113
+ async removeLocal (query = {}) {
114
+ for await (const { wrapper } of records(type, { ...query, includeExpired: true })) await eventStore.remove([['e', wrapper.id]])
115
+ },
116
+ async prune ({ now: at = now() } = {}) {
117
+ // Personal copies preserve the inner expiration on their signed wrapper.
118
+ // Cleanup must not decrypt the entire seed archive on startup/request.
119
+ for await (const wrapper of pages(eventStore, await filterFor(type))) {
120
+ const expiry = Number(wrapper.tags.find(tag => tag[0] === 'expiration')?.[1])
121
+ if (Number.isSafeInteger(expiry) && expiry <= at) await eventStore.remove([['e', wrapper.id]])
122
+ }
123
+ },
124
+ async revoke (rows) {
125
+ check()
126
+ const [owner] = await ready()
127
+ for (let i = 0; i < rows.length; i += 100) {
128
+ const group = rows.slice(i, i + 100).filter(row => row.expiresAt > now())
129
+ if (!group.length) continue
130
+ const tags = []; let createdAt = now(), expiresAt = 0
131
+ for (const row of group) {
132
+ const recordId = row.recordId || encodeRecord(type, row).tags[0][1]
133
+ for await (const { wrapper } of records(type, { recordId })) createdAt = Math.max(createdAt, wrapper.created_at)
134
+ tags.push(['a', `30078:${owner}:${recordId}`]); expiresAt = Math.max(expiresAt, row.expiresAt)
135
+ }
136
+ if (expiresAt <= createdAt) continue
137
+ committed(await eventStore.addPersonalCopy({ kind: 5, created_at: createdAt, tags: [...tags, ['k', '30078'], ['expiration', String(expiresAt)]], content: '' }, { context: '' }))
138
+ }
139
+ }
140
+ }
141
+ }
142
+ const seeds = store('seed'), authorizations = store('grant')
143
+ authorizations.find = async query => {
144
+ let best
145
+ for await (const row of authorizations.iterate(query)) if (!best || row.sharedAt > best.sharedAt || (row.sharedAt === best.sharedAt && (row.expiresAt < best.expiresAt || (row.expiresAt === best.expiresAt && row.recordId < best.recordId)))) best = row
146
+ return best
147
+ }
148
+ return { seeds, authorizations, close () { closed = true; mirrors.clear(); grants.clear() } }
149
+ }
150
+
151
+ export function createEventStoreChunkStorage ({ eventStore }) {
152
+ if (!eventStore?.query || !eventStore?.addPersonalCopy) throw new ValidationError('INVALID_EVENT_STORE_OPTIONS')
153
+ const storage = {
154
+ async read (root, index) {
155
+ const { results } = await eventStore.query({ kinds: [34601], '#d': [NMMR.deriveChunkId(root, index)], limit: 1 })
156
+ return results[0]
157
+ },
158
+ async save (event, descriptor) {
159
+ const decoded = decodeIrfsChunk(event)
160
+ if (decoded.root !== descriptor.root) throw new ValidationError('FILE_CHUNK_DESCRIPTOR_MISMATCH')
161
+ committed(await eventStore.addPersonalCopy({ kind: 34601, created_at: event.created_at, tags: structuredClone(event.tags), content: event.content }, { context: `dm:${descriptor.peerPubkey}` }))
162
+ }
163
+ }
164
+ storage.stream = (descriptor, { signal } = {}) => chunkStream({ read: storage.read, ...descriptor, signal })
165
+ return storage
166
+ }
167
+ export function createEventStoreMessageStorage ({ eventStore }) {
168
+ if (!eventStore?.addPersonalCopy) throw new ValidationError('INVALID_EVENT_STORE_OPTIONS')
169
+ return {
170
+ async save (event, { peerPubkey, hearsay = false } = {}) {
171
+ const result = await eventStore.addPersonalCopy(event, { context: `dm:${peerPubkey}`, hearsay })
172
+ if (!result?.result?.ok && !['blocked', 'expired'].includes(result?.result?.code)) committed(result)
173
+ return result
174
+ }
175
+ }
176
+ }
@@ -0,0 +1,152 @@
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. An optional adapter supplies `storage.read(root, index, descriptor)`
7
+ and `storage.save(event, descriptor)`; otherwise the library uses its bounded local
8
+ cache. `save` must resolve only after persistence.
9
+ The descriptor is `{ controlChannelPubkey, peerPubkey, root, size?, sharedAt? }`.
10
+ `sharedAt` is a stable sharing timestamp, never a read/retry timestamp.
11
+
12
+ For one-to-one chats, resolve the signer with
13
+ `withSharedKey(peerPubkey, fileChannelInfo(root))`: the canonical context is
14
+ `dm:media:<lowercase MMR root>`. Original and thumbnail roots use separate
15
+ channels under the same convention. Do not put this context in a public tag or
16
+ append it to the thumbnail URL. This is not a group-key derivation scheme.
17
+
18
+ - `register(descriptor)` resolves and checks the file channel without watching it.
19
+ - `authorizeSeeding(descriptor, { receiverPubkeys, sharedAt })` durably grants
20
+ the listed identities indexed recovery of this root on this control/data pair.
21
+ The application calls it for an explicit share, using its persisted message
22
+ timestamp (standalone 1063 uses its own timestamp). Registration, cache hits
23
+ and incoming requests never grant access. Missing router `p` is not a wildcard.
24
+ - `publishChunk(descriptor, event)` checks a 34601 proof and the descriptor,
25
+ captures ciphertext only in watchtower mode, and publishes to the recipient's relays.
26
+ Every wrapper must receive at least one relay acceptance. Yield between calls
27
+ so text/control sends can run. Publish all file chunks before announcing 1063/9.
28
+ - `download(descriptor, { manual, thumbnail, signal })` returns the verified,
29
+ persisted file descriptor. Automatic originals are limited to 1 MiB and
30
+ automatic thumbnails to 51,000 bytes. Larger/unknown originals require action;
31
+ already complete local files remain accessible. Unknown-size manual downloads
32
+ bootstrap their total from a verified chunk and learn size from the last one.
33
+ - `observe(callback)` returns an unsubscribe function. Updates include control
34
+ channel, root, status, persisted useful bytes (`completed`), total and errors.
35
+ - `cancel(controlChannelPubkey, root)` cancels the shared transfer; individual
36
+ caller signals only stop that caller waiting. Partial chunks are retained.
37
+ - `close()` releases subscriptions, timers and storage. Messenger pause/unwatch/
38
+ close also stop work; resume restarts paused transfers, never explicit cancels.
39
+
40
+ There are at most two active downloads per coordinator; thumbnails precede
41
+ manual originals, then small automatic originals. Open live input before reading
42
+ history or asking seeders. A failed historical read does not block peer recovery.
43
+ Deduplication and completion depend on verified, persisted chunk indices, not
44
+ publication ACKs or a terminal response. Ordinary DM left-edge temporal recovery
45
+ is unchanged.
46
+
47
+ ## Recovery protocol
48
+
49
+ The authenticated direct request travels on the conversation's `dm` channel:
50
+
51
+ ```js
52
+ { code: 'fileChunksRequest_p5cc', payload: {
53
+ fileChannelPubkey,
54
+ missingRanges: [[0, 15]] // inclusive, sorted, disjoint; at most 16 indices
55
+ } }
56
+ ```
57
+
58
+ A seeder replies from `storage.read`, skipping unavailable indices and validating
59
+ local proofs before sending. Each JSONL record is:
60
+
61
+ ```js
62
+ { recordType: 'irfsChunk_v1', index, total, proof, content }
63
+ ```
64
+
65
+ `proof` and `content` use Base93. The receiver reconstructs a 34601 template,
66
+ checks its proof against the expected root, and persists it without claiming the
67
+ original announcer authored the reconstructed event. The outer reply remains
68
+ individually encrypted for its recipient. Watchtowers continue replying with
69
+ `routerEnvelopeRow_v1`; they need not decrypt original bytes.
70
+
71
+ The reply travels on the **file** channel, with code `fileChunksReply_p5cc` and
72
+ the existing compact-record JSONL packer's `index`, `isLast`, and `jsonl` fields.
73
+ The inner reply's `q` references the request event. No new terminal availability
74
+ message is required and `isLast` never establishes file completeness. Late
75
+ responses from authorized seeders remain useful during an active download.
76
+ Seed replies run outside the DM dispatcher, with at most two reply workers and
77
+ sixteen queued requests; workers yield between records so control traffic can
78
+ continue. Requests discarded at the queue bound can be retried.
79
+
80
+ Request one seeder first and another after two seconds, recalculating missing
81
+ indices. A new persisted chunk resets the inactivity clock; duplicates do not.
82
+ After contacting all selected seeders, 30 seconds without progress makes the
83
+ transfer retryable. Each new attempt rechecks local storage and relay history.
84
+
85
+ Router 26300 uses `p` for its optional recipient, `f` for transport sender,
86
+ optional `imkc`, `c` for outer fragmentation, and `i` for the IRFS chunk index.
87
+ Repeat `i` across every fragment. It survives seed compaction, and must agree
88
+ with the validated 34601. It is encrypted within the router, never a public tag.
89
+ Carrier 26400 continues to use only `id`/`c`; inner control recipient `r` and
90
+ NIP-94 file-reference `r` are unchanged. There is no router `r` read fallback.
91
+ The existing 65,536-byte event ceiling includes the additional router overhead.
92
+
93
+ ## Storage and scope
94
+
95
+ Seeder authorizations live in
96
+ `libp2r2p:private-messenger:<owner>:file-authorizations:idb-queue`, with `key`
97
+ (unique control/data/recipient) and `channel` indexes. Records hold root, peer,
98
+ optional size, recipient, `sharedAt`, and absolute `expiresAt`; no ciphertext,
99
+ chunk bytes, signer or file-retention reference. The queue rejects capacity
100
+ failures instead of silently evicting grants. Grants survive restart and expire
101
+ at the original sharing time plus parent retention (default seven days), subject
102
+ to a subsequently shorter parent policy. Only a newer explicit share renews them.
103
+
104
+ Direct validated, persisted deliveries may authorize their actual local receiver
105
+ using the descriptor's `sharedAt`. Cache reads and recovery replies never renew
106
+ or create grants. Applications must not infer authorization for a third-party
107
+ announcer from its knowledge of a root. Group membership/revocation is not part
108
+ of this DM implementation; future group requests also need current membership.
109
+
110
+ Serving requires both live authorization and locally available chunks. The
111
+ catalog never pins NostrDB roots or recreates missing bytes. Checks run before
112
+ reads and again before each publication. Shared cleanup removes invalid grants
113
+ at startup and every minute while running; expired grants are immediately
114
+ unusable even before physical cleanup. Identity cleanup includes the catalog.
115
+
116
+ Watchtower ciphertext remains in
117
+ `libp2r2p:private-messenger:<owner>:file-seeds:idb-queue`, with its existing
118
+ 64 MiB FIFO budget and parent retention. The authorization catalog starts empty;
119
+ there is no migration or compatibility path for unpublished seeder file seeds.
120
+
121
+ Seed responses require the matching control/data channel pair and requester.
122
+ A channel holder can group traffic by its public channel author; no plaintext
123
+ root/index tag is exposed. This does not hide timing or repeated transfers of
124
+ the same pair/root.
125
+
126
+ Multi-device seed synchronization and group-channel derivation remain separate
127
+ follow-ups. Applications own durable manual-download intent, metadata retention
128
+ and UI policy. Removing one message must not delete a root retained by another.
129
+
130
+ ## Optional persistence (0.11.1)
131
+
132
+ `storage`, `authorizationStorage`, and `seedStorage` are optional. The latter two
133
+ use the semantic contracts in `private-messenger/event-store`; omitted stores
134
+ retain their existing indexed local implementation. External stores are owned by
135
+ the caller. Synced grants are checked against the current channel mode/retention
136
+ before serving, and are never deleted globally because a channel is not loaded.
137
+
138
+ With no chunk adapter, `createFileCache` owns
139
+ `<messenger prefix>:file-chunks:idb` (files and chunks stores). Its default
140
+ `cacheMaxBytes` is 256 MiB of useful decoded bytes; physical IndexedDB overhead is
141
+ additional. Deduplication uses root/index. FIFO root eviction discards inactive
142
+ files to admit new data. Durable renewable reservations protect active transfers
143
+ and readers across tabs; stale reservations expire after two minutes. A blocked
144
+ transfer waits with cancellation. Unknown sizes reserve the budget exclusively;
145
+ an individual oversized file raises `FILE_EXCEEDS_CACHE_CAPACITY`. Physical quota
146
+ failures try discarding additional inactive roots before propagating.
147
+
148
+ Outgoing chunks are saved before publication; this does not authorize recipients.
149
+ `readChunk(root, index)` reads local data. `stream(descriptor, { signal })` uses
150
+ bounded sequential reads and backpressure, not a whole-file Blob. Cancel streams
151
+ when unused. Cache eviction can remove a previously completed file, so new
152
+ requests recheck persisted indices. The cache is included in identity cleanup.
@@ -0,0 +1,159 @@
1
+ import { chunkStream } from './stream.js'
2
+ import { ValidationError } from '../../../error/index.js'
3
+ import { decodeIrfsChunk, IRFS_CHUNK_BYTES } from '../../../irfs/index.js'
4
+
5
+ export const DEFAULT_FILE_CACHE_BYTES = 256 * 1024 * 1024
6
+ const request = value => new Promise((resolve, reject) => { value.onsuccess = () => resolve(value.result); value.onerror = () => reject(value.error) })
7
+ const done = tx => new Promise((resolve, reject) => { tx.oncomplete = resolve; tx.onabort = () => reject(tx.error || new Error('FILE_CACHE_ABORTED')); tx.onerror = () => {} })
8
+
9
+ // Payload budget is useful decoded bytes, not the browser's physical disk usage.
10
+ // Files and chunk mutations commit together; durable leases fence other tabs.
11
+ export function createFileCache ({ prefix, indexedDB = globalThis.indexedDB, maxBytes = DEFAULT_FILE_CACHE_BYTES, now = Date.now }) {
12
+ if (!indexedDB?.open || !prefix || !Number.isSafeInteger(maxBytes) || maxBytes < 1) throw new ValidationError('INVALID_FILE_CACHE_OPTIONS')
13
+ let closed = false
14
+ const tokens = new Map()
15
+ const waiters = new Set()
16
+ const dbPromise = new Promise((resolve, reject) => {
17
+ const opening = indexedDB.open(`${prefix}:file-chunks:idb`, 1)
18
+ opening.onerror = () => reject(opening.error)
19
+ opening.onupgradeneeded = () => {
20
+ opening.result.createObjectStore('files', { keyPath: 'root' })
21
+ opening.result.createObjectStore('chunks', { keyPath: ['root', 'index'] }).createIndex('root', 'root')
22
+ }
23
+ opening.onsuccess = () => { opening.result.onversionchange = () => opening.result.close(); resolve(opening.result) }
24
+ })
25
+ const notify = () => { for (const wake of waiters) wake() }
26
+ const assertOpen = () => { if (closed) throw new Error('FILE_CACHE_CLOSED') }
27
+ async function transaction (work) {
28
+ assertOpen()
29
+ const db = await dbPromise
30
+ assertOpen()
31
+ const tx = db.transaction(['files', 'chunks'], 'readwrite')
32
+ const completion = done(tx)
33
+ try { const result = await work(tx.objectStore('files'), tx.objectStore('chunks')); await completion; return result } catch (error) { try { tx.abort() } catch {} await completion.catch(() => {}); throw error }
34
+ }
35
+ async function removeRoot (files, chunks, root) {
36
+ await new Promise((resolve, reject) => {
37
+ const cursor = chunks.index('root').openCursor(root)
38
+ cursor.onerror = () => reject(cursor.error)
39
+ cursor.onsuccess = () => { if (!cursor.result) return resolve(); cursor.result.delete(); cursor.result.continue() }
40
+ })
41
+ await request(files.delete(root))
42
+ }
43
+ const live = file => Object.fromEntries(Object.entries(file.leases || {}).filter(([, lease]) => lease.until > now()))
44
+ const cost = file => Math.max(file.used, ...Object.values(file.leases || {}).map(v => v.size))
45
+ async function attempt (root, size, token) {
46
+ return transaction(async (files, chunks) => {
47
+ const all = await request(files.getAll())
48
+ for (const file of all) file.leases = live(file)
49
+ let file = all.find(f => f.root === root)
50
+ if (!file) { file = { root, used: 0, createdAt: now(), leases: {} }; all.push(file) }
51
+ file.leases[token] = { size, until: now() + 120000 }
52
+ let total = all.reduce((sum, f) => sum + cost(f), 0)
53
+ const victims = all.filter(f => f.root !== root && !Object.keys(f.leases).length).sort((a, b) => a.createdAt - b.createdAt || a.root.localeCompare(b.root))
54
+ if (total - victims.reduce((sum, f) => sum + cost(f), 0) > maxBytes) return false
55
+ for (const victim of victims) {
56
+ if (total <= maxBytes) break
57
+ await removeRoot(files, chunks, victim.root); total -= cost(victim)
58
+ }
59
+ await request(files.put(file)); return true
60
+ })
61
+ }
62
+ async function wait (signal) {
63
+ signal?.throwIfAborted(); assertOpen()
64
+ await new Promise((resolve, reject) => {
65
+ const finish = error => { clearTimeout(timer); waiters.delete(wake); signal?.removeEventListener('abort', abort); error ? reject(error) : resolve() }
66
+ const wake = () => finish(), abort = () => finish(signal.reason)
67
+ const timer = setTimeout(wake, 250)
68
+ waiters.add(wake); signal?.addEventListener('abort', abort, { once: true })
69
+ })
70
+ }
71
+ async function reserve (root, size, { signal } = {}) {
72
+ if (!/^[0-9a-f]{64}$/.test(root)) throw new ValidationError('INVALID_FILE_ROOT')
73
+ size ??= maxBytes
74
+ if (!Number.isSafeInteger(size) || size < 1) throw new ValidationError('INVALID_FILE_SIZE')
75
+ if (size > maxBytes) throw Object.assign(new Error('FILE_EXCEEDS_CACHE_CAPACITY'), { code: 'FILE_EXCEEDS_CACHE_CAPACITY', maxBytes })
76
+ const token = crypto.randomUUID()
77
+ while (true) {
78
+ signal?.throwIfAborted(); assertOpen()
79
+ if (await attempt(root, size, token)) break
80
+ await wait(signal)
81
+ }
82
+ tokens.set(token, root)
83
+ let released = false
84
+ return async () => {
85
+ if (released) return
86
+ released = true; tokens.delete(token)
87
+ if (!closed) {
88
+ await transaction(async files => {
89
+ const file = await request(files.get(root)); if (!file) return
90
+ delete file.leases[token]; await request(files.put(file))
91
+ })
92
+ }
93
+ notify()
94
+ }
95
+ }
96
+ const timer = setInterval(() => {
97
+ if (!tokens.size || closed) return
98
+ transaction(async files => {
99
+ for (const [token, root] of tokens) {
100
+ const file = await request(files.get(root))
101
+ if (file?.leases[token]) { file.leases[token].until = now() + 120000; await request(files.put(file)) }
102
+ }
103
+ }).catch(() => {})
104
+ }, 30000)
105
+ timer.unref?.()
106
+ const api = {
107
+ maxBytes, reserve,
108
+ async read (root, index) {
109
+ assertOpen(); const db = await dbPromise
110
+ const tx = db.transaction('chunks', 'readonly')
111
+ const row = await request(tx.objectStore('chunks').get([root, index]))
112
+ return row?.event
113
+ },
114
+ async save (event, descriptor) {
115
+ const decoded = decodeIrfsChunk(event)
116
+ if (decoded.root !== descriptor.root) throw new ValidationError('FILE_CHUNK_DESCRIPTOR_MISMATCH')
117
+ if (descriptor.size !== undefined && (decoded.total !== Math.ceil(descriptor.size / IRFS_CHUNK_BYTES) || (decoded.index === decoded.total - 1 && decoded.contentBytes.length !== descriptor.size - decoded.index * IRFS_CHUNK_BYTES))) throw new ValidationError('FILE_CHUNK_DESCRIPTOR_MISMATCH')
118
+ const actualSize = decoded.index === decoded.total - 1 ? decoded.index * IRFS_CHUNK_BYTES + decoded.contentBytes.length : undefined
119
+ if (actualSize > maxBytes || decoded.index * IRFS_CHUNK_BYTES + decoded.contentBytes.length > maxBytes) throw Object.assign(new Error('FILE_EXCEEDS_CACHE_CAPACITY'), { code: 'FILE_EXCEEDS_CACHE_CAPACITY' })
120
+ const release = await reserve(decoded.root, descriptor.size ?? actualSize)
121
+ try {
122
+ while (true) {
123
+ try {
124
+ await transaction(async (files, chunks) => {
125
+ const file = await request(files.get(decoded.root))
126
+ const prior = await request(chunks.get([decoded.root, decoded.index]))
127
+ file.used += decoded.contentBytes.length - (prior?.bytes || 0)
128
+ await request(chunks.put({ root: decoded.root, index: decoded.index, bytes: decoded.contentBytes.length, event: structuredClone(event) }))
129
+ await request(files.put(file))
130
+ })
131
+ break
132
+ } catch (error) {
133
+ if (error?.name !== 'QuotaExceededError') throw error
134
+ const removed = await transaction(async (files, chunks) => {
135
+ const candidates = (await request(files.getAll())).filter(f => f.root !== decoded.root && !Object.keys(live(f)).length).sort((a, b) => a.createdAt - b.createdAt)
136
+ if (!candidates.length) return false
137
+ await removeRoot(files, chunks, candidates[0].root)
138
+ return true
139
+ })
140
+ if (!removed) throw error
141
+ }
142
+ }
143
+ } finally { await release() }
144
+ },
145
+ async stream (descriptor, { signal } = {}) {
146
+ const release = await reserve(descriptor.root, descriptor.size, { signal })
147
+ return chunkStream({ read: api.read, root: descriptor.root, size: descriptor.size, signal, release })
148
+ },
149
+ async close () {
150
+ if (closed) return
151
+ clearInterval(timer)
152
+ await transaction(async files => {
153
+ for (const [token, root] of tokens) { const file = await request(files.get(root)); if (file) { delete file.leases[token]; await request(files.put(file)) } }
154
+ })
155
+ closed = true; tokens.clear(); notify(); (await dbPromise).close()
156
+ }
157
+ }
158
+ return api
159
+ }