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 +23 -0
- package/nip27/index.js +1 -0
- package/nip94/README.md +13 -0
- package/nip94/index.js +56 -2
- package/package.json +4 -1
- package/private-channel/helpers/chunk-size.js +1 -1
- package/private-channel/helpers/chunks.js +1 -1
- package/private-channel/helpers/event.js +14 -3
- package/private-channel/index.js +25 -7
- package/private-channel/services/received-chunks.js +6 -3
- package/private-message/index.js +21 -7
- package/private-messenger/event-store/README.md +110 -0
- package/private-messenger/event-store/helpers/codec.js +136 -0
- package/private-messenger/event-store/index.js +176 -0
- package/private-messenger/file/README.md +152 -0
- package/private-messenger/file/helpers/cache.js +159 -0
- package/private-messenger/file/helpers/catalog.js +87 -0
- package/private-messenger/file/helpers/stream.js +31 -0
- package/private-messenger/file/index.js +433 -0
- package/private-messenger/index.js +45 -10
- package/private-messenger/recovery/index.js +1 -1
- package/private-messenger/services/storage-maintenance.js +3 -0
- package/private-messenger/session/README.md +39 -0
- package/private-messenger/session/helpers/publication.js +40 -0
- package/private-messenger/session/helpers/signer.js +13 -0
- package/private-messenger/session/helpers/work-storage.js +32 -0
- package/private-messenger/session/index.js +307 -0
|
@@ -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
|
+
}
|