libp2r2p 0.7.0 → 0.8.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
CHANGED
|
@@ -21,6 +21,8 @@ const messenger = await createPrivateMessenger({
|
|
|
21
21
|
userSigner,
|
|
22
22
|
contentKeySigner,
|
|
23
23
|
offlineRecoverySeconds: 7 * 24 * 60 * 60,
|
|
24
|
+
staleChannelSeconds: 45 * 24 * 60 * 60,
|
|
25
|
+
identityStorageRetentionSeconds: 60 * 24 * 60 * 60,
|
|
24
26
|
channels: [{
|
|
25
27
|
signer: privateChannelSigner,
|
|
26
28
|
relays: ['wss://relay.example'],
|
|
@@ -152,21 +154,42 @@ know database names or enumerate IndexedDB. Pass `temporaryStorageArea` only
|
|
|
152
154
|
when the messenger was configured to use a Storage area other than the default
|
|
153
155
|
`sessionStorage`.
|
|
154
156
|
|
|
155
|
-
Each principal signer owns internal
|
|
156
|
-
databases. The messenger updates
|
|
157
|
-
closes all handles in `await messenger.close()`.
|
|
158
|
-
|
|
157
|
+
Each principal signer owns an internal storage set containing message,
|
|
158
|
+
recovery-seed, and channel-state databases. The messenger updates its activity
|
|
159
|
+
lease while it is open and closes all handles in `await messenger.close()`.
|
|
160
|
+
The complete set is removed after `identityStorageRetentionSeconds` without
|
|
161
|
+
use (60 days by default), including messages that were never consumed.
|
|
159
162
|
Maintenance runs on every initialization and every six hours while a messenger
|
|
160
163
|
is active; failed deletions remain journaled and retry automatically.
|
|
161
164
|
|
|
162
|
-
Channel recovery state inside an otherwise active identity
|
|
163
|
-
|
|
165
|
+
Channel recovery state inside an otherwise active identity uses the separate
|
|
166
|
+
`staleChannelSeconds` cleanup policy (45 days by default). Active instances
|
|
167
|
+
record the channels they administer, so a channel remains protected while any
|
|
168
|
+
instance or tab still uses it. Offline recovery defaults to seven days.
|
|
164
169
|
Set `offlineRecoverySeconds` on an individual channel to override the
|
|
165
170
|
messenger default for its recovery seeds, offline ranges, new outer-event
|
|
166
171
|
expiration, and new incomplete receive groups. Updating a channel applies a
|
|
167
172
|
shorter window immediately to its stored seeds and ranges; increasing it does
|
|
168
173
|
not recreate data already removed.
|
|
169
174
|
|
|
175
|
+
The effective recovery duration is capped by both `staleChannelSeconds` and
|
|
176
|
+
`identityStorageRetentionSeconds`. The requested per-channel value remains
|
|
177
|
+
stored separately, so raising a cap affects new retention decisions without
|
|
178
|
+
recreating data already removed. Both retention policies can be changed by an
|
|
179
|
+
instance at runtime; omitted fields retain their current persisted values:
|
|
180
|
+
|
|
181
|
+
```js
|
|
182
|
+
await messenger.update({
|
|
183
|
+
staleChannelSeconds: 30 * 24 * 60 * 60,
|
|
184
|
+
identityStorageRetentionSeconds: 90 * 24 * 60 * 60
|
|
185
|
+
})
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
The policies are persisted per principal identity. If multiple instances use
|
|
189
|
+
the same identity, the last confirmed policy update wins and is propagated to
|
|
190
|
+
the others. A zero policy disables durable recovery immediately. Identity
|
|
191
|
+
storage itself remains protected until the final active lease closes.
|
|
192
|
+
|
|
170
193
|
Set a channel's `offlineRecoverySeconds` to `0` to disable durable recovery for
|
|
171
194
|
that channel. The messenger then stores no recovery seeds, tracks no offline
|
|
172
195
|
ranges, contacts no seeders, publishes no seeder presence, and uses no recovery
|
package/package.json
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// userSigner,
|
|
4
4
|
// contentKeySigner, // optional when userSigner handles content keys internally
|
|
5
5
|
// nymSigner: optionalDefaultNymSigner,
|
|
6
|
+
// staleChannelSeconds: optionalStaleChannelRetention,
|
|
7
|
+
// identityStorageRetentionSeconds: optionalIdentityStorageRetention,
|
|
6
8
|
// channels: [{ signer: privateChannelSigner, relays, mode: 'leecher', seeders: optionalSeederPubkeys, offlineRecoverySeconds: optionalChannelOverride }],
|
|
7
9
|
// onContentKeyChange: event => reviewContentKeyUse(event),
|
|
8
10
|
// onError: err => reportPrivateMessengerError(err)
|
|
@@ -30,7 +32,7 @@
|
|
|
30
32
|
// - Re-watching after reload fetches the gap from lastSeenAt to now.
|
|
31
33
|
// - Browser offline/online events add explicit offline ranges with a small skew.
|
|
32
34
|
// - Recovery defaults to 7 days and can be overridden per channel; zero disables durable recovery.
|
|
33
|
-
// - Channel state not watched
|
|
35
|
+
// - Channel state not actively leased or watched within the configured stale window is pruned.
|
|
34
36
|
// - Seeders announce presence every 10min and are used for the relay-uncovered left edge of a missed range.
|
|
35
37
|
// - Configured seeders are all asked; auto-discovered seeders are capped to the 8 most recently active.
|
|
36
38
|
// - Seeder/watchtower channels store reconstructed router events in a separate IndexedDB queue and auto-reply to recovery asks.
|
|
@@ -47,9 +49,11 @@ import { createChannelStateStore } from './services/channel-state.js'
|
|
|
47
49
|
import { DEFAULT_STALE_CHANNEL_SECONDS } from './constants/index.js'
|
|
48
50
|
import {
|
|
49
51
|
activatePrivateMessengerStorage,
|
|
52
|
+
DEFAULT_IDENTITY_STORAGE_RETENTION_SECONDS,
|
|
50
53
|
maintainPrivateMessengerStorage,
|
|
51
54
|
PRIVATE_MESSENGER_STORAGE_HEARTBEAT_MS,
|
|
52
55
|
PRIVATE_MESSENGER_STORAGE_MAINTENANCE_MS,
|
|
56
|
+
readPrivateMessengerStorage,
|
|
53
57
|
releasePrivateMessengerStorage
|
|
54
58
|
} from './services/storage-maintenance.js'
|
|
55
59
|
import {
|
|
@@ -66,6 +70,7 @@ import {
|
|
|
66
70
|
} from './recovery/index.js'
|
|
67
71
|
|
|
68
72
|
export { DEFAULT_STALE_CHANNEL_SECONDS } from './constants/index.js'
|
|
73
|
+
export { DEFAULT_IDENTITY_STORAGE_RETENTION_SECONDS } from './services/storage-maintenance.js'
|
|
69
74
|
export {
|
|
70
75
|
compactSeedNymCarriers,
|
|
71
76
|
compactSeedRouterRows,
|
|
@@ -81,6 +86,7 @@ export {
|
|
|
81
86
|
|
|
82
87
|
const DEFAULT_OFFLINE_RECOVERY_SECONDS = 7 * 24 * 60 * 60
|
|
83
88
|
const MAX_OFFLINE_RECOVERY_SECONDS = Math.floor(Number.MAX_SAFE_INTEGER / 1000)
|
|
89
|
+
const STORAGE_POLICY_BROADCAST_CHANNEL = 'libp2r2p:private-messenger:storage-policy'
|
|
84
90
|
const DEFAULT_OFFLINE_SKEW_SECONDS = 30
|
|
85
91
|
const DEFAULT_RELOAD_GAP_DELAY_MS = 500
|
|
86
92
|
const DEFAULT_SEEDER_PRESENCE_INTERVAL_MS = 10 * 60 * 1000
|
|
@@ -125,6 +131,20 @@ function normalizeOfflineRecoverySeconds (value) {
|
|
|
125
131
|
return value
|
|
126
132
|
}
|
|
127
133
|
|
|
134
|
+
function normalizeStaleChannelSeconds (value) {
|
|
135
|
+
if (!Number.isSafeInteger(value) || value < 0 || value > MAX_OFFLINE_RECOVERY_SECONDS) {
|
|
136
|
+
throw new Error('INVALID_STALE_CHANNEL_SECONDS')
|
|
137
|
+
}
|
|
138
|
+
return value
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function normalizeIdentityStorageRetentionSeconds (value) {
|
|
142
|
+
if (!Number.isSafeInteger(value) || value < 0 || value > MAX_OFFLINE_RECOVERY_SECONDS) {
|
|
143
|
+
throw new Error('INVALID_IDENTITY_STORAGE_RETENTION_SECONDS')
|
|
144
|
+
}
|
|
145
|
+
return value
|
|
146
|
+
}
|
|
147
|
+
|
|
128
148
|
function uniq (values) {
|
|
129
149
|
return [...new Set((values || []).filter(Boolean))]
|
|
130
150
|
}
|
|
@@ -142,6 +162,10 @@ function parseJson (raw, fallback) {
|
|
|
142
162
|
try { return JSON.parse(raw || '') } catch { return fallback }
|
|
143
163
|
}
|
|
144
164
|
|
|
165
|
+
function sameStateValue (left, right) {
|
|
166
|
+
return JSON.stringify(left) === JSON.stringify(right)
|
|
167
|
+
}
|
|
168
|
+
|
|
145
169
|
function storesRecoverySeeds (mode) {
|
|
146
170
|
return mode === 'seeder' || mode === 'watchtower'
|
|
147
171
|
}
|
|
@@ -164,6 +188,7 @@ export class PrivateMessenger {
|
|
|
164
188
|
constructor ({
|
|
165
189
|
offlineRecoverySeconds = DEFAULT_OFFLINE_RECOVERY_SECONDS,
|
|
166
190
|
staleChannelSeconds = DEFAULT_STALE_CHANNEL_SECONDS,
|
|
191
|
+
identityStorageRetentionSeconds = DEFAULT_IDENTITY_STORAGE_RETENTION_SECONDS,
|
|
167
192
|
offlineSkewSeconds = DEFAULT_OFFLINE_SKEW_SECONDS,
|
|
168
193
|
reloadGapDelayMs = DEFAULT_RELOAD_GAP_DELAY_MS,
|
|
169
194
|
seederPresenceIntervalMs = DEFAULT_SEEDER_PRESENCE_INTERVAL_MS,
|
|
@@ -188,10 +213,12 @@ export class PrivateMessenger {
|
|
|
188
213
|
_setInterval = globalThis.setInterval.bind(globalThis),
|
|
189
214
|
_clearInterval = globalThis.clearInterval.bind(globalThis),
|
|
190
215
|
_storageSetInterval = globalThis.setInterval.bind(globalThis),
|
|
191
|
-
_storageClearInterval = globalThis.clearInterval.bind(globalThis)
|
|
216
|
+
_storageClearInterval = globalThis.clearInterval.bind(globalThis),
|
|
217
|
+
_BroadcastChannel = _indexedDB === globalThis.indexedDB ? globalThis.BroadcastChannel : undefined
|
|
192
218
|
} = {}) {
|
|
193
219
|
this.offlineRecoverySeconds = normalizeOfflineRecoverySeconds(offlineRecoverySeconds)
|
|
194
|
-
this.staleChannelSeconds = staleChannelSeconds
|
|
220
|
+
this.staleChannelSeconds = normalizeStaleChannelSeconds(staleChannelSeconds)
|
|
221
|
+
this.identityStorageRetentionSeconds = normalizeIdentityStorageRetentionSeconds(identityStorageRetentionSeconds)
|
|
195
222
|
this.offlineSkewSeconds = offlineSkewSeconds
|
|
196
223
|
this.reloadGapDelayMs = reloadGapDelayMs
|
|
197
224
|
this.seederPresenceIntervalMs = seederPresenceIntervalMs
|
|
@@ -217,6 +244,7 @@ export class PrivateMessenger {
|
|
|
217
244
|
this._clearInterval = _clearInterval
|
|
218
245
|
this._storageSetInterval = _storageSetInterval
|
|
219
246
|
this._storageClearInterval = _storageClearInterval
|
|
247
|
+
this._BroadcastChannel = _BroadcastChannel
|
|
220
248
|
|
|
221
249
|
this.userSigner = null
|
|
222
250
|
this.contentKeySigner = null
|
|
@@ -245,6 +273,10 @@ export class PrivateMessenger {
|
|
|
245
273
|
this.storageHeartbeatTimer = null
|
|
246
274
|
this.storageMaintenanceTimer = null
|
|
247
275
|
this.storageMaintenancePromise = null
|
|
276
|
+
this.storagePolicyRevision = 0
|
|
277
|
+
this.storagePolicyNeedsApply = false
|
|
278
|
+
this.storagePolicyBroadcast = null
|
|
279
|
+
this.storagePolicyRefreshTail = Promise.resolve()
|
|
248
280
|
this.closePromise = null
|
|
249
281
|
this.initSettledPromise = null
|
|
250
282
|
this.initialized = false
|
|
@@ -264,13 +296,20 @@ export class PrivateMessenger {
|
|
|
264
296
|
this.nymSigner = nymSigner || null
|
|
265
297
|
this.userPubkey = await userSigner.getPublicKey()
|
|
266
298
|
this.prefix = `libp2r2p:private-messenger:${this.userPubkey}`
|
|
267
|
-
await activatePrivateMessengerStorage({
|
|
299
|
+
const storageSnapshot = await activatePrivateMessengerStorage({
|
|
268
300
|
userPubkey: this.userPubkey,
|
|
269
301
|
leaseId: this.storageLeaseId,
|
|
302
|
+
activeChannelPubkeys: [],
|
|
303
|
+
storagePolicy: {
|
|
304
|
+
staleChannelSeconds: this.staleChannelSeconds,
|
|
305
|
+
identityStorageRetentionSeconds: this.identityStorageRetentionSeconds
|
|
306
|
+
},
|
|
270
307
|
indexedDB: this._indexedDB
|
|
271
308
|
})
|
|
309
|
+
this.applyStoragePolicySnapshot(storageSnapshot)
|
|
272
310
|
this.storageActive = true
|
|
273
311
|
this.lastStorageTouch = Date.now()
|
|
312
|
+
this.startStoragePolicyBroadcast()
|
|
274
313
|
this.assertOpen()
|
|
275
314
|
await PrivateMessenger.maintainStorage({
|
|
276
315
|
indexedDB: this._indexedDB,
|
|
@@ -302,13 +341,15 @@ export class PrivateMessenger {
|
|
|
302
341
|
this.assertOpen()
|
|
303
342
|
this.startStorageMaintenance()
|
|
304
343
|
this.state = { channels: await this.stateStore.load() }
|
|
305
|
-
await this.cleanupStaleChannels()
|
|
306
344
|
await this.update({ userSigner, contentKeySigner, nymSigner: this.nymSigner, channels, relays, mode })
|
|
307
345
|
await this.pruneStoredSeeds()
|
|
308
346
|
this.initialized = true
|
|
347
|
+
await this.refreshAndApplyStoragePolicy()
|
|
348
|
+
this.broadcastStoragePolicyChange()
|
|
309
349
|
return this
|
|
310
350
|
} catch (err) {
|
|
311
351
|
this.stopStorageMaintenance()
|
|
352
|
+
this.stopStoragePolicyBroadcast()
|
|
312
353
|
try { await this.queueOperationTail } catch {}
|
|
313
354
|
try { await this.stateWriteTail } catch {}
|
|
314
355
|
await Promise.allSettled([
|
|
@@ -338,10 +379,102 @@ export class PrivateMessenger {
|
|
|
338
379
|
if (this.closePromise) throw new Error('PRIVATE_MESSENGER_CLOSED')
|
|
339
380
|
}
|
|
340
381
|
|
|
382
|
+
applyStoragePolicySnapshot (snapshot) {
|
|
383
|
+
if (!snapshot) return false
|
|
384
|
+
const staleChannelSeconds = normalizeStaleChannelSeconds(snapshot.staleChannelSeconds)
|
|
385
|
+
const identityStorageRetentionSeconds = normalizeIdentityStorageRetentionSeconds(
|
|
386
|
+
snapshot.identityStorageRetentionSeconds
|
|
387
|
+
)
|
|
388
|
+
const policyRevision = Math.max(0, Number(snapshot.policyRevision) || 0)
|
|
389
|
+
const changed = this.staleChannelSeconds !== staleChannelSeconds ||
|
|
390
|
+
this.identityStorageRetentionSeconds !== identityStorageRetentionSeconds
|
|
391
|
+
this.staleChannelSeconds = staleChannelSeconds
|
|
392
|
+
this.identityStorageRetentionSeconds = identityStorageRetentionSeconds
|
|
393
|
+
this.storagePolicyRevision = policyRevision
|
|
394
|
+
if (changed && this.initialized) this.storagePolicyNeedsApply = true
|
|
395
|
+
return changed
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
startStoragePolicyBroadcast () {
|
|
399
|
+
if (this.storagePolicyBroadcast || typeof this._BroadcastChannel !== 'function') return
|
|
400
|
+
try {
|
|
401
|
+
const channel = new this._BroadcastChannel(STORAGE_POLICY_BROADCAST_CHANNEL)
|
|
402
|
+
channel.unref?.()
|
|
403
|
+
channel.onmessage = event => {
|
|
404
|
+
const message = event?.data
|
|
405
|
+
if (message?.userPubkey !== this.userPubkey) return
|
|
406
|
+
if (!Number.isSafeInteger(message.policyRevision) || message.policyRevision <= (this.storagePolicyRevision || 0)) return
|
|
407
|
+
this.refreshAndApplyStoragePolicy().catch(err => {
|
|
408
|
+
try { this.onError?.(err) } catch {}
|
|
409
|
+
})
|
|
410
|
+
}
|
|
411
|
+
this.storagePolicyBroadcast = channel
|
|
412
|
+
} catch {}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
stopStoragePolicyBroadcast () {
|
|
416
|
+
const channel = this.storagePolicyBroadcast
|
|
417
|
+
this.storagePolicyBroadcast = null
|
|
418
|
+
if (!channel) return
|
|
419
|
+
channel.onmessage = null
|
|
420
|
+
channel.close?.()
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
broadcastStoragePolicyChange () {
|
|
424
|
+
try {
|
|
425
|
+
this.storagePolicyBroadcast?.postMessage({
|
|
426
|
+
userPubkey: this.userPubkey,
|
|
427
|
+
policyRevision: this.storagePolicyRevision || 0
|
|
428
|
+
})
|
|
429
|
+
} catch (err) {
|
|
430
|
+
try { this.onError?.(err) } catch {}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async readStoragePolicySnapshot () {
|
|
435
|
+
if (!this.userPubkey) return null
|
|
436
|
+
const snapshot = await readPrivateMessengerStorage({
|
|
437
|
+
userPubkey: this.userPubkey,
|
|
438
|
+
indexedDB: this._indexedDB
|
|
439
|
+
})
|
|
440
|
+
this.applyStoragePolicySnapshot(snapshot)
|
|
441
|
+
return snapshot
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async applyPendingStoragePolicy () {
|
|
445
|
+
if (!this.storagePolicyNeedsApply || !this.initialized || this.closePromise) return false
|
|
446
|
+
this.storagePolicyNeedsApply = false
|
|
447
|
+
try {
|
|
448
|
+
const channels = [...this.channels.values()]
|
|
449
|
+
await this.applyRecoveryPolicies(channels)
|
|
450
|
+
await this.cleanupStaleChannels()
|
|
451
|
+
const pubkeys = [...this.channels.keys()]
|
|
452
|
+
if (pubkeys.length) {
|
|
453
|
+
await this.unwatch(pubkeys)
|
|
454
|
+
await this.watch(pubkeys)
|
|
455
|
+
}
|
|
456
|
+
await this.reconcilePresencePublishers()
|
|
457
|
+
return true
|
|
458
|
+
} catch (err) {
|
|
459
|
+
this.storagePolicyNeedsApply = true
|
|
460
|
+
throw err
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
refreshAndApplyStoragePolicy () {
|
|
465
|
+
const previous = this.storagePolicyRefreshTail || Promise.resolve()
|
|
466
|
+
const refresh = previous.catch(() => {}).then(async () => {
|
|
467
|
+
await this.readStoragePolicySnapshot()
|
|
468
|
+
return this.applyPendingStoragePolicy()
|
|
469
|
+
})
|
|
470
|
+
this.storagePolicyRefreshTail = refresh
|
|
471
|
+
return refresh
|
|
472
|
+
}
|
|
473
|
+
|
|
341
474
|
startStorageMaintenance () {
|
|
342
475
|
if (this.storageHeartbeatTimer || this.storageMaintenanceTimer) return
|
|
343
476
|
this.storageHeartbeatTimer = this._storageSetInterval(() => (
|
|
344
|
-
this.
|
|
477
|
+
this.runStorageHeartbeat().catch(err => {
|
|
345
478
|
try { this.onError?.(err) } catch {}
|
|
346
479
|
})
|
|
347
480
|
), PRIVATE_MESSENGER_STORAGE_HEARTBEAT_MS)
|
|
@@ -362,13 +495,15 @@ export class PrivateMessenger {
|
|
|
362
495
|
|
|
363
496
|
runStorageMaintenance () {
|
|
364
497
|
if (this.storageMaintenancePromise) return this.storageMaintenancePromise
|
|
365
|
-
const maintenance =
|
|
366
|
-
PrivateMessenger.maintainStorage({
|
|
498
|
+
const maintenance = (async () => {
|
|
499
|
+
await PrivateMessenger.maintainStorage({
|
|
367
500
|
indexedDB: this._indexedDB,
|
|
368
501
|
temporaryStorageArea: this.temporaryStorageArea
|
|
369
|
-
})
|
|
370
|
-
this.
|
|
371
|
-
|
|
502
|
+
})
|
|
503
|
+
await this.refreshAndApplyStoragePolicy()
|
|
504
|
+
await this.cleanupStaleChannels()
|
|
505
|
+
await this.pruneStoredSeeds()
|
|
506
|
+
})().catch(err => {
|
|
372
507
|
try { this.onError?.(err) } catch {}
|
|
373
508
|
}).finally(() => {
|
|
374
509
|
if (this.storageMaintenancePromise === maintenance) this.storageMaintenancePromise = null
|
|
@@ -377,6 +512,12 @@ export class PrivateMessenger {
|
|
|
377
512
|
return maintenance
|
|
378
513
|
}
|
|
379
514
|
|
|
515
|
+
async runStorageHeartbeat () {
|
|
516
|
+
await this.stampActiveChannelActivity()
|
|
517
|
+
await this.touchStorageActivity({ force: true })
|
|
518
|
+
await this.applyPendingStoragePolicy()
|
|
519
|
+
}
|
|
520
|
+
|
|
380
521
|
async touchStorageActivity ({ force = false } = {}) {
|
|
381
522
|
if (!this.storageActive || this.closePromise) return false
|
|
382
523
|
if (this.storageTouchPromise) return this.storageTouchPromise
|
|
@@ -387,9 +528,13 @@ export class PrivateMessenger {
|
|
|
387
528
|
const touch = activatePrivateMessengerStorage({
|
|
388
529
|
userPubkey: this.userPubkey,
|
|
389
530
|
leaseId: this.storageLeaseId,
|
|
531
|
+
activeChannelPubkeys: [...this.channels.keys()],
|
|
390
532
|
indexedDB: this._indexedDB,
|
|
391
533
|
now
|
|
392
|
-
}).then(
|
|
534
|
+
}).then(snapshot => {
|
|
535
|
+
this.applyStoragePolicySnapshot(snapshot)
|
|
536
|
+
return true
|
|
537
|
+
}, err => {
|
|
393
538
|
this.lastStorageTouch = previousTouch
|
|
394
539
|
throw err
|
|
395
540
|
}).finally(() => {
|
|
@@ -435,8 +580,24 @@ export class PrivateMessenger {
|
|
|
435
580
|
})
|
|
436
581
|
}
|
|
437
582
|
|
|
438
|
-
async update (
|
|
583
|
+
async update (options = {}) {
|
|
439
584
|
this.assertOpen()
|
|
585
|
+
const {
|
|
586
|
+
userSigner = this.userSigner,
|
|
587
|
+
contentKeySigner = this.contentKeySigner,
|
|
588
|
+
nymSigner = this.nymSigner,
|
|
589
|
+
channels = [...this.channels.values()],
|
|
590
|
+
relays = [],
|
|
591
|
+
mode = 'leecher'
|
|
592
|
+
} = options
|
|
593
|
+
const updatesStalePolicy = Object.hasOwn(options, 'staleChannelSeconds')
|
|
594
|
+
const updatesIdentityPolicy = Object.hasOwn(options, 'identityStorageRetentionSeconds')
|
|
595
|
+
let nextStaleChannelSeconds = updatesStalePolicy
|
|
596
|
+
? normalizeStaleChannelSeconds(options.staleChannelSeconds)
|
|
597
|
+
: this.staleChannelSeconds
|
|
598
|
+
let nextIdentityStorageRetentionSeconds = updatesIdentityPolicy
|
|
599
|
+
? normalizeIdentityStorageRetentionSeconds(options.identityStorageRetentionSeconds)
|
|
600
|
+
: this.identityStorageRetentionSeconds
|
|
440
601
|
if (userSigner) {
|
|
441
602
|
const userPubkey = await userSigner.getPublicKey?.()
|
|
442
603
|
if (!userPubkey) throw new Error('USER_SIGNER_REQUIRED')
|
|
@@ -449,19 +610,48 @@ export class PrivateMessenger {
|
|
|
449
610
|
const nextChannels = await this.normalizeChannels(channels, { relays, mode })
|
|
450
611
|
this.assertOpen()
|
|
451
612
|
const nextPubkeys = new Set(nextChannels.map(channel => channel.pubkey))
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
613
|
+
const removedPubkeys = [...this.channels.keys()].filter(pubkey => !nextPubkeys.has(pubkey))
|
|
614
|
+
const updatesStoragePolicy = updatesStalePolicy || updatesIdentityPolicy
|
|
615
|
+
|
|
616
|
+
if (updatesStoragePolicy) {
|
|
617
|
+
const currentPolicy = await this.readStoragePolicySnapshot()
|
|
618
|
+
if (!updatesStalePolicy) nextStaleChannelSeconds = currentPolicy?.staleChannelSeconds ?? this.staleChannelSeconds
|
|
619
|
+
if (!updatesIdentityPolicy) {
|
|
620
|
+
nextIdentityStorageRetentionSeconds = currentPolicy?.identityStorageRetentionSeconds ?? this.identityStorageRetentionSeconds
|
|
457
621
|
}
|
|
458
622
|
}
|
|
623
|
+
|
|
624
|
+
if (removedPubkeys.length) {
|
|
625
|
+
await this.stampChannelActivity(removedPubkeys)
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const storageSnapshot = await activatePrivateMessengerStorage({
|
|
629
|
+
userPubkey: this.userPubkey,
|
|
630
|
+
leaseId: this.storageLeaseId,
|
|
631
|
+
activeChannelPubkeys: [...nextPubkeys],
|
|
632
|
+
storagePolicy: updatesStoragePolicy
|
|
633
|
+
? {
|
|
634
|
+
staleChannelSeconds: nextStaleChannelSeconds,
|
|
635
|
+
identityStorageRetentionSeconds: nextIdentityStorageRetentionSeconds
|
|
636
|
+
}
|
|
637
|
+
: undefined,
|
|
638
|
+
indexedDB: this._indexedDB
|
|
639
|
+
})
|
|
640
|
+
this.applyStoragePolicySnapshot(storageSnapshot)
|
|
641
|
+
this.lastStorageTouch = Date.now()
|
|
642
|
+
if (updatesStoragePolicy) this.broadcastStoragePolicyChange()
|
|
643
|
+
|
|
644
|
+
await this.unwatch([...this.channels.keys()])
|
|
645
|
+
this.channels.clear()
|
|
459
646
|
for (const channel of nextChannels) this.channels.set(channel.pubkey, channel)
|
|
460
647
|
|
|
461
|
-
await this.cleanupStaleChannels()
|
|
648
|
+
await this.cleanupStaleChannels({ storageSnapshot })
|
|
462
649
|
await this.applyRecoveryPolicies(nextChannels)
|
|
463
650
|
await this.watch()
|
|
464
651
|
await this.reconcilePresencePublishers()
|
|
652
|
+
if (this.storagePolicyRevision === storageSnapshot.policyRevision) {
|
|
653
|
+
this.storagePolicyNeedsApply = false
|
|
654
|
+
}
|
|
465
655
|
return this
|
|
466
656
|
}
|
|
467
657
|
|
|
@@ -551,14 +741,19 @@ export class PrivateMessenger {
|
|
|
551
741
|
return structuredClone(this.state)
|
|
552
742
|
}
|
|
553
743
|
|
|
554
|
-
writeState (state) {
|
|
555
|
-
this.touchStorageActivity().catch(err => this.onError?.(err))
|
|
744
|
+
writeState (state, { touchStorage = true } = {}) {
|
|
745
|
+
if (touchStorage) this.touchStorageActivity().catch(err => this.onError?.(err))
|
|
746
|
+
const previous = this.state?.channels || {}
|
|
556
747
|
const next = {
|
|
557
748
|
channels: isPlainObject(state?.channels) ? structuredClone(state.channels) : {}
|
|
558
749
|
}
|
|
750
|
+
const changed = Object.fromEntries(Object.entries(next.channels)
|
|
751
|
+
.filter(([pubkey, value]) => !sameStateValue(previous[pubkey], value)))
|
|
752
|
+
const removed = Object.keys(previous).filter(pubkey => !Object.hasOwn(next.channels, pubkey))
|
|
559
753
|
this.state = next
|
|
560
|
-
|
|
561
|
-
const
|
|
754
|
+
if (!Object.keys(changed).length && !removed.length) return this.stateWriteTail
|
|
755
|
+
const snapshot = structuredClone(changed)
|
|
756
|
+
const write = this.stateWriteTail.then(() => this.stateStore.update(snapshot, removed))
|
|
562
757
|
this.stateWriteTail = write.catch(err => {
|
|
563
758
|
try { this.onError?.(err) } catch {}
|
|
564
759
|
})
|
|
@@ -569,6 +764,29 @@ export class PrivateMessenger {
|
|
|
569
764
|
await this.stateWriteTail
|
|
570
765
|
}
|
|
571
766
|
|
|
767
|
+
async stampChannelActivity (pubkeys) {
|
|
768
|
+
if (!this.stateStore || !this.state) return false
|
|
769
|
+
pubkeys = [...new Set(pubkeys || [])].filter(pubkey => this.state.channels[pubkey])
|
|
770
|
+
if (!pubkeys.length) return false
|
|
771
|
+
const lastWatchedAt = nowSeconds()
|
|
772
|
+
for (const pubkey of pubkeys) {
|
|
773
|
+
this.state.channels[pubkey] = {
|
|
774
|
+
...this.state.channels[pubkey],
|
|
775
|
+
lastWatchedAt
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
const write = this.stateWriteTail.then(() => this.stateStore.touch(pubkeys, lastWatchedAt))
|
|
779
|
+
this.stateWriteTail = write.catch(err => {
|
|
780
|
+
try { this.onError?.(err) } catch {}
|
|
781
|
+
})
|
|
782
|
+
await write
|
|
783
|
+
return true
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
stampActiveChannelActivity () {
|
|
787
|
+
return this.stampChannelActivity([...this.channels.keys()])
|
|
788
|
+
}
|
|
789
|
+
|
|
572
790
|
updateChannelState (pubkey, patch) {
|
|
573
791
|
const state = this.readState()
|
|
574
792
|
const current = state.channels[pubkey] || {}
|
|
@@ -578,9 +796,19 @@ export class PrivateMessenger {
|
|
|
578
796
|
}
|
|
579
797
|
|
|
580
798
|
removeChannelState (pubkey) {
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
799
|
+
this.removeChannelStates([pubkey])
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
removeChannelStates (pubkeys) {
|
|
803
|
+
pubkeys = [...new Set(pubkeys || [])]
|
|
804
|
+
for (const pubkey of pubkeys) delete this.state.channels[pubkey]
|
|
805
|
+
if (!pubkeys.length) return this.stateWriteTail
|
|
806
|
+
this.touchStorageActivity().catch(err => this.onError?.(err))
|
|
807
|
+
const write = this.stateWriteTail.then(() => this.stateStore.update({}, pubkeys))
|
|
808
|
+
this.stateWriteTail = write.catch(err => {
|
|
809
|
+
try { this.onError?.(err) } catch {}
|
|
810
|
+
})
|
|
811
|
+
return write
|
|
584
812
|
}
|
|
585
813
|
|
|
586
814
|
markSeen (pubkey, createdAt = nowSeconds()) {
|
|
@@ -1270,7 +1498,7 @@ export class PrivateMessenger {
|
|
|
1270
1498
|
return channel.autoDeletionCapability ?? this.autoDeletionCapability
|
|
1271
1499
|
}
|
|
1272
1500
|
|
|
1273
|
-
|
|
1501
|
+
requestedOfflineRecoverySecondsFor (channelOrPubkey) {
|
|
1274
1502
|
const channel = typeof channelOrPubkey === 'string'
|
|
1275
1503
|
? this.channels.get(channelOrPubkey)
|
|
1276
1504
|
: channelOrPubkey
|
|
@@ -1282,6 +1510,18 @@ export class PrivateMessenger {
|
|
|
1282
1510
|
: normalizeOfflineRecoverySeconds(persisted)
|
|
1283
1511
|
}
|
|
1284
1512
|
|
|
1513
|
+
offlineRecoverySecondsFor (channelOrPubkey) {
|
|
1514
|
+
return Math.min(
|
|
1515
|
+
this.requestedOfflineRecoverySecondsFor(channelOrPubkey),
|
|
1516
|
+
this.staleChannelSeconds,
|
|
1517
|
+
this.identityStorageRetentionSeconds
|
|
1518
|
+
)
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
staleChannelSecondsForCleanup () {
|
|
1522
|
+
return Math.min(this.staleChannelSeconds, this.identityStorageRetentionSeconds)
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1285
1525
|
eventExpirationSecondsFor (channel) {
|
|
1286
1526
|
return this.offlineRecoverySecondsFor(channel) || privateChannel.EXPIRATION_SECONDS
|
|
1287
1527
|
}
|
|
@@ -1297,13 +1537,14 @@ export class PrivateMessenger {
|
|
|
1297
1537
|
const now = nowSeconds()
|
|
1298
1538
|
for (const channel of channels) {
|
|
1299
1539
|
const current = state.channels[channel.pubkey] || {}
|
|
1300
|
-
const
|
|
1301
|
-
|
|
1302
|
-
|
|
1540
|
+
const requestedSeconds = channel.offlineRecoverySeconds
|
|
1541
|
+
const effectiveSeconds = this.offlineRecoverySecondsFor(channel)
|
|
1542
|
+
current.offlineRecoverySeconds = requestedSeconds
|
|
1543
|
+
if (!effectiveSeconds) {
|
|
1303
1544
|
delete current.openOfflineStart
|
|
1304
1545
|
current.offlineRanges = []
|
|
1305
1546
|
} else {
|
|
1306
|
-
const cutoff = now -
|
|
1547
|
+
const cutoff = now - effectiveSeconds
|
|
1307
1548
|
current.offlineRanges = mergeRanges((current.offlineRanges || [])
|
|
1308
1549
|
.filter(range => range.end >= cutoff)
|
|
1309
1550
|
.map(range => ({ ...range, start: Math.max(range.start, cutoff) })))
|
|
@@ -1568,6 +1809,7 @@ export class PrivateMessenger {
|
|
|
1568
1809
|
await this.flushStateWrites()
|
|
1569
1810
|
await this.queue.removeBy('byChannel', pubkey)
|
|
1570
1811
|
await this.seedQueue.removeBy('byChannel', pubkey)
|
|
1812
|
+
await this.touchStorageActivity({ force: true })
|
|
1571
1813
|
this.ensureRelayListWatcher()
|
|
1572
1814
|
})
|
|
1573
1815
|
}
|
|
@@ -1576,19 +1818,26 @@ export class PrivateMessenger {
|
|
|
1576
1818
|
return this.runQueueOperation(() => this.queue.clear())
|
|
1577
1819
|
}
|
|
1578
1820
|
|
|
1579
|
-
async cleanupStaleChannels () {
|
|
1821
|
+
async cleanupStaleChannels ({ storageSnapshot } = {}) {
|
|
1580
1822
|
if (!this.prefix) return
|
|
1823
|
+
storageSnapshot ||= await this.readStoragePolicySnapshot()
|
|
1824
|
+
if (!storageSnapshot) return
|
|
1825
|
+
const activeChannelPubkeys = new Set(storageSnapshot.activeChannelPubkeys || [])
|
|
1581
1826
|
return this.runQueueOperation(async () => {
|
|
1582
|
-
|
|
1583
|
-
const
|
|
1827
|
+
await this.flushStateWrites()
|
|
1828
|
+
const state = { channels: await this.stateStore.load() }
|
|
1829
|
+
const cutoff = nowSeconds() - this.staleChannelSecondsForCleanup()
|
|
1830
|
+
const stalePubkeys = []
|
|
1584
1831
|
for (const [pubkey, channel] of Object.entries(state.channels)) {
|
|
1832
|
+
if (activeChannelPubkeys.has(pubkey)) continue
|
|
1585
1833
|
if ((channel.lastWatchedAt || 0) >= cutoff) continue
|
|
1586
1834
|
delete state.channels[pubkey]
|
|
1835
|
+
stalePubkeys.push(pubkey)
|
|
1587
1836
|
await this.queue?.removeBy('byChannel', pubkey)
|
|
1588
1837
|
await this.seedQueue?.removeBy('byChannel', pubkey)
|
|
1589
1838
|
}
|
|
1590
|
-
this.
|
|
1591
|
-
await this.
|
|
1839
|
+
this.state = state
|
|
1840
|
+
if (stalePubkeys.length) await this.removeChannelStates(stalePubkeys)
|
|
1592
1841
|
})
|
|
1593
1842
|
}
|
|
1594
1843
|
|
|
@@ -1636,15 +1885,18 @@ export class PrivateMessenger {
|
|
|
1636
1885
|
this.stopOffline = null
|
|
1637
1886
|
this.stopOnline = null
|
|
1638
1887
|
this.stopStorageMaintenance()
|
|
1888
|
+
this.stopStoragePolicyBroadcast()
|
|
1639
1889
|
|
|
1640
1890
|
this.closePromise = (async () => {
|
|
1641
1891
|
let unwatchError
|
|
1642
1892
|
try { await unwatchPromise } catch (err) { unwatchError = err }
|
|
1643
1893
|
await initSettledPromise
|
|
1894
|
+
await this.stampActiveChannelActivity()
|
|
1644
1895
|
await this.queueOperationTail
|
|
1645
1896
|
await this.stateWriteTail
|
|
1646
1897
|
try { await this.storageTouchPromise } catch {}
|
|
1647
1898
|
await this.storageMaintenancePromise
|
|
1899
|
+
try { await this.storagePolicyRefreshTail } catch {}
|
|
1648
1900
|
await Promise.all([
|
|
1649
1901
|
this.queue?.close?.(),
|
|
1650
1902
|
this.seedQueue?.close?.(),
|
|
@@ -1658,6 +1910,12 @@ export class PrivateMessenger {
|
|
|
1658
1910
|
})
|
|
1659
1911
|
}
|
|
1660
1912
|
this.storageActive = false
|
|
1913
|
+
if (this.identityStorageRetentionSeconds === 0) {
|
|
1914
|
+
await PrivateMessenger.maintainStorage({
|
|
1915
|
+
indexedDB: this._indexedDB,
|
|
1916
|
+
temporaryStorageArea: this.temporaryStorageArea
|
|
1917
|
+
})
|
|
1918
|
+
}
|
|
1661
1919
|
if (unwatchError) throw unwatchError
|
|
1662
1920
|
})()
|
|
1663
1921
|
return this.closePromise
|
|
@@ -11,12 +11,14 @@ database `${prefix}:state:idb`, version 1
|
|
|
11
11
|
channels, keyPath "pubkey"
|
|
12
12
|
pubkey watched channel public key
|
|
13
13
|
value evolving channel-recovery state, including last-seen/watched times,
|
|
14
|
-
mode, relays, seeders,
|
|
14
|
+
mode, relays, seeders, requested offline-recovery retention, offline
|
|
15
15
|
ranges, active offline-range start,
|
|
16
16
|
per-seeder activity, and sent/received content-key usage
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
Changed channel records are upserted individually and explicit removals are
|
|
19
|
+
applied in the same transaction. This prevents one active instance from
|
|
20
|
+
clearing state owned by another instance of the same principal identity.
|
|
21
|
+
`value` remains an extensible internal object rather than a public schema.
|
|
20
22
|
*/
|
|
21
23
|
|
|
22
24
|
function deferred () {
|
|
@@ -108,16 +110,34 @@ export async function createChannelStateStore ({ prefix, indexedDB = globalThis.
|
|
|
108
110
|
})
|
|
109
111
|
}
|
|
110
112
|
|
|
111
|
-
async function
|
|
113
|
+
async function update (channels, removedPubkeys = []) {
|
|
112
114
|
const snapshot = cloneChannels(channels)
|
|
115
|
+
const removals = [...new Set(removedPubkeys || [])]
|
|
113
116
|
await runTransaction('readwrite', async tx => {
|
|
114
|
-
await run('
|
|
117
|
+
for (const pubkey of removals) await run('delete', [pubkey], CHANNELS_STORE, null, { tx })
|
|
115
118
|
for (const [pubkey, value] of Object.entries(snapshot)) {
|
|
116
119
|
await run('put', [{ pubkey, value }], CHANNELS_STORE, null, { tx })
|
|
117
120
|
}
|
|
118
121
|
})
|
|
119
122
|
}
|
|
120
123
|
|
|
124
|
+
async function touch (pubkeys, lastWatchedAt) {
|
|
125
|
+
const uniquePubkeys = [...new Set(pubkeys || [])]
|
|
126
|
+
if (!uniquePubkeys.length) return
|
|
127
|
+
await runTransaction('readwrite', async tx => {
|
|
128
|
+
for (const pubkey of uniquePubkeys) {
|
|
129
|
+
const current = (await run('get', [pubkey], CHANNELS_STORE, null, { tx })).result
|
|
130
|
+
await run('put', [{
|
|
131
|
+
pubkey,
|
|
132
|
+
value: {
|
|
133
|
+
...(current?.value && typeof current.value === 'object' ? current.value : {}),
|
|
134
|
+
lastWatchedAt
|
|
135
|
+
}
|
|
136
|
+
}], CHANNELS_STORE, null, { tx })
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
|
|
121
141
|
function close () {
|
|
122
142
|
if (closePromise) return closePromise
|
|
123
143
|
closed = true
|
|
@@ -128,5 +148,5 @@ export async function createChannelStateStore ({ prefix, indexedDB = globalThis.
|
|
|
128
148
|
return closePromise
|
|
129
149
|
}
|
|
130
150
|
|
|
131
|
-
return { load,
|
|
151
|
+
return { load, update, touch, close }
|
|
132
152
|
}
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { run } from '../../idb/index.js'
|
|
2
2
|
import { cleanupReceivedChunkStorage } from '../../private-channel/services/received-chunks.js'
|
|
3
3
|
import { cleanupTemporaryStorage } from '../../temporary-storage/index.js'
|
|
4
|
+
import { DEFAULT_STALE_CHANNEL_SECONDS } from '../constants/index.js'
|
|
4
5
|
|
|
5
6
|
const REGISTRY_DATABASE = 'libp2r2p:private-messenger:registry:idb'
|
|
6
7
|
const REGISTRY_VERSION = 1
|
|
7
|
-
const
|
|
8
|
+
const STORAGE_SETS_STORE = 'storageSets'
|
|
8
9
|
const LEASES_STORE = 'leases'
|
|
9
10
|
const LOCK_NAME = 'libp2r2p:private-messenger:storage-maintenance'
|
|
10
11
|
const LEASE_MS = 2 * 60 * 60 * 1000 // 2 hours
|
|
11
|
-
const
|
|
12
|
+
const DEFAULT_IDENTITY_STORAGE_RETENTION_SECONDS = 60 * 24 * 60 * 60
|
|
13
|
+
const MAX_RETENTION_SECONDS = Math.floor(Number.MAX_SAFE_INTEGER / 1000)
|
|
12
14
|
const MAX_RETRY_MS = 5 * 60 * 1000
|
|
13
15
|
|
|
14
16
|
const localLocks = new WeakMap()
|
|
@@ -19,19 +21,23 @@ IndexedDB schema, shared by every PrivateMessenger instance on this origin:
|
|
|
19
21
|
|
|
20
22
|
database "libp2r2p:private-messenger:registry:idb", version 1
|
|
21
23
|
|
|
22
|
-
|
|
24
|
+
storageSets, keyPath "userPubkey"
|
|
23
25
|
userPubkey primary signer public key and storage-set coordinate
|
|
24
26
|
lastUsedAt most recent durable activity in milliseconds
|
|
25
27
|
leaseUntil active-instance lease expiry in milliseconds
|
|
26
28
|
status "ready" or "delete_pending"
|
|
27
29
|
attempts consecutive failed storage-set deletions
|
|
28
30
|
nextAttemptAt earliest retry time in milliseconds
|
|
31
|
+
staleChannelSeconds channel-state retention requested by the last configuring instance
|
|
32
|
+
identityStorageRetentionSeconds identity database-set retention after its last activity
|
|
33
|
+
policyRevision monotonically increasing storage-policy revision
|
|
29
34
|
|
|
30
35
|
leases, keyPath "key"
|
|
31
36
|
key `${userPubkey}:${leaseId}`
|
|
32
37
|
userPubkey principal signer public key
|
|
33
38
|
leaseId per-PrivateMessenger instance identifier
|
|
34
39
|
leaseUntil instance lease expiry in milliseconds
|
|
40
|
+
activeChannelPubkeys channels currently administered by that instance
|
|
35
41
|
|
|
36
42
|
leases indexes
|
|
37
43
|
byUserPubkey userPubkey
|
|
@@ -53,8 +59,8 @@ function openRegistry (indexedDB) {
|
|
|
53
59
|
request.onblocked = () => reject(new Error('IDB_DATABASE_BLOCKED'))
|
|
54
60
|
request.onupgradeneeded = () => {
|
|
55
61
|
const db = request.result
|
|
56
|
-
if (!db.objectStoreNames.contains(
|
|
57
|
-
db.createObjectStore(
|
|
62
|
+
if (!db.objectStoreNames.contains(STORAGE_SETS_STORE)) {
|
|
63
|
+
db.createObjectStore(STORAGE_SETS_STORE, { keyPath: 'userPubkey' })
|
|
58
64
|
}
|
|
59
65
|
if (!db.objectStoreNames.contains(LEASES_STORE)) {
|
|
60
66
|
const leases = db.createObjectStore(LEASES_STORE, { keyPath: 'key' })
|
|
@@ -72,7 +78,7 @@ function openRegistry (indexedDB) {
|
|
|
72
78
|
async function withRegistryTransaction (indexedDB, mode, work) {
|
|
73
79
|
const db = await openRegistry(indexedDB)
|
|
74
80
|
try {
|
|
75
|
-
const tx = db.transaction([
|
|
81
|
+
const tx = db.transaction([STORAGE_SETS_STORE, LEASES_STORE], mode)
|
|
76
82
|
const done = transactionDone(tx)
|
|
77
83
|
try {
|
|
78
84
|
const result = await work(tx)
|
|
@@ -100,9 +106,20 @@ async function readLeases (tx, userPubkey) {
|
|
|
100
106
|
return records.filter(record => record?.userPubkey === userPubkey)
|
|
101
107
|
}
|
|
102
108
|
|
|
103
|
-
|
|
104
|
-
|
|
109
|
+
function normalizeRetentionSeconds (value, fallback) {
|
|
110
|
+
return Number.isSafeInteger(value) && value >= 0 && value <= MAX_RETENTION_SECONDS
|
|
111
|
+
? value
|
|
112
|
+
: fallback
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function normalizeActiveChannelPubkeys (values) {
|
|
116
|
+
return [...new Set((values || []).filter(value => typeof value === 'string' && value))].sort()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function refreshLeaseState (tx, storageSet, now, { removeExpired = false } = {}) {
|
|
120
|
+
const leases = await readLeases(tx, storageSet.userPubkey)
|
|
105
121
|
let leaseUntil = 0
|
|
122
|
+
const activeChannelPubkeys = new Set()
|
|
106
123
|
for (const lease of leases) {
|
|
107
124
|
const expiresAt = Math.max(0, Number(lease.leaseUntil) || 0)
|
|
108
125
|
if (expiresAt <= now) {
|
|
@@ -110,12 +127,18 @@ async function refreshLeaseUntil (tx, bundle, now, { removeExpired = false } = {
|
|
|
110
127
|
continue
|
|
111
128
|
}
|
|
112
129
|
leaseUntil = Math.max(leaseUntil, expiresAt)
|
|
130
|
+
for (const pubkey of normalizeActiveChannelPubkeys(lease.activeChannelPubkeys)) {
|
|
131
|
+
activeChannelPubkeys.add(pubkey)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
storageSet.leaseUntil = leaseUntil
|
|
135
|
+
return {
|
|
136
|
+
leaseUntil,
|
|
137
|
+
activeChannelPubkeys: [...activeChannelPubkeys].sort()
|
|
113
138
|
}
|
|
114
|
-
bundle.leaseUntil = leaseUntil
|
|
115
|
-
return leaseUntil
|
|
116
139
|
}
|
|
117
140
|
|
|
118
|
-
function
|
|
141
|
+
function normalizeStorageSet (record) {
|
|
119
142
|
if (!record?.userPubkey) return null
|
|
120
143
|
return {
|
|
121
144
|
userPubkey: String(record.userPubkey),
|
|
@@ -123,7 +146,24 @@ function normalizeBundle (record) {
|
|
|
123
146
|
leaseUntil: Math.max(0, Number(record.leaseUntil) || 0),
|
|
124
147
|
status: record.status === 'delete_pending' ? 'delete_pending' : 'ready',
|
|
125
148
|
attempts: Math.max(0, Math.floor(Number(record.attempts) || 0)),
|
|
126
|
-
nextAttemptAt: Math.max(0, Number(record.nextAttemptAt) || 0)
|
|
149
|
+
nextAttemptAt: Math.max(0, Number(record.nextAttemptAt) || 0),
|
|
150
|
+
staleChannelSeconds: normalizeRetentionSeconds(
|
|
151
|
+
record.staleChannelSeconds,
|
|
152
|
+
DEFAULT_STALE_CHANNEL_SECONDS
|
|
153
|
+
),
|
|
154
|
+
identityStorageRetentionSeconds: normalizeRetentionSeconds(
|
|
155
|
+
record.identityStorageRetentionSeconds,
|
|
156
|
+
DEFAULT_IDENTITY_STORAGE_RETENTION_SECONDS
|
|
157
|
+
),
|
|
158
|
+
policyRevision: Math.max(0, Math.floor(Number(record.policyRevision) || 0))
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function storagePolicy (storageSet) {
|
|
163
|
+
return {
|
|
164
|
+
staleChannelSeconds: storageSet.staleChannelSeconds,
|
|
165
|
+
identityStorageRetentionSeconds: storageSet.identityStorageRetentionSeconds,
|
|
166
|
+
policyRevision: storageSet.policyRevision
|
|
127
167
|
}
|
|
128
168
|
}
|
|
129
169
|
|
|
@@ -178,23 +218,29 @@ function retryDelay (attempts) {
|
|
|
178
218
|
return Math.min(MAX_RETRY_MS, 1000 * (2 ** Math.min(9, Math.max(0, attempts - 1))))
|
|
179
219
|
}
|
|
180
220
|
|
|
181
|
-
async function
|
|
221
|
+
async function markAndListDueStorageSets (indexedDB, now) {
|
|
182
222
|
return withRegistryTransaction(indexedDB, 'readwrite', async tx => {
|
|
183
|
-
const records = (await run('getAll', [],
|
|
223
|
+
const records = (await run('getAll', [], STORAGE_SETS_STORE, null, { tx })).result
|
|
184
224
|
const due = []
|
|
185
225
|
for (const raw of records) {
|
|
186
|
-
const
|
|
187
|
-
if (!
|
|
188
|
-
await
|
|
189
|
-
const leaseActive =
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
226
|
+
const storageSet = normalizeStorageSet(raw)
|
|
227
|
+
if (!storageSet) continue
|
|
228
|
+
await refreshLeaseState(tx, storageSet, now, { removeExpired: true })
|
|
229
|
+
const leaseActive = storageSet.leaseUntil > now
|
|
230
|
+
const retentionMs = storageSet.identityStorageRetentionSeconds * 1000
|
|
231
|
+
const expired = storageSet.lastUsedAt + retentionMs <= now
|
|
232
|
+
if (leaseActive || !expired) {
|
|
233
|
+
storageSet.status = 'ready'
|
|
234
|
+
storageSet.attempts = 0
|
|
235
|
+
storageSet.nextAttemptAt = 0
|
|
236
|
+
} else if (storageSet.status === 'ready') {
|
|
237
|
+
storageSet.status = 'delete_pending'
|
|
238
|
+
storageSet.attempts = 0
|
|
239
|
+
storageSet.nextAttemptAt = now
|
|
194
240
|
}
|
|
195
|
-
await run('put', [
|
|
196
|
-
if (
|
|
197
|
-
due.push(
|
|
241
|
+
await run('put', [storageSet], STORAGE_SETS_STORE, null, { tx })
|
|
242
|
+
if (storageSet.status === 'delete_pending' && !leaseActive && storageSet.nextAttemptAt <= now) {
|
|
243
|
+
due.push(storageSet)
|
|
198
244
|
}
|
|
199
245
|
}
|
|
200
246
|
return due
|
|
@@ -203,35 +249,43 @@ async function markAndListDueBundles (indexedDB, now) {
|
|
|
203
249
|
|
|
204
250
|
async function finishDeleteAttempt (indexedDB, attempted, deleted, now) {
|
|
205
251
|
return withRegistryTransaction(indexedDB, 'readwrite', async tx => {
|
|
206
|
-
const current =
|
|
207
|
-
if (!current || current.status !== 'delete_pending'
|
|
252
|
+
const current = normalizeStorageSet((await run('get', [attempted.userPubkey], STORAGE_SETS_STORE, null, { tx })).result)
|
|
253
|
+
if (!current || current.status !== 'delete_pending') return false
|
|
254
|
+
await refreshLeaseState(tx, current, now, { removeExpired: true })
|
|
255
|
+
if (current.leaseUntil > now) {
|
|
256
|
+
current.status = 'ready'
|
|
257
|
+
current.attempts = 0
|
|
258
|
+
current.nextAttemptAt = 0
|
|
259
|
+
await run('put', [current], STORAGE_SETS_STORE, null, { tx })
|
|
260
|
+
return false
|
|
261
|
+
}
|
|
208
262
|
if (deleted) {
|
|
209
263
|
for (const lease of await readLeases(tx, attempted.userPubkey)) {
|
|
210
264
|
await run('delete', [lease.key], LEASES_STORE, null, { tx })
|
|
211
265
|
}
|
|
212
|
-
await run('delete', [attempted.userPubkey],
|
|
266
|
+
await run('delete', [attempted.userPubkey], STORAGE_SETS_STORE, null, { tx })
|
|
213
267
|
return true
|
|
214
268
|
}
|
|
215
269
|
current.attempts++
|
|
216
270
|
current.nextAttemptAt = now + retryDelay(current.attempts)
|
|
217
|
-
await run('put', [current],
|
|
271
|
+
await run('put', [current], STORAGE_SETS_STORE, null, { tx })
|
|
218
272
|
return false
|
|
219
273
|
})
|
|
220
274
|
}
|
|
221
275
|
|
|
222
276
|
async function maintainRegistry (indexedDB, now) {
|
|
223
|
-
const due = await
|
|
224
|
-
for (const
|
|
225
|
-
const deleted = await deleteStorageSet(indexedDB,
|
|
226
|
-
await finishDeleteAttempt(indexedDB,
|
|
277
|
+
const due = await markAndListDueStorageSets(indexedDB, now)
|
|
278
|
+
for (const storageSet of due) {
|
|
279
|
+
const deleted = await deleteStorageSet(indexedDB, storageSet.userPubkey)
|
|
280
|
+
await finishDeleteAttempt(indexedDB, storageSet, deleted, now)
|
|
227
281
|
}
|
|
228
282
|
const nextAttemptAt = await withRegistryTransaction(indexedDB, 'readonly', async tx => {
|
|
229
|
-
const records = (await run('getAll', [],
|
|
283
|
+
const records = (await run('getAll', [], STORAGE_SETS_STORE, null, { tx })).result
|
|
230
284
|
let earliest = Infinity
|
|
231
285
|
for (const raw of records) {
|
|
232
|
-
const
|
|
233
|
-
if (
|
|
234
|
-
earliest = Math.min(earliest,
|
|
286
|
+
const storageSet = normalizeStorageSet(raw)
|
|
287
|
+
if (storageSet?.status !== 'delete_pending' || storageSet.leaseUntil > now) continue
|
|
288
|
+
earliest = Math.min(earliest, storageSet.nextAttemptAt)
|
|
235
289
|
}
|
|
236
290
|
return Number.isFinite(earliest) ? earliest : null
|
|
237
291
|
})
|
|
@@ -268,6 +322,8 @@ export async function maintainPrivateMessengerStorage ({
|
|
|
268
322
|
export function activatePrivateMessengerStorage ({
|
|
269
323
|
userPubkey,
|
|
270
324
|
leaseId,
|
|
325
|
+
activeChannelPubkeys,
|
|
326
|
+
storagePolicy: nextPolicy,
|
|
271
327
|
indexedDB = globalThis.indexedDB,
|
|
272
328
|
now = Date.now()
|
|
273
329
|
} = {}) {
|
|
@@ -276,22 +332,61 @@ export function activatePrivateMessengerStorage ({
|
|
|
276
332
|
return withMaintenanceLock(indexedDB, () => withRegistryTransaction(indexedDB, 'readwrite', async tx => {
|
|
277
333
|
userPubkey = String(userPubkey)
|
|
278
334
|
leaseId = String(leaseId)
|
|
279
|
-
const current =
|
|
335
|
+
const current = normalizeStorageSet((await run('get', [userPubkey], STORAGE_SETS_STORE, null, { tx })).result)
|
|
336
|
+
const currentLease = (await run('get', [leaseKey(userPubkey, leaseId)], LEASES_STORE, null, { tx })).result
|
|
337
|
+
const channels = activeChannelPubkeys === undefined
|
|
338
|
+
? normalizeActiveChannelPubkeys(currentLease?.activeChannelPubkeys)
|
|
339
|
+
: normalizeActiveChannelPubkeys(activeChannelPubkeys)
|
|
280
340
|
await run('put', [{
|
|
281
341
|
key: leaseKey(userPubkey, leaseId),
|
|
282
342
|
userPubkey,
|
|
283
343
|
leaseId,
|
|
284
|
-
leaseUntil: now + LEASE_MS
|
|
344
|
+
leaseUntil: now + LEASE_MS,
|
|
345
|
+
activeChannelPubkeys: channels
|
|
285
346
|
}], LEASES_STORE, null, { tx })
|
|
286
|
-
const
|
|
347
|
+
const hasPolicy = nextPolicy !== undefined
|
|
348
|
+
const storageSet = {
|
|
287
349
|
userPubkey,
|
|
288
350
|
lastUsedAt: now,
|
|
289
351
|
leaseUntil: Math.max(current?.leaseUntil || 0, now + LEASE_MS),
|
|
290
352
|
status: 'ready',
|
|
291
353
|
attempts: 0,
|
|
292
|
-
nextAttemptAt: 0
|
|
354
|
+
nextAttemptAt: 0,
|
|
355
|
+
staleChannelSeconds: hasPolicy
|
|
356
|
+
? nextPolicy.staleChannelSeconds
|
|
357
|
+
: current?.staleChannelSeconds ?? DEFAULT_STALE_CHANNEL_SECONDS,
|
|
358
|
+
identityStorageRetentionSeconds: hasPolicy
|
|
359
|
+
? nextPolicy.identityStorageRetentionSeconds
|
|
360
|
+
: current?.identityStorageRetentionSeconds ?? DEFAULT_IDENTITY_STORAGE_RETENTION_SECONDS,
|
|
361
|
+
policyRevision: hasPolicy
|
|
362
|
+
? (current?.policyRevision || 0) + 1
|
|
363
|
+
: current?.policyRevision || 0
|
|
364
|
+
}
|
|
365
|
+
await run('put', [storageSet], STORAGE_SETS_STORE, null, { tx })
|
|
366
|
+
const leaseState = await refreshLeaseState(tx, storageSet, now)
|
|
367
|
+
return {
|
|
368
|
+
...storagePolicy(storageSet),
|
|
369
|
+
activeChannelPubkeys: leaseState.activeChannelPubkeys
|
|
370
|
+
}
|
|
371
|
+
}))
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export function readPrivateMessengerStorage ({
|
|
375
|
+
userPubkey,
|
|
376
|
+
indexedDB = globalThis.indexedDB,
|
|
377
|
+
now = Date.now()
|
|
378
|
+
} = {}) {
|
|
379
|
+
if (!userPubkey) return Promise.reject(new Error('USER_PUBKEY_REQUIRED'))
|
|
380
|
+
return withMaintenanceLock(indexedDB, () => withRegistryTransaction(indexedDB, 'readwrite', async tx => {
|
|
381
|
+
userPubkey = String(userPubkey)
|
|
382
|
+
const storageSet = normalizeStorageSet((await run('get', [userPubkey], STORAGE_SETS_STORE, null, { tx })).result)
|
|
383
|
+
if (!storageSet) return null
|
|
384
|
+
const leaseState = await refreshLeaseState(tx, storageSet, now, { removeExpired: true })
|
|
385
|
+
await run('put', [storageSet], STORAGE_SETS_STORE, null, { tx })
|
|
386
|
+
return {
|
|
387
|
+
...storagePolicy(storageSet),
|
|
388
|
+
activeChannelPubkeys: leaseState.activeChannelPubkeys
|
|
293
389
|
}
|
|
294
|
-
await run('put', [bundle], BUNDLES_STORE, null, { tx })
|
|
295
390
|
}))
|
|
296
391
|
}
|
|
297
392
|
|
|
@@ -306,19 +401,20 @@ export function releasePrivateMessengerStorage ({
|
|
|
306
401
|
return withMaintenanceLock(indexedDB, () => withRegistryTransaction(indexedDB, 'readwrite', async tx => {
|
|
307
402
|
userPubkey = String(userPubkey)
|
|
308
403
|
leaseId = String(leaseId)
|
|
309
|
-
const current =
|
|
404
|
+
const current = normalizeStorageSet((await run('get', [userPubkey], STORAGE_SETS_STORE, null, { tx })).result)
|
|
310
405
|
if (!current) return false
|
|
311
406
|
await run('delete', [leaseKey(userPubkey, leaseId)], LEASES_STORE, null, { tx })
|
|
312
|
-
await
|
|
407
|
+
await refreshLeaseState(tx, current, now, { removeExpired: true })
|
|
313
408
|
current.lastUsedAt = now
|
|
314
409
|
current.status = 'ready'
|
|
315
410
|
current.attempts = 0
|
|
316
411
|
current.nextAttemptAt = 0
|
|
317
|
-
await run('put', [current],
|
|
412
|
+
await run('put', [current], STORAGE_SETS_STORE, null, { tx })
|
|
318
413
|
return true
|
|
319
414
|
}))
|
|
320
415
|
}
|
|
321
416
|
|
|
322
417
|
export const PRIVATE_MESSENGER_STORAGE_HEARTBEAT_MS = 60 * 60 * 1000
|
|
323
418
|
export const PRIVATE_MESSENGER_STORAGE_MAINTENANCE_MS = 6 * 60 * 60 * 1000
|
|
324
|
-
export const
|
|
419
|
+
export const PRIVATE_MESSENGER_IDENTITY_STORAGE_RETENTION_MS = DEFAULT_IDENTITY_STORAGE_RETENTION_SECONDS * 1000
|
|
420
|
+
export { DEFAULT_IDENTITY_STORAGE_RETENTION_SECONDS }
|