libp2r2p 0.11.24 → 0.11.26
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 +13 -4
- package/network/README.md +31 -0
- package/network/index.js +55 -13
- package/package.json +1 -1
- package/private-channel/index.js +75 -67
- package/private-message/index.js +26 -7
- package/private-messenger/file/index.js +5 -5
- package/private-messenger/helpers/fallback-race.js +3 -2
- package/private-messenger/helpers/pause-recovery.js +4 -1
- package/private-messenger/helpers/send-routing.js +3 -2
- package/private-messenger/index.js +29 -10
- package/private-messenger/session/README.md +36 -2
- package/private-messenger/session/helpers/publication-journal.js +137 -0
- package/private-messenger/session/index.js +154 -48
package/README.md
CHANGED
|
@@ -10,7 +10,8 @@ power-ups used by that messenger.
|
|
|
10
10
|
|
|
11
11
|
For remote-work scheduling, see [`libp2r2p/network`](network/README.md):
|
|
12
12
|
`isOnline` probes connectivity and `onOnline` shares recovery monitoring,
|
|
13
|
-
including retries when the browser omits its native `online` event
|
|
13
|
+
including retries when the browser omits its native `online` event and optional
|
|
14
|
+
30-second expedited windows for foreground remote sends.
|
|
14
15
|
|
|
15
16
|
## Relay reads and lifecycle
|
|
16
17
|
|
|
@@ -633,9 +634,13 @@ tasks cannot deliver into a new channel generation. Mandatory persistence still
|
|
|
633
634
|
owns its storage pause; incomplete history remains a pending recovery interval.
|
|
634
635
|
Subscription setup is bounded separately from presence, so a slow announcement
|
|
635
636
|
does not hold the other channels' readers. Sessions prioritize user persistence
|
|
636
|
-
and publication before the next background presence signer call.
|
|
637
|
-
|
|
638
|
-
|
|
637
|
+
and publication before the next background presence signer call. `onPreparedSeed`
|
|
638
|
+
receives its existing router/jsonl fields plus `publication`, containing all
|
|
639
|
+
signed fragments, recipient/channel identities and recovery rows for that logical
|
|
640
|
+
event. The callback commits durable recovery before publication: a direct
|
|
641
|
+
messenger uses its seed store; the session uses its encrypted outbox journal and
|
|
642
|
+
archives indexed personal copies independently. No alternative legacy publisher
|
|
643
|
+
is retained. Only one logical event/chunk is prepared per session entry at a time.
|
|
639
644
|
|
|
640
645
|
`private-channel.fetch` reports incomplete reads as an `AggregateError` with
|
|
641
646
|
stable `code: 'PRIVATE_CHANNEL_FETCH_INCOMPLETE'`. Its message names relays,
|
|
@@ -1241,5 +1246,9 @@ Private messenger/session constructors accept optional `fallbackDelayMs` (`null`
|
|
|
1241
1246
|
by default). Automatic NIP-65 sends can start configured fallback relays after
|
|
1242
1247
|
the chosen delay while primary publications retain their deadlines. Explicit
|
|
1243
1248
|
routes keep sequential fallback; every encrypted recipient must be covered.
|
|
1249
|
+
Session `enqueue` accepts optional monotonic `attemptStartedAt`; supplied values
|
|
1250
|
+
make preparation consume that delay. Retries/reloads start a fresh attempt and
|
|
1251
|
+
uploads budget each chunk independently. Monotonic clocks never enter storage or
|
|
1252
|
+
Nostr events. Omitted timestamps keep the publication-local delay.
|
|
1244
1253
|
`RelayPool.sendEvent` accepts a consumer `signal` without closing shared sockets.
|
|
1245
1254
|
See [fallback and cancellation](private-messenger/session/README.md#relay-fallback).
|
package/network/README.md
CHANGED
|
@@ -30,3 +30,34 @@ Successful probes do not guarantee a particular relay or HTTP server is reachabl
|
|
|
30
30
|
Handle request failures, cancellations, and timeouts separately. Browsers can
|
|
31
31
|
suspend background tabs, so recovery notification has no wall-clock guarantee.
|
|
32
32
|
Consumers should keep their own work queues but should not duplicate probe loops.
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
## Expedited connectivity detection
|
|
36
|
+
|
|
37
|
+
`onOnline(handler, { strict = false, expediteForMs = 0, onDebug })` can request
|
|
38
|
+
an optional urgent window (clamped to 30,000 ms; finite, nonnegative durations).
|
|
39
|
+
The same realm/mode monitor coalesces subscriptions: urgent requests can advance
|
|
40
|
+
the next check, at least two seconds after the previous start. Failed checks
|
|
41
|
+
repeat at five seconds between starts, without overlapping slow checks. Native
|
|
42
|
+
online/focus/visibility wakeups remain available. Removing a subscription removes
|
|
43
|
+
its demand without stopping other listeners.
|
|
44
|
+
|
|
45
|
+
The urgent loop does not spend/reset the ordinary jittered backoff. At expiry it
|
|
46
|
+
returns to the ordinary deadline, or a fresh interval of the preserved retry step
|
|
47
|
+
if that deadline passed; it never catches up missed checks. For example, a normal
|
|
48
|
+
60-second wait remains a 60-second step after an unsuccessful urgent window.
|
|
49
|
+
Connectivity returning after the window must await normal recovery or a new user
|
|
50
|
+
send/retry; automatic retries never extend urgency. `expediteForMs` is scheduling
|
|
51
|
+
advice, not evidence of Internet connectivity or a relay/ACK delivery deadline.
|
|
52
|
+
|
|
53
|
+
Private-message sessions remember a runtime-only 30-second deadline for new
|
|
54
|
+
remote intents and manual retry. Only work actually waiting for network subscribes
|
|
55
|
+
with the remaining time, sharing one subscription per session. Self-chat, restored
|
|
56
|
+
outbox entries, chunks and automatic retries never renew that deadline. Lock and
|
|
57
|
+
close discard urgency. A confirmed monitor result resumes the existing automatic
|
|
58
|
+
pause owner without another immediate probe; explicit pauses remain caller-owned.
|
|
59
|
+
|
|
60
|
+
Optional `onDebug` reports only probe reason, state, remaining window and duration.
|
|
61
|
+
Consumers wire it only in development; it is disabled by default. The monitor
|
|
62
|
+
retains no diagnostic history. Session diagnostics additionally correlate the
|
|
63
|
+
network wait and pause-release duration with the event ID.
|
package/network/index.js
CHANGED
|
@@ -133,6 +133,8 @@ export function createConnectivityMonitor ({
|
|
|
133
133
|
_setTimeout = globalThis.setTimeout,
|
|
134
134
|
_clearTimeout = globalThis.clearTimeout,
|
|
135
135
|
_random = Math.random,
|
|
136
|
+
_now = () => performance.now(),
|
|
137
|
+
onDebug,
|
|
136
138
|
reportError = error => console.error('Online listener failed', error)
|
|
137
139
|
} = {}) {
|
|
138
140
|
if (check !== undefined && typeof check !== 'function') throw new ValidationError('INVALID_CONNECTIVITY_CHECK')
|
|
@@ -148,21 +150,49 @@ export function createConnectivityMonitor ({
|
|
|
148
150
|
try { Promise.resolve(entry.handler()).catch(reportError) } catch (error) { reportError(error) }
|
|
149
151
|
}
|
|
150
152
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
153
|
+
const urgencyEnd = () => Math.max(0, ...[...listeners].map(entry => entry.expediteUntil))
|
|
154
|
+
function arm (current) {
|
|
155
|
+
if (session !== current || current.pending || !listeners.size) return
|
|
156
|
+
if (current.timer != null) clearTimer(current.timer)
|
|
157
|
+
const now = _now()
|
|
158
|
+
const until = urgencyEnd()
|
|
159
|
+
const urgent = until > now && (!current.online || current.urgentDue != null)
|
|
160
|
+
// Urgent checks do not consume or reset the ordinary retry sequence.
|
|
161
|
+
if (!urgent && current.normalDue <= now) current.normalDue = now + current.normalDelay
|
|
162
|
+
let due = current.normalDue
|
|
163
|
+
if (urgent) due = Math.min(until, current.urgentDue ?? current.lastStartedAt + 5000)
|
|
154
164
|
current.timer = setTimer(() => {
|
|
155
165
|
current.timer = null
|
|
156
|
-
|
|
157
|
-
|
|
166
|
+
if (urgent && _now() >= urgencyEnd()) { arm(current); return }
|
|
167
|
+
return probe(current, urgent ? 'expedited' : 'backoff')
|
|
168
|
+
}, Math.max(0, due - now))
|
|
158
169
|
current.timer?.unref?.()
|
|
159
170
|
}
|
|
160
171
|
|
|
161
|
-
|
|
172
|
+
function schedule (current, expedited) {
|
|
173
|
+
if (session !== current || !listeners.size) return
|
|
174
|
+
if (!expedited || current.online) {
|
|
175
|
+
const delay = current.online ? 60000 : RETRY_DELAYS[Math.min(current.retry++, RETRY_DELAYS.length - 1)]
|
|
176
|
+
current.normalDelay = Math.round(delay * (0.8 + _random() * 0.4))
|
|
177
|
+
current.normalDue = _now() + current.normalDelay
|
|
178
|
+
}
|
|
179
|
+
current.urgentDue = null
|
|
180
|
+
arm(current)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function probe (current, reason = 'wake') {
|
|
162
184
|
if (session !== current || current.pending) return
|
|
163
185
|
if (current.timer != null) clearTimer(current.timer)
|
|
164
186
|
current.timer = null
|
|
165
187
|
current.pending = true
|
|
188
|
+
current.lastStartedAt = _now()
|
|
189
|
+
const expedited = reason === 'expedited'
|
|
190
|
+
const startedAt = _now()
|
|
191
|
+
const report = values => {
|
|
192
|
+
const entry = { stage: 'connectivity-probe', reason, expediteRemainingMs: Math.max(0, urgencyEnd() - _now()), ...values }
|
|
193
|
+
for (const callback of [onDebug, ...[...listeners].map(listener => listener.onDebug)]) { try { callback?.(entry) } catch {} }
|
|
194
|
+
}
|
|
195
|
+
report({ state: 'started' })
|
|
166
196
|
try {
|
|
167
197
|
const online = await runCheck({ signal: current.controller.signal })
|
|
168
198
|
if (session !== current) return
|
|
@@ -179,12 +209,13 @@ export function createConnectivityMonitor ({
|
|
|
179
209
|
for (const entry of listeners) entry.delivered = false
|
|
180
210
|
} finally {
|
|
181
211
|
current.pending = false
|
|
182
|
-
|
|
212
|
+
report({ state: current.online ? 'online' : 'offline', elapsedMs: _now() - startedAt })
|
|
213
|
+
schedule(current, expedited)
|
|
183
214
|
}
|
|
184
215
|
}
|
|
185
216
|
|
|
186
217
|
function start () {
|
|
187
|
-
const current = { online: false, retry: 0, timer: null, pending: false, controller: new AbortController() }
|
|
218
|
+
const current = { online: false, retry: 0, timer: null, pending: false, lastStartedAt: -Infinity, normalDue: Infinity, normalDelay: 5000, urgentDue: null, controller: new AbortController() }
|
|
188
219
|
session = current
|
|
189
220
|
current.wake = () => { probe(current) }
|
|
190
221
|
current.connectionChanged = () => {
|
|
@@ -213,18 +244,29 @@ export function createConnectivityMonitor ({
|
|
|
213
244
|
}
|
|
214
245
|
|
|
215
246
|
// Notify once on confirmed initial connectivity, then after each detected reconnection.
|
|
216
|
-
function onOnline (handler) {
|
|
247
|
+
function onOnline (handler, { expediteForMs = 0, onDebug } = {}) {
|
|
217
248
|
if (typeof handler !== 'function') throw new ValidationError('INVALID_ONLINE_HANDLER')
|
|
218
|
-
|
|
249
|
+
if (!Number.isFinite(expediteForMs) || expediteForMs < 0) throw new ValidationError('INVALID_EXPEDITE_FOR_MS')
|
|
250
|
+
if (onDebug !== undefined && typeof onDebug !== 'function') throw new ValidationError('INVALID_CONNECTIVITY_DEBUG')
|
|
251
|
+
const entry = { handler, onDebug, delivered: false, expediteUntil: _now() + Math.min(expediteForMs, 30000) }
|
|
219
252
|
listeners.add(entry)
|
|
220
253
|
if (!session) start()
|
|
221
254
|
else {
|
|
222
255
|
const current = session
|
|
223
|
-
|
|
256
|
+
if (expediteForMs > 0 || urgencyEnd() > _now()) {
|
|
257
|
+
current.urgentDue = Math.max(_now(), current.lastStartedAt + 2000)
|
|
258
|
+
arm(current)
|
|
259
|
+
} else {
|
|
260
|
+
queueMicrotask(() => {
|
|
261
|
+
if (urgencyEnd() > _now()) arm(current)
|
|
262
|
+
else probe(current)
|
|
263
|
+
})
|
|
264
|
+
}
|
|
224
265
|
}
|
|
225
266
|
return () => {
|
|
226
267
|
if (!listeners.delete(entry)) return
|
|
227
268
|
if (!listeners.size) stop()
|
|
269
|
+
else arm(session)
|
|
228
270
|
}
|
|
229
271
|
}
|
|
230
272
|
|
|
@@ -234,12 +276,12 @@ export function createConnectivityMonitor ({
|
|
|
234
276
|
const defaultMonitors = new Map()
|
|
235
277
|
|
|
236
278
|
// Share probes, capped backoff and wake-up listeners per mode in this realm.
|
|
237
|
-
export function onOnline (handler, { strict = false } = {}) {
|
|
279
|
+
export function onOnline (handler, { strict = false, expediteForMs = 0, onDebug } = {}) {
|
|
238
280
|
const key = strict ? 'strict' : 'lenient'
|
|
239
281
|
let monitor = defaultMonitors.get(key)
|
|
240
282
|
if (!monitor) {
|
|
241
283
|
monitor = createConnectivityMonitor({ strict })
|
|
242
284
|
defaultMonitors.set(key, monitor)
|
|
243
285
|
}
|
|
244
|
-
return monitor.onOnline(handler)
|
|
286
|
+
return monitor.onOnline(handler, { expediteForMs, onDebug })
|
|
245
287
|
}
|
package/package.json
CHANGED
package/private-channel/index.js
CHANGED
|
@@ -143,6 +143,7 @@ async function prepareRoutedMessage ({ senderSigner, imkcSigner, privateChannelS
|
|
|
143
143
|
channelPubkey,
|
|
144
144
|
channelReaderPubkey,
|
|
145
145
|
preparedRows,
|
|
146
|
+
innerEventId: getEventHash({ ...event, pubkey: event.pubkey || senderPubkey }),
|
|
146
147
|
imkcPubkey,
|
|
147
148
|
imkcProof
|
|
148
149
|
}
|
|
@@ -152,69 +153,74 @@ async function prepareRoutedMessage ({ senderSigner, imkcSigner, privateChannelS
|
|
|
152
153
|
}
|
|
153
154
|
}
|
|
154
155
|
|
|
155
|
-
async function * wrapPreparedEvents ({ privateChannelSigner, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey, expirationSeconds = EXPIRATION_SECONDS, context }) {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
156
|
+
async function * wrapPreparedEvents ({ privateChannelSigner, senderSigner, event, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey, expirationSeconds = EXPIRATION_SECONDS, context, preparedPublication }) {
|
|
157
|
+
let publication = preparedPublication
|
|
158
|
+
if (publication) {
|
|
159
|
+
const channelPubkey = await privateChannelSigner.getPublicKey()
|
|
160
|
+
const expected = receiverPubkeys(receivers).sort().join(',')
|
|
161
|
+
const innerEventId = getEventHash({ ...event, pubkey: event.pubkey || await senderSigner.getPublicKey() })
|
|
162
|
+
if (publication.innerEventId !== innerEventId || publication.channelPubkey !== channelPubkey || !Array.isArray(publication.receiverPubkeys) || publication.receiverPubkeys.slice().sort().join(',') !== expected ||
|
|
163
|
+
!Array.isArray(publication.events) || !publication.events.length || !Array.isArray(publication.seeds) ||
|
|
164
|
+
publication.events.some(event => !isValidEvent(event) || event.pubkey !== channelPubkey || event.kind !== PRIVATE_BROADCAST_KIND)) {
|
|
165
|
+
throw new ValidationError('INVALID_PREPARED_PUBLICATION')
|
|
166
|
+
}
|
|
167
|
+
if (publication.events.some(event => Number(event.tags.find(tag => tag[0] === 'expiration')?.[1]) <= nowSeconds())) throw new ValidationError('PREPARED_PUBLICATION_EXPIRED')
|
|
168
|
+
} else {
|
|
169
|
+
const routerSeckey = generateSecretKey()
|
|
170
|
+
const routerPubkey = getPublicKey(routerSeckey)
|
|
171
|
+
const receiverPubkeyList = receiverPubkeys(receivers)
|
|
172
|
+
const routerReceiverTag = receiverTag ?? (receiverPubkeyList.length === 1 ? receiverPubkeyList[0] : '')
|
|
173
|
+
const rowIndexes = preparedRowIndexesForReceivers(context.preparedRows, receivers)
|
|
174
|
+
const total = preparedChunkCount(context.preparedRows, rowIndexes)
|
|
175
|
+
const seedRouter = makeRouterEvent({ pubkey: routerPubkey, senderPubkey: context.senderPubkey, imkcPubkey: context.imkcPubkey, imkcProof: context.imkcProof, receiverPubkey: routerReceiverTag, chunkIndex: 0, chunkTotal: 1, fileChunkIndex, content: '' })
|
|
165
176
|
const payloadRow = readPreparedRow(context.preparedRows, 0)
|
|
166
|
-
|
|
167
|
-
|
|
177
|
+
const seeds = rowIndexes.map(rowIndex => ({ channelPubkey: context.channelPubkey, router: seedRouter, jsonl: `${payloadRow}\n${readPreparedRow(context.preparedRows, rowIndex)}\n` }))
|
|
178
|
+
const events = []
|
|
179
|
+
let index = 0
|
|
180
|
+
for (const content of preparedChunks(context.preparedRows, rowIndexes)) {
|
|
181
|
+
const router = finalizeEvent(makeRouterEvent({
|
|
182
|
+
pubkey: routerPubkey, senderPubkey: context.senderPubkey, imkcPubkey: context.imkcPubkey,
|
|
183
|
+
imkcProof: context.imkcProof, receiverPubkey: routerReceiverTag, fileChunkIndex,
|
|
184
|
+
chunkIndex: index++, chunkTotal: total, content
|
|
185
|
+
}), routerSeckey)
|
|
186
|
+
const createdAt = nowSeconds()
|
|
187
|
+
const outer = await privateChannelSigner.signEvent({
|
|
188
|
+
kind: PRIVATE_BROADCAST_KIND, created_at: createdAt,
|
|
189
|
+
tags: privateBroadcastTags({ deletionPubkey, createdAt, expirationSeconds }),
|
|
190
|
+
content: await nip44v3EncryptText(privateChannelSigner, context.channelReaderPubkey, PRIVATE_BROADCAST_KIND, JSON.stringify(router))
|
|
191
|
+
})
|
|
192
|
+
if (eventByteLength(outer) > MAX_EVENT_BYTES) throw new ValidationError('EVENT_TOO_LARGE')
|
|
193
|
+
events.push(outer)
|
|
168
194
|
}
|
|
195
|
+
publication = { innerEventId: context.innerEventId, channelPubkey: context.channelPubkey, receiverPubkeys: receiverPubkeyList, events, seeds }
|
|
169
196
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
senderPubkey: context.senderPubkey,
|
|
175
|
-
imkcPubkey: context.imkcPubkey,
|
|
176
|
-
imkcProof: context.imkcProof,
|
|
177
|
-
receiverPubkey: routerReceiverTag,
|
|
178
|
-
fileChunkIndex,
|
|
179
|
-
chunkIndex: index++,
|
|
180
|
-
chunkTotal: total,
|
|
181
|
-
content
|
|
182
|
-
}), routerSeckey)
|
|
183
|
-
const createdAt = nowSeconds()
|
|
184
|
-
const prepareOuter = async () => privateChannelSigner.signEvent({
|
|
185
|
-
kind: PRIVATE_BROADCAST_KIND,
|
|
186
|
-
created_at: createdAt,
|
|
187
|
-
tags: privateBroadcastTags({ deletionPubkey, createdAt, expirationSeconds }),
|
|
188
|
-
content: await nip44v3EncryptText(privateChannelSigner, context.channelReaderPubkey, PRIVATE_BROADCAST_KIND, JSON.stringify(router))
|
|
189
|
-
})
|
|
190
|
-
// These independent steps may run together, but no envelope is yielded
|
|
191
|
-
// until every seed is durable. Settle both before releasing prepared rows.
|
|
192
|
-
const [persistence, prepared] = await Promise.allSettled([index === 1 ? persistSeeds() : undefined, prepareOuter()])
|
|
193
|
-
if (persistence.status === 'rejected') throw persistence.reason
|
|
194
|
-
if (prepared.status === 'rejected') throw prepared.reason
|
|
195
|
-
const outer = prepared.value
|
|
196
|
-
if (eventByteLength(outer) > MAX_EVENT_BYTES) throw new ValidationError('EVENT_TOO_LARGE')
|
|
197
|
-
yield outer
|
|
197
|
+
// The callback commits recovery data before any bytes leave this publisher.
|
|
198
|
+
// A session commits its encrypted journal; a direct messenger commits seeds.
|
|
199
|
+
if (onPreparedSeed) {
|
|
200
|
+
for (const seed of publication.seeds) await onPreparedSeed({ ...seed, publication })
|
|
198
201
|
}
|
|
202
|
+
yield * publication.events
|
|
199
203
|
}
|
|
200
204
|
|
|
201
205
|
// Streaming version of wrapEvent
|
|
202
|
-
export async function * wrapEvents ({ senderSigner, imkcSigner, privateChannelSigner = senderSigner, privateChannelReaderPubkey, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey, event, expirationSeconds = EXPIRATION_SECONDS, temporaryStorageArea, _getIykcProofs = getIykcProofs }) {
|
|
206
|
+
export async function * wrapEvents ({ senderSigner, imkcSigner, privateChannelSigner = senderSigner, privateChannelReaderPubkey, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey, event, expirationSeconds = EXPIRATION_SECONDS, temporaryStorageArea, _getIykcProofs = getIykcProofs, preparedPublication }) {
|
|
203
207
|
const normalizedDeletionPubkey = normalizeDeletionPubkey(deletionPubkey)
|
|
204
|
-
const context =
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
208
|
+
const context = preparedPublication
|
|
209
|
+
? null
|
|
210
|
+
: await prepareRoutedMessage({
|
|
211
|
+
senderSigner,
|
|
212
|
+
imkcSigner,
|
|
213
|
+
privateChannelSigner,
|
|
214
|
+
privateChannelReaderPubkey,
|
|
215
|
+
receivers,
|
|
216
|
+
event,
|
|
217
|
+
temporaryStorageArea,
|
|
218
|
+
_getIykcProofs
|
|
219
|
+
})
|
|
214
220
|
try {
|
|
215
|
-
yield * wrapPreparedEvents({ privateChannelSigner, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey: normalizedDeletionPubkey, expirationSeconds, context })
|
|
221
|
+
yield * wrapPreparedEvents({ privateChannelSigner, senderSigner, event, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey: normalizedDeletionPubkey, expirationSeconds, context, preparedPublication })
|
|
216
222
|
} finally {
|
|
217
|
-
cleanupEnvelopeRows(context.preparedRows)
|
|
223
|
+
if (context) cleanupEnvelopeRows(context.preparedRows)
|
|
218
224
|
}
|
|
219
225
|
}
|
|
220
226
|
|
|
@@ -552,35 +558,37 @@ function withRecoveryRelays (relays, recoveryRelays) {
|
|
|
552
558
|
return uniq([...(relays || []), ...(recoveryRelays || [])])
|
|
553
559
|
}
|
|
554
560
|
|
|
555
|
-
export async function publish ({ senderSigner, imkcSigner, privateChannelSigner = senderSigner, privateChannelReaderPubkey, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey, event, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, _getIykcProofs = getIykcProofs, _publish = sendToRelays }) {
|
|
561
|
+
export async function publish ({ senderSigner, imkcSigner, privateChannelSigner = senderSigner, privateChannelReaderPubkey, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey, event, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, _getIykcProofs = getIykcProofs, _publish = sendToRelays, preparedPublication }) {
|
|
556
562
|
const normalizedDeletionPubkey = normalizeDeletionPubkey(deletionPubkey)
|
|
557
563
|
const results = []
|
|
558
564
|
// Relays are grouped only when they have the exact same recipient pubkey set
|
|
559
565
|
const groups = groupedRelayReceivers({ relayToReceivers, receivers })
|
|
560
566
|
if (groups) {
|
|
561
|
-
const context =
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
567
|
+
const context = preparedPublication
|
|
568
|
+
? null
|
|
569
|
+
: await prepareRoutedMessage({
|
|
570
|
+
senderSigner,
|
|
571
|
+
imkcSigner,
|
|
572
|
+
privateChannelSigner,
|
|
573
|
+
privateChannelReaderPubkey,
|
|
574
|
+
receivers,
|
|
575
|
+
event,
|
|
576
|
+
temporaryStorageArea,
|
|
577
|
+
_getIykcProofs
|
|
578
|
+
})
|
|
571
579
|
try {
|
|
572
580
|
for (const group of groups) {
|
|
573
|
-
for await (const wrappedEvent of wrapPreparedEvents({ privateChannelSigner, receivers: group.receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey: normalizedDeletionPubkey, expirationSeconds, context })) {
|
|
581
|
+
for await (const wrappedEvent of wrapPreparedEvents({ privateChannelSigner, senderSigner, event, receivers: group.receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey: normalizedDeletionPubkey, expirationSeconds, context, preparedPublication })) {
|
|
574
582
|
results.push(await _publish(wrappedEvent, withRecoveryRelays(group.relays, recoveryRelays), { receiverPubkeys: receiverPubkeys(group.receivers), primaryRelays: group.relays }))
|
|
575
583
|
}
|
|
576
584
|
}
|
|
577
585
|
} finally {
|
|
578
|
-
cleanupEnvelopeRows(context.preparedRows)
|
|
586
|
+
if (context) cleanupEnvelopeRows(context.preparedRows)
|
|
579
587
|
}
|
|
580
588
|
return results
|
|
581
589
|
}
|
|
582
590
|
|
|
583
|
-
for await (const wrappedEvent of wrapEvents({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey: normalizedDeletionPubkey, event, expirationSeconds, temporaryStorageArea, _getIykcProofs })) {
|
|
591
|
+
for await (const wrappedEvent of wrapEvents({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey: normalizedDeletionPubkey, event, expirationSeconds, temporaryStorageArea, _getIykcProofs, preparedPublication })) {
|
|
584
592
|
results.push(await _publish(wrappedEvent, withRecoveryRelays(relays, recoveryRelays), { receiverPubkeys: receiverPubkeys(receivers), primaryRelays: relays }))
|
|
585
593
|
}
|
|
586
594
|
return results
|
package/private-message/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { normalizeRelayUrl } from '../url/index.js'
|
|
2
2
|
import { isValidEvent } from '../event/index.js'
|
|
3
3
|
import { ValidationError } from '../error/index.js'
|
|
4
|
-
import { generateKeypair } from '../key/index.js'
|
|
4
|
+
import { generateKeypair, keypairFromSeckey } from '../key/index.js'
|
|
5
5
|
import * as privateChannel from '../private-channel/index.js'
|
|
6
6
|
import { normalizeRumor, deliveryInfo } from '../private-channel/helpers/rumor.js'
|
|
7
7
|
|
|
@@ -41,11 +41,19 @@ function normalizeDeletionPubkey (deletionPubkey) {
|
|
|
41
41
|
return deletionPubkey.toLowerCase()
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
function resolveDeletionCapability ({ deletionPubkey, deletionSeckey, autoDeletionCapability = true }) {
|
|
44
|
+
function resolveDeletionCapability ({ deletionPubkey, deletionSeckey, autoDeletionCapability = true, preparedPublication }) {
|
|
45
45
|
if (deletionSeckey !== undefined) throw new ValidationError('DELETION_SECKEY_NOT_ACCEPTED')
|
|
46
46
|
if (typeof autoDeletionCapability !== 'boolean') throw new ValidationError('AUTO_DELETION_CAPABILITY_BOOLEAN_REQUIRED')
|
|
47
47
|
|
|
48
48
|
const normalizedDeletionPubkey = normalizeDeletionPubkey(deletionPubkey)
|
|
49
|
+
if (preparedPublication) {
|
|
50
|
+
const capability = preparedPublication.deletionCapability
|
|
51
|
+
if (!capability || (capability.deletionPubkey !== undefined && normalizeDeletionPubkey(capability.deletionPubkey) !== capability.deletionPubkey) ||
|
|
52
|
+
(normalizedDeletionPubkey && normalizedDeletionPubkey !== capability.deletionPubkey) ||
|
|
53
|
+
(capability.deletionSeckey !== undefined && keypairFromSeckey(capability.deletionSeckey).pubkey !== capability.deletionPubkey) ||
|
|
54
|
+
preparedPublication.events?.some(event => event.tags.find(tag => tag[0] === 's')?.[1] !== capability.deletionPubkey)) throw new ValidationError('INVALID_PREPARED_PUBLICATION')
|
|
55
|
+
return { ...capability }
|
|
56
|
+
}
|
|
49
57
|
if (normalizedDeletionPubkey) return { deletionPubkey: normalizedDeletionPubkey }
|
|
50
58
|
if (!autoDeletionCapability) return {}
|
|
51
59
|
|
|
@@ -53,6 +61,14 @@ function resolveDeletionCapability ({ deletionPubkey, deletionSeckey, autoDeleti
|
|
|
53
61
|
return { deletionPubkey: pubkey, deletionSeckey: seckey }
|
|
54
62
|
}
|
|
55
63
|
|
|
64
|
+
function preparedSeedHandler (onPreparedSeed, deletion) {
|
|
65
|
+
if (!onPreparedSeed) return undefined
|
|
66
|
+
return seed => {
|
|
67
|
+
seed.publication.deletionCapability = deletion
|
|
68
|
+
return onPreparedSeed(seed)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
56
72
|
function withDelivery (result, reports, deletionSeckey) {
|
|
57
73
|
const delivery = { reports }
|
|
58
74
|
if (deletionSeckey !== undefined) delivery.deletionSeckey = deletionSeckey
|
|
@@ -652,10 +668,11 @@ async function sendPrivateMessage ({
|
|
|
652
668
|
_getIykcProofs,
|
|
653
669
|
fileChunkIndex,
|
|
654
670
|
onPreparedSeed,
|
|
671
|
+
preparedPublication,
|
|
655
672
|
_publish = privateChannel.publish
|
|
656
673
|
}) {
|
|
657
674
|
if (!privateChannelSigner?.getPublicKey) throw new ValidationError('PRIVATE_CHANNEL_WRITER_REQUIRED')
|
|
658
|
-
return _publish({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag, deletionPubkey, event, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, fileChunkIndex, onPreparedSeed, _getIykcProofs })
|
|
675
|
+
return _publish({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag, deletionPubkey, event, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, fileChunkIndex, onPreparedSeed, preparedPublication, _getIykcProofs })
|
|
659
676
|
}
|
|
660
677
|
|
|
661
678
|
async function sendNymMessage ({
|
|
@@ -860,13 +877,14 @@ export async function broadcastRumor ({
|
|
|
860
877
|
autoDeletionCapability = true,
|
|
861
878
|
fileChunkIndex,
|
|
862
879
|
onPreparedSeed,
|
|
880
|
+
preparedPublication,
|
|
863
881
|
_publish = privateChannel.publish
|
|
864
882
|
}) {
|
|
865
883
|
const receivers = uniq(receiverPubkeys)
|
|
866
884
|
if (!receivers.length) throw new ValidationError('NO_RECEIVERS')
|
|
867
885
|
const { event, wireEvent } = await makeOutgoingRumor({ senderSigner, rumor })
|
|
868
|
-
const deletion = resolveDeletionCapability({ deletionPubkey, deletionSeckey, autoDeletionCapability })
|
|
869
|
-
const reports = await sendPrivateMessage({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag: '', deletionPubkey: deletion.deletionPubkey, event: wireEvent, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, fileChunkIndex, onPreparedSeed, _getIykcProofs, _publish })
|
|
886
|
+
const deletion = resolveDeletionCapability({ deletionPubkey, deletionSeckey, autoDeletionCapability, preparedPublication })
|
|
887
|
+
const reports = await sendPrivateMessage({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag: '', deletionPubkey: deletion.deletionPubkey, event: wireEvent, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, fileChunkIndex, onPreparedSeed: preparedSeedHandler(onPreparedSeed, deletion), preparedPublication, _getIykcProofs, _publish })
|
|
870
888
|
return withDelivery({ rumor: event }, reports, deletion.deletionSeckey)
|
|
871
889
|
}
|
|
872
890
|
|
|
@@ -888,13 +906,14 @@ export async function broadcastEvent ({
|
|
|
888
906
|
autoDeletionCapability = true,
|
|
889
907
|
fileChunkIndex,
|
|
890
908
|
onPreparedSeed,
|
|
909
|
+
preparedPublication,
|
|
891
910
|
_publish = privateChannel.publish
|
|
892
911
|
}) {
|
|
893
912
|
const receivers = uniq(receiverPubkeys)
|
|
894
913
|
if (!receivers.length) throw new ValidationError('NO_RECEIVERS')
|
|
895
914
|
const wireEvent = assertValidSignedEvent({ ...event, tags: cloneTags(event?.tags) })
|
|
896
|
-
const deletion = resolveDeletionCapability({ deletionPubkey, deletionSeckey, autoDeletionCapability })
|
|
897
|
-
const reports = await sendPrivateMessage({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag: '', deletionPubkey: deletion.deletionPubkey, event: wireEvent, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, fileChunkIndex, onPreparedSeed, _getIykcProofs, _publish })
|
|
915
|
+
const deletion = resolveDeletionCapability({ deletionPubkey, deletionSeckey, autoDeletionCapability, preparedPublication })
|
|
916
|
+
const reports = await sendPrivateMessage({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag: '', deletionPubkey: deletion.deletionPubkey, event: wireEvent, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, fileChunkIndex, onPreparedSeed: preparedSeedHandler(onPreparedSeed, deletion), preparedPublication, _getIykcProofs, _publish })
|
|
898
917
|
return withDelivery({ event: wireEvent }, reports, deletion.deletionSeckey)
|
|
899
918
|
}
|
|
900
919
|
|
|
@@ -144,14 +144,14 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
|
|
|
144
144
|
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')
|
|
145
145
|
return decoded
|
|
146
146
|
}
|
|
147
|
-
async function routing (file, receiverPubkey, signal) {
|
|
147
|
+
async function routing (file, receiverPubkey, signal, attemptStartedAt, onDebug) {
|
|
148
148
|
const parent = messenger.requireWritableChannel(file.controlChannelPubkey)
|
|
149
|
-
return messenger.resolveSendRouting({ channel: parent, receiverPubkeys: [receiverPubkey], signal })
|
|
149
|
+
return messenger.resolveSendRouting({ channel: parent, receiverPubkeys: [receiverPubkey], signal, attemptStartedAt, onDebug })
|
|
150
150
|
}
|
|
151
151
|
function sendOptions (file, routes) {
|
|
152
152
|
return { senderSigner: messenger.userSigner, imkcSigner: messenger.contentKeySigner, privateChannelSigner: file.signer, ...routes, temporaryStorageArea: messenger.temporaryStorageArea, expirationSeconds: messenger.eventExpirationSecondsFor(file.controlChannelPubkey), _getIykcProofs: messenger.contentKeyLookup() }
|
|
153
153
|
}
|
|
154
|
-
async function publishChunk (descriptor, event, { signal } = {}) {
|
|
154
|
+
async function publishChunk (descriptor, event, { signal, attemptStartedAt, onPreparedSeed, preparedPublication, onDebug } = {}) {
|
|
155
155
|
const file = await register(descriptor)
|
|
156
156
|
usable(file)
|
|
157
157
|
const decoded = checked(file, event)
|
|
@@ -162,7 +162,7 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
|
|
|
162
162
|
}
|
|
163
163
|
let result
|
|
164
164
|
try {
|
|
165
|
-
const options = { ...sendOptions(file, await routing(file, file.peerPubkey, signal)), receiverPubkeys: [file.peerPubkey], fileChunkIndex: decoded.index, ...(messenger.channels.get(file.controlChannelPubkey)?.mode === 'watchtower' ? { onPreparedSeed: seed => saveSeed(file, seed) } : {}) }
|
|
165
|
+
const options = { ...sendOptions(file, await routing(file, file.peerPubkey, signal, attemptStartedAt, onDebug)), receiverPubkeys: [file.peerPubkey], fileChunkIndex: decoded.index, preparedPublication, ...(onPreparedSeed ? { onPreparedSeed } : messenger.channels.get(file.controlChannelPubkey)?.mode === 'watchtower' ? { onPreparedSeed: seed => saveSeed(file, seed) } : {}) }
|
|
166
166
|
result = event.sig ? await _messages.broadcastEvent({ ...options, event }) : await _messages.broadcastRumor({ ...options, rumor: event })
|
|
167
167
|
await assertMessagePublished(result, result?.rumor || result?.event || { ...event, pubkey: event.pubkey || messenger.userPubkey })
|
|
168
168
|
} catch (error) { await uploadLeases.get(file.root)?.(); uploadLeases.delete(file.root); throw error }
|
|
@@ -463,5 +463,5 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
|
|
|
463
463
|
const timer = setInterval(() => { prune().catch(onError) }, 60000)
|
|
464
464
|
timer.unref?.()
|
|
465
465
|
messenger.extensions.add(extension)
|
|
466
|
-
return { register, authorizeSeeding, publishChunk, download, readChunk: (root, index) => storage.read(root, index), stream: (descriptor, options = {}) => storage.stream ? storage.stream(descriptor, options) : chunkStream({ read: storage.read.bind(storage), ...descriptor, ...options }), close: extension.close, cancel (control, root) { const job = jobs.get(keyFor(control, root)); if (job) { job.pauseRequested = false; cancelJob(job) } }, observe (listener) { listeners.add(listener); for (const job of jobs.values()) emit(job); return () => listeners.delete(listener) } }
|
|
466
|
+
return { register, authorizeSeeding, publishChunk, archiveSeed: async (descriptor, seed) => saveSeed(await register(descriptor), seed), download, readChunk: (root, index) => storage.read(root, index), stream: (descriptor, options = {}) => storage.stream ? storage.stream(descriptor, options) : chunkStream({ read: storage.read.bind(storage), ...descriptor, ...options }), close: extension.close, cancel (control, root) { const job = jobs.get(keyFor(control, root)); if (job) { job.pauseRequested = false; cancelJob(job) } }, observe (listener) { listeners.add(listener); for (const job of jobs.values()) emit(job); return () => listeners.delete(listener) } }
|
|
467
467
|
}
|
|
@@ -2,7 +2,7 @@ import { isReplaceableRelayFailure } from '../../relay/helpers/failure.js'
|
|
|
2
2
|
|
|
3
3
|
// Each invocation owns one already signed outer event and its encrypted subset.
|
|
4
4
|
// Lanes share coverage and the tried set; only started reports outlive the race.
|
|
5
|
-
export function publishWithEarlyFallback ({ event, receivers, first, mirrors, tried, nextPrimary, nextFallback, sendEvent, isOnline, isCurrent, signal, pauseSignal, remember, delay, now, setTimer, clearTimer }) {
|
|
5
|
+
export function publishWithEarlyFallback ({ event, receivers, first, mirrors, tried, nextPrimary, nextFallback, sendEvent, isOnline, isCurrent, signal, pauseSignal, remember, delay, attemptStartedAt, monotonicNow = () => performance.now(), now, setTimer, clearTimer }) {
|
|
6
6
|
const pending = new Set(receivers)
|
|
7
7
|
const summaries = []
|
|
8
8
|
const transportSignal = signal && pauseSignal ? AbortSignal.any([signal, pauseSignal]) : signal || pauseSignal
|
|
@@ -120,7 +120,8 @@ export function publishWithEarlyFallback ({ event, receivers, first, mirrors, tr
|
|
|
120
120
|
}
|
|
121
121
|
if (transportSignal?.aborted || !isCurrent()) { finish(false); return outcome.promise }
|
|
122
122
|
transportSignal?.addEventListener('abort', interrupted, { once: true })
|
|
123
|
-
const
|
|
123
|
+
const remaining = attemptStartedAt === undefined ? delay : Math.max(0, delay - Math.max(0, monotonicNow() - attemptStartedAt))
|
|
124
|
+
const deadline = now() + remaining
|
|
124
125
|
const failed = reason => {
|
|
125
126
|
if (done) return
|
|
126
127
|
done = true; clearTimer(timer); timer = null
|
|
@@ -33,11 +33,14 @@ export function createPauseRecovery ({ attempt, online, onOnline, retryable, onE
|
|
|
33
33
|
const run = () => {
|
|
34
34
|
if (!active() || job.work) return job.work
|
|
35
35
|
const work = (async () => {
|
|
36
|
-
|
|
36
|
+
const confirmedOnline = job.confirmedOnline
|
|
37
|
+
job.confirmedOnline = false
|
|
38
|
+
if (online && !confirmedOnline && !await online({ signal: job.controller.signal })) {
|
|
37
39
|
if (!active()) return
|
|
38
40
|
job.stopOnline = onOnline(() => {
|
|
39
41
|
if (!active()) return
|
|
40
42
|
job.stopOnline?.(); job.stopOnline = null
|
|
43
|
+
job.confirmedOnline = true
|
|
41
44
|
schedule(0)
|
|
42
45
|
})
|
|
43
46
|
return
|
|
@@ -32,10 +32,11 @@ function routeGroups (routes) {
|
|
|
32
32
|
return [...groups.values()]
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey, primaryRelays, primaryRelayToReceivers, fallbackRelays = [], fallbackDelayMs = null, exclusions, pickRelays, recoveryRelays, publish, publishNymEvent, sendEvent, isOnline, isCurrent, signal, pauseSignal, now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout }) {
|
|
35
|
+
export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey, primaryRelays, primaryRelayToReceivers, fallbackRelays = [], fallbackDelayMs = null, exclusions, pickRelays, recoveryRelays, publish, publishNymEvent, sendEvent, isOnline, isCurrent, signal, pauseSignal, attemptStartedAt, monotonicNow = () => performance.now(), now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout }) {
|
|
36
36
|
// Keep discovery's defaults when NIP-65 is unavailable. Explicit maps/lists
|
|
37
37
|
// retain their fanout; configured fallbacks are a separate last-resort stage.
|
|
38
38
|
fallbackDelayMs = normalizeFallbackDelay(fallbackDelayMs)
|
|
39
|
+
if (attemptStartedAt !== undefined && (!Number.isFinite(attemptStartedAt) || attemptStartedAt < 0)) throw new ValidationError('INVALID_ATTEMPT_STARTED_AT')
|
|
39
40
|
const transportSignal = signal && pauseSignal ? AbortSignal.any([signal, pauseSignal]) : signal || pauseSignal
|
|
40
41
|
const explicit = Boolean(primaryRelays || primaryRelayToReceivers)
|
|
41
42
|
const primaryRoutes = primaryRelayToReceivers || (primaryRelays
|
|
@@ -98,7 +99,7 @@ export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey,
|
|
|
98
99
|
event, receivers, first: first.length ? { receivers, relays: first } : null, mirrors, tried,
|
|
99
100
|
nextPrimary: pending => pick(pending, false), nextFallback: pending => pick(pending, true),
|
|
100
101
|
sendEvent, isOnline, isCurrent, signal, pauseSignal, remember,
|
|
101
|
-
delay: fallbackDelayMs, now, setTimer, clearTimer
|
|
102
|
+
delay: fallbackDelayMs, attemptStartedAt, monotonicNow, now, setTimer, clearTimer
|
|
102
103
|
})
|
|
103
104
|
if (!result.success && current()) exclusions.clear()
|
|
104
105
|
return result
|