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 +6 -0
- package/package.json +3 -1
- 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 +27 -2
- package/private-messenger/file/helpers/cache.js +159 -0
- package/private-messenger/file/helpers/catalog.js +23 -1
- package/private-messenger/file/helpers/stream.js +31 -0
- package/private-messenger/file/index.js +42 -18
- package/private-messenger/index.js +27 -10
- package/private-messenger/services/storage-maintenance.js +1 -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
|
@@ -6,7 +6,29 @@ const keyFor = row => `${row.controlChannelPubkey}:${row.fileChannelPubkey}:${ro
|
|
|
6
6
|
|
|
7
7
|
// A grant contains no payload, key material or retention reference in the file
|
|
8
8
|
// store. Serialize read/merge/write with other coordinators for this identity.
|
|
9
|
-
export function createFileCatalog ({ messenger, now }) {
|
|
9
|
+
export function createFileCatalog ({ messenger, now, storage }) {
|
|
10
|
+
if (storage) {
|
|
11
|
+
const valid = row => {
|
|
12
|
+
const parent = messenger.channels.get(row.controlChannelPubkey)
|
|
13
|
+
return parent?.mode === 'seeder' && row.expiresAt > now() && row.sharedAt + messenger.offlineRecoverySecondsFor(parent) > now()
|
|
14
|
+
}
|
|
15
|
+
return {
|
|
16
|
+
ready: Promise.resolve(),
|
|
17
|
+
async authorize (file, receivers, sharedAt) {
|
|
18
|
+
const { controlChannelPubkey, fileChannelPubkey, peerPubkey, root, size } = file
|
|
19
|
+
for (const receiverPubkey of receivers) {
|
|
20
|
+
const row = { controlChannelPubkey, fileChannelPubkey, peerPubkey, root, size, receiverPubkey, sharedAt, expiresAt: sharedAt + messenger.offlineRecoverySecondsFor(controlChannelPubkey) }
|
|
21
|
+
if (valid(row)) await storage.put(row)
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
async find (controlChannelPubkey, fileChannelPubkey, receiverPubkey) {
|
|
25
|
+
const row = await storage.find({ controlChannelPubkey, fileChannelPubkey, receiverPubkey })
|
|
26
|
+
return row && valid(row) ? row : null
|
|
27
|
+
},
|
|
28
|
+
prune: () => storage.prune({ now: now() }),
|
|
29
|
+
async close () {}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
10
32
|
const prefix = `${messenger.prefix}:file-authorizations`
|
|
11
33
|
let database
|
|
12
34
|
const db = () => (database ??= createQueue({ prefix, indexedDB: messenger._indexedDB, evictionPolicy: 'reject', indexes: { key: { keyPath: 'key', unique: true }, channel: 'fileChannelPubkey' } }))
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { decodeIrfsChunk } from '../../../irfs/index.js'
|
|
2
|
+
import { ValidationError } from '../../../error/index.js'
|
|
3
|
+
|
|
4
|
+
export function chunkStream ({ read, root, size, signal, release = async () => {} }) {
|
|
5
|
+
let index = 0, total, bytes = 0, settled = false, controller
|
|
6
|
+
const finish = async () => {
|
|
7
|
+
if (settled) return
|
|
8
|
+
settled = true; signal?.removeEventListener('abort', abort)
|
|
9
|
+
await release()
|
|
10
|
+
}
|
|
11
|
+
const abort = () => { if (settled) return; controller.error(signal.reason); finish().catch(() => {}) }
|
|
12
|
+
return new ReadableStream({
|
|
13
|
+
start (value) { controller = value; signal?.addEventListener('abort', abort, { once: true }); if (signal?.aborted) abort() },
|
|
14
|
+
async pull () {
|
|
15
|
+
try {
|
|
16
|
+
signal?.throwIfAborted()
|
|
17
|
+
const event = await read(root, index)
|
|
18
|
+
signal?.throwIfAborted()
|
|
19
|
+
if (settled) return
|
|
20
|
+
if (!event) throw new Error('FILE_UNAVAILABLE')
|
|
21
|
+
const chunk = decodeIrfsChunk(event)
|
|
22
|
+
if (chunk.root !== root || chunk.index !== index || (total !== undefined && total !== chunk.total)) throw new ValidationError('FILE_CHUNK_DESCRIPTOR_MISMATCH')
|
|
23
|
+
total = chunk.total; index++; bytes += chunk.contentBytes.length
|
|
24
|
+
if (index === total && size !== undefined && bytes !== size) throw new ValidationError('FILE_SIZE_MISMATCH')
|
|
25
|
+
controller.enqueue(chunk.contentBytes)
|
|
26
|
+
if (index === total) { await finish(); controller.close() }
|
|
27
|
+
} catch (error) { if (!settled) { await finish(); controller.error(error) } }
|
|
28
|
+
},
|
|
29
|
+
cancel: finish
|
|
30
|
+
}, { highWaterMark: 1 })
|
|
31
|
+
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import NMMR from 'nmmr'
|
|
2
|
+
import { chunkStream } from './helpers/stream.js'
|
|
3
|
+
import { createFileCache } from './helpers/cache.js'
|
|
4
|
+
export { createFileCache, DEFAULT_FILE_CACHE_BYTES } from './helpers/cache.js'
|
|
2
5
|
import { createFileCatalog } from './helpers/catalog.js'
|
|
3
6
|
import { ValidationError } from '../../error/index.js'
|
|
4
7
|
import { IRFS_CHUNK_BYTES, decodeIrfsChunk } from '../../irfs/index.js'
|
|
@@ -60,10 +63,13 @@ function waitForTransfer (promise, signal) {
|
|
|
60
63
|
})
|
|
61
64
|
}
|
|
62
65
|
|
|
63
|
-
// One coordinator per account.
|
|
64
|
-
//
|
|
65
|
-
export function createPrivateFileTransfer ({ messenger, resolveChannel, storage, onError = () => {}, _transport = channelTransport, _messages = messages, _hedgeMs = 2000, _idleMs = 30000, _now = now }) {
|
|
66
|
-
if (!messenger?.extensions || typeof resolveChannel !== 'function' || !storage
|
|
66
|
+
// One coordinator per account. The caller resolves signers and may supply
|
|
67
|
+
// storage; recovery, validation and scheduling belong here.
|
|
68
|
+
export function createPrivateFileTransfer ({ messenger, resolveChannel, storage, authorizationStorage, seedStorage, cacheMaxBytes, onError = () => {}, _transport = channelTransport, _messages = messages, _hedgeMs = 2000, _idleMs = 30000, _now = now }) {
|
|
69
|
+
if (!messenger?.extensions || typeof resolveChannel !== 'function' || (storage && (!storage.read || !storage.save))) throw new ValidationError('INVALID_FILE_TRANSFER_OPTIONS')
|
|
70
|
+
const ownsStorage = !storage
|
|
71
|
+
storage ||= createFileCache({ prefix: messenger.prefix, indexedDB: messenger._indexedDB, maxBytes: cacheMaxBytes })
|
|
72
|
+
const uploadLeases = new Map()
|
|
67
73
|
const registrations = new Map()
|
|
68
74
|
const jobs = new Map()
|
|
69
75
|
const listeners = new Set()
|
|
@@ -75,7 +81,7 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
|
|
|
75
81
|
let closed = false
|
|
76
82
|
let queuePromise
|
|
77
83
|
const queue = () => (queuePromise ??= createQueue({ prefix: `${messenger.prefix}:file-seeds`, indexedDB: messenger._indexedDB, maxBytes: 64 * 1024 * 1024, evictionPolicy: 'fifo', indexes: { key: { keyPath: 'key', unique: true }, channel: 'fileChannelPubkey' } }))
|
|
78
|
-
const catalog = createFileCatalog({ messenger, now: _now })
|
|
84
|
+
const catalog = createFileCatalog({ messenger, now: _now, storage: authorizationStorage })
|
|
79
85
|
catalog.ready.catch(onError)
|
|
80
86
|
const keyFor = (control, root) => `${control}:${root}`
|
|
81
87
|
const emit = job => { for (const listener of listeners) listener({ root: job.file.root, controlChannelPubkey: job.file.controlChannelPubkey, status: job.status, completed: job.completed, total: job.file.size, error: job.error }) }
|
|
@@ -116,6 +122,7 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
|
|
|
116
122
|
}
|
|
117
123
|
async function prune () {
|
|
118
124
|
await catalog.prune()
|
|
125
|
+
if (seedStorage) { await seedStorage.prune({ now: _now() }); return }
|
|
119
126
|
if (!queuePromise) return
|
|
120
127
|
await (await queue()).removeWhere(row => {
|
|
121
128
|
const parent = messenger.channels.get(row.controlChannelPubkey)
|
|
@@ -126,11 +133,13 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
|
|
|
126
133
|
if (messenger.channels.get(file.controlChannelPubkey)?.mode !== 'watchtower' || !seedEnabled(file)) return
|
|
127
134
|
const index = readFileChunkIndex(seed.router)
|
|
128
135
|
if (index === undefined) return
|
|
129
|
-
const db = await queue()
|
|
136
|
+
const db = seedStorage ? null : await queue()
|
|
130
137
|
for (const row of compactSeedRouterRows(seed)) {
|
|
131
138
|
if (![file.peerPubkey, messenger.userPubkey].includes(row.receiverPubkey)) continue
|
|
132
139
|
const key = `${file.controlChannelPubkey}:${file.fileChannelPubkey}:${row.receiverPubkey}:${index}`
|
|
133
|
-
|
|
140
|
+
const record = { ...row, key, root: file.root, controlChannelPubkey: file.controlChannelPubkey, fileChannelPubkey: file.fileChannelPubkey, peerPubkey: file.peerPubkey, chunkIndex: index, receivedAt: _now(), expiresAt: _now() + messenger.offlineRecoverySecondsFor(file.controlChannelPubkey) }
|
|
141
|
+
if (seedStorage) await seedStorage.put(record)
|
|
142
|
+
else await db.putBy('key', record)
|
|
134
143
|
}
|
|
135
144
|
await prune()
|
|
136
145
|
}
|
|
@@ -151,9 +160,17 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
|
|
|
151
160
|
usable(file)
|
|
152
161
|
const decoded = checked(file, event)
|
|
153
162
|
await catalog.ready
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
163
|
+
if (ownsStorage) {
|
|
164
|
+
if (!uploadLeases.has(file.root)) uploadLeases.set(file.root, await storage.reserve(file.root, file.size))
|
|
165
|
+
try { await storage.save(event, file) } catch (error) { await uploadLeases.get(file.root)?.(); uploadLeases.delete(file.root); throw error }
|
|
166
|
+
}
|
|
167
|
+
let result
|
|
168
|
+
try {
|
|
169
|
+
const options = { ...sendOptions(file, await routing(file, file.peerPubkey)), receiverPubkeys: [file.peerPubkey], fileChunkIndex: decoded.index, ...(messenger.channels.get(file.controlChannelPubkey)?.mode === 'watchtower' ? { onPreparedSeed: seed => saveSeed(file, seed) } : {}) }
|
|
170
|
+
result = event.sig ? await _messages.broadcastEvent({ ...options, event }) : await _messages.broadcastRumor({ ...options, rumor: event })
|
|
171
|
+
assertPublished(result)
|
|
172
|
+
} catch (error) { await uploadLeases.get(file.root)?.(); uploadLeases.delete(file.root); throw error }
|
|
173
|
+
if (decoded.index === decoded.total - 1) { await uploadLeases.get(file.root)?.(); uploadLeases.delete(file.root) }
|
|
157
174
|
// A scheduler turn between chunks lets queued text/control publications run.
|
|
158
175
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
159
176
|
return result
|
|
@@ -198,9 +215,11 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
|
|
|
198
215
|
return true
|
|
199
216
|
}
|
|
200
217
|
if (messenger.channels.get(controlChannelPubkey)?.mode !== 'watchtower') return true
|
|
201
|
-
const db = await queue()
|
|
218
|
+
const db = seedStorage ? null : await queue()
|
|
202
219
|
let packer
|
|
203
|
-
|
|
220
|
+
const candidates = seedStorage ? seedStorage.iterate({ controlChannelPubkey, fileChannelPubkey: payload.fileChannelPubkey, receiverPubkey: question.pubkey, indices: [...indices] }) : db.storedItemsBy('channel', payload.fileChannelPubkey)
|
|
221
|
+
for await (const seed of candidates) {
|
|
222
|
+
if (seed.expiresAt <= _now() || seed.receivedAt + messenger.offlineRecoverySecondsFor(controlChannelPubkey) <= _now() || (seedStorage && !await seedStorage.has(seed))) continue
|
|
204
223
|
if (seed.controlChannelPubkey !== controlChannelPubkey || seed.receiverPubkey !== question.pubkey || !indices.has(seed.chunkIndex) || !seedEnabled(seed)) continue
|
|
205
224
|
const file = await register(seed)
|
|
206
225
|
usable(file)
|
|
@@ -218,7 +237,7 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
|
|
|
218
237
|
async function run (job) {
|
|
219
238
|
const file = job.file
|
|
220
239
|
const signal = job.controller.signal
|
|
221
|
-
let sub
|
|
240
|
+
let sub, release
|
|
222
241
|
const historyController = new AbortController()
|
|
223
242
|
let ingest = Promise.resolve()
|
|
224
243
|
let total = file.size === undefined ? undefined : Math.ceil(file.size / IRFS_CHUNK_BYTES)
|
|
@@ -286,6 +305,7 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
|
|
|
286
305
|
return ingest
|
|
287
306
|
}
|
|
288
307
|
try {
|
|
308
|
+
release = await storage.reserve?.(file.root, file.size, { signal })
|
|
289
309
|
job.status = 'downloading'; emit(job)
|
|
290
310
|
// Resume from independently verified local chunks, never from an ACK.
|
|
291
311
|
for (let index = 0; index < (total ?? 1); index++) {
|
|
@@ -304,7 +324,11 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
|
|
|
304
324
|
await sub.ready
|
|
305
325
|
signal.throwIfAborted()
|
|
306
326
|
// Partial/failed history still permits recovery from peers.
|
|
307
|
-
const history = _transport.fetch({ ...options, signal: AbortSignal.any([signal, historyController.signal]), since: 0 }).catch(error => {
|
|
327
|
+
const history = _transport.fetch({ ...options, signal: AbortSignal.any([signal, historyController.signal]), since: 0 }).catch(error => {
|
|
328
|
+
// Completion aborts the remaining history read in finally. Only its
|
|
329
|
+
// own cancellation is expected; genuine history failures stay visible.
|
|
330
|
+
if (!signal.aborted && !(historyController.signal.aborted && error === historyController.signal.reason)) onError(error)
|
|
331
|
+
})
|
|
308
332
|
work.add(history); history.finally(() => work.delete(history))
|
|
309
333
|
const seeders = [...validSeeders].sort((a, b) => (successes.get(b) || 0) - (successes.get(a) || 0) || Math.random() - 0.5)
|
|
310
334
|
while (true) {
|
|
@@ -329,7 +353,7 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
|
|
|
329
353
|
await new Promise(resolve => { const timer = setTimeout(done, Math.min(100, _hedgeMs)); function done () { clearTimeout(timer); signal.removeEventListener('abort', done); resolve() } signal.addEventListener('abort', done, { once: true }) })
|
|
330
354
|
}
|
|
331
355
|
} finally { signal.removeEventListener('abort', stop) }
|
|
332
|
-
} finally { historyController.abort(); await sub?.close(); await ingest }
|
|
356
|
+
} finally { historyController.abort(); await sub?.close(); await ingest; await release?.() }
|
|
333
357
|
if (job.completed !== file.size) throw new ValidationError('FILE_SIZE_MISMATCH')
|
|
334
358
|
}
|
|
335
359
|
function pump () {
|
|
@@ -366,7 +390,7 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
|
|
|
366
390
|
}
|
|
367
391
|
const key = keyFor(file.controlChannelPubkey, file.root)
|
|
368
392
|
let job = jobs.get(key)
|
|
369
|
-
if (!job || ['error', 'cancelled', 'paused'].includes(job.status)) {
|
|
393
|
+
if (!job || ['error', 'cancelled', 'paused', ...(ownsStorage ? ['complete'] : [])].includes(job.status)) {
|
|
370
394
|
const controller = new AbortController()
|
|
371
395
|
const { promise, resolve, reject } = Promise.withResolvers()
|
|
372
396
|
job = { file, controller, resolve, reject, promise, completed: 0, manual, thumbnail, priority: thumbnail ? 2 : manual ? 1 : 0, status: 'queued' }
|
|
@@ -404,10 +428,10 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
|
|
|
404
428
|
for (const job of jobs.values()) if (job.pauseRequested && job.status !== 'complete') download(job.file, { manual: job.manual, thumbnail: job.thumbnail }).catch(onError)
|
|
405
429
|
},
|
|
406
430
|
unwatch (channels) { for (const job of jobs.values()) if (channels.includes(job.file.controlChannelPubkey)) cancelJob(job) },
|
|
407
|
-
async close () { if (closed) return; closed = true; extension.pause(); clearInterval(timer); await Promise.allSettled([...work]); await catalog.close(); if (queuePromise) await (await queuePromise).close(); messenger.extensions.delete(extension); listeners.clear() }
|
|
431
|
+
async close () { if (closed) return; closed = true; extension.pause(); clearInterval(timer); await Promise.allSettled([...work]); await catalog.close(); for (const release of uploadLeases.values()) await release(); uploadLeases.clear(); if (ownsStorage) await storage.close(); if (queuePromise) await (await queuePromise).close(); messenger.extensions.delete(extension); listeners.clear() }
|
|
408
432
|
}
|
|
409
433
|
const timer = setInterval(() => { prune().catch(onError) }, 60000)
|
|
410
434
|
timer.unref?.()
|
|
411
435
|
messenger.extensions.add(extension)
|
|
412
|
-
return { register, authorizeSeeding, publishChunk, download, close: extension.close, cancel (control, root) { const job = jobs.get(keyFor(control, root)); if (job) { job.pauseRequested = false; cancelJob(job) } }, observe (listener) { listeners.add(listener); for (const job of jobs.values()) emit(job); return () => listeners.delete(listener) } }
|
|
436
|
+
return { register, authorizeSeeding, publishChunk, download, readChunk: (root, index) => storage.read(root, index), stream: (descriptor, options = {}) => storage.stream ? storage.stream(descriptor, options) : chunkStream({ read: storage.read.bind(storage), ...descriptor, ...options }), close: extension.close, cancel (control, root) { const job = jobs.get(keyFor(control, root)); if (job) { job.pauseRequested = false; cancelJob(job) } }, observe (listener) { listeners.add(listener); for (const job of jobs.values()) emit(job); return () => listeners.delete(listener) } }
|
|
413
437
|
}
|
|
@@ -201,6 +201,7 @@ export class PrivateMessenger {
|
|
|
201
201
|
maxDynamicRecoverySeeders = DEFAULT_MAX_DYNAMIC_RECOVERY_SEEDERS,
|
|
202
202
|
messageQueueMaxBytes = DEFAULT_MESSAGE_QUEUE_MAX_BYTES,
|
|
203
203
|
seedQueueMaxBytes = DEFAULT_SEED_QUEUE_MAX_BYTES,
|
|
204
|
+
seedStorage,
|
|
204
205
|
temporaryStorageArea = globalThis.sessionStorage,
|
|
205
206
|
autoDeletionCapability = true,
|
|
206
207
|
_indexedDB = globalThis.indexedDB,
|
|
@@ -232,6 +233,7 @@ export class PrivateMessenger {
|
|
|
232
233
|
this.maxDynamicRecoverySeeders = maxDynamicRecoverySeeders
|
|
233
234
|
this.messageQueueMaxBytes = messageQueueMaxBytes
|
|
234
235
|
this.seedQueueMaxBytes = seedQueueMaxBytes
|
|
236
|
+
this.seedStorage = seedStorage
|
|
235
237
|
this.temporaryStorageArea = temporaryStorageArea
|
|
236
238
|
this.autoDeletionCapability = normalizeAutoDeletionCapability(autoDeletionCapability)
|
|
237
239
|
this._indexedDB = _indexedDB
|
|
@@ -348,13 +350,15 @@ export class PrivateMessenger {
|
|
|
348
350
|
indexedDB: this._indexedDB
|
|
349
351
|
})
|
|
350
352
|
this.assertOpen()
|
|
351
|
-
this.seedQueue =
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
353
|
+
this.seedQueue = this.seedStorage
|
|
354
|
+
? null
|
|
355
|
+
: await createQueue({
|
|
356
|
+
prefix: `${this.prefix}:seeds`,
|
|
357
|
+
indexes: SEED_QUEUE_INDEXES,
|
|
358
|
+
maxBytes: this.seedQueueMaxBytes,
|
|
359
|
+
evictionPolicy: 'fifo',
|
|
360
|
+
indexedDB: this._indexedDB
|
|
361
|
+
})
|
|
358
362
|
this.assertOpen()
|
|
359
363
|
this.stateStore = await createChannelStateStore({
|
|
360
364
|
prefix: this.prefix,
|
|
@@ -1342,6 +1346,16 @@ export class PrivateMessenger {
|
|
|
1342
1346
|
async enqueueSeed (channelPubkey, seed) {
|
|
1343
1347
|
if (!this.offlineRecoverySecondsFor(channelPubkey)) return
|
|
1344
1348
|
const receivedAt = nowSeconds()
|
|
1349
|
+
if (this.seedStorage) {
|
|
1350
|
+
const rows = seed.recordType === NYM_CARRIER_SEED_RECORD_TYPE || seed.carriers?.length
|
|
1351
|
+
? [{ recordType: NYM_CARRIER_SEED_RECORD_TYPE, carriers: compactSeedNymCarriers(seed.carriers) }]
|
|
1352
|
+
: compactSeedRouterRows(seed)
|
|
1353
|
+
for (const row of rows) {
|
|
1354
|
+
const time = seedRecordTime(row) || receivedAt
|
|
1355
|
+
await this.seedStorage.put({ ...row, channelPubkey, receivedAt, expiresAt: time + this.offlineRecoverySecondsFor(channelPubkey) })
|
|
1356
|
+
}
|
|
1357
|
+
return
|
|
1358
|
+
}
|
|
1345
1359
|
if (seed.recordType === NYM_CARRIER_SEED_RECORD_TYPE || seed.carriers?.length) {
|
|
1346
1360
|
const carriers = compactSeedNymCarriers(seed.carriers)
|
|
1347
1361
|
const recordTime = nymCarrierRecordTime({ carriers }) || seed.outer?.created_at || receivedAt
|
|
@@ -1932,7 +1946,9 @@ export class PrivateMessenger {
|
|
|
1932
1946
|
})
|
|
1933
1947
|
|
|
1934
1948
|
if (this.offlineRecoverySecondsFor(channelPubkey)) {
|
|
1935
|
-
for await (const seed of this.seedQueue.storedItemsBy('byChannel', channelPubkey)) {
|
|
1949
|
+
for await (const seed of this.seedStorage ? this.seedStorage.iterate({ channelPubkey, receiverPubkey: message.event?.pubkey, since, until }) : this.seedQueue.storedItemsBy('byChannel', channelPubkey)) {
|
|
1950
|
+
if (seedRecordTime(seed) < nowSeconds() - this.offlineRecoverySecondsFor(channelPubkey)) continue
|
|
1951
|
+
if (this.seedStorage && !await this.seedStorage.has(seed)) continue
|
|
1936
1952
|
await packer.update(seed)
|
|
1937
1953
|
}
|
|
1938
1954
|
}
|
|
@@ -2108,7 +2124,7 @@ export class PrivateMessenger {
|
|
|
2108
2124
|
this.removeChannelState(pubkey)
|
|
2109
2125
|
await this.flushStateWrites()
|
|
2110
2126
|
await this.queue.removeBy('byChannel', pubkey)
|
|
2111
|
-
await this.seedQueue.removeBy('byChannel', pubkey)
|
|
2127
|
+
await (this.seedStorage ? this.seedStorage.removeLocal({ channelPubkey: pubkey }) : this.seedQueue.removeBy('byChannel', pubkey))
|
|
2112
2128
|
await this.touchStorageActivity({ force: true })
|
|
2113
2129
|
this.ensureRelayListWatcher()
|
|
2114
2130
|
})
|
|
@@ -2134,7 +2150,7 @@ export class PrivateMessenger {
|
|
|
2134
2150
|
delete state.channels[pubkey]
|
|
2135
2151
|
stalePubkeys.push(pubkey)
|
|
2136
2152
|
await this.queue?.removeBy('byChannel', pubkey)
|
|
2137
|
-
await this.seedQueue?.removeBy('byChannel', pubkey)
|
|
2153
|
+
await (this.seedStorage ? this.seedStorage.removeLocal({ channelPubkey: pubkey }) : this.seedQueue?.removeBy('byChannel', pubkey))
|
|
2138
2154
|
}
|
|
2139
2155
|
this.state = state
|
|
2140
2156
|
if (stalePubkeys.length) await this.removeChannelStates(stalePubkeys)
|
|
@@ -2142,6 +2158,7 @@ export class PrivateMessenger {
|
|
|
2142
2158
|
}
|
|
2143
2159
|
|
|
2144
2160
|
async pruneStoredSeeds (channelPubkey) {
|
|
2161
|
+
if (this.seedStorage) { await this.seedStorage.prune({ now: nowSeconds() }); return }
|
|
2145
2162
|
if (!this.seedQueue) return
|
|
2146
2163
|
const keyRange = globalThis.IDBKeyRange
|
|
2147
2164
|
if (!channelPubkey) {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Private message sessions
|
|
2
|
+
|
|
3
|
+
`createPrivateMessageSession(options)` coordinates one identity's DM inbox,
|
|
4
|
+
outbox and IRFS attachments without presentation state. It builds on
|
|
5
|
+
PrivateMessenger and the file coordinator, not another recovery protocol.
|
|
6
|
+
|
|
7
|
+
Provide `owner`, a byte-based NIP-44 v3 `signer` with `withSharedKey`, and either
|
|
8
|
+
`eventStore` or a `messageStorage.save(event, { peerPubkey, hearsay })` adapter.
|
|
9
|
+
Optional `chunkStorage` and `recoveryStorage` (`seeds`, `authorizations`) select
|
|
10
|
+
persistence; absent file storage uses the library cache. `mode` defaults to
|
|
11
|
+
`seeder`, `seedersForPeer(peer)` to `[peer]`, and `allowedKinds` to
|
|
12
|
+
`[5, 9, 1063, 34601]`. The DM context is `dm`; file contexts are `dm:media:<root>`.
|
|
13
|
+
`createMessengerSigner` adapts this byte signer to the transport's Base64 API.
|
|
14
|
+
|
|
15
|
+
Methods: `setPeers`, `setAvailable(boolean)`, `enqueue`, `retry`, `cancel`,
|
|
16
|
+
`download`, `cancelDownload`, and `close`. Callbacks are `onOutbox`, `onMedia`,
|
|
17
|
+
and `onError`. The caller maps its account permissions/connectivity/lock state
|
|
18
|
+
to availability and owns view subscriptions. There are no DOM or UI imports.
|
|
19
|
+
|
|
20
|
+
`enqueue({ peer, event, context, requiredFiles, deletion })` preserves prepared
|
|
21
|
+
identities. Local message commits and remote stages have independent checkpoints.
|
|
22
|
+
It sends original/thumbnail chunks before announcements, yields for small messages,
|
|
23
|
+
and never interprets relay rejection text as signer permission denial. Inbox ACK
|
|
24
|
+
follows durable storage; failed storage nacks and pauses ingestion. Hearsay cannot
|
|
25
|
+
execute an authoritative deletion. Completion of file recovery means verified,
|
|
26
|
+
persisted indices, not a relay ACK.
|
|
27
|
+
|
|
28
|
+
`openOutbox` and `openDownloads` inject work storage. Their default is exported as
|
|
29
|
+
`createPrivateSessionStorage({ owner, signer, indexedDB, namespace, prefix })`:
|
|
30
|
+
IDB queue with unique IDs, encrypted payloads, reject-on-capacity and atomic
|
|
31
|
+
existing-only checkpoints. Default prefix is `libp2r2p:private-messenger:session`;
|
|
32
|
+
namespace is `outbox` or `downloads`. These contain pending user work, not a cache;
|
|
33
|
+
no automatic age eviction or synchronization is performed. `close()` releases
|
|
34
|
+
connections without deleting pending work. Apps can preserve existing storage by
|
|
35
|
+
passing their established factories, as Zillion does.
|
|
36
|
+
|
|
37
|
+
External recovery factories are owned by their caller and must be closed after
|
|
38
|
+
the session. Work storage opened by the session is closed by the session.
|
|
39
|
+
This is a DM profile; group membership/key derivation is not implemented.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { getEventHash } from '../../../event/index.js'
|
|
2
|
+
|
|
3
|
+
// A report describes one outer event, not one relay. Each outer event needs
|
|
4
|
+
// at least one relay acknowledgement. Keep the successful fast path immediate.
|
|
5
|
+
export async function assertMessagePublished (result, event) {
|
|
6
|
+
const reports = result?.delivery?.reports
|
|
7
|
+
const present = Array.isArray(reports) && reports.length > 0
|
|
8
|
+
if (present && reports.every(report => report?.success === true)) return
|
|
9
|
+
|
|
10
|
+
const failures = present
|
|
11
|
+
? await Promise.all(reports.map(async (report, index) => {
|
|
12
|
+
if (report?.success === true) return null
|
|
13
|
+
let settled
|
|
14
|
+
try { settled = await report?.promise } catch (reason) { settled = { errors: [{ reason }] } }
|
|
15
|
+
// Never retain the broadcast result: it includes plaintext and may carry
|
|
16
|
+
// delivery.deletionSeckey. Native relay errors retain transport causes.
|
|
17
|
+
return {
|
|
18
|
+
index,
|
|
19
|
+
total: settled?.total ?? report?.total,
|
|
20
|
+
fulfilled: settled?.fulfilled,
|
|
21
|
+
errors: (settled?.errors || []).map(({ relay, reason }) => ({ relay, reason }))
|
|
22
|
+
}
|
|
23
|
+
})).then(values => values.filter(Boolean))
|
|
24
|
+
: []
|
|
25
|
+
const reason = present ? 'RELAY_PUBLICATION_FAILED' : 'NO_DELIVERY_REPORTS'
|
|
26
|
+
const details = failures.map(report => {
|
|
27
|
+
const errors = report.errors.map(({ relay, reason }) => {
|
|
28
|
+
const category = reason?.category ? ` [${reason.category}]` : ''
|
|
29
|
+
return `${relay || 'unknown relay'}${category}: ${reason?.message || String(reason)}`
|
|
30
|
+
})
|
|
31
|
+
return `report ${report.index + 1} (${report.total ?? '?'} relays): ${errors.join('; ') || (report.total === 0 ? 'NO_RELAYS' : 'NO_RELAY_ACKNOWLEDGEMENT')}`
|
|
32
|
+
})
|
|
33
|
+
throw Object.assign(new Error(`MESSAGE_NOT_PUBLISHED: ${details.join(' | ') || reason}`), {
|
|
34
|
+
code: 'MESSAGE_NOT_PUBLISHED',
|
|
35
|
+
reason,
|
|
36
|
+
eventId: getEventHash(event),
|
|
37
|
+
eventKind: event.kind,
|
|
38
|
+
reports: failures
|
|
39
|
+
})
|
|
40
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { base64ToBytes, bytesToBase64 } from '../../../base64/index.js'
|
|
2
|
+
|
|
3
|
+
// The library transport uses its Base64 signer contract. Conversion stays at
|
|
4
|
+
// this adapter; the injected launcher/vault channel always carries raw bytes.
|
|
5
|
+
export function messengerSigner (signer) {
|
|
6
|
+
return {
|
|
7
|
+
getPublicKey: () => signer.getPublicKey(),
|
|
8
|
+
getRelays: () => signer.getRelays(),
|
|
9
|
+
signEvent: event => signer.signEvent(event),
|
|
10
|
+
nip44v3Encrypt: (peer, kind, scope, text) => signer.nip44v3.encrypt(peer, kind, scope, base64ToBytes(text).buffer),
|
|
11
|
+
nip44v3Decrypt: async (peer, kind, scope, ciphertext) => bytesToBase64(new Uint8Array(await signer.nip44v3.decrypt(peer, kind, scope, ciphertext)))
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { createQueue } from '../../../idb-queue/index.js'
|
|
2
|
+
|
|
3
|
+
// Only ciphertext and opaque event IDs are persisted here. Chunks are committed
|
|
4
|
+
// to the configured file store before an entry is accepted.
|
|
5
|
+
export async function createChatOutbox ({ owner, signer, indexedDB = globalThis.indexedDB, namespace = 'outbox', prefix = 'libp2r2p:private-messenger:session' }) {
|
|
6
|
+
const queue = await createQueue({
|
|
7
|
+
prefix: `${prefix}:${namespace}:${owner}`,
|
|
8
|
+
indexes: { byId: { keyPath: 'id', unique: true } },
|
|
9
|
+
evictionPolicy: 'reject',
|
|
10
|
+
indexedDB
|
|
11
|
+
})
|
|
12
|
+
return {
|
|
13
|
+
async put (entry, { existing = false } = {}) {
|
|
14
|
+
const bytes = new TextEncoder().encode(JSON.stringify(entry)).buffer
|
|
15
|
+
const ciphertext = await signer.nip44v3.encrypt(owner, 9, `${prefix}:${namespace}`, bytes)
|
|
16
|
+
return queue.putBy('byId', { id: entry.id, ciphertext }, { existingOnly: existing })
|
|
17
|
+
},
|
|
18
|
+
async list () {
|
|
19
|
+
const entries = []
|
|
20
|
+
for await (const row of queue.storedItemsBy('byId')) {
|
|
21
|
+
const bytes = await signer.nip44v3.decrypt(owner, 9, `${prefix}:${namespace}`, row.ciphertext)
|
|
22
|
+
const entry = JSON.parse(new TextDecoder().decode(bytes))
|
|
23
|
+
if (entry.id !== row.id) throw new Error('INVALID_OUTBOX_ENTRY')
|
|
24
|
+
entries.push(entry)
|
|
25
|
+
}
|
|
26
|
+
return entries
|
|
27
|
+
},
|
|
28
|
+
has: id => queue.someBy('byId', id),
|
|
29
|
+
remove: id => queue.removeBy('byId', id),
|
|
30
|
+
close: () => queue.close()
|
|
31
|
+
}
|
|
32
|
+
}
|