libp2r2p 0.10.28 → 0.11.0
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 +17 -0
- package/nip27/index.js +1 -0
- package/nip94/README.md +13 -0
- package/nip94/index.js +56 -2
- package/package.json +2 -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/file/README.md +127 -0
- package/private-messenger/file/helpers/catalog.js +65 -0
- package/private-messenger/file/index.js +413 -0
- package/private-messenger/index.js +18 -0
- package/private-messenger/recovery/index.js +1 -1
- package/private-messenger/services/storage-maintenance.js +2 -0
package/README.md
CHANGED
|
@@ -890,3 +890,20 @@ complete per-relay report without delaying the first-success return. Connection
|
|
|
890
890
|
establishment retains its separate 3-second deadline. PrivateMessenger inherits
|
|
891
891
|
this publication policy, including seeder/recovery messages. A timeout indicates
|
|
892
892
|
missing acknowledgement and cannot prove that the recipient did not receive it.
|
|
893
|
+
|
|
894
|
+
## Private file channels (0.11)
|
|
895
|
+
|
|
896
|
+
`libp2r2p/private-messenger/file` provides `createPrivateFileTransfer` for
|
|
897
|
+
separate per-root media channels, bounded indexed recovery and persisted-byte
|
|
898
|
+
progress. [The file API and protocol](private-messenger/file/README.md) document
|
|
899
|
+
signer/storage adapters and lifecycle. All original and thumbnail chunks belong
|
|
900
|
+
to `dm:media:<root>`; small authenticated recovery requests stay on `dm`.
|
|
901
|
+
NIP-94 supports `thumbnail` metadata with unordered `mark`/`size` root extensions.
|
|
902
|
+
|
|
903
|
+
This release changes the encrypted router recipient tag from `r` to `p` without
|
|
904
|
+
legacy fallback. Upgrade communicating consumers together. Inner ask/reply/tell
|
|
905
|
+
`r` tags, file-reference `r` tags, and carrier 26400 are unchanged. Seeders now
|
|
906
|
+
capture outgoing ordinary-message ciphertext before publishing, even without relay
|
|
907
|
+
echo. For files, seeders instead keep expiring recipient authorizations and serve
|
|
908
|
+
verified local chunks as `irfsChunk_v1`; watchtowers retain ciphertext seeds.
|
|
909
|
+
No group derivation or multi-device seed replication is introduced.
|
package/nip27/index.js
CHANGED
package/nip94/README.md
CHANGED
|
@@ -38,3 +38,16 @@ A bare/empty/duplicate/invalid value throws `INVALID_MEDIA_METADATA_DOWNLOAD`;
|
|
|
38
38
|
`extractMedia()` carries the string flag for ordinary URLs and nfile URLs,
|
|
39
39
|
including long nfile entities and `?localOnly=1`. Metadata fragments do not
|
|
40
40
|
change the bytes or root of a file; clients strip them from download routes.
|
|
41
|
+
|
|
42
|
+
## Thumbnails
|
|
43
|
+
|
|
44
|
+
`thumbnail: { url, root?, size?, sha256? }` emits a `thumb` tag and, when a root
|
|
45
|
+
is supplied, `['r', root, 'mark thumb', 'size <bytes>']`. `sha256` is the optional
|
|
46
|
+
third thumb-tag value. Original `r` stays unmarked. Decode validates nfile/root
|
|
47
|
+
agreement separately for original and thumbnail.
|
|
48
|
+
|
|
49
|
+
Only `r[1]` has a fixed position. `parseRootReference(tag)` parses the variadic
|
|
50
|
+
fields by their names; `mark` and `size` may appear in either order. Unknown
|
|
51
|
+
extensions are ignored. Duplicate recognized fields, ambiguous roots and unsafe
|
|
52
|
+
sizes are rejected. ThumbHash remains unchanged Base64, including through
|
|
53
|
+
percent-encoded NIP-27 `thumbhash` fragments; legacy `blurhash` still parses.
|
package/nip94/index.js
CHANGED
|
@@ -19,10 +19,36 @@ export function decodeFileMetadata (event) {
|
|
|
19
19
|
try { url = new URL(result.url) } catch { throw new ValidationError('INVALID_FILE_METADATA_URL') }
|
|
20
20
|
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password) throw new ValidationError('INVALID_FILE_METADATA_URL')
|
|
21
21
|
if (typeof result.mime !== 'string' || !/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/.test(result.mime)) throw new ValidationError('INVALID_FILE_METADATA_MIME')
|
|
22
|
-
for (const [tag, key] of [['
|
|
22
|
+
for (const [tag, key] of [['service', 'service'], ['thumbhash', 'thumbhash'], ['x', 'sha256'], ['ox', 'originalSha256'], ['alt', 'alt']]) {
|
|
23
23
|
const value = field(tag)
|
|
24
24
|
if (value !== undefined) result[key] = value
|
|
25
25
|
}
|
|
26
|
+
const roots = event.tags.filter(tag => Array.isArray(tag) && tag[0] === 'r').map(parseRootReference)
|
|
27
|
+
const originals = roots.filter(ref => ref.mark === undefined)
|
|
28
|
+
const thumbnails = roots.filter(ref => ref.mark === 'thumb')
|
|
29
|
+
if (originals.length > 1 || thumbnails.length > 1) throw new ValidationError('INVALID_FILE_METADATA_ROOT_REFERENCE')
|
|
30
|
+
if (originals.length) result.root = originals[0].root
|
|
31
|
+
const thumbUrl = field('thumb')
|
|
32
|
+
if (thumbUrl !== undefined) {
|
|
33
|
+
const thumbnail = { url: thumbUrl }
|
|
34
|
+
let parsed
|
|
35
|
+
try { parsed = new URL(thumbUrl) } catch { throw new ValidationError('INVALID_FILE_METADATA_THUMBNAIL') }
|
|
36
|
+
if (!['https:', 'http:'].includes(parsed.protocol) || parsed.username || parsed.password) throw new ValidationError('INVALID_FILE_METADATA_THUMBNAIL')
|
|
37
|
+
if (thumbnails.length) {
|
|
38
|
+
thumbnail.root = thumbnails[0].root
|
|
39
|
+
if (thumbnails[0].size !== undefined) thumbnail.size = thumbnails[0].size
|
|
40
|
+
}
|
|
41
|
+
const hash = event.tags.find(tag => tag[0] === 'thumb')?.[2]
|
|
42
|
+
if (hash !== undefined) {
|
|
43
|
+
if (!/^[0-9a-f]{64}$/.test(hash)) throw new ValidationError('INVALID_FILE_METADATA_HASH')
|
|
44
|
+
thumbnail.sha256 = hash
|
|
45
|
+
}
|
|
46
|
+
if (parsed.origin === 'https://nostr.alt') {
|
|
47
|
+
const file = nfileDecode(parsed.pathname.slice(1))
|
|
48
|
+
if (thumbnail.root && thumbnail.root !== file.root) throw new ValidationError('FILE_METADATA_NFILE_MISMATCH')
|
|
49
|
+
}
|
|
50
|
+
result.thumbnail = thumbnail
|
|
51
|
+
} else if (thumbnails.length) throw new ValidationError('INVALID_FILE_METADATA_THUMBNAIL')
|
|
26
52
|
for (const key of ['root', 'sha256', 'originalSha256']) if (result[key] !== undefined && !/^[0-9a-f]{64}$/.test(result[key])) throw new ValidationError('INVALID_FILE_METADATA_HASH')
|
|
27
53
|
const size = field('size')
|
|
28
54
|
if (size !== undefined) {
|
|
@@ -49,7 +75,35 @@ export function createFileMetadata ({ caption = '', created_at: createdAt = Math
|
|
|
49
75
|
if (metadata.download !== undefined && !['0', '1'].includes(metadata.download)) throw new ValidationError('INVALID_FILE_METADATA_DOWNLOAD')
|
|
50
76
|
const fields = [['download', metadata.download], ['url', metadata.url], ['m', metadata.mime], ['r', metadata.root], ['size', metadata.size], ['service', metadata.service], ['thumbhash', metadata.thumbhash], ['x', metadata.sha256], ['ox', metadata.originalSha256], ['alt', metadata.alt]]
|
|
51
77
|
if (metadata.width !== undefined || metadata.height !== undefined) fields.push(['dim', `${metadata.width}x${metadata.height}`])
|
|
52
|
-
|
|
78
|
+
if (metadata.thumbnail !== undefined) {
|
|
79
|
+
const thumb = metadata.thumbnail
|
|
80
|
+
if (!thumb || typeof thumb !== 'object') throw new ValidationError('INVALID_FILE_METADATA_THUMBNAIL')
|
|
81
|
+
fields.push(thumb.sha256 === undefined ? ['thumb', thumb.url] : ['thumb', thumb.url, thumb.sha256])
|
|
82
|
+
if (thumb.root !== undefined) fields.push(['r', thumb.root, 'mark thumb', ...(thumb.size === undefined ? [] : [`size ${thumb.size}`])])
|
|
83
|
+
}
|
|
84
|
+
const event = { kind: FILE_METADATA, created_at: createdAt, content: caption, tags: [...fields.filter(([, value]) => value !== undefined).map(([key, ...values]) => [key, ...values.map(String)]), ...tags.map(tag => [...tag])] }
|
|
53
85
|
decodeFileMetadata(event)
|
|
54
86
|
return event
|
|
55
87
|
}
|
|
88
|
+
|
|
89
|
+
// Only the root has a fixed position; extensions are unordered key/value fields.
|
|
90
|
+
export function parseRootReference (tag) {
|
|
91
|
+
if (!Array.isArray(tag) || tag[0] !== 'r' || typeof tag[1] !== 'string' || !/^[0-9a-f]{64}$/.test(tag[1])) throw new ValidationError('INVALID_FILE_METADATA_ROOT_REFERENCE')
|
|
92
|
+
const result = { root: tag[1] }
|
|
93
|
+
for (const field of tag.slice(2)) {
|
|
94
|
+
if (typeof field !== 'string') throw new ValidationError('INVALID_FILE_METADATA_ROOT_REFERENCE')
|
|
95
|
+
const separator = field.indexOf(' ')
|
|
96
|
+
const key = separator < 0 ? field : field.slice(0, separator)
|
|
97
|
+
if (!['mark', 'size'].includes(key)) continue
|
|
98
|
+
if (Object.hasOwn(result, key) || separator < 0) throw new ValidationError('INVALID_FILE_METADATA_ROOT_REFERENCE')
|
|
99
|
+
const value = field.slice(separator + 1)
|
|
100
|
+
if (key === 'mark') {
|
|
101
|
+
if (!value) throw new ValidationError('INVALID_FILE_METADATA_ROOT_REFERENCE')
|
|
102
|
+
result.mark = value
|
|
103
|
+
} else {
|
|
104
|
+
if (!/^(0|[1-9][0-9]*)$/.test(value) || !Number.isSafeInteger(Number(value))) throw new ValidationError('INVALID_FILE_METADATA_SIZE')
|
|
105
|
+
result.size = Number(value)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return result
|
|
109
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "libp2r2p",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Peer-to-relay-to-peer",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"p2r2p",
|
|
@@ -86,6 +86,7 @@
|
|
|
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/file": "./private-messenger/file/index.js",
|
|
89
90
|
"./private-messenger/recovery": "./private-messenger/recovery/index.js",
|
|
90
91
|
"./relay": "./relay/index.js",
|
|
91
92
|
"./temporary-storage": "./temporary-storage/index.js",
|
|
@@ -16,7 +16,7 @@ function routerPlaintextByteLengthForChunk (jsonlByteLength) {
|
|
|
16
16
|
kind: ROUTER_KIND,
|
|
17
17
|
pubkey: SAMPLE_PUBKEY,
|
|
18
18
|
created_at: MAX_TIME_SECONDS,
|
|
19
|
-
tags: [['f', SAMPLE_PUBKEY], ['imkc', SAMPLE_PUBKEY, `${MAX_TIME_SECONDS}:${SAMPLE_SIGNATURE}`], ['c', MAX_CHUNK_TAG_VALUE, MAX_CHUNK_TAG_VALUE], ['
|
|
19
|
+
tags: [['f', SAMPLE_PUBKEY], ['imkc', SAMPLE_PUBKEY, `${MAX_TIME_SECONDS}:${SAMPLE_SIGNATURE}`], ['c', MAX_CHUNK_TAG_VALUE, MAX_CHUNK_TAG_VALUE], ['p', SAMPLE_PUBKEY], ['i', String(Number.MAX_SAFE_INTEGER)]],
|
|
20
20
|
content: 'A'.repeat(base64EncodedByteLength(jsonlByteLength)),
|
|
21
21
|
id: SAMPLE_PUBKEY,
|
|
22
22
|
sig: SAMPLE_SIGNATURE
|
|
@@ -156,7 +156,7 @@ function setPreparedRow (id, index, row, temporaryStorage) {
|
|
|
156
156
|
storageFor(temporaryStorage).setItem(rowTempKey(id, index), row)
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
-
function readPreparedRow (preparedRows, index) {
|
|
159
|
+
export function readPreparedRow (preparedRows, index) {
|
|
160
160
|
const row = storageFor(preparedRows.temporaryStorage).getItem(rowTempKey(preparedRows.id, index))
|
|
161
161
|
if (typeof row !== 'string') throw new ValidationError('MISSING_PREPARED_ROW')
|
|
162
162
|
return row
|
|
@@ -12,7 +12,7 @@ export function eventByteLength (event) {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
export function readReceiverTag (event) {
|
|
15
|
-
return event.tags?.find(t => t[0] === '
|
|
15
|
+
return event.tags?.find(t => t[0] === 'p')?.[1] || ''
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
export function readSenderTag (event) {
|
|
@@ -47,14 +47,18 @@ export function readChunkTag (event) {
|
|
|
47
47
|
return { index, total }
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
export function makeRouterEvent ({ pubkey, senderPubkey, imkcPubkey, imkcProof, receiverPubkey, chunkIndex, chunkTotal, content }) {
|
|
50
|
+
export function makeRouterEvent ({ pubkey, senderPubkey, imkcPubkey, imkcProof, receiverPubkey, chunkIndex, chunkTotal, fileChunkIndex, content }) {
|
|
51
51
|
const tags = [['f', senderPubkey]]
|
|
52
52
|
if (imkcPubkey) {
|
|
53
53
|
if (!imkcProof) throw new ValidationError('INVALID_IMKC_PROOF')
|
|
54
54
|
tags.push(['imkc', imkcPubkey, imkcProof])
|
|
55
55
|
}
|
|
56
|
+
if (fileChunkIndex !== undefined) {
|
|
57
|
+
if (!Number.isSafeInteger(fileChunkIndex) || fileChunkIndex < 0) throw new ValidationError('INVALID_FILE_CHUNK_INDEX')
|
|
58
|
+
tags.push(['i', String(fileChunkIndex)])
|
|
59
|
+
}
|
|
56
60
|
tags.push(['c', String(chunkIndex), String(chunkTotal)])
|
|
57
|
-
if (receiverPubkey) tags.push(['
|
|
61
|
+
if (receiverPubkey) tags.push(['p', receiverPubkey])
|
|
58
62
|
return { kind: ROUTER_KIND, pubkey, created_at: nowSeconds(), tags, content }
|
|
59
63
|
}
|
|
60
64
|
|
|
@@ -67,3 +71,10 @@ export function makeNymCarrierEvent ({ innerId, chunkIndex, chunkTotal, content,
|
|
|
67
71
|
content
|
|
68
72
|
}
|
|
69
73
|
}
|
|
74
|
+
|
|
75
|
+
export function readFileChunkIndex (event) {
|
|
76
|
+
const tags = event.tags?.filter(tag => tag[0] === 'i') || []
|
|
77
|
+
if (!tags.length) return undefined
|
|
78
|
+
if (tags.length !== 1 || tags[0].length !== 2 || !/^(0|[1-9][0-9]*)$/.test(tags[0][1]) || !Number.isSafeInteger(Number(tags[0][1]))) throw new ValidationError('INVALID_FILE_CHUNK_INDEX')
|
|
79
|
+
return Number(tags[0][1])
|
|
80
|
+
}
|
package/private-channel/index.js
CHANGED
|
@@ -18,12 +18,13 @@ import {
|
|
|
18
18
|
prepareEnvelopeRows,
|
|
19
19
|
preparedRowIndexesForReceivers,
|
|
20
20
|
readChunkContent,
|
|
21
|
+
readPreparedRow,
|
|
21
22
|
receiverPubkeys,
|
|
22
23
|
receiverPubkeysWithoutContentKeys,
|
|
23
24
|
writeChunksFromPreparedRows
|
|
24
25
|
} from './helpers/chunks.js'
|
|
25
26
|
import { EXPIRATION_SECONDS, MAX_EVENT_BYTES, NYM_CARRIER_KIND, PRIVATE_BROADCAST_KIND, ROUTER_KIND } from './constants/index.js'
|
|
26
|
-
import { eventByteLength, hasImkcTag, makeNymCarrierEvent, makeRouterEvent, nowSeconds, readChunkTag, readIdTag, readImkcProof, readImkcTag, readReceiverTag, readSenderTag } from './helpers/event.js'
|
|
27
|
+
import { eventByteLength, hasImkcTag, makeNymCarrierEvent, makeRouterEvent, nowSeconds, readChunkTag, readFileChunkIndex, readIdTag, readImkcProof, readImkcTag, readReceiverTag, readSenderTag } from './helpers/event.js'
|
|
27
28
|
import { createReceivedChunkStore, DEFAULT_RECEIVED_CHUNK_MAX_BYTES, DEFAULT_RECEIVED_CHUNK_TTL_MS } from './services/received-chunks.js'
|
|
28
29
|
|
|
29
30
|
export { EXPIRATION_SECONDS, MAX_EVENT_BYTES, NYM_CARRIER_KIND, PRIVATE_BROADCAST_KIND, ROUTER_KIND } from './constants/index.js'
|
|
@@ -131,7 +132,7 @@ async function prepareRoutedMessage ({ senderSigner, imkcSigner, privateChannelS
|
|
|
131
132
|
}
|
|
132
133
|
}
|
|
133
134
|
|
|
134
|
-
async function * wrapPreparedEvents ({ privateChannelSigner, receivers, receiverTag, deletionPubkey, expirationSeconds = EXPIRATION_SECONDS, context }) {
|
|
135
|
+
async function * wrapPreparedEvents ({ privateChannelSigner, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey, expirationSeconds = EXPIRATION_SECONDS, context }) {
|
|
135
136
|
const routerSeckey = generateSecretKey()
|
|
136
137
|
const routerPubkey = getPublicKey(routerSeckey)
|
|
137
138
|
const receiverPubkeyList = receiverPubkeys(receivers)
|
|
@@ -144,6 +145,13 @@ async function * wrapPreparedEvents ({ privateChannelSigner, receivers, receiver
|
|
|
144
145
|
const temporaryStorage = context.preparedRows.temporaryStorage
|
|
145
146
|
|
|
146
147
|
try {
|
|
148
|
+
if (onPreparedSeed) {
|
|
149
|
+
const router = makeRouterEvent({ pubkey: routerPubkey, senderPubkey: context.senderPubkey, imkcPubkey: context.imkcPubkey, imkcProof: context.imkcProof, receiverPubkey: routerReceiverTag, chunkIndex: 0, chunkTotal: 1, fileChunkIndex, content: '' })
|
|
150
|
+
const payloadRow = readPreparedRow(context.preparedRows, 0)
|
|
151
|
+
for (const rowIndex of rowIndexes) {
|
|
152
|
+
await onPreparedSeed({ channelPubkey: context.channelPubkey, router, jsonl: `${payloadRow}\n${readPreparedRow(context.preparedRows, rowIndex)}\n` })
|
|
153
|
+
}
|
|
154
|
+
}
|
|
147
155
|
for (let index = 0; index < total; index++) {
|
|
148
156
|
const content = readChunkContent(id, index, temporaryStorage)
|
|
149
157
|
const router = finalizeEvent(makeRouterEvent({
|
|
@@ -152,6 +160,7 @@ async function * wrapPreparedEvents ({ privateChannelSigner, receivers, receiver
|
|
|
152
160
|
imkcPubkey: context.imkcPubkey,
|
|
153
161
|
imkcProof: context.imkcProof,
|
|
154
162
|
receiverPubkey: routerReceiverTag,
|
|
163
|
+
fileChunkIndex,
|
|
155
164
|
chunkIndex: index,
|
|
156
165
|
chunkTotal: total,
|
|
157
166
|
content
|
|
@@ -172,7 +181,7 @@ async function * wrapPreparedEvents ({ privateChannelSigner, receivers, receiver
|
|
|
172
181
|
}
|
|
173
182
|
|
|
174
183
|
// Streaming version of wrapEvent
|
|
175
|
-
export async function * wrapEvents ({ senderSigner, imkcSigner, privateChannelSigner = senderSigner, privateChannelReaderPubkey, receivers, receiverTag, deletionPubkey, event, expirationSeconds = EXPIRATION_SECONDS, temporaryStorageArea, _getIykcProofs = getIykcProofs }) {
|
|
184
|
+
export async function * wrapEvents ({ senderSigner, imkcSigner, privateChannelSigner = senderSigner, privateChannelReaderPubkey, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey, event, expirationSeconds = EXPIRATION_SECONDS, temporaryStorageArea, _getIykcProofs = getIykcProofs }) {
|
|
176
185
|
const normalizedDeletionPubkey = normalizeDeletionPubkey(deletionPubkey)
|
|
177
186
|
const context = await prepareRoutedMessage({
|
|
178
187
|
senderSigner,
|
|
@@ -185,7 +194,7 @@ export async function * wrapEvents ({ senderSigner, imkcSigner, privateChannelSi
|
|
|
185
194
|
_getIykcProofs
|
|
186
195
|
})
|
|
187
196
|
try {
|
|
188
|
-
yield * wrapPreparedEvents({ privateChannelSigner, receivers, receiverTag, deletionPubkey: normalizedDeletionPubkey, expirationSeconds, context })
|
|
197
|
+
yield * wrapPreparedEvents({ privateChannelSigner, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey: normalizedDeletionPubkey, expirationSeconds, context })
|
|
189
198
|
} finally {
|
|
190
199
|
cleanupEnvelopeRows(context.preparedRows)
|
|
191
200
|
}
|
|
@@ -415,6 +424,12 @@ export async function unwrapEvent ({ receiverSigner, privateChannelSigner = rece
|
|
|
415
424
|
channelReaderSigner,
|
|
416
425
|
channelReaderPubkey: privateChannelReaderPubkey
|
|
417
426
|
})
|
|
427
|
+
return unwrapRouterEvent({ router, receiverSigner, receiverPubkey, channelPubkey })
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Recovery records contain an encrypted recipient row, not the original outer
|
|
431
|
+
// signature. Authenticate the control response before invoking this decoder.
|
|
432
|
+
export async function unwrapRouterEvent ({ router, receiverSigner, receiverPubkey, channelPubkey }) {
|
|
418
433
|
if (router.kind !== ROUTER_KIND) throw new ValidationError('INVALID_ROUTER_KIND')
|
|
419
434
|
if (receiverPubkey && readReceiverTag(router) && readReceiverTag(router) !== receiverPubkey) return null
|
|
420
435
|
|
|
@@ -506,7 +521,7 @@ function withRecoveryRelays (relays, recoveryRelays) {
|
|
|
506
521
|
return uniq([...(relays || []), ...(recoveryRelays || [])])
|
|
507
522
|
}
|
|
508
523
|
|
|
509
|
-
export async function publish ({ senderSigner, imkcSigner, privateChannelSigner = senderSigner, privateChannelReaderPubkey, receivers, receiverTag, deletionPubkey, event, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, _getIykcProofs = getIykcProofs, _publish = sendToRelays }) {
|
|
524
|
+
export async function publish ({ senderSigner, imkcSigner, privateChannelSigner = senderSigner, privateChannelReaderPubkey, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey, event, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, _getIykcProofs = getIykcProofs, _publish = sendToRelays }) {
|
|
510
525
|
const normalizedDeletionPubkey = normalizeDeletionPubkey(deletionPubkey)
|
|
511
526
|
const results = []
|
|
512
527
|
// Relays are grouped only when they have the exact same recipient pubkey set
|
|
@@ -524,7 +539,7 @@ export async function publish ({ senderSigner, imkcSigner, privateChannelSigner
|
|
|
524
539
|
})
|
|
525
540
|
try {
|
|
526
541
|
for (const group of groups) {
|
|
527
|
-
for await (const wrappedEvent of wrapPreparedEvents({ privateChannelSigner, receivers: group.receivers, receiverTag, deletionPubkey: normalizedDeletionPubkey, expirationSeconds, context })) {
|
|
542
|
+
for await (const wrappedEvent of wrapPreparedEvents({ privateChannelSigner, receivers: group.receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey: normalizedDeletionPubkey, expirationSeconds, context })) {
|
|
528
543
|
results.push(await _publish(wrappedEvent, withRecoveryRelays(group.relays, recoveryRelays)))
|
|
529
544
|
}
|
|
530
545
|
}
|
|
@@ -534,7 +549,7 @@ export async function publish ({ senderSigner, imkcSigner, privateChannelSigner
|
|
|
534
549
|
return results
|
|
535
550
|
}
|
|
536
551
|
|
|
537
|
-
for await (const wrappedEvent of wrapEvents({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag, deletionPubkey: normalizedDeletionPubkey, event, expirationSeconds, temporaryStorageArea, _getIykcProofs })) {
|
|
552
|
+
for await (const wrappedEvent of wrapEvents({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey: normalizedDeletionPubkey, event, expirationSeconds, temporaryStorageArea, _getIykcProofs })) {
|
|
538
553
|
results.push(await _publish(wrappedEvent, withRecoveryRelays(relays, recoveryRelays)))
|
|
539
554
|
}
|
|
540
555
|
return results
|
|
@@ -765,6 +780,7 @@ function createProcessor ({
|
|
|
765
780
|
const senderPubkey = readSenderTag(router)
|
|
766
781
|
if (receiverPubkey && readReceiverTag(router) && readReceiverTag(router) !== receiverPubkey && senderPubkey !== receiverPubkey) return
|
|
767
782
|
const { index, total } = readChunkTag(router)
|
|
783
|
+
readFileChunkIndex(router)
|
|
768
784
|
groupKey = receivedChunks.groupKeyFor(channelPubkey, router.pubkey)
|
|
769
785
|
if (ignoredGroups.has(groupKey)) return
|
|
770
786
|
|
|
@@ -773,6 +789,7 @@ function createProcessor ({
|
|
|
773
789
|
const meta = await receivedChunks.put({
|
|
774
790
|
channelPubkey,
|
|
775
791
|
routerPubkey: router.pubkey,
|
|
792
|
+
descriptor: JSON.stringify(router.tags.filter(tag => tag[0] !== 'c')),
|
|
776
793
|
index,
|
|
777
794
|
total,
|
|
778
795
|
contentBytes: base64ToBytes(router.content),
|
|
@@ -976,6 +993,7 @@ export function subscribe ({ receiverSigner, iykcSigner, privateChannelSigner =
|
|
|
976
993
|
const consumePromise = consumeEvents()
|
|
977
994
|
|
|
978
995
|
return {
|
|
996
|
+
ready: events.ready || Promise.resolve(),
|
|
979
997
|
close () {
|
|
980
998
|
controller.abort()
|
|
981
999
|
return consumePromise
|
|
@@ -135,6 +135,7 @@ function normalizeMeta (meta, fallbackTtlMs = DEFAULT_RECEIVED_CHUNK_TTL_MS) {
|
|
|
135
135
|
return {
|
|
136
136
|
groupKey: String(meta.groupKey),
|
|
137
137
|
channelPubkey: String(meta.channelPubkey || ''),
|
|
138
|
+
descriptor: meta.descriptor,
|
|
138
139
|
routerPubkey: String(meta.routerPubkey || ''),
|
|
139
140
|
total,
|
|
140
141
|
received: normalizeReceived(meta.received),
|
|
@@ -274,7 +275,7 @@ export function createReceivedChunkStore ({
|
|
|
274
275
|
return cleanupStaleRaw(nowMs)
|
|
275
276
|
}
|
|
276
277
|
|
|
277
|
-
async function putOnce ({ channelPubkey, routerPubkey, index, total, contentBytes, ttlMs }) {
|
|
278
|
+
async function putOnce ({ channelPubkey, routerPubkey, index, total, contentBytes, ttlMs, descriptor }) {
|
|
278
279
|
const groupKey = groupKeyFor(channelPubkey, routerPubkey)
|
|
279
280
|
const bytes = normalizeBytes(contentBytes)
|
|
280
281
|
const now = Date.now()
|
|
@@ -287,6 +288,7 @@ export function createReceivedChunkStore ({
|
|
|
287
288
|
await deleteGroupInTransaction(tx, groupKey, usage, meta)
|
|
288
289
|
meta = null
|
|
289
290
|
}
|
|
291
|
+
if (meta && meta.descriptor !== descriptor) throw new ValidationError('INCONSISTENT_ROUTER_DESCRIPTOR')
|
|
290
292
|
if (meta && meta.total !== total) {
|
|
291
293
|
await deleteGroupInTransaction(tx, groupKey, usage, meta)
|
|
292
294
|
meta = null
|
|
@@ -296,6 +298,7 @@ export function createReceivedChunkStore ({
|
|
|
296
298
|
groupKey,
|
|
297
299
|
channelPubkey,
|
|
298
300
|
routerPubkey,
|
|
301
|
+
descriptor,
|
|
299
302
|
total,
|
|
300
303
|
received: {},
|
|
301
304
|
receivedCount: 0,
|
|
@@ -364,7 +367,7 @@ export function createReceivedChunkStore ({
|
|
|
364
367
|
})
|
|
365
368
|
}
|
|
366
369
|
|
|
367
|
-
async function put ({ channelPubkey, routerPubkey, index, total, contentBytes, ttlMs }) {
|
|
370
|
+
async function put ({ channelPubkey, routerPubkey, index, total, contentBytes, ttlMs, descriptor }) {
|
|
368
371
|
if (!channelPubkey || !routerPubkey) throw new ValidationError('RECEIVED_CHUNK_GROUP_REQUIRED')
|
|
369
372
|
if (!Number.isSafeInteger(index) || !Number.isSafeInteger(total) || index < 0 || total < 1 || index >= total) {
|
|
370
373
|
throw new ValidationError('INVALID_RECEIVED_CHUNK_INDEX')
|
|
@@ -373,7 +376,7 @@ export function createReceivedChunkStore ({
|
|
|
373
376
|
await ready()
|
|
374
377
|
while (true) {
|
|
375
378
|
try {
|
|
376
|
-
const result = await putOnce({ channelPubkey, routerPubkey, index, total, contentBytes: bytes, ttlMs })
|
|
379
|
+
const result = await putOnce({ channelPubkey, routerPubkey, index, total, contentBytes: bytes, ttlMs, descriptor })
|
|
377
380
|
if (result.tooLarge) throw new Error('RECEIVED_CHUNK_GROUP_TOO_LARGE')
|
|
378
381
|
return result.meta
|
|
379
382
|
} catch (err) {
|
package/private-message/index.js
CHANGED
|
@@ -517,10 +517,12 @@ async function sendPrivateMessage ({
|
|
|
517
517
|
temporaryStorageArea,
|
|
518
518
|
deletionPubkey,
|
|
519
519
|
_getIykcProofs,
|
|
520
|
+
fileChunkIndex,
|
|
521
|
+
onPreparedSeed,
|
|
520
522
|
_publish = privateChannel.publish
|
|
521
523
|
}) {
|
|
522
524
|
if (!privateChannelSigner?.getPublicKey) throw new ValidationError('PRIVATE_CHANNEL_WRITER_REQUIRED')
|
|
523
|
-
return _publish({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag, deletionPubkey, event, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, _getIykcProofs })
|
|
525
|
+
return _publish({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag, deletionPubkey, event, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, fileChunkIndex, onPreparedSeed, _getIykcProofs })
|
|
524
526
|
}
|
|
525
527
|
|
|
526
528
|
async function sendNymMessage ({
|
|
@@ -560,6 +562,8 @@ export async function ask ({
|
|
|
560
562
|
deletionPubkey,
|
|
561
563
|
deletionSeckey,
|
|
562
564
|
autoDeletionCapability = true,
|
|
565
|
+
fileChunkIndex,
|
|
566
|
+
onPreparedSeed,
|
|
563
567
|
_publish = privateChannel.publish,
|
|
564
568
|
_assertWatching
|
|
565
569
|
}) {
|
|
@@ -583,7 +587,7 @@ export async function ask ({
|
|
|
583
587
|
})
|
|
584
588
|
})
|
|
585
589
|
const deletion = resolveDeletionCapability({ deletionPubkey, deletionSeckey, autoDeletionCapability })
|
|
586
|
-
const reports = await sendPrivateMessage({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers: [receiverPubkey], receiverTag: receiverPubkey, deletionPubkey: deletion.deletionPubkey, event: wireEvent, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, _getIykcProofs, _publish })
|
|
590
|
+
const reports = await sendPrivateMessage({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers: [receiverPubkey], receiverTag: receiverPubkey, deletionPubkey: deletion.deletionPubkey, event: wireEvent, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, fileChunkIndex, onPreparedSeed, _getIykcProofs, _publish })
|
|
587
591
|
|
|
588
592
|
return withDelivery({ question }, reports, deletion.deletionSeckey)
|
|
589
593
|
}
|
|
@@ -609,6 +613,8 @@ export async function reply ({
|
|
|
609
613
|
deletionPubkey,
|
|
610
614
|
deletionSeckey,
|
|
611
615
|
autoDeletionCapability = true,
|
|
616
|
+
fileChunkIndex,
|
|
617
|
+
onPreparedSeed,
|
|
612
618
|
_publish = privateChannel.publish
|
|
613
619
|
}) {
|
|
614
620
|
if (!question?.id) throw new ValidationError('QUESTION_REQUIRED')
|
|
@@ -622,7 +628,7 @@ export async function reply ({
|
|
|
622
628
|
})
|
|
623
629
|
})
|
|
624
630
|
const deletion = resolveDeletionCapability({ deletionPubkey, deletionSeckey, autoDeletionCapability })
|
|
625
|
-
const reports = await sendPrivateMessage({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers: [receiverPubkey], receiverTag: receiverPubkey, deletionPubkey: deletion.deletionPubkey, event: wireEvent, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, _getIykcProofs, _publish })
|
|
631
|
+
const reports = await sendPrivateMessage({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers: [receiverPubkey], receiverTag: receiverPubkey, deletionPubkey: deletion.deletionPubkey, event: wireEvent, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, fileChunkIndex, onPreparedSeed, _getIykcProofs, _publish })
|
|
626
632
|
return withDelivery({ reply: event }, reports, deletion.deletionSeckey)
|
|
627
633
|
}
|
|
628
634
|
|
|
@@ -646,6 +652,8 @@ export async function tell ({
|
|
|
646
652
|
deletionPubkey,
|
|
647
653
|
deletionSeckey,
|
|
648
654
|
autoDeletionCapability = true,
|
|
655
|
+
fileChunkIndex,
|
|
656
|
+
onPreparedSeed,
|
|
649
657
|
_publish = privateChannel.publish
|
|
650
658
|
}) {
|
|
651
659
|
if (!receiverPubkey) throw new ValidationError('RECEIVER_PUBKEY_REQUIRED')
|
|
@@ -658,7 +666,7 @@ export async function tell ({
|
|
|
658
666
|
})
|
|
659
667
|
})
|
|
660
668
|
const deletion = resolveDeletionCapability({ deletionPubkey, deletionSeckey, autoDeletionCapability })
|
|
661
|
-
const reports = await sendPrivateMessage({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers: [receiverPubkey], receiverTag: receiverPubkey, deletionPubkey: deletion.deletionPubkey, event: wireEvent, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, _getIykcProofs, _publish })
|
|
669
|
+
const reports = await sendPrivateMessage({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers: [receiverPubkey], receiverTag: receiverPubkey, deletionPubkey: deletion.deletionPubkey, event: wireEvent, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, fileChunkIndex, onPreparedSeed, _getIykcProofs, _publish })
|
|
662
670
|
return withDelivery({ tell: event }, reports, deletion.deletionSeckey)
|
|
663
671
|
}
|
|
664
672
|
|
|
@@ -682,6 +690,8 @@ export async function yell ({
|
|
|
682
690
|
deletionPubkey,
|
|
683
691
|
deletionSeckey,
|
|
684
692
|
autoDeletionCapability = true,
|
|
693
|
+
fileChunkIndex,
|
|
694
|
+
onPreparedSeed,
|
|
685
695
|
_publish = privateChannel.publish
|
|
686
696
|
}) {
|
|
687
697
|
const receivers = uniq(receiverPubkeys)
|
|
@@ -695,7 +705,7 @@ export async function yell ({
|
|
|
695
705
|
})
|
|
696
706
|
})
|
|
697
707
|
const deletion = resolveDeletionCapability({ deletionPubkey, deletionSeckey, autoDeletionCapability })
|
|
698
|
-
const reports = await sendPrivateMessage({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag: '', deletionPubkey: deletion.deletionPubkey, event: wireEvent, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, _getIykcProofs, _publish })
|
|
708
|
+
const reports = await sendPrivateMessage({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag: '', deletionPubkey: deletion.deletionPubkey, event: wireEvent, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, fileChunkIndex, onPreparedSeed, _getIykcProofs, _publish })
|
|
699
709
|
return withDelivery({ yell: event }, reports, deletion.deletionSeckey)
|
|
700
710
|
}
|
|
701
711
|
|
|
@@ -715,13 +725,15 @@ export async function broadcastRumor ({
|
|
|
715
725
|
deletionPubkey,
|
|
716
726
|
deletionSeckey,
|
|
717
727
|
autoDeletionCapability = true,
|
|
728
|
+
fileChunkIndex,
|
|
729
|
+
onPreparedSeed,
|
|
718
730
|
_publish = privateChannel.publish
|
|
719
731
|
}) {
|
|
720
732
|
const receivers = uniq(receiverPubkeys)
|
|
721
733
|
if (!receivers.length) throw new ValidationError('NO_RECEIVERS')
|
|
722
734
|
const { event, wireEvent } = await makeOutgoingRumor({ senderSigner, rumor })
|
|
723
735
|
const deletion = resolveDeletionCapability({ deletionPubkey, deletionSeckey, autoDeletionCapability })
|
|
724
|
-
const reports = await sendPrivateMessage({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag: '', deletionPubkey: deletion.deletionPubkey, event: wireEvent, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, _getIykcProofs, _publish })
|
|
736
|
+
const reports = await sendPrivateMessage({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag: '', deletionPubkey: deletion.deletionPubkey, event: wireEvent, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, fileChunkIndex, onPreparedSeed, _getIykcProofs, _publish })
|
|
725
737
|
return withDelivery({ rumor: event }, reports, deletion.deletionSeckey)
|
|
726
738
|
}
|
|
727
739
|
|
|
@@ -741,13 +753,15 @@ export async function broadcastEvent ({
|
|
|
741
753
|
deletionPubkey,
|
|
742
754
|
deletionSeckey,
|
|
743
755
|
autoDeletionCapability = true,
|
|
756
|
+
fileChunkIndex,
|
|
757
|
+
onPreparedSeed,
|
|
744
758
|
_publish = privateChannel.publish
|
|
745
759
|
}) {
|
|
746
760
|
const receivers = uniq(receiverPubkeys)
|
|
747
761
|
if (!receivers.length) throw new ValidationError('NO_RECEIVERS')
|
|
748
762
|
const wireEvent = assertValidSignedEvent({ ...event, tags: cloneTags(event?.tags) })
|
|
749
763
|
const deletion = resolveDeletionCapability({ deletionPubkey, deletionSeckey, autoDeletionCapability })
|
|
750
|
-
const reports = await sendPrivateMessage({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag: '', deletionPubkey: deletion.deletionPubkey, event: wireEvent, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, _getIykcProofs, _publish })
|
|
764
|
+
const reports = await sendPrivateMessage({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag: '', deletionPubkey: deletion.deletionPubkey, event: wireEvent, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, fileChunkIndex, onPreparedSeed, _getIykcProofs, _publish })
|
|
751
765
|
return withDelivery({ event: wireEvent }, reports, deletion.deletionSeckey)
|
|
752
766
|
}
|
|
753
767
|
|
|
@@ -0,0 +1,127 @@
|
|
|
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 and supplies `storage.read(root, index, descriptor)` and
|
|
7
|
+
`storage.save(event, descriptor)`. The latter must resolve only after persistence.
|
|
8
|
+
The descriptor is `{ controlChannelPubkey, peerPubkey, root, size?, sharedAt? }`.
|
|
9
|
+
`sharedAt` is a stable sharing timestamp, never a read/retry timestamp.
|
|
10
|
+
|
|
11
|
+
For one-to-one chats, resolve the signer with
|
|
12
|
+
`withSharedKey(peerPubkey, fileChannelInfo(root))`: the canonical context is
|
|
13
|
+
`dm:media:<lowercase MMR root>`. Original and thumbnail roots use separate
|
|
14
|
+
channels under the same convention. Do not put this context in a public tag or
|
|
15
|
+
append it to the thumbnail URL. This is not a group-key derivation scheme.
|
|
16
|
+
|
|
17
|
+
- `register(descriptor)` resolves and checks the file channel without watching it.
|
|
18
|
+
- `authorizeSeeding(descriptor, { receiverPubkeys, sharedAt })` durably grants
|
|
19
|
+
the listed identities indexed recovery of this root on this control/data pair.
|
|
20
|
+
The application calls it for an explicit share, using its persisted message
|
|
21
|
+
timestamp (standalone 1063 uses its own timestamp). Registration, cache hits
|
|
22
|
+
and incoming requests never grant access. Missing router `p` is not a wildcard.
|
|
23
|
+
- `publishChunk(descriptor, event)` checks a 34601 proof and the descriptor,
|
|
24
|
+
captures ciphertext only in watchtower mode, and publishes to the recipient's relays.
|
|
25
|
+
Every wrapper must receive at least one relay acceptance. Yield between calls
|
|
26
|
+
so text/control sends can run. Publish all file chunks before announcing 1063/9.
|
|
27
|
+
- `download(descriptor, { manual, thumbnail, signal })` returns the verified,
|
|
28
|
+
persisted file descriptor. Automatic originals are limited to 1 MiB and
|
|
29
|
+
automatic thumbnails to 51,000 bytes. Larger/unknown originals require action;
|
|
30
|
+
already complete local files remain accessible. Unknown-size manual downloads
|
|
31
|
+
bootstrap their total from a verified chunk and learn size from the last one.
|
|
32
|
+
- `observe(callback)` returns an unsubscribe function. Updates include control
|
|
33
|
+
channel, root, status, persisted useful bytes (`completed`), total and errors.
|
|
34
|
+
- `cancel(controlChannelPubkey, root)` cancels the shared transfer; individual
|
|
35
|
+
caller signals only stop that caller waiting. Partial chunks are retained.
|
|
36
|
+
- `close()` releases subscriptions, timers and storage. Messenger pause/unwatch/
|
|
37
|
+
close also stop work; resume restarts paused transfers, never explicit cancels.
|
|
38
|
+
|
|
39
|
+
There are at most two active downloads per coordinator; thumbnails precede
|
|
40
|
+
manual originals, then small automatic originals. Open live input before reading
|
|
41
|
+
history or asking seeders. A failed historical read does not block peer recovery.
|
|
42
|
+
Deduplication and completion depend on verified, persisted chunk indices, not
|
|
43
|
+
publication ACKs or a terminal response. Ordinary DM left-edge temporal recovery
|
|
44
|
+
is unchanged.
|
|
45
|
+
|
|
46
|
+
## Recovery protocol
|
|
47
|
+
|
|
48
|
+
The authenticated direct request travels on the conversation's `dm` channel:
|
|
49
|
+
|
|
50
|
+
```js
|
|
51
|
+
{ code: 'fileChunksRequest_p5cc', payload: {
|
|
52
|
+
fileChannelPubkey,
|
|
53
|
+
missingRanges: [[0, 15]] // inclusive, sorted, disjoint; at most 16 indices
|
|
54
|
+
} }
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
A seeder replies from `storage.read`, skipping unavailable indices and validating
|
|
58
|
+
local proofs before sending. Each JSONL record is:
|
|
59
|
+
|
|
60
|
+
```js
|
|
61
|
+
{ recordType: 'irfsChunk_v1', index, total, proof, content }
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`proof` and `content` use Base93. The receiver reconstructs a 34601 template,
|
|
65
|
+
checks its proof against the expected root, and persists it without claiming the
|
|
66
|
+
original announcer authored the reconstructed event. The outer reply remains
|
|
67
|
+
individually encrypted for its recipient. Watchtowers continue replying with
|
|
68
|
+
`routerEnvelopeRow_v1`; they need not decrypt original bytes.
|
|
69
|
+
|
|
70
|
+
The reply travels on the **file** channel, with code `fileChunksReply_p5cc` and
|
|
71
|
+
the existing compact-record JSONL packer's `index`, `isLast`, and `jsonl` fields.
|
|
72
|
+
The inner reply's `q` references the request event. No new terminal availability
|
|
73
|
+
message is required and `isLast` never establishes file completeness. Late
|
|
74
|
+
responses from authorized seeders remain useful during an active download.
|
|
75
|
+
Seed replies run outside the DM dispatcher, with at most two reply workers and
|
|
76
|
+
sixteen queued requests; workers yield between records so control traffic can
|
|
77
|
+
continue. Requests discarded at the queue bound can be retried.
|
|
78
|
+
|
|
79
|
+
Request one seeder first and another after two seconds, recalculating missing
|
|
80
|
+
indices. A new persisted chunk resets the inactivity clock; duplicates do not.
|
|
81
|
+
After contacting all selected seeders, 30 seconds without progress makes the
|
|
82
|
+
transfer retryable. Each new attempt rechecks local storage and relay history.
|
|
83
|
+
|
|
84
|
+
Router 26300 uses `p` for its optional recipient, `f` for transport sender,
|
|
85
|
+
optional `imkc`, `c` for outer fragmentation, and `i` for the IRFS chunk index.
|
|
86
|
+
Repeat `i` across every fragment. It survives seed compaction, and must agree
|
|
87
|
+
with the validated 34601. It is encrypted within the router, never a public tag.
|
|
88
|
+
Carrier 26400 continues to use only `id`/`c`; inner control recipient `r` and
|
|
89
|
+
NIP-94 file-reference `r` are unchanged. There is no router `r` read fallback.
|
|
90
|
+
The existing 65,536-byte event ceiling includes the additional router overhead.
|
|
91
|
+
|
|
92
|
+
## Storage and scope
|
|
93
|
+
|
|
94
|
+
Seeder authorizations live in
|
|
95
|
+
`libp2r2p:private-messenger:<owner>:file-authorizations:idb-queue`, with `key`
|
|
96
|
+
(unique control/data/recipient) and `channel` indexes. Records hold root, peer,
|
|
97
|
+
optional size, recipient, `sharedAt`, and absolute `expiresAt`; no ciphertext,
|
|
98
|
+
chunk bytes, signer or file-retention reference. The queue rejects capacity
|
|
99
|
+
failures instead of silently evicting grants. Grants survive restart and expire
|
|
100
|
+
at the original sharing time plus parent retention (default seven days), subject
|
|
101
|
+
to a subsequently shorter parent policy. Only a newer explicit share renews them.
|
|
102
|
+
|
|
103
|
+
Direct validated, persisted deliveries may authorize their actual local receiver
|
|
104
|
+
using the descriptor's `sharedAt`. Cache reads and recovery replies never renew
|
|
105
|
+
or create grants. Applications must not infer authorization for a third-party
|
|
106
|
+
announcer from its knowledge of a root. Group membership/revocation is not part
|
|
107
|
+
of this DM implementation; future group requests also need current membership.
|
|
108
|
+
|
|
109
|
+
Serving requires both live authorization and locally available chunks. The
|
|
110
|
+
catalog never pins NostrDB roots or recreates missing bytes. Checks run before
|
|
111
|
+
reads and again before each publication. Shared cleanup removes invalid grants
|
|
112
|
+
at startup and every minute while running; expired grants are immediately
|
|
113
|
+
unusable even before physical cleanup. Identity cleanup includes the catalog.
|
|
114
|
+
|
|
115
|
+
Watchtower ciphertext remains in
|
|
116
|
+
`libp2r2p:private-messenger:<owner>:file-seeds:idb-queue`, with its existing
|
|
117
|
+
64 MiB FIFO budget and parent retention. The authorization catalog starts empty;
|
|
118
|
+
there is no migration or compatibility path for unpublished seeder file seeds.
|
|
119
|
+
|
|
120
|
+
Seed responses require the matching control/data channel pair and requester.
|
|
121
|
+
A channel holder can group traffic by its public channel author; no plaintext
|
|
122
|
+
root/index tag is exposed. This does not hide timing or repeated transfers of
|
|
123
|
+
the same pair/root.
|
|
124
|
+
|
|
125
|
+
Multi-device seed synchronization and group-channel derivation remain separate
|
|
126
|
+
follow-ups. Applications own durable manual-download intent, metadata retention
|
|
127
|
+
and UI policy. Removing one message must not delete a root retained by another.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { ValidationError } from '../../../error/index.js'
|
|
2
|
+
import { createQueue } from '../../../idb-queue/index.js'
|
|
3
|
+
|
|
4
|
+
const localLocks = new WeakMap()
|
|
5
|
+
const keyFor = row => `${row.controlChannelPubkey}:${row.fileChannelPubkey}:${row.receiverPubkey}`
|
|
6
|
+
|
|
7
|
+
// A grant contains no payload, key material or retention reference in the file
|
|
8
|
+
// store. Serialize read/merge/write with other coordinators for this identity.
|
|
9
|
+
export function createFileCatalog ({ messenger, now }) {
|
|
10
|
+
const prefix = `${messenger.prefix}:file-authorizations`
|
|
11
|
+
let database
|
|
12
|
+
const db = () => (database ??= createQueue({ prefix, indexedDB: messenger._indexedDB, evictionPolicy: 'reject', indexes: { key: { keyPath: 'key', unique: true }, channel: 'fileChannelPubkey' } }))
|
|
13
|
+
function lock (work) {
|
|
14
|
+
if (globalThis.navigator?.locks?.request) return navigator.locks.request(prefix, work)
|
|
15
|
+
let locks = localLocks.get(messenger._indexedDB)
|
|
16
|
+
if (!locks) localLocks.set(messenger._indexedDB, (locks = new Map()))
|
|
17
|
+
const task = (locks.get(prefix) || Promise.resolve()).catch(() => {}).then(work)
|
|
18
|
+
locks.set(prefix, task.catch(() => {}))
|
|
19
|
+
return task
|
|
20
|
+
}
|
|
21
|
+
function isValid (row) {
|
|
22
|
+
const parent = messenger.channels.get(row.controlChannelPubkey)
|
|
23
|
+
return parent?.mode === 'seeder' && row.expiresAt > now() && row.sharedAt + messenger.offlineRecoverySecondsFor(parent) > now()
|
|
24
|
+
}
|
|
25
|
+
async function put (file, receiverPubkey, sharedAt, expiresAt) {
|
|
26
|
+
const { controlChannelPubkey, fileChannelPubkey, peerPubkey, root, size } = file
|
|
27
|
+
const row = { controlChannelPubkey, fileChannelPubkey, peerPubkey, root, size, receiverPubkey, sharedAt, expiresAt }
|
|
28
|
+
row.key = keyFor(row)
|
|
29
|
+
if (!isValid(row)) return
|
|
30
|
+
const queue = await db()
|
|
31
|
+
const prior = await queue.getBy('key', row.key)
|
|
32
|
+
if (prior && (prior.root !== root || prior.peerPubkey !== peerPubkey)) throw new ValidationError('FILE_AUTHORIZATION_CONFLICT')
|
|
33
|
+
// Replaying an older share cannot shorten or renew a newer grant.
|
|
34
|
+
if (prior && prior.sharedAt >= sharedAt && isValid(prior)) return
|
|
35
|
+
await queue.putBy('key', row)
|
|
36
|
+
}
|
|
37
|
+
async function pruneUnlocked () {
|
|
38
|
+
await (await db()).removeWhere(row => !isValid(row))
|
|
39
|
+
}
|
|
40
|
+
const ready = lock(pruneUnlocked)
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
ready,
|
|
44
|
+
async authorize (file, receivers, sharedAt) {
|
|
45
|
+
await ready
|
|
46
|
+
return lock(async () => {
|
|
47
|
+
await pruneUnlocked()
|
|
48
|
+
const expiresAt = sharedAt + messenger.offlineRecoverySecondsFor(file.controlChannelPubkey)
|
|
49
|
+
for (const receiver of receivers) await put(file, receiver, sharedAt, expiresAt)
|
|
50
|
+
})
|
|
51
|
+
},
|
|
52
|
+
async find (controlChannelPubkey, fileChannelPubkey, receiverPubkey) {
|
|
53
|
+
await ready
|
|
54
|
+
return lock(async () => {
|
|
55
|
+
const queue = await db()
|
|
56
|
+
const key = keyFor({ controlChannelPubkey, fileChannelPubkey, receiverPubkey })
|
|
57
|
+
const row = await queue.getBy('key', key)
|
|
58
|
+
if (row && !isValid(row)) { await queue.removeBy('key', key); return null }
|
|
59
|
+
return row
|
|
60
|
+
})
|
|
61
|
+
},
|
|
62
|
+
async prune () { await ready; return lock(pruneUnlocked) },
|
|
63
|
+
async close () { await ready.catch(() => {}); await lock(async () => { if (database) await (await database.catch(() => null))?.close() }) }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import NMMR from 'nmmr'
|
|
2
|
+
import { createFileCatalog } from './helpers/catalog.js'
|
|
3
|
+
import { ValidationError } from '../../error/index.js'
|
|
4
|
+
import { IRFS_CHUNK_BYTES, decodeIrfsChunk } from '../../irfs/index.js'
|
|
5
|
+
import { createQueue } from '../../idb-queue/index.js'
|
|
6
|
+
import * as channelTransport from '../../private-channel/index.js'
|
|
7
|
+
import * as messages from '../../private-message/index.js'
|
|
8
|
+
import { readFileChunkIndex } from '../../private-channel/helpers/event.js'
|
|
9
|
+
import { compactSeedRouterRows, compactRecordsFromSeed, createEventReplyPacker, ROUTER_SEED_RECORD_TYPE } from '../recovery/index.js'
|
|
10
|
+
|
|
11
|
+
export const FILE_CHUNKS_REQUEST_CODE = 'fileChunksRequest_p5cc'
|
|
12
|
+
export const FILE_CHUNKS_REPLY_CODE = 'fileChunksReply_p5cc'
|
|
13
|
+
export const IRFS_CHUNK_RECORD_TYPE = 'irfsChunk_v1'
|
|
14
|
+
export const AUTO_DOWNLOAD_BYTES = 1024 * 1024
|
|
15
|
+
export const FILE_REQUEST_CHUNKS = 16
|
|
16
|
+
const HEX = /^[0-9a-f]{64}$/
|
|
17
|
+
const now = () => Math.floor(Date.now() / 1000)
|
|
18
|
+
|
|
19
|
+
export function fileChannelInfo (root) {
|
|
20
|
+
if (typeof root !== 'string' || !HEX.test(root)) throw new ValidationError('INVALID_FILE_ROOT')
|
|
21
|
+
return `dm:media:${root}`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function decodeMissingRanges (ranges) {
|
|
25
|
+
if (!Array.isArray(ranges) || !ranges.length) throw new ValidationError('INVALID_FILE_CHUNK_RANGES')
|
|
26
|
+
const indices = []
|
|
27
|
+
let previous = -1
|
|
28
|
+
for (const range of ranges) {
|
|
29
|
+
if (!Array.isArray(range) || range.length !== 2) throw new ValidationError('INVALID_FILE_CHUNK_RANGES')
|
|
30
|
+
const [first, last] = range
|
|
31
|
+
if (![first, last].every(Number.isSafeInteger) || first < 0 || first <= previous || last < first || last - first + 1 > FILE_REQUEST_CHUNKS - indices.length) throw new ValidationError('INVALID_FILE_CHUNK_RANGES')
|
|
32
|
+
for (let index = first; index <= last; index++) indices.push(index)
|
|
33
|
+
previous = last
|
|
34
|
+
}
|
|
35
|
+
return indices
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function rangesFor (indices) {
|
|
39
|
+
const ranges = []
|
|
40
|
+
for (const index of indices) {
|
|
41
|
+
const last = ranges.at(-1)
|
|
42
|
+
if (last && last[1] + 1 === index) last[1] = index
|
|
43
|
+
else ranges.push([index, index])
|
|
44
|
+
}
|
|
45
|
+
return ranges
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function assertPublished (result) {
|
|
49
|
+
const reports = result?.delivery?.reports
|
|
50
|
+
if (!Array.isArray(reports) || !reports.length || !reports.every(report => report?.success === true)) throw Object.assign(new Error('FILE_NOT_PUBLISHED'), { code: 'MESSAGE_NOT_PUBLISHED' })
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function waitForTransfer (promise, signal) {
|
|
54
|
+
signal?.throwIfAborted()
|
|
55
|
+
if (!signal) return promise
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
const aborted = () => reject(signal.reason)
|
|
58
|
+
signal.addEventListener('abort', aborted, { once: true })
|
|
59
|
+
promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', aborted))
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// One coordinator per account. Signer derivation and the local IRFS database
|
|
64
|
+
// belong to the caller; recovery, validation and scheduling belong here.
|
|
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?.read || !storage?.save) throw new ValidationError('INVALID_FILE_TRANSFER_OPTIONS')
|
|
67
|
+
const registrations = new Map()
|
|
68
|
+
const jobs = new Map()
|
|
69
|
+
const listeners = new Set()
|
|
70
|
+
const successes = new Map()
|
|
71
|
+
const work = new Set()
|
|
72
|
+
const requests = []
|
|
73
|
+
let replying = 0
|
|
74
|
+
let active = 0
|
|
75
|
+
let closed = false
|
|
76
|
+
let queuePromise
|
|
77
|
+
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 })
|
|
79
|
+
catalog.ready.catch(onError)
|
|
80
|
+
const keyFor = (control, root) => `${control}:${root}`
|
|
81
|
+
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 }) }
|
|
82
|
+
const usable = file => {
|
|
83
|
+
if (closed) throw new Error('FILE_TRANSFER_CLOSED')
|
|
84
|
+
messenger.requireWritableChannel(file.controlChannelPubkey)
|
|
85
|
+
if (!messenger.desiredChannels.has(file.controlChannelPubkey)) throw new Error('FILE_TRANSFER_CHANNEL_UNWATCHED')
|
|
86
|
+
}
|
|
87
|
+
async function register ({ controlChannelPubkey, peerPubkey, root, size, sharedAt }) {
|
|
88
|
+
fileChannelInfo(root)
|
|
89
|
+
if (!HEX.test(controlChannelPubkey) || !HEX.test(peerPubkey) || (size !== undefined && (!Number.isSafeInteger(size) || size < 1))) throw new ValidationError('INVALID_FILE_TRANSFER_DESCRIPTOR')
|
|
90
|
+
if (sharedAt !== undefined && (!Number.isSafeInteger(sharedAt) || sharedAt < 0)) throw new ValidationError('INVALID_FILE_SHARED_AT')
|
|
91
|
+
const key = keyFor(controlChannelPubkey, root)
|
|
92
|
+
const previous = registrations.get(key)
|
|
93
|
+
if (previous && (previous.peerPubkey !== peerPubkey || (size !== undefined && previous.size !== undefined && previous.size !== size))) throw new ValidationError('CONFLICTING_FILE_TRANSFER_DESCRIPTOR')
|
|
94
|
+
const file = previous || { controlChannelPubkey, peerPubkey, root }
|
|
95
|
+
if (size !== undefined) file.size = size
|
|
96
|
+
if (sharedAt !== undefined) file.sharedAt = Math.max(file.sharedAt ?? 0, sharedAt)
|
|
97
|
+
registrations.set(key, file)
|
|
98
|
+
file.ready ||= (async () => {
|
|
99
|
+
file.signer = await resolveChannel({ controlChannelPubkey, peerPubkey, root, info: fileChannelInfo(root) })
|
|
100
|
+
file.fileChannelPubkey = await file.signer.getPublicKey()
|
|
101
|
+
if (!HEX.test(file.fileChannelPubkey)) throw new ValidationError('INVALID_FILE_CHANNEL_PUBKEY')
|
|
102
|
+
})().catch(error => { if (registrations.get(key) === file) registrations.delete(key); throw error })
|
|
103
|
+
await file.ready
|
|
104
|
+
return file
|
|
105
|
+
}
|
|
106
|
+
function seedEnabled (file) {
|
|
107
|
+
const parent = messenger.channels.get(file.controlChannelPubkey)
|
|
108
|
+
return ['seeder', 'watchtower'].includes(parent?.mode) && messenger.offlineRecoverySecondsFor(parent) > 0
|
|
109
|
+
}
|
|
110
|
+
async function authorizeSeeding (descriptor, { receiverPubkeys, sharedAt } = {}) {
|
|
111
|
+
if (!Array.isArray(receiverPubkeys) || !receiverPubkeys.length || !receiverPubkeys.every(key => typeof key === 'string' && HEX.test(key))) throw new ValidationError('INVALID_FILE_AUTHORIZATION_RECEIVERS')
|
|
112
|
+
if (!Number.isSafeInteger(sharedAt) || sharedAt < 0) throw new ValidationError('INVALID_FILE_SHARED_AT')
|
|
113
|
+
const file = await register(descriptor)
|
|
114
|
+
usable(file)
|
|
115
|
+
await catalog.authorize(file, [...new Set(receiverPubkeys)], sharedAt)
|
|
116
|
+
}
|
|
117
|
+
async function prune () {
|
|
118
|
+
await catalog.prune()
|
|
119
|
+
if (!queuePromise) return
|
|
120
|
+
await (await queue()).removeWhere(row => {
|
|
121
|
+
const parent = messenger.channels.get(row.controlChannelPubkey)
|
|
122
|
+
return parent?.mode !== 'watchtower' || !seedEnabled(row) || row.expiresAt <= _now() || row.receivedAt + messenger.offlineRecoverySecondsFor(parent) <= _now()
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
async function saveSeed (file, seed) {
|
|
126
|
+
if (messenger.channels.get(file.controlChannelPubkey)?.mode !== 'watchtower' || !seedEnabled(file)) return
|
|
127
|
+
const index = readFileChunkIndex(seed.router)
|
|
128
|
+
if (index === undefined) return
|
|
129
|
+
const db = await queue()
|
|
130
|
+
for (const row of compactSeedRouterRows(seed)) {
|
|
131
|
+
if (![file.peerPubkey, messenger.userPubkey].includes(row.receiverPubkey)) continue
|
|
132
|
+
const key = `${file.controlChannelPubkey}:${file.fileChannelPubkey}:${row.receiverPubkey}:${index}`
|
|
133
|
+
await db.putBy('key', { ...row, key, root: file.root, controlChannelPubkey: file.controlChannelPubkey, fileChannelPubkey: file.fileChannelPubkey, peerPubkey: file.peerPubkey, chunkIndex: index, receivedAt: _now(), expiresAt: _now() + messenger.offlineRecoverySecondsFor(file.controlChannelPubkey) })
|
|
134
|
+
}
|
|
135
|
+
await prune()
|
|
136
|
+
}
|
|
137
|
+
function checked (file, event, index) {
|
|
138
|
+
const decoded = decodeIrfsChunk(event)
|
|
139
|
+
if (decoded.root !== file.root || (index !== undefined && decoded.index !== index) || (file.size !== undefined && (decoded.total !== Math.ceil(file.size / IRFS_CHUNK_BYTES) || (decoded.index === decoded.total - 1 && decoded.contentBytes.length !== file.size - decoded.index * IRFS_CHUNK_BYTES)))) throw new ValidationError('FILE_CHUNK_DESCRIPTOR_MISMATCH')
|
|
140
|
+
return decoded
|
|
141
|
+
}
|
|
142
|
+
async function routing (file, receiverPubkey) {
|
|
143
|
+
const parent = messenger.requireWritableChannel(file.controlChannelPubkey)
|
|
144
|
+
return messenger.resolveSendRouting({ channel: parent, receiverPubkeys: [receiverPubkey] })
|
|
145
|
+
}
|
|
146
|
+
function sendOptions (file, routes) {
|
|
147
|
+
return { senderSigner: messenger.userSigner, imkcSigner: messenger.contentKeySigner, privateChannelSigner: file.signer, ...routes, temporaryStorageArea: messenger.temporaryStorageArea, expirationSeconds: messenger.eventExpirationSecondsFor(file.controlChannelPubkey), _getIykcProofs: messenger.contentKeyLookup() }
|
|
148
|
+
}
|
|
149
|
+
async function publishChunk (descriptor, event) {
|
|
150
|
+
const file = await register(descriptor)
|
|
151
|
+
usable(file)
|
|
152
|
+
const decoded = checked(file, event)
|
|
153
|
+
await catalog.ready
|
|
154
|
+
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) } : {}) }
|
|
155
|
+
const result = event.sig ? await _messages.broadcastEvent({ ...options, event }) : await _messages.broadcastRumor({ ...options, rumor: event })
|
|
156
|
+
assertPublished(result)
|
|
157
|
+
// A scheduler turn between chunks lets queued text/control publications run.
|
|
158
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
159
|
+
return result
|
|
160
|
+
}
|
|
161
|
+
async function replyToRequest (controlChannelPubkey, message) {
|
|
162
|
+
if (message.payload?.code !== FILE_CHUNKS_REQUEST_CODE) return false
|
|
163
|
+
const question = message.question || message.event
|
|
164
|
+
if (message.provenance === 'hearsay' || message.senderPubkey !== question?.pubkey || question.tags?.find(tag => tag[0] === 'r')?.[1] !== messenger.userPubkey) return true
|
|
165
|
+
const payload = message.payload.payload
|
|
166
|
+
if (!HEX.test(payload?.fileChannelPubkey)) throw new ValidationError('INVALID_FILE_TRANSFER_DESCRIPTOR')
|
|
167
|
+
const indices = new Set(decodeMissingRanges(payload.missingRanges))
|
|
168
|
+
await prune()
|
|
169
|
+
const grant = await catalog.find(controlChannelPubkey, payload.fileChannelPubkey, question.pubkey)
|
|
170
|
+
if (grant) {
|
|
171
|
+
const file = await register(grant)
|
|
172
|
+
usable(file)
|
|
173
|
+
if (file.fileChannelPubkey !== payload.fileChannelPubkey) throw new ValidationError('FILE_CHANNEL_MISMATCH')
|
|
174
|
+
const routes = await routing(file, question.pubkey)
|
|
175
|
+
const packer = createEventReplyPacker({
|
|
176
|
+
messenger: {
|
|
177
|
+
reply: async options => {
|
|
178
|
+
usable(file)
|
|
179
|
+
if (!await catalog.find(controlChannelPubkey, file.fileChannelPubkey, question.pubkey)) return
|
|
180
|
+
return _messages.reply({ ...sendOptions(file, routes), ...options })
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
channelPubkey: file.fileChannelPubkey, question, receiverPubkey: question.pubkey,
|
|
184
|
+
code: FILE_CHUNKS_REPLY_CODE, eventsPerChunk: 1, recordsFromInput: record => [record]
|
|
185
|
+
})
|
|
186
|
+
for (const index of indices) {
|
|
187
|
+
usable(file)
|
|
188
|
+
if (!await catalog.find(controlChannelPubkey, file.fileChannelPubkey, question.pubkey)) break
|
|
189
|
+
const event = await storage.read(file.root, index, file)
|
|
190
|
+
if (!event) continue
|
|
191
|
+
let decoded
|
|
192
|
+
try { decoded = checked(file, event, index) } catch (error) { if (error instanceof ValidationError) { onError(error); continue } throw error }
|
|
193
|
+
const proof = event.tags.find(tag => tag[0] === 'mmr')[3]
|
|
194
|
+
await packer.update({ recordType: IRFS_CHUNK_RECORD_TYPE, index, total: decoded.total, proof, content: event.content })
|
|
195
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
196
|
+
}
|
|
197
|
+
await packer.finalize()
|
|
198
|
+
return true
|
|
199
|
+
}
|
|
200
|
+
if (messenger.channels.get(controlChannelPubkey)?.mode !== 'watchtower') return true
|
|
201
|
+
const db = await queue()
|
|
202
|
+
let packer
|
|
203
|
+
for await (const seed of db.storedItemsBy('channel', payload.fileChannelPubkey)) {
|
|
204
|
+
if (seed.controlChannelPubkey !== controlChannelPubkey || seed.receiverPubkey !== question.pubkey || !indices.has(seed.chunkIndex) || !seedEnabled(seed)) continue
|
|
205
|
+
const file = await register(seed)
|
|
206
|
+
usable(file)
|
|
207
|
+
if (file.fileChannelPubkey !== payload.fileChannelPubkey) throw new ValidationError('FILE_CHANNEL_MISMATCH')
|
|
208
|
+
if (!packer) {
|
|
209
|
+
const routes = await routing(file, question.pubkey)
|
|
210
|
+
packer = createEventReplyPacker({ messenger: { reply: async options => { usable(file); return _messages.reply({ ...sendOptions(file, routes), ...options }) } }, channelPubkey: file.fileChannelPubkey, question, receiverPubkey: question.pubkey, code: FILE_CHUNKS_REPLY_CODE, eventsPerChunk: 1, recordsFromInput: row => compactRecordsFromSeed(row, { receiverPubkey: question.pubkey }) })
|
|
211
|
+
}
|
|
212
|
+
await packer.update(seed)
|
|
213
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
214
|
+
}
|
|
215
|
+
await packer?.finalize()
|
|
216
|
+
return true
|
|
217
|
+
}
|
|
218
|
+
async function run (job) {
|
|
219
|
+
const file = job.file
|
|
220
|
+
const signal = job.controller.signal
|
|
221
|
+
let sub
|
|
222
|
+
const historyController = new AbortController()
|
|
223
|
+
let ingest = Promise.resolve()
|
|
224
|
+
let total = file.size === undefined ? undefined : Math.ceil(file.size / IRFS_CHUNK_BYTES)
|
|
225
|
+
const persisted = new Set()
|
|
226
|
+
let lastProgress = Date.now()
|
|
227
|
+
let requestRoundAt = Date.now()
|
|
228
|
+
const requestedSeeders = new Set()
|
|
229
|
+
const validSeeders = new Set([file.peerPubkey, ...messenger.recoverySeeders(file.controlChannelPubkey)])
|
|
230
|
+
let terminalError
|
|
231
|
+
const fail = error => { terminalError ||= error }
|
|
232
|
+
async function accept (event, index, { save = true } = {}) {
|
|
233
|
+
signal.throwIfAborted()
|
|
234
|
+
usable(file)
|
|
235
|
+
const chunk = checked(file, event, index)
|
|
236
|
+
if (total !== undefined && chunk.total !== total) throw new ValidationError('FILE_CHUNK_TOTAL_MISMATCH')
|
|
237
|
+
total = chunk.total
|
|
238
|
+
if (persisted.has(chunk.index)) return
|
|
239
|
+
if (save) await storage.save(event, file)
|
|
240
|
+
signal.throwIfAborted()
|
|
241
|
+
persisted.add(chunk.index)
|
|
242
|
+
job.completed += chunk.contentBytes.length
|
|
243
|
+
lastProgress = Date.now()
|
|
244
|
+
if (chunk.index === total - 1 && file.size === undefined) file.size = (total - 1) * IRFS_CHUNK_BYTES + chunk.contentBytes.length
|
|
245
|
+
emit(job)
|
|
246
|
+
}
|
|
247
|
+
const consume = (event, outer, meta) => {
|
|
248
|
+
const task = ingest.then(async () => {
|
|
249
|
+
if (signal.aborted) return
|
|
250
|
+
if (event.kind === messages.REPLY_KIND) {
|
|
251
|
+
const reply = messages.parseRumorContent(event)
|
|
252
|
+
if (reply.code !== FILE_CHUNKS_REPLY_CODE || meta.provenance === 'hearsay' || meta.senderPubkey !== event.pubkey || !validSeeders.has(event.pubkey) || event.tags?.find(tag => tag[0] === 'r')?.[1] !== messenger.userPubkey) return
|
|
253
|
+
const jsonl = reply.payload?.jsonl
|
|
254
|
+
if (typeof jsonl !== 'string') throw new ValidationError('INVALID_FILE_CHUNK_REPLY')
|
|
255
|
+
const rows = jsonl.split('\n').filter(Boolean)
|
|
256
|
+
if (rows.length > FILE_REQUEST_CHUNKS) throw new ValidationError('INVALID_FILE_CHUNK_REPLY')
|
|
257
|
+
for (const line of rows) {
|
|
258
|
+
let record
|
|
259
|
+
try { record = JSON.parse(line) } catch { throw new ValidationError('INVALID_FILE_CHUNK_REPLY') }
|
|
260
|
+
if (record.recordType === IRFS_CHUNK_RECORD_TYPE) {
|
|
261
|
+
if (!Number.isSafeInteger(record.index) || record.index < 0 || !Number.isSafeInteger(record.total) || record.total <= record.index || typeof record.content !== 'string' || record.content.length > 100000 || typeof record.proof !== 'string' || record.proof.length > 8192) throw new ValidationError('INVALID_FILE_CHUNK_REPLY')
|
|
262
|
+
const chunk = { kind: 34601, created_at: event.created_at, tags: [['d', NMMR.deriveChunkId(file.root, record.index)], ['mmr', String(record.index), String(record.total), record.proof]], content: record.content }
|
|
263
|
+
await accept(chunk, record.index)
|
|
264
|
+
successes.set(event.pubkey, Date.now())
|
|
265
|
+
continue
|
|
266
|
+
}
|
|
267
|
+
if (record.recordType !== ROUTER_SEED_RECORD_TYPE) continue
|
|
268
|
+
const index = readFileChunkIndex(record.router)
|
|
269
|
+
if (index === undefined) throw new ValidationError('INVALID_FILE_CHUNK_INDEX')
|
|
270
|
+
const original = await _transport.unwrapRouterEvent({ router: record.router, receiverSigner: messenger.userSigner, receiverPubkey: messenger.userPubkey, channelPubkey: file.fileChannelPubkey })
|
|
271
|
+
if (original) { await accept(original, index); successes.set(event.pubkey, Date.now()) }
|
|
272
|
+
}
|
|
273
|
+
} else {
|
|
274
|
+
if (meta.senderPubkey !== file.peerPubkey) return
|
|
275
|
+
const index = readFileChunkIndex(meta.router)
|
|
276
|
+
if (index === undefined) throw new ValidationError('INVALID_FILE_CHUNK_INDEX')
|
|
277
|
+
await accept(event, index)
|
|
278
|
+
// Only direct, validated delivery proves this identity was a recipient.
|
|
279
|
+
// Local cache hits and recovery replies never create/renew a grant.
|
|
280
|
+
if (file.sharedAt !== undefined) await authorizeSeeding(file, { receiverPubkeys: [messenger.userPubkey], sharedAt: file.sharedAt })
|
|
281
|
+
}
|
|
282
|
+
})
|
|
283
|
+
// Malformed remote records do not poison the ingestion chain. Operational
|
|
284
|
+
// persistence failures stop the job so completion cannot outrun storage.
|
|
285
|
+
ingest = task.catch(error => { if (error instanceof ValidationError) onError(error); else if (!signal.aborted) fail(error) })
|
|
286
|
+
return ingest
|
|
287
|
+
}
|
|
288
|
+
try {
|
|
289
|
+
job.status = 'downloading'; emit(job)
|
|
290
|
+
// Resume from independently verified local chunks, never from an ACK.
|
|
291
|
+
for (let index = 0; index < (total ?? 1); index++) {
|
|
292
|
+
signal.throwIfAborted()
|
|
293
|
+
const event = await storage.read(file.root, index, file)
|
|
294
|
+
if (event) await accept(event, index, { save: false })
|
|
295
|
+
}
|
|
296
|
+
if (total !== undefined && persisted.size === total) return
|
|
297
|
+
const parent = messenger.requireWritableChannel(file.controlChannelPubkey)
|
|
298
|
+
const relays = await messenger.resolveWatchRelays(parent)
|
|
299
|
+
const options = { receiverSigner: messenger.userSigner, privateChannelSigner: file.signer, privateChannelPubkey: file.fileChannelPubkey, receiverPubkey: messenger.userPubkey, relays, receivedChunkIndexedDB: messenger._indexedDB, receivedChunkScope: `${messenger.userPubkey}:file:${file.fileChannelPubkey}`, onEvent: consume, onSeedEvent: seed => saveSeed(file, seed), mode: messenger.channels.get(file.controlChannelPubkey)?.mode === 'watchtower' ? 'watchtower' : 'leecher', onError }
|
|
300
|
+
sub = _transport.subscribe({ ...options, liveOnly: true })
|
|
301
|
+
const stop = () => { sub?.close() }
|
|
302
|
+
signal.addEventListener('abort', stop, { once: true })
|
|
303
|
+
try {
|
|
304
|
+
await sub.ready
|
|
305
|
+
signal.throwIfAborted()
|
|
306
|
+
// Partial/failed history still permits recovery from peers.
|
|
307
|
+
const history = _transport.fetch({ ...options, signal: AbortSignal.any([signal, historyController.signal]), since: 0 }).catch(error => { if (!signal.aborted) onError(error) })
|
|
308
|
+
work.add(history); history.finally(() => work.delete(history))
|
|
309
|
+
const seeders = [...validSeeders].sort((a, b) => (successes.get(b) || 0) - (successes.get(a) || 0) || Math.random() - 0.5)
|
|
310
|
+
while (true) {
|
|
311
|
+
signal.throwIfAborted(); usable(file)
|
|
312
|
+
await ingest
|
|
313
|
+
if (terminalError) throw terminalError
|
|
314
|
+
if (total !== undefined && persisted.size === total) break
|
|
315
|
+
const missing = []
|
|
316
|
+
for (let index = 0; index < (total ?? 1) && missing.length < FILE_REQUEST_CHUNKS; index++) if (!persisted.has(index)) missing.push(index)
|
|
317
|
+
const remaining = seeders.filter(peer => !requestedSeeders.has(peer))
|
|
318
|
+
if (remaining.length && Date.now() >= requestRoundAt) {
|
|
319
|
+
const peer = remaining[0]
|
|
320
|
+
requestedSeeders.add(peer)
|
|
321
|
+
requestRoundAt = Date.now() + _hedgeMs
|
|
322
|
+
// The request is on dm; its compact reply is on the file channel.
|
|
323
|
+
await messenger.ask({ channelPubkey: file.controlChannelPubkey, receiverPubkey: peer, code: FILE_CHUNKS_REQUEST_CODE, payload: { fileChannelPubkey: file.fileChannelPubkey, missingRanges: rangesFor(missing) } }).catch(onError)
|
|
324
|
+
}
|
|
325
|
+
if (!remaining.length && Date.now() - Math.max(lastProgress, requestRoundAt - _hedgeMs) >= _idleMs) throw new Error('FILE_DOWNLOAD_STALLED')
|
|
326
|
+
// Start a fresh batch when all positions of the previous batch arrived.
|
|
327
|
+
if (job.batch?.every(index => persisted.has(index))) { requestedSeeders.clear(); requestRoundAt = Date.now(); job.batch = null }
|
|
328
|
+
job.batch ||= missing
|
|
329
|
+
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
|
+
}
|
|
331
|
+
} finally { signal.removeEventListener('abort', stop) }
|
|
332
|
+
} finally { historyController.abort(); await sub?.close(); await ingest }
|
|
333
|
+
if (job.completed !== file.size) throw new ValidationError('FILE_SIZE_MISMATCH')
|
|
334
|
+
}
|
|
335
|
+
function pump () {
|
|
336
|
+
if (closed) return
|
|
337
|
+
for (const job of [...jobs.values()].filter(job => job.status === 'queued').sort((a, b) => b.priority - a.priority)) {
|
|
338
|
+
if (active >= 2) break
|
|
339
|
+
active++; job.status = 'starting'
|
|
340
|
+
const task = run(job).then(() => { job.status = 'complete'; emit(job); job.resolve(job.file) }, error => { job.status = job.pauseRequested ? 'paused' : job.controller.signal.aborted ? 'cancelled' : 'error'; job.error = error; emit(job); job.reject(error) }).finally(() => { active--; work.delete(task); pump() })
|
|
341
|
+
work.add(task)
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
async function download (descriptor, { manual = false, thumbnail = false, signal } = {}) {
|
|
345
|
+
const file = await register(descriptor)
|
|
346
|
+
usable(file)
|
|
347
|
+
const existing = jobs.get(keyFor(file.controlChannelPubkey, file.root))
|
|
348
|
+
if (existing && !['error', 'cancelled', 'paused'].includes(existing.status)) return waitForTransfer(existing.promise, signal)
|
|
349
|
+
if (!manual && (file.size === undefined || file.size > (thumbnail ? IRFS_CHUNK_BYTES : AUTO_DOWNLOAD_BYTES))) {
|
|
350
|
+
// Locally complete files need no network consent, including the sender's
|
|
351
|
+
// own originals and files already downloaded before a restart.
|
|
352
|
+
let total, completed = 0
|
|
353
|
+
for (let index = 0; index < (total ?? 1); index++) {
|
|
354
|
+
signal?.throwIfAborted(); usable(file)
|
|
355
|
+
const event = await storage.read(file.root, index, file)
|
|
356
|
+
if (!event) throw new Error('FILE_DOWNLOAD_REQUIRES_ACTION')
|
|
357
|
+
const chunk = checked(file, event, index)
|
|
358
|
+
if (total !== undefined && total !== chunk.total) throw new ValidationError('FILE_CHUNK_TOTAL_MISMATCH')
|
|
359
|
+
total = chunk.total; completed += chunk.contentBytes.length
|
|
360
|
+
}
|
|
361
|
+
file.size ??= completed
|
|
362
|
+
if (completed !== file.size) throw new ValidationError('FILE_SIZE_MISMATCH')
|
|
363
|
+
const job = { file, completed, status: 'complete', promise: Promise.resolve(file) }
|
|
364
|
+
jobs.set(keyFor(file.controlChannelPubkey, file.root), job); emit(job)
|
|
365
|
+
return file
|
|
366
|
+
}
|
|
367
|
+
const key = keyFor(file.controlChannelPubkey, file.root)
|
|
368
|
+
let job = jobs.get(key)
|
|
369
|
+
if (!job || ['error', 'cancelled', 'paused'].includes(job.status)) {
|
|
370
|
+
const controller = new AbortController()
|
|
371
|
+
const { promise, resolve, reject } = Promise.withResolvers()
|
|
372
|
+
job = { file, controller, resolve, reject, promise, completed: 0, manual, thumbnail, priority: thumbnail ? 2 : manual ? 1 : 0, status: 'queued' }
|
|
373
|
+
jobs.set(key, job); emit(job); pump()
|
|
374
|
+
}
|
|
375
|
+
// A view leaving does not cancel another consumer's shared download.
|
|
376
|
+
return waitForTransfer(job.promise, signal)
|
|
377
|
+
}
|
|
378
|
+
const cancelJob = job => {
|
|
379
|
+
if (['complete', 'error', 'cancelled'].includes(job.status)) return
|
|
380
|
+
const error = new DOMException('Download cancelled', 'AbortError')
|
|
381
|
+
job.controller.abort(error)
|
|
382
|
+
if (job.status === 'queued') { job.status = job.pauseRequested ? 'paused' : 'cancelled'; job.reject(error); emit(job) }
|
|
383
|
+
}
|
|
384
|
+
function drainRequests () {
|
|
385
|
+
if (closed) return
|
|
386
|
+
while (replying < 2 && requests.length) {
|
|
387
|
+
const [channel, message] = requests.shift()
|
|
388
|
+
replying++
|
|
389
|
+
const task = replyToRequest(channel, message).catch(error => { if (!(closed && error.message === 'FILE_TRANSFER_CLOSED')) onError(error) }).finally(() => { replying--; work.delete(task); drainRequests() })
|
|
390
|
+
work.add(task)
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
const extension = {
|
|
394
|
+
handleAsk (channel, message) {
|
|
395
|
+
if (message.payload?.code !== FILE_CHUNKS_REQUEST_CODE) return false
|
|
396
|
+
// Never hold the DM dispatcher while publishing a batch of file bytes.
|
|
397
|
+
// Bound both workers and queued requests; discarded excess is retryable.
|
|
398
|
+
if (!closed && requests.length < 16) { requests.push([channel, message]); drainRequests() }
|
|
399
|
+
return true
|
|
400
|
+
},
|
|
401
|
+
pause () { requests.length = 0; for (const job of jobs.values()) { if (!['complete', 'error', 'cancelled'].includes(job.status)) job.pauseRequested = true; cancelJob(job) } },
|
|
402
|
+
async resume () {
|
|
403
|
+
await Promise.allSettled([...work])
|
|
404
|
+
for (const job of jobs.values()) if (job.pauseRequested && job.status !== 'complete') download(job.file, { manual: job.manual, thumbnail: job.thumbnail }).catch(onError)
|
|
405
|
+
},
|
|
406
|
+
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() }
|
|
408
|
+
}
|
|
409
|
+
const timer = setInterval(() => { prune().catch(onError) }, 60000)
|
|
410
|
+
timer.unref?.()
|
|
411
|
+
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) } }
|
|
413
|
+
}
|
|
@@ -265,6 +265,7 @@ export class PrivateMessenger {
|
|
|
265
265
|
this.state = { channels: {} }
|
|
266
266
|
this.stateWriteTail = Promise.resolve()
|
|
267
267
|
this.stateWriteError = null
|
|
268
|
+
this.extensions = new Set()
|
|
268
269
|
this.channels = new Map()
|
|
269
270
|
this.stopByChannel = new Map()
|
|
270
271
|
this.desiredChannels = new Set()
|
|
@@ -1128,6 +1129,7 @@ export class PrivateMessenger {
|
|
|
1128
1129
|
|
|
1129
1130
|
unwatch (channels) {
|
|
1130
1131
|
const pubkeys = channels ? uniq(Array.isArray(channels) ? channels : [channels]) : [...this.desiredChannels]
|
|
1132
|
+
for (const extension of this.extensions) extension.unwatch?.(pubkeys)
|
|
1131
1133
|
for (const pubkey of pubkeys) this.desiredChannels.delete(pubkey)
|
|
1132
1134
|
this.recordInterruption(pubkeys)
|
|
1133
1135
|
for (const controller of this.recoveryControllers) {
|
|
@@ -1140,6 +1142,7 @@ export class PrivateMessenger {
|
|
|
1140
1142
|
if (typeof reason !== 'string' || !reason.trim()) throw new ValidationError('PAUSE_REASON_REQUIRED')
|
|
1141
1143
|
this.assertOpen()
|
|
1142
1144
|
this.pauseReasons.add(reason)
|
|
1145
|
+
for (const extension of this.extensions) extension.pause?.()
|
|
1143
1146
|
this.recordInterruption(this.desiredChannels)
|
|
1144
1147
|
for (const controller of this.recoveryControllers) controller.abort()
|
|
1145
1148
|
return Promise.all([this.stopWatches([...this.desiredChannels]), this.flushStateWrites()])
|
|
@@ -1164,6 +1167,7 @@ export class PrivateMessenger {
|
|
|
1164
1167
|
if (this.pauseReasons.size || this.closePromise) return
|
|
1165
1168
|
await this.reconcilePresencePublishers()
|
|
1166
1169
|
await this.recoverOfflineRanges(channels)
|
|
1170
|
+
for (const extension of this.extensions) await extension.resume?.()
|
|
1167
1171
|
})()
|
|
1168
1172
|
this.resumeWork = work
|
|
1169
1173
|
try { await work } catch (err) {
|
|
@@ -1238,6 +1242,7 @@ export class PrivateMessenger {
|
|
|
1238
1242
|
async handleAsk (channelPubkey, message) {
|
|
1239
1243
|
if (message.provenance === 'hearsay') return this.enqueueRumor('message', channelPubkey, message)
|
|
1240
1244
|
this.trackSeederActivity(channelPubkey, message)
|
|
1245
|
+
for (const extension of this.extensions) if (await extension.handleAsk?.(channelPubkey, message)) return
|
|
1241
1246
|
if (doesModeStoreRecoverySeeds(this.channels.get(channelPubkey)?.mode) && messageCode(message) === MISSING_MESSAGES_ASK_CODE) {
|
|
1242
1247
|
await this.replyWithStoredSeeds(channelPubkey, message)
|
|
1243
1248
|
return
|
|
@@ -1329,6 +1334,11 @@ export class PrivateMessenger {
|
|
|
1329
1334
|
this.onMessageQueued?.()
|
|
1330
1335
|
}
|
|
1331
1336
|
|
|
1337
|
+
outgoingSeedHandler (channelPubkey) {
|
|
1338
|
+
if (!doesModeStoreRecoverySeeds(this.channels.get(channelPubkey)?.mode) || !this.offlineRecoverySecondsFor(channelPubkey)) return undefined
|
|
1339
|
+
return seed => this.enqueueSeed(channelPubkey, seed)
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1332
1342
|
async enqueueSeed (channelPubkey, seed) {
|
|
1333
1343
|
if (!this.offlineRecoverySecondsFor(channelPubkey)) return
|
|
1334
1344
|
const receivedAt = nowSeconds()
|
|
@@ -1494,6 +1504,7 @@ export class PrivateMessenger {
|
|
|
1494
1504
|
payload,
|
|
1495
1505
|
error,
|
|
1496
1506
|
content,
|
|
1507
|
+
onPreparedSeed: this.outgoingSeedHandler(channelPubkey),
|
|
1497
1508
|
_getIykcProofs: this.contentKeyLookup()
|
|
1498
1509
|
})
|
|
1499
1510
|
}
|
|
@@ -1520,6 +1531,7 @@ export class PrivateMessenger {
|
|
|
1520
1531
|
payload,
|
|
1521
1532
|
error,
|
|
1522
1533
|
content,
|
|
1534
|
+
onPreparedSeed: this.outgoingSeedHandler(channelPubkey),
|
|
1523
1535
|
_getIykcProofs: this.contentKeyLookup()
|
|
1524
1536
|
})
|
|
1525
1537
|
}
|
|
@@ -1544,6 +1556,7 @@ export class PrivateMessenger {
|
|
|
1544
1556
|
payload,
|
|
1545
1557
|
error,
|
|
1546
1558
|
content,
|
|
1559
|
+
onPreparedSeed: this.outgoingSeedHandler(channelPubkey),
|
|
1547
1560
|
_getIykcProofs: this.contentKeyLookup()
|
|
1548
1561
|
})
|
|
1549
1562
|
}
|
|
@@ -1568,6 +1581,7 @@ export class PrivateMessenger {
|
|
|
1568
1581
|
payload,
|
|
1569
1582
|
error,
|
|
1570
1583
|
content,
|
|
1584
|
+
onPreparedSeed: this.outgoingSeedHandler(channelPubkey),
|
|
1571
1585
|
_getIykcProofs: this.contentKeyLookup()
|
|
1572
1586
|
})
|
|
1573
1587
|
}
|
|
@@ -1588,6 +1602,7 @@ export class PrivateMessenger {
|
|
|
1588
1602
|
deletionPubkey,
|
|
1589
1603
|
autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
|
|
1590
1604
|
rumor,
|
|
1605
|
+
onPreparedSeed: this.outgoingSeedHandler(channelPubkey),
|
|
1591
1606
|
_getIykcProofs: this.contentKeyLookup()
|
|
1592
1607
|
})
|
|
1593
1608
|
}
|
|
@@ -1608,6 +1623,7 @@ export class PrivateMessenger {
|
|
|
1608
1623
|
deletionPubkey,
|
|
1609
1624
|
autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
|
|
1610
1625
|
event,
|
|
1626
|
+
onPreparedSeed: this.outgoingSeedHandler(channelPubkey),
|
|
1611
1627
|
_getIykcProofs: this.contentKeyLookup()
|
|
1612
1628
|
})
|
|
1613
1629
|
}
|
|
@@ -1664,6 +1680,7 @@ export class PrivateMessenger {
|
|
|
1664
1680
|
autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
|
|
1665
1681
|
code: SEEDER_PRESENCE_CODE,
|
|
1666
1682
|
payload: {},
|
|
1683
|
+
onPreparedSeed: this.outgoingSeedHandler(channelPubkey),
|
|
1667
1684
|
_getIykcProofs: this.contentKeyLookup()
|
|
1668
1685
|
})
|
|
1669
1686
|
}
|
|
@@ -2177,6 +2194,7 @@ export class PrivateMessenger {
|
|
|
2177
2194
|
let unwatchError
|
|
2178
2195
|
try { await unwatchPromise } catch (err) { unwatchError = err }
|
|
2179
2196
|
await initSettledPromise
|
|
2197
|
+
await Promise.all([...this.extensions].map(extension => extension.close?.()))
|
|
2180
2198
|
await this.stampActiveChannelActivity()
|
|
2181
2199
|
await this.queueOperationTail
|
|
2182
2200
|
await Promise.allSettled([...this.recoveries.values()])
|
|
@@ -193,7 +193,7 @@ function compactNymCarriersFromSeed (seed, { since, until }) {
|
|
|
193
193
|
}]
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
-
function compactRecordsFromSeed (seed, { receiverPubkey, since, until }) {
|
|
196
|
+
export function compactRecordsFromSeed (seed, { receiverPubkey, since, until }) {
|
|
197
197
|
if (seed?.recordType === NYM_CARRIER_SEED_RECORD_TYPE) {
|
|
198
198
|
return compactNymCarriersFromSeed(seed, { since, until })
|
|
199
199
|
}
|