libp2r2p 0.10.28 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -0
- package/nip27/index.js +1 -0
- package/nip94/README.md +13 -0
- package/nip94/index.js +56 -2
- package/package.json +4 -1
- package/private-channel/helpers/chunk-size.js +1 -1
- package/private-channel/helpers/chunks.js +1 -1
- package/private-channel/helpers/event.js +14 -3
- package/private-channel/index.js +25 -7
- package/private-channel/services/received-chunks.js +6 -3
- package/private-message/index.js +21 -7
- package/private-messenger/event-store/README.md +110 -0
- package/private-messenger/event-store/helpers/codec.js +136 -0
- package/private-messenger/event-store/index.js +176 -0
- package/private-messenger/file/README.md +152 -0
- package/private-messenger/file/helpers/cache.js +159 -0
- package/private-messenger/file/helpers/catalog.js +87 -0
- package/private-messenger/file/helpers/stream.js +31 -0
- package/private-messenger/file/index.js +433 -0
- package/private-messenger/index.js +45 -10
- package/private-messenger/recovery/index.js +1 -1
- package/private-messenger/services/storage-maintenance.js +3 -0
- package/private-messenger/session/README.md +39 -0
- package/private-messenger/session/helpers/publication.js +40 -0
- package/private-messenger/session/helpers/signer.js +13 -0
- package/private-messenger/session/helpers/work-storage.js +32 -0
- package/private-messenger/session/index.js +307 -0
|
@@ -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
|
|
@@ -265,6 +267,7 @@ export class PrivateMessenger {
|
|
|
265
267
|
this.state = { channels: {} }
|
|
266
268
|
this.stateWriteTail = Promise.resolve()
|
|
267
269
|
this.stateWriteError = null
|
|
270
|
+
this.extensions = new Set()
|
|
268
271
|
this.channels = new Map()
|
|
269
272
|
this.stopByChannel = new Map()
|
|
270
273
|
this.desiredChannels = new Set()
|
|
@@ -347,13 +350,15 @@ export class PrivateMessenger {
|
|
|
347
350
|
indexedDB: this._indexedDB
|
|
348
351
|
})
|
|
349
352
|
this.assertOpen()
|
|
350
|
-
this.seedQueue =
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
+
})
|
|
357
362
|
this.assertOpen()
|
|
358
363
|
this.stateStore = await createChannelStateStore({
|
|
359
364
|
prefix: this.prefix,
|
|
@@ -1128,6 +1133,7 @@ export class PrivateMessenger {
|
|
|
1128
1133
|
|
|
1129
1134
|
unwatch (channels) {
|
|
1130
1135
|
const pubkeys = channels ? uniq(Array.isArray(channels) ? channels : [channels]) : [...this.desiredChannels]
|
|
1136
|
+
for (const extension of this.extensions) extension.unwatch?.(pubkeys)
|
|
1131
1137
|
for (const pubkey of pubkeys) this.desiredChannels.delete(pubkey)
|
|
1132
1138
|
this.recordInterruption(pubkeys)
|
|
1133
1139
|
for (const controller of this.recoveryControllers) {
|
|
@@ -1140,6 +1146,7 @@ export class PrivateMessenger {
|
|
|
1140
1146
|
if (typeof reason !== 'string' || !reason.trim()) throw new ValidationError('PAUSE_REASON_REQUIRED')
|
|
1141
1147
|
this.assertOpen()
|
|
1142
1148
|
this.pauseReasons.add(reason)
|
|
1149
|
+
for (const extension of this.extensions) extension.pause?.()
|
|
1143
1150
|
this.recordInterruption(this.desiredChannels)
|
|
1144
1151
|
for (const controller of this.recoveryControllers) controller.abort()
|
|
1145
1152
|
return Promise.all([this.stopWatches([...this.desiredChannels]), this.flushStateWrites()])
|
|
@@ -1164,6 +1171,7 @@ export class PrivateMessenger {
|
|
|
1164
1171
|
if (this.pauseReasons.size || this.closePromise) return
|
|
1165
1172
|
await this.reconcilePresencePublishers()
|
|
1166
1173
|
await this.recoverOfflineRanges(channels)
|
|
1174
|
+
for (const extension of this.extensions) await extension.resume?.()
|
|
1167
1175
|
})()
|
|
1168
1176
|
this.resumeWork = work
|
|
1169
1177
|
try { await work } catch (err) {
|
|
@@ -1238,6 +1246,7 @@ export class PrivateMessenger {
|
|
|
1238
1246
|
async handleAsk (channelPubkey, message) {
|
|
1239
1247
|
if (message.provenance === 'hearsay') return this.enqueueRumor('message', channelPubkey, message)
|
|
1240
1248
|
this.trackSeederActivity(channelPubkey, message)
|
|
1249
|
+
for (const extension of this.extensions) if (await extension.handleAsk?.(channelPubkey, message)) return
|
|
1241
1250
|
if (doesModeStoreRecoverySeeds(this.channels.get(channelPubkey)?.mode) && messageCode(message) === MISSING_MESSAGES_ASK_CODE) {
|
|
1242
1251
|
await this.replyWithStoredSeeds(channelPubkey, message)
|
|
1243
1252
|
return
|
|
@@ -1329,9 +1338,24 @@ export class PrivateMessenger {
|
|
|
1329
1338
|
this.onMessageQueued?.()
|
|
1330
1339
|
}
|
|
1331
1340
|
|
|
1341
|
+
outgoingSeedHandler (channelPubkey) {
|
|
1342
|
+
if (!doesModeStoreRecoverySeeds(this.channels.get(channelPubkey)?.mode) || !this.offlineRecoverySecondsFor(channelPubkey)) return undefined
|
|
1343
|
+
return seed => this.enqueueSeed(channelPubkey, seed)
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1332
1346
|
async enqueueSeed (channelPubkey, seed) {
|
|
1333
1347
|
if (!this.offlineRecoverySecondsFor(channelPubkey)) return
|
|
1334
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
|
+
}
|
|
1335
1359
|
if (seed.recordType === NYM_CARRIER_SEED_RECORD_TYPE || seed.carriers?.length) {
|
|
1336
1360
|
const carriers = compactSeedNymCarriers(seed.carriers)
|
|
1337
1361
|
const recordTime = nymCarrierRecordTime({ carriers }) || seed.outer?.created_at || receivedAt
|
|
@@ -1494,6 +1518,7 @@ export class PrivateMessenger {
|
|
|
1494
1518
|
payload,
|
|
1495
1519
|
error,
|
|
1496
1520
|
content,
|
|
1521
|
+
onPreparedSeed: this.outgoingSeedHandler(channelPubkey),
|
|
1497
1522
|
_getIykcProofs: this.contentKeyLookup()
|
|
1498
1523
|
})
|
|
1499
1524
|
}
|
|
@@ -1520,6 +1545,7 @@ export class PrivateMessenger {
|
|
|
1520
1545
|
payload,
|
|
1521
1546
|
error,
|
|
1522
1547
|
content,
|
|
1548
|
+
onPreparedSeed: this.outgoingSeedHandler(channelPubkey),
|
|
1523
1549
|
_getIykcProofs: this.contentKeyLookup()
|
|
1524
1550
|
})
|
|
1525
1551
|
}
|
|
@@ -1544,6 +1570,7 @@ export class PrivateMessenger {
|
|
|
1544
1570
|
payload,
|
|
1545
1571
|
error,
|
|
1546
1572
|
content,
|
|
1573
|
+
onPreparedSeed: this.outgoingSeedHandler(channelPubkey),
|
|
1547
1574
|
_getIykcProofs: this.contentKeyLookup()
|
|
1548
1575
|
})
|
|
1549
1576
|
}
|
|
@@ -1568,6 +1595,7 @@ export class PrivateMessenger {
|
|
|
1568
1595
|
payload,
|
|
1569
1596
|
error,
|
|
1570
1597
|
content,
|
|
1598
|
+
onPreparedSeed: this.outgoingSeedHandler(channelPubkey),
|
|
1571
1599
|
_getIykcProofs: this.contentKeyLookup()
|
|
1572
1600
|
})
|
|
1573
1601
|
}
|
|
@@ -1588,6 +1616,7 @@ export class PrivateMessenger {
|
|
|
1588
1616
|
deletionPubkey,
|
|
1589
1617
|
autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
|
|
1590
1618
|
rumor,
|
|
1619
|
+
onPreparedSeed: this.outgoingSeedHandler(channelPubkey),
|
|
1591
1620
|
_getIykcProofs: this.contentKeyLookup()
|
|
1592
1621
|
})
|
|
1593
1622
|
}
|
|
@@ -1608,6 +1637,7 @@ export class PrivateMessenger {
|
|
|
1608
1637
|
deletionPubkey,
|
|
1609
1638
|
autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
|
|
1610
1639
|
event,
|
|
1640
|
+
onPreparedSeed: this.outgoingSeedHandler(channelPubkey),
|
|
1611
1641
|
_getIykcProofs: this.contentKeyLookup()
|
|
1612
1642
|
})
|
|
1613
1643
|
}
|
|
@@ -1664,6 +1694,7 @@ export class PrivateMessenger {
|
|
|
1664
1694
|
autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
|
|
1665
1695
|
code: SEEDER_PRESENCE_CODE,
|
|
1666
1696
|
payload: {},
|
|
1697
|
+
onPreparedSeed: this.outgoingSeedHandler(channelPubkey),
|
|
1667
1698
|
_getIykcProofs: this.contentKeyLookup()
|
|
1668
1699
|
})
|
|
1669
1700
|
}
|
|
@@ -1915,7 +1946,9 @@ export class PrivateMessenger {
|
|
|
1915
1946
|
})
|
|
1916
1947
|
|
|
1917
1948
|
if (this.offlineRecoverySecondsFor(channelPubkey)) {
|
|
1918
|
-
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
|
|
1919
1952
|
await packer.update(seed)
|
|
1920
1953
|
}
|
|
1921
1954
|
}
|
|
@@ -2091,7 +2124,7 @@ export class PrivateMessenger {
|
|
|
2091
2124
|
this.removeChannelState(pubkey)
|
|
2092
2125
|
await this.flushStateWrites()
|
|
2093
2126
|
await this.queue.removeBy('byChannel', pubkey)
|
|
2094
|
-
await this.seedQueue.removeBy('byChannel', pubkey)
|
|
2127
|
+
await (this.seedStorage ? this.seedStorage.removeLocal({ channelPubkey: pubkey }) : this.seedQueue.removeBy('byChannel', pubkey))
|
|
2095
2128
|
await this.touchStorageActivity({ force: true })
|
|
2096
2129
|
this.ensureRelayListWatcher()
|
|
2097
2130
|
})
|
|
@@ -2117,7 +2150,7 @@ export class PrivateMessenger {
|
|
|
2117
2150
|
delete state.channels[pubkey]
|
|
2118
2151
|
stalePubkeys.push(pubkey)
|
|
2119
2152
|
await this.queue?.removeBy('byChannel', pubkey)
|
|
2120
|
-
await this.seedQueue?.removeBy('byChannel', pubkey)
|
|
2153
|
+
await (this.seedStorage ? this.seedStorage.removeLocal({ channelPubkey: pubkey }) : this.seedQueue?.removeBy('byChannel', pubkey))
|
|
2121
2154
|
}
|
|
2122
2155
|
this.state = state
|
|
2123
2156
|
if (stalePubkeys.length) await this.removeChannelStates(stalePubkeys)
|
|
@@ -2125,6 +2158,7 @@ export class PrivateMessenger {
|
|
|
2125
2158
|
}
|
|
2126
2159
|
|
|
2127
2160
|
async pruneStoredSeeds (channelPubkey) {
|
|
2161
|
+
if (this.seedStorage) { await this.seedStorage.prune({ now: nowSeconds() }); return }
|
|
2128
2162
|
if (!this.seedQueue) return
|
|
2129
2163
|
const keyRange = globalThis.IDBKeyRange
|
|
2130
2164
|
if (!channelPubkey) {
|
|
@@ -2177,6 +2211,7 @@ export class PrivateMessenger {
|
|
|
2177
2211
|
let unwatchError
|
|
2178
2212
|
try { await unwatchPromise } catch (err) { unwatchError = err }
|
|
2179
2213
|
await initSettledPromise
|
|
2214
|
+
await Promise.all([...this.extensions].map(extension => extension.close?.()))
|
|
2180
2215
|
await this.stampActiveChannelActivity()
|
|
2181
2216
|
await this.queueOperationTail
|
|
2182
2217
|
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
|
}
|
|
@@ -186,6 +186,9 @@ function storageDatabaseNames (userPubkey) {
|
|
|
186
186
|
return [
|
|
187
187
|
`${prefix}:idb-queue`,
|
|
188
188
|
`${prefix}:seeds:idb-queue`,
|
|
189
|
+
`${prefix}:file-seeds:idb-queue`,
|
|
190
|
+
`${prefix}:file-authorizations:idb-queue`,
|
|
191
|
+
`${prefix}:file-chunks:idb`,
|
|
189
192
|
`${prefix}:state:idb`
|
|
190
193
|
]
|
|
191
194
|
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { createEventStoreChunkStorage, createEventStoreMessageStorage } from '../event-store/index.js'
|
|
2
|
+
import { createPrivateFileTransfer } from '../file/index.js'
|
|
3
|
+
import { createPrivateMessenger } from '../index.js'
|
|
4
|
+
import { getEventHash, isValidEvent, isSerializableEvent } from '../../event/index.js'
|
|
5
|
+
import { decodeIrfsChunk, IRFS_CHUNK_BYTES } from '../../irfs/index.js'
|
|
6
|
+
import { decodeFileMetadata } from '../../nip94/index.js'
|
|
7
|
+
import { createChatOutbox } from './helpers/work-storage.js'
|
|
8
|
+
import { messengerSigner } from './helpers/signer.js'
|
|
9
|
+
import { assertMessagePublished } from './helpers/publication.js'
|
|
10
|
+
|
|
11
|
+
export function wireEvent (value, owner) {
|
|
12
|
+
const event = { kind: value.kind, created_at: value.created_at, tags: structuredClone(value.tags), content: value.content, pubkey: value.pubkey || owner }
|
|
13
|
+
if (value.sig) return { ...event, id: value.id, sig: value.sig }
|
|
14
|
+
return event
|
|
15
|
+
}
|
|
16
|
+
// Relay rejection text must not be interpreted as a local signer denial.
|
|
17
|
+
const retryable = error => error?.code === 'MESSAGE_NOT_PUBLISHED' || !/DENIED|PERMISSION|REVOKED|READ_ONLY|INVALID|BLOCKED|EXPIRED|NOT_IN_PERSONA/i.test(`${error?.code || ''} ${error?.message || ''}`)
|
|
18
|
+
|
|
19
|
+
export function createPrivateMessageSession ({ owner, signer, eventStore, messageStorage, chunkStorage, recoveryStorage, mode = 'seeder', seedersForPeer = peer => [peer], allowedKinds = [5, 9, 1063, 34601], onMedia = () => {}, onOutbox = () => {}, onError = () => {}, Messenger = createPrivateMessenger, openOutbox = createChatOutbox, FileTransfer = createPrivateFileTransfer, openDownloads = options => createChatOutbox({ ...options, namespace: 'downloads' }) }) {
|
|
20
|
+
const userSigner = messengerSigner(signer)
|
|
21
|
+
messageStorage ||= createEventStoreMessageStorage({ eventStore })
|
|
22
|
+
chunkStorage ||= eventStore ? createEventStoreChunkStorage({ eventStore }) : undefined
|
|
23
|
+
const peers = new Set()
|
|
24
|
+
const channels = new Map()
|
|
25
|
+
const deniedPeers = new Set()
|
|
26
|
+
const entries = new Map()
|
|
27
|
+
const cancelled = new Set()
|
|
28
|
+
let messenger
|
|
29
|
+
let fileTransfers
|
|
30
|
+
let downloads
|
|
31
|
+
const downloadIntents = new Map()
|
|
32
|
+
const downloadEpochs = new Map()
|
|
33
|
+
let downloadWriteTail = Promise.resolve()
|
|
34
|
+
const writeDownloadIntent = operation => {
|
|
35
|
+
const work = downloadWriteTail.catch(() => {}).then(operation)
|
|
36
|
+
downloadWriteTail = work
|
|
37
|
+
return work
|
|
38
|
+
}
|
|
39
|
+
let storage
|
|
40
|
+
let initialized
|
|
41
|
+
let closed = false
|
|
42
|
+
let available = false
|
|
43
|
+
let lifecycle = 0
|
|
44
|
+
let configuring = Promise.resolve()
|
|
45
|
+
let sending
|
|
46
|
+
let draining
|
|
47
|
+
const idleWaiters = new Set()
|
|
48
|
+
const settled = () => { if (!sending && !draining) { for (const resolve of idleWaiters) resolve(); idleWaiters.clear() } }
|
|
49
|
+
const emit = () => onOutbox([...entries.values()].filter(entry => !entry.deletion))
|
|
50
|
+
const ready = () => (initialized ??= (async () => {
|
|
51
|
+
storage = await openOutbox({ owner, signer })
|
|
52
|
+
for (const entry of await storage.list()) entries.set(entry.id, entry)
|
|
53
|
+
emit()
|
|
54
|
+
})().catch(async error => { await storage?.close(); storage = null; initialized = null; throw error }))
|
|
55
|
+
async function configure () {
|
|
56
|
+
if (closed || !available) return
|
|
57
|
+
await ready()
|
|
58
|
+
const version = lifecycle
|
|
59
|
+
const values = []
|
|
60
|
+
for (const peer of peers) {
|
|
61
|
+
if (deniedPeers.has(peer)) continue
|
|
62
|
+
let channel = channels.get(peer)
|
|
63
|
+
if (!channel) {
|
|
64
|
+
try {
|
|
65
|
+
const scoped = messengerSigner(signer.withSharedKey(peer, 'dm'))
|
|
66
|
+
channel = { signer: scoped, pubkey: await scoped.getPublicKey(), mode, seeders: seedersForPeer(peer) }
|
|
67
|
+
channels.set(peer, channel)
|
|
68
|
+
} catch (error) { if (!retryable(error)) deniedPeers.add(peer); onError(error); continue }
|
|
69
|
+
}
|
|
70
|
+
if (closed || !available || version !== lifecycle) return
|
|
71
|
+
values.push(channel)
|
|
72
|
+
}
|
|
73
|
+
if (!messenger) messenger = await Messenger({ seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys: false, onMessageQueued: () => drain(), onError })
|
|
74
|
+
if (closed || !available || version !== lifecycle) { await messenger.pause('signer'); return }
|
|
75
|
+
await messenger.update({ channels: values })
|
|
76
|
+
await messenger.resume('signer')
|
|
77
|
+
if (!fileTransfers) {
|
|
78
|
+
fileTransfers = FileTransfer({
|
|
79
|
+
messenger, onError,
|
|
80
|
+
resolveChannel: ({ peerPubkey, info }) => messengerSigner(signer.withSharedKey(peerPubkey, info)),
|
|
81
|
+
storage: chunkStorage,
|
|
82
|
+
seedStorage: recoveryStorage?.seeds,
|
|
83
|
+
authorizationStorage: recoveryStorage?.authorizations
|
|
84
|
+
})
|
|
85
|
+
fileTransfers.observe(state => {
|
|
86
|
+
const peer = [...channels].find(([, value]) => value.pubkey === state.controlChannelPubkey)?.[0]
|
|
87
|
+
if (peer) {
|
|
88
|
+
onMedia({ ...state, peer })
|
|
89
|
+
if (state.status === 'complete') {
|
|
90
|
+
const id = intentId({ peer, root: state.root })
|
|
91
|
+
if (downloadIntents.delete(id)) writeDownloadIntent(() => downloads?.remove(id)).catch(onError)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
downloads = await openDownloads({ owner, signer })
|
|
96
|
+
for (const entry of await downloads.list()) downloadIntents.set(entry.id, entry)
|
|
97
|
+
}
|
|
98
|
+
for (const entry of downloadIntents.values()) if (peers.has(entry.file.peer)) download(entry.file, { manual: true }).catch(onError)
|
|
99
|
+
drain(); pump()
|
|
100
|
+
}
|
|
101
|
+
function schedule () {
|
|
102
|
+
const work = configuring.catch(() => {}).then(configure)
|
|
103
|
+
configuring = work
|
|
104
|
+
work.catch(onError)
|
|
105
|
+
return work
|
|
106
|
+
}
|
|
107
|
+
async function drain () {
|
|
108
|
+
if (draining || closed || !available || !messenger) return
|
|
109
|
+
draining = true
|
|
110
|
+
const deferred = []
|
|
111
|
+
try {
|
|
112
|
+
while (true) {
|
|
113
|
+
if (closed || !available) break
|
|
114
|
+
const delivery = await messenger.nextMessage()
|
|
115
|
+
if (!delivery) break
|
|
116
|
+
const { message, ack, nack } = delivery
|
|
117
|
+
try {
|
|
118
|
+
const peer = [...channels].find(([, value]) => value.pubkey === message.channelPubkey)?.[0]
|
|
119
|
+
if (!peer) { deferred.push(nack); continue }
|
|
120
|
+
if (message.senderPubkey !== peer) { await ack(); continue }
|
|
121
|
+
if (!peers.has(peer)) { deferred.push(nack); continue }
|
|
122
|
+
const event = wireEvent(message.event, peer)
|
|
123
|
+
const valid = event.pubkey === peer || message.provenance === 'hearsay' || message.provenance === 'signed'
|
|
124
|
+
if (!valid || !isSerializableEvent(event) || !allowedKinds.includes(event.kind) || (event.sig && !isValidEvent(event)) || getEventHash(event) !== message.event.id) { await ack(); continue }
|
|
125
|
+
if (event.kind === 34601) {
|
|
126
|
+
try { decodeIrfsChunk(event) } catch (error) { onError(error); await ack(); continue }
|
|
127
|
+
}
|
|
128
|
+
if (event.kind === 5 && (message.provenance === 'hearsay' || event.pubkey !== peer)) { await ack(); continue }
|
|
129
|
+
if (!available || closed) { await nack(); break }
|
|
130
|
+
const saved = await messageStorage.save(event, { peerPubkey: peer, hearsay: message.provenance === 'hearsay' })
|
|
131
|
+
if (!saved?.result?.ok && !['blocked', 'expired'].includes(saved?.result?.code)) throw Object.assign(new Error('INBOX_STORAGE_FAILED'), { code: saved?.result?.code?.toUpperCase() })
|
|
132
|
+
await ack()
|
|
133
|
+
} catch (error) {
|
|
134
|
+
await nack()
|
|
135
|
+
onError(error)
|
|
136
|
+
// Retain the failed record and suspend network ingestion until recovery.
|
|
137
|
+
await messenger.pause('storage')
|
|
138
|
+
break
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
} finally { await Promise.all(deferred.map(nack => nack())); draining = false; settled() }
|
|
142
|
+
}
|
|
143
|
+
async function publish (peer, event) {
|
|
144
|
+
if (deniedPeers.has(peer)) throw new Error('PERMISSION_DENIED')
|
|
145
|
+
if (!available || !peers.has(peer) || !channels.has(peer)) throw new Error('CHAT_UNAVAILABLE')
|
|
146
|
+
const options = { channelPubkey: channels.get(peer)?.pubkey, receiverPubkeys: [peer] }
|
|
147
|
+
const report = event.sig ? await messenger.broadcastEvent({ ...options, event }) : await messenger.broadcastRumor({ ...options, rumor: event })
|
|
148
|
+
// Published is relay acceptance, never a peer receipt.
|
|
149
|
+
await assertMessagePublished(report, event)
|
|
150
|
+
}
|
|
151
|
+
async function sendEntry (entry) {
|
|
152
|
+
entry.status = 'pending'; emit()
|
|
153
|
+
try {
|
|
154
|
+
const active = async () => {
|
|
155
|
+
if (storage.has && !await storage.has(entry.id)) cancelled.add(entry.id)
|
|
156
|
+
if (closed || !available || cancelled.has(entry.id) || (entry.peer !== owner && !peers.has(entry.peer))) throw new Error('CHAT_UNAVAILABLE')
|
|
157
|
+
}
|
|
158
|
+
// Personal copies must commit even if publishing attachment bytes
|
|
159
|
+
// fails offline. Remote progress remains independently retryable.
|
|
160
|
+
for (let index = 0; index < entry.events.length; index++) {
|
|
161
|
+
await active()
|
|
162
|
+
const event = entry.events[index]
|
|
163
|
+
if (!entry.localSaved[index]) {
|
|
164
|
+
const hearsay = !event.sig && event.pubkey !== owner
|
|
165
|
+
const result = await messageStorage.save(event, { peerPubkey: entry.peer, hearsay })
|
|
166
|
+
if (result?.result?.code === 'blocked') { cancelled.add(entry.id); await active() }
|
|
167
|
+
if (!result?.result?.ok) throw Object.assign(new Error('MESSAGE_STORAGE_FAILED'), { code: result?.result?.code?.toUpperCase() })
|
|
168
|
+
entry.localSaved[index] = true
|
|
169
|
+
await storage.put(entry, { existing: true })
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// Read/publish one chunk at a time. Progress is durable after each
|
|
173
|
+
// accepted chunk; replay after interruption keeps its original identity.
|
|
174
|
+
for (; entry.peer !== owner && entry.fileIndex < entry.files.length; entry.fileIndex++, entry.chunkIndex = 0) {
|
|
175
|
+
const file = entry.files[entry.fileIndex]
|
|
176
|
+
await active()
|
|
177
|
+
await fileTransfers.authorizeSeeding({ controlChannelPubkey: channels.get(entry.peer).pubkey, peerPubkey: entry.peer, root: file.root, size: file.size }, { receiverPubkeys: [entry.peer], sharedAt: entry.event.created_at })
|
|
178
|
+
for (; entry.chunkIndex < Math.ceil(file.size / IRFS_CHUNK_BYTES); entry.chunkIndex++) {
|
|
179
|
+
await active()
|
|
180
|
+
const chunk = chunkStorage ? await chunkStorage.read(file.root, entry.chunkIndex, file) : await fileTransfers.readChunk(file.root, entry.chunkIndex)
|
|
181
|
+
if (!chunk || decodeIrfsChunk(chunk).root !== file.root) { if (file.optional) break; throw new Error('FILE_UNAVAILABLE') }
|
|
182
|
+
if (!fileTransfers) throw new Error('FILE_TRANSFER_UNAVAILABLE')
|
|
183
|
+
await fileTransfers.publishChunk({ controlChannelPubkey: channels.get(entry.peer).pubkey, peerPubkey: entry.peer, root: file.root, size: file.size }, wireEvent(chunk, owner))
|
|
184
|
+
const completed = entry.files.slice(0, entry.fileIndex).reduce((sum, value) => sum + value.size, 0) + Math.min(file.size, (entry.chunkIndex + 1) * IRFS_CHUNK_BYTES)
|
|
185
|
+
entry.uploadProgress = { completed, total: entry.files.reduce((sum, value) => sum + value.size, 0) }
|
|
186
|
+
emit()
|
|
187
|
+
await active()
|
|
188
|
+
await storage.put({ ...entry, chunkIndex: entry.chunkIndex + 1 }, { existing: true })
|
|
189
|
+
// Newly queued text and deletion controls can pass an in-flight file.
|
|
190
|
+
let admitted = 0
|
|
191
|
+
for (const pending of entries.values()) {
|
|
192
|
+
if (closed || !available || admitted >= 8) break
|
|
193
|
+
if (pending.files.length || pending.failed || cancelled.has(pending.id) || (pending.peer !== owner && !peers.has(pending.peer))) continue
|
|
194
|
+
admitted++
|
|
195
|
+
await sendEntry(pending)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
for (; entry.index < entry.events.length; entry.index++) {
|
|
200
|
+
await active()
|
|
201
|
+
const event = entry.events[entry.index]
|
|
202
|
+
if (entry.peer !== owner) await publish(entry.peer, event)
|
|
203
|
+
await active()
|
|
204
|
+
await storage.put({ ...entry, index: entry.index + 1 }, { existing: true })
|
|
205
|
+
}
|
|
206
|
+
await storage.remove(entry.id)
|
|
207
|
+
entries.delete(entry.id)
|
|
208
|
+
emit()
|
|
209
|
+
} catch (error) {
|
|
210
|
+
if (cancelled.has(entry.id)) { await storage.remove(entry.id); entries.delete(entry.id) } else { entry.status = 'error'; entry.failed = true; entry.retryable = retryable(error); await storage.put(entry, { existing: true }).catch(onError); onError(error) }
|
|
211
|
+
emit()
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
async function pump () {
|
|
215
|
+
if (sending || !available || closed || !messenger || !storage) return
|
|
216
|
+
sending = true
|
|
217
|
+
try {
|
|
218
|
+
for (const entry of entries.values()) {
|
|
219
|
+
if (closed || !available) break
|
|
220
|
+
if ((entry.peer !== owner && !peers.has(entry.peer)) || entry.failed || cancelled.has(entry.id)) continue
|
|
221
|
+
await sendEntry(entry)
|
|
222
|
+
}
|
|
223
|
+
} finally { sending = false; settled() }
|
|
224
|
+
}
|
|
225
|
+
const intentId = file => getEventHash({ kind: 0, pubkey: owner, created_at: 0, tags: [], content: `${file.peer}:${file.root}` })
|
|
226
|
+
async function download (file, options = {}) {
|
|
227
|
+
if (file.peer === owner) return file
|
|
228
|
+
if (!available || closed || !peers.has(file.peer) || !fileTransfers) throw new Error('CHAT_UNAVAILABLE')
|
|
229
|
+
const id = intentId(file)
|
|
230
|
+
if (options.manual) {
|
|
231
|
+
const epoch = downloadEpochs.get(id) || 0
|
|
232
|
+
const cancelled = () => { if ((downloadEpochs.get(id) || 0) !== epoch) throw new DOMException('Download cancelled', 'AbortError') }
|
|
233
|
+
await writeDownloadIntent(async () => {
|
|
234
|
+
cancelled()
|
|
235
|
+
await downloads.put({ id, file })
|
|
236
|
+
cancelled()
|
|
237
|
+
downloadIntents.set(id, { id, file })
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
if (!available || closed || !peers.has(file.peer)) throw new Error('CHAT_UNAVAILABLE')
|
|
241
|
+
const result = await fileTransfers.download({ controlChannelPubkey: channels.get(file.peer).pubkey, peerPubkey: file.peer, root: file.root, size: file.size, sharedAt: file.sharedAt }, options)
|
|
242
|
+
if (downloadIntents.delete(id)) await writeDownloadIntent(() => downloads.remove(id))
|
|
243
|
+
return result
|
|
244
|
+
}
|
|
245
|
+
async function cancelDownload (file) {
|
|
246
|
+
fileTransfers?.cancel(channels.get(file.peer)?.pubkey, file.root)
|
|
247
|
+
const id = intentId(file)
|
|
248
|
+
downloadEpochs.set(id, (downloadEpochs.get(id) || 0) + 1)
|
|
249
|
+
downloadIntents.delete(id)
|
|
250
|
+
await writeDownloadIntent(() => downloads?.remove(id))
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
download, cancelDownload,
|
|
254
|
+
async setPeers (values) {
|
|
255
|
+
peers.clear(); for (const peer of values) if (peer !== owner) peers.add(peer)
|
|
256
|
+
for (const peer of deniedPeers) if (!peers.has(peer)) deniedPeers.delete(peer)
|
|
257
|
+
lifecycle++
|
|
258
|
+
return schedule()
|
|
259
|
+
},
|
|
260
|
+
async setAvailable (value) {
|
|
261
|
+
available = value === true
|
|
262
|
+
lifecycle++
|
|
263
|
+
if (!available) { await messenger?.pause('signer'); return }
|
|
264
|
+
await ready()
|
|
265
|
+
for (const entry of entries.values()) if (entry.retryable) entry.failed = false
|
|
266
|
+
await messenger?.resume('storage')
|
|
267
|
+
return schedule()
|
|
268
|
+
},
|
|
269
|
+
async enqueue ({ peer, event, context = [], requiredFiles = [], deletion = false }) {
|
|
270
|
+
if (closed || !available || (peer !== owner && !peers.has(peer))) throw new Error('CHAT_UNAVAILABLE')
|
|
271
|
+
await ready()
|
|
272
|
+
const main = wireEvent(event, owner)
|
|
273
|
+
const id = getEventHash(main)
|
|
274
|
+
const events = [...context.map(value => wireEvent(value, owner)), main]
|
|
275
|
+
const files = events.filter(value => value.kind === 1063).map(value => ({ ...decodeFileMetadata(value), optional: !requiredFiles.includes(getEventHash(value)) })).filter(file => file.service === 'irfs').flatMap(file => [file, ...(file.thumbnail?.root ? [{ ...file.thumbnail, optional: file.optional }] : [])])
|
|
276
|
+
const entry = { id, peer, event: { ...main, id }, events, files, index: 0, fileIndex: 0, chunkIndex: 0, localSaved: context.map(() => true).concat(false), status: 'pending', deletion }
|
|
277
|
+
await storage.put(entry)
|
|
278
|
+
entries.set(id, entry); emit(); pump()
|
|
279
|
+
return id
|
|
280
|
+
},
|
|
281
|
+
async retry (id) {
|
|
282
|
+
await ready()
|
|
283
|
+
const entry = entries.get(id)
|
|
284
|
+
if (entry) { entry.failed = false; if (deniedPeers.delete(entry.peer)) await schedule(); return pump() }
|
|
285
|
+
},
|
|
286
|
+
async cancel (id) {
|
|
287
|
+
cancelled.add(id)
|
|
288
|
+
await ready()
|
|
289
|
+
entries.delete(id)
|
|
290
|
+
await storage.remove(id)
|
|
291
|
+
emit()
|
|
292
|
+
},
|
|
293
|
+
async close () {
|
|
294
|
+
closed = true; available = false; lifecycle++
|
|
295
|
+
await Promise.allSettled([configuring, initialized])
|
|
296
|
+
await messenger?.pause('closed')
|
|
297
|
+
if (sending || draining) await new Promise(resolve => idleWaiters.add(resolve))
|
|
298
|
+
await messenger?.close()
|
|
299
|
+
await storage?.close()
|
|
300
|
+
await downloadWriteTail.catch(onError)
|
|
301
|
+
await downloads?.close()
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export { createChatOutbox as createPrivateSessionStorage } from './helpers/work-storage.js'
|
|
307
|
+
export { messengerSigner as createMessengerSigner } from './helpers/signer.js'
|