libp2r2p 0.11.0 → 0.11.2

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
@@ -907,3 +907,9 @@ capture outgoing ordinary-message ciphertext before publishing, even without rel
907
907
  echo. For files, seeders instead keep expiring recipient authorizations and serve
908
908
  verified local chunks as `irfsChunk_v1`; watchtowers retain ciphertext seeds.
909
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.11.0",
3
+ "version": "0.11.2",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -86,8 +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",
89
90
  "./private-messenger/file": "./private-messenger/file/index.js",
90
91
  "./private-messenger/recovery": "./private-messenger/recovery/index.js",
92
+ "./private-messenger/session": "./private-messenger/session/index.js",
91
93
  "./relay": "./relay/index.js",
92
94
  "./temporary-storage": "./temporary-storage/index.js",
93
95
  "./url": "./url/index.js",
@@ -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.
@@ -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
+ }
@@ -3,8 +3,9 @@
3
3
  `createPrivateFileTransfer({ messenger, resolveChannel, storage, onError })`
4
4
  creates one account coordinator attached to an initialized PrivateMessenger.
5
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.
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.
8
9
  The descriptor is `{ controlChannelPubkey, peerPubkey, root, size?, sharedAt? }`.
9
10
  `sharedAt` is a stable sharing timestamp, never a read/retry timestamp.
10
11
 
@@ -125,3 +126,27 @@ the same pair/root.
125
126
  Multi-device seed synchronization and group-channel derivation remain separate
126
127
  follow-ups. Applications own durable manual-download intent, metadata retention
127
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
+ }