libp2r2p 0.7.0 → 0.9.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.
Files changed (50) hide show
  1. package/README.md +112 -8
  2. package/base16/index.js +6 -3
  3. package/base36/index.js +12 -8
  4. package/base62/index.js +15 -11
  5. package/base64/index.js +18 -2
  6. package/base93/index.js +19 -7
  7. package/content-key/event/index.js +40 -12
  8. package/double-dh/index.js +21 -6
  9. package/ecdh/index.js +12 -1
  10. package/error/index.js +32 -0
  11. package/event/helpers/serialize.js +31 -0
  12. package/event/index.js +116 -0
  13. package/i18n/index.js +15 -13
  14. package/idb/index.js +5 -3
  15. package/idb-queue/index.js +21 -20
  16. package/index.js +10 -0
  17. package/key/index.js +31 -18
  18. package/kind/index.js +244 -0
  19. package/nip04/index.js +47 -0
  20. package/nip05/index.js +61 -0
  21. package/nip19/index.js +176 -43
  22. package/nip44/helpers.js +95 -0
  23. package/nip44/index.js +14 -0
  24. package/nip44-v3/index.js +35 -16
  25. package/nip46/helpers/frame.js +6 -6
  26. package/nip46/helpers/url.js +8 -6
  27. package/nip46/services/bunker-signer.js +7 -6
  28. package/nip46/services/client.js +8 -7
  29. package/nip46/services/server-session.js +8 -7
  30. package/nip46/services/transport.js +9 -8
  31. package/nip96/index.js +285 -0
  32. package/nip98/index.js +56 -0
  33. package/nwt/index.js +241 -0
  34. package/package.json +22 -3
  35. package/private-channel/helpers/chunks.js +7 -6
  36. package/private-channel/helpers/event.js +5 -4
  37. package/private-channel/index.js +63 -61
  38. package/private-channel/services/received-chunks.js +4 -3
  39. package/private-message/index.js +32 -31
  40. package/private-messenger/index.js +313 -54
  41. package/private-messenger/recovery/index.js +15 -14
  42. package/private-messenger/services/channel-state.js +28 -7
  43. package/private-messenger/services/storage-maintenance.js +142 -46
  44. package/relay/helpers/hll.js +3 -1
  45. package/relay/services/query.js +3 -3
  46. package/relay/services/relay-connection.js +222 -121
  47. package/relay/services/relay-pool.js +13 -10
  48. package/temporary-storage/index.js +3 -1
  49. package/url/index.js +131 -0
  50. package/web-storage-queue/index.js +8 -6
@@ -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 for 45 days is pruned.
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.
@@ -39,6 +41,7 @@
39
41
 
40
42
  import * as privateMessage from '../private-message/index.js'
41
43
  import { bytesToBase64 } from '../base64/index.js'
44
+ import { ValidationError } from '../error/index.js'
42
45
  import { getRelaysByPubkey, pickRelaysForPubkeys, subscribeRelayListUpdates } from '../relay/index.js'
43
46
  import * as privateChannel from '../private-channel/index.js'
44
47
  import { DEFAULT_RECEIVED_CHUNK_TTL_MS } from '../private-channel/services/received-chunks.js'
@@ -47,9 +50,11 @@ import { createChannelStateStore } from './services/channel-state.js'
47
50
  import { DEFAULT_STALE_CHANNEL_SECONDS } from './constants/index.js'
48
51
  import {
49
52
  activatePrivateMessengerStorage,
53
+ DEFAULT_IDENTITY_STORAGE_RETENTION_SECONDS,
50
54
  maintainPrivateMessengerStorage,
51
55
  PRIVATE_MESSENGER_STORAGE_HEARTBEAT_MS,
52
56
  PRIVATE_MESSENGER_STORAGE_MAINTENANCE_MS,
57
+ readPrivateMessengerStorage,
53
58
  releasePrivateMessengerStorage
54
59
  } from './services/storage-maintenance.js'
55
60
  import {
@@ -66,6 +71,7 @@ import {
66
71
  } from './recovery/index.js'
67
72
 
68
73
  export { DEFAULT_STALE_CHANNEL_SECONDS } from './constants/index.js'
74
+ export { DEFAULT_IDENTITY_STORAGE_RETENTION_SECONDS } from './services/storage-maintenance.js'
69
75
  export {
70
76
  compactSeedNymCarriers,
71
77
  compactSeedRouterRows,
@@ -81,6 +87,7 @@ export {
81
87
 
82
88
  const DEFAULT_OFFLINE_RECOVERY_SECONDS = 7 * 24 * 60 * 60
83
89
  const MAX_OFFLINE_RECOVERY_SECONDS = Math.floor(Number.MAX_SAFE_INTEGER / 1000)
90
+ const STORAGE_POLICY_BROADCAST_CHANNEL = 'libp2r2p:private-messenger:storage-policy'
84
91
  const DEFAULT_OFFLINE_SKEW_SECONDS = 30
85
92
  const DEFAULT_RELOAD_GAP_DELAY_MS = 500
86
93
  const DEFAULT_SEEDER_PRESENCE_INTERVAL_MS = 10 * 60 * 1000
@@ -120,7 +127,21 @@ function nowSeconds () {
120
127
 
121
128
  function normalizeOfflineRecoverySeconds (value) {
122
129
  if (!Number.isSafeInteger(value) || value < 0 || value > MAX_OFFLINE_RECOVERY_SECONDS) {
123
- throw new Error('INVALID_OFFLINE_RECOVERY_SECONDS')
130
+ throw new ValidationError('INVALID_OFFLINE_RECOVERY_SECONDS')
131
+ }
132
+ return value
133
+ }
134
+
135
+ function normalizeStaleChannelSeconds (value) {
136
+ if (!Number.isSafeInteger(value) || value < 0 || value > MAX_OFFLINE_RECOVERY_SECONDS) {
137
+ throw new ValidationError('INVALID_STALE_CHANNEL_SECONDS')
138
+ }
139
+ return value
140
+ }
141
+
142
+ function normalizeIdentityStorageRetentionSeconds (value) {
143
+ if (!Number.isSafeInteger(value) || value < 0 || value > MAX_OFFLINE_RECOVERY_SECONDS) {
144
+ throw new ValidationError('INVALID_IDENTITY_STORAGE_RETENTION_SECONDS')
124
145
  }
125
146
  return value
126
147
  }
@@ -130,7 +151,7 @@ function uniq (values) {
130
151
  }
131
152
 
132
153
  function normalizeAutoDeletionCapability (value) {
133
- if (typeof value !== 'boolean') throw new Error('AUTO_DELETION_CAPABILITY_BOOLEAN_REQUIRED')
154
+ if (typeof value !== 'boolean') throw new ValidationError('AUTO_DELETION_CAPABILITY_BOOLEAN_REQUIRED')
134
155
  return value
135
156
  }
136
157
 
@@ -142,7 +163,11 @@ function parseJson (raw, fallback) {
142
163
  try { return JSON.parse(raw || '') } catch { return fallback }
143
164
  }
144
165
 
145
- function storesRecoverySeeds (mode) {
166
+ function areStateValuesEqual (left, right) {
167
+ return JSON.stringify(left) === JSON.stringify(right)
168
+ }
169
+
170
+ function doesModeStoreRecoverySeeds (mode) {
146
171
  return mode === 'seeder' || mode === 'watchtower'
147
172
  }
148
173
 
@@ -164,6 +189,7 @@ export class PrivateMessenger {
164
189
  constructor ({
165
190
  offlineRecoverySeconds = DEFAULT_OFFLINE_RECOVERY_SECONDS,
166
191
  staleChannelSeconds = DEFAULT_STALE_CHANNEL_SECONDS,
192
+ identityStorageRetentionSeconds = DEFAULT_IDENTITY_STORAGE_RETENTION_SECONDS,
167
193
  offlineSkewSeconds = DEFAULT_OFFLINE_SKEW_SECONDS,
168
194
  reloadGapDelayMs = DEFAULT_RELOAD_GAP_DELAY_MS,
169
195
  seederPresenceIntervalMs = DEFAULT_SEEDER_PRESENCE_INTERVAL_MS,
@@ -188,10 +214,12 @@ export class PrivateMessenger {
188
214
  _setInterval = globalThis.setInterval.bind(globalThis),
189
215
  _clearInterval = globalThis.clearInterval.bind(globalThis),
190
216
  _storageSetInterval = globalThis.setInterval.bind(globalThis),
191
- _storageClearInterval = globalThis.clearInterval.bind(globalThis)
217
+ _storageClearInterval = globalThis.clearInterval.bind(globalThis),
218
+ _BroadcastChannel = _indexedDB === globalThis.indexedDB ? globalThis.BroadcastChannel : undefined
192
219
  } = {}) {
193
220
  this.offlineRecoverySeconds = normalizeOfflineRecoverySeconds(offlineRecoverySeconds)
194
- this.staleChannelSeconds = staleChannelSeconds
221
+ this.staleChannelSeconds = normalizeStaleChannelSeconds(staleChannelSeconds)
222
+ this.identityStorageRetentionSeconds = normalizeIdentityStorageRetentionSeconds(identityStorageRetentionSeconds)
195
223
  this.offlineSkewSeconds = offlineSkewSeconds
196
224
  this.reloadGapDelayMs = reloadGapDelayMs
197
225
  this.seederPresenceIntervalMs = seederPresenceIntervalMs
@@ -217,6 +245,7 @@ export class PrivateMessenger {
217
245
  this._clearInterval = _clearInterval
218
246
  this._storageSetInterval = _storageSetInterval
219
247
  this._storageClearInterval = _storageClearInterval
248
+ this._BroadcastChannel = _BroadcastChannel
220
249
 
221
250
  this.userSigner = null
222
251
  this.contentKeySigner = null
@@ -245,13 +274,17 @@ export class PrivateMessenger {
245
274
  this.storageHeartbeatTimer = null
246
275
  this.storageMaintenanceTimer = null
247
276
  this.storageMaintenancePromise = null
277
+ this.storagePolicyRevision = 0
278
+ this.storagePolicyNeedsApply = false
279
+ this.storagePolicyBroadcast = null
280
+ this.storagePolicyRefreshTail = Promise.resolve()
248
281
  this.closePromise = null
249
282
  this.initSettledPromise = null
250
283
  this.initialized = false
251
284
  }
252
285
 
253
286
  async init ({ userSigner, contentKeySigner, nymSigner, channels = [], relays = [], mode = 'leecher' }) {
254
- if (!userSigner?.getPublicKey) throw new Error('USER_SIGNER_REQUIRED')
287
+ if (!userSigner?.getPublicKey) throw new ValidationError('USER_SIGNER_REQUIRED')
255
288
  this.assertOpen()
256
289
  if (this.initSettledPromise) throw new Error('PRIVATE_MESSENGER_INIT_IN_PROGRESS')
257
290
  if (this.initialized) throw new Error('PRIVATE_MESSENGER_ALREADY_INITIALIZED')
@@ -264,13 +297,20 @@ export class PrivateMessenger {
264
297
  this.nymSigner = nymSigner || null
265
298
  this.userPubkey = await userSigner.getPublicKey()
266
299
  this.prefix = `libp2r2p:private-messenger:${this.userPubkey}`
267
- await activatePrivateMessengerStorage({
300
+ const storageSnapshot = await activatePrivateMessengerStorage({
268
301
  userPubkey: this.userPubkey,
269
302
  leaseId: this.storageLeaseId,
303
+ activeChannelPubkeys: [],
304
+ storagePolicy: {
305
+ staleChannelSeconds: this.staleChannelSeconds,
306
+ identityStorageRetentionSeconds: this.identityStorageRetentionSeconds
307
+ },
270
308
  indexedDB: this._indexedDB
271
309
  })
310
+ this.applyStoragePolicySnapshot(storageSnapshot)
272
311
  this.storageActive = true
273
312
  this.lastStorageTouch = Date.now()
313
+ this.startStoragePolicyBroadcast()
274
314
  this.assertOpen()
275
315
  await PrivateMessenger.maintainStorage({
276
316
  indexedDB: this._indexedDB,
@@ -302,13 +342,15 @@ export class PrivateMessenger {
302
342
  this.assertOpen()
303
343
  this.startStorageMaintenance()
304
344
  this.state = { channels: await this.stateStore.load() }
305
- await this.cleanupStaleChannels()
306
345
  await this.update({ userSigner, contentKeySigner, nymSigner: this.nymSigner, channels, relays, mode })
307
346
  await this.pruneStoredSeeds()
308
347
  this.initialized = true
348
+ await this.refreshAndApplyStoragePolicy()
349
+ this.broadcastStoragePolicyChange()
309
350
  return this
310
351
  } catch (err) {
311
352
  this.stopStorageMaintenance()
353
+ this.stopStoragePolicyBroadcast()
312
354
  try { await this.queueOperationTail } catch {}
313
355
  try { await this.stateWriteTail } catch {}
314
356
  await Promise.allSettled([
@@ -338,10 +380,102 @@ export class PrivateMessenger {
338
380
  if (this.closePromise) throw new Error('PRIVATE_MESSENGER_CLOSED')
339
381
  }
340
382
 
383
+ applyStoragePolicySnapshot (snapshot) {
384
+ if (!snapshot) return false
385
+ const staleChannelSeconds = normalizeStaleChannelSeconds(snapshot.staleChannelSeconds)
386
+ const identityStorageRetentionSeconds = normalizeIdentityStorageRetentionSeconds(
387
+ snapshot.identityStorageRetentionSeconds
388
+ )
389
+ const policyRevision = Math.max(0, Number(snapshot.policyRevision) || 0)
390
+ const changed = this.staleChannelSeconds !== staleChannelSeconds ||
391
+ this.identityStorageRetentionSeconds !== identityStorageRetentionSeconds
392
+ this.staleChannelSeconds = staleChannelSeconds
393
+ this.identityStorageRetentionSeconds = identityStorageRetentionSeconds
394
+ this.storagePolicyRevision = policyRevision
395
+ if (changed && this.initialized) this.storagePolicyNeedsApply = true
396
+ return changed
397
+ }
398
+
399
+ startStoragePolicyBroadcast () {
400
+ if (this.storagePolicyBroadcast || typeof this._BroadcastChannel !== 'function') return
401
+ try {
402
+ const channel = new this._BroadcastChannel(STORAGE_POLICY_BROADCAST_CHANNEL)
403
+ channel.unref?.()
404
+ channel.onmessage = event => {
405
+ const message = event?.data
406
+ if (message?.userPubkey !== this.userPubkey) return
407
+ if (!Number.isSafeInteger(message.policyRevision) || message.policyRevision <= (this.storagePolicyRevision || 0)) return
408
+ this.refreshAndApplyStoragePolicy().catch(err => {
409
+ try { this.onError?.(err) } catch {}
410
+ })
411
+ }
412
+ this.storagePolicyBroadcast = channel
413
+ } catch {}
414
+ }
415
+
416
+ stopStoragePolicyBroadcast () {
417
+ const channel = this.storagePolicyBroadcast
418
+ this.storagePolicyBroadcast = null
419
+ if (!channel) return
420
+ channel.onmessage = null
421
+ channel.close?.()
422
+ }
423
+
424
+ broadcastStoragePolicyChange () {
425
+ try {
426
+ this.storagePolicyBroadcast?.postMessage({
427
+ userPubkey: this.userPubkey,
428
+ policyRevision: this.storagePolicyRevision || 0
429
+ })
430
+ } catch (err) {
431
+ try { this.onError?.(err) } catch {}
432
+ }
433
+ }
434
+
435
+ async readStoragePolicySnapshot () {
436
+ if (!this.userPubkey) return null
437
+ const snapshot = await readPrivateMessengerStorage({
438
+ userPubkey: this.userPubkey,
439
+ indexedDB: this._indexedDB
440
+ })
441
+ this.applyStoragePolicySnapshot(snapshot)
442
+ return snapshot
443
+ }
444
+
445
+ async applyPendingStoragePolicy () {
446
+ if (!this.storagePolicyNeedsApply || !this.initialized || this.closePromise) return false
447
+ this.storagePolicyNeedsApply = false
448
+ try {
449
+ const channels = [...this.channels.values()]
450
+ await this.applyRecoveryPolicies(channels)
451
+ await this.cleanupStaleChannels()
452
+ const pubkeys = [...this.channels.keys()]
453
+ if (pubkeys.length) {
454
+ await this.unwatch(pubkeys)
455
+ await this.watch(pubkeys)
456
+ }
457
+ await this.reconcilePresencePublishers()
458
+ return true
459
+ } catch (err) {
460
+ this.storagePolicyNeedsApply = true
461
+ throw err
462
+ }
463
+ }
464
+
465
+ refreshAndApplyStoragePolicy () {
466
+ const previous = this.storagePolicyRefreshTail || Promise.resolve()
467
+ const refresh = previous.catch(() => {}).then(async () => {
468
+ await this.readStoragePolicySnapshot()
469
+ return this.applyPendingStoragePolicy()
470
+ })
471
+ this.storagePolicyRefreshTail = refresh
472
+ return refresh
473
+ }
474
+
341
475
  startStorageMaintenance () {
342
476
  if (this.storageHeartbeatTimer || this.storageMaintenanceTimer) return
343
477
  this.storageHeartbeatTimer = this._storageSetInterval(() => (
344
- this.touchStorageActivity({ force: true }).catch(err => {
478
+ this.runStorageHeartbeat().catch(err => {
345
479
  try { this.onError?.(err) } catch {}
346
480
  })
347
481
  ), PRIVATE_MESSENGER_STORAGE_HEARTBEAT_MS)
@@ -362,13 +496,15 @@ export class PrivateMessenger {
362
496
 
363
497
  runStorageMaintenance () {
364
498
  if (this.storageMaintenancePromise) return this.storageMaintenancePromise
365
- const maintenance = Promise.all([
366
- PrivateMessenger.maintainStorage({
499
+ const maintenance = (async () => {
500
+ await PrivateMessenger.maintainStorage({
367
501
  indexedDB: this._indexedDB,
368
502
  temporaryStorageArea: this.temporaryStorageArea
369
- }),
370
- this.pruneStoredSeeds()
371
- ]).catch(err => {
503
+ })
504
+ await this.refreshAndApplyStoragePolicy()
505
+ await this.cleanupStaleChannels()
506
+ await this.pruneStoredSeeds()
507
+ })().catch(err => {
372
508
  try { this.onError?.(err) } catch {}
373
509
  }).finally(() => {
374
510
  if (this.storageMaintenancePromise === maintenance) this.storageMaintenancePromise = null
@@ -377,6 +513,12 @@ export class PrivateMessenger {
377
513
  return maintenance
378
514
  }
379
515
 
516
+ async runStorageHeartbeat () {
517
+ await this.stampActiveChannelActivity()
518
+ await this.touchStorageActivity({ force: true })
519
+ await this.applyPendingStoragePolicy()
520
+ }
521
+
380
522
  async touchStorageActivity ({ force = false } = {}) {
381
523
  if (!this.storageActive || this.closePromise) return false
382
524
  if (this.storageTouchPromise) return this.storageTouchPromise
@@ -387,9 +529,13 @@ export class PrivateMessenger {
387
529
  const touch = activatePrivateMessengerStorage({
388
530
  userPubkey: this.userPubkey,
389
531
  leaseId: this.storageLeaseId,
532
+ activeChannelPubkeys: [...this.channels.keys()],
390
533
  indexedDB: this._indexedDB,
391
534
  now
392
- }).then(() => true, err => {
535
+ }).then(snapshot => {
536
+ this.applyStoragePolicySnapshot(snapshot)
537
+ return true
538
+ }, err => {
393
539
  this.lastStorageTouch = previousTouch
394
540
  throw err
395
541
  }).finally(() => {
@@ -435,12 +581,28 @@ export class PrivateMessenger {
435
581
  })
436
582
  }
437
583
 
438
- async update ({ userSigner = this.userSigner, contentKeySigner = this.contentKeySigner, nymSigner = this.nymSigner, channels = [...this.channels.values()], relays = [], mode = 'leecher' } = {}) {
584
+ async update (options = {}) {
439
585
  this.assertOpen()
586
+ const {
587
+ userSigner = this.userSigner,
588
+ contentKeySigner = this.contentKeySigner,
589
+ nymSigner = this.nymSigner,
590
+ channels = [...this.channels.values()],
591
+ relays = [],
592
+ mode = 'leecher'
593
+ } = options
594
+ const updatesStalePolicy = Object.hasOwn(options, 'staleChannelSeconds')
595
+ const updatesIdentityPolicy = Object.hasOwn(options, 'identityStorageRetentionSeconds')
596
+ let nextStaleChannelSeconds = updatesStalePolicy
597
+ ? normalizeStaleChannelSeconds(options.staleChannelSeconds)
598
+ : this.staleChannelSeconds
599
+ let nextIdentityStorageRetentionSeconds = updatesIdentityPolicy
600
+ ? normalizeIdentityStorageRetentionSeconds(options.identityStorageRetentionSeconds)
601
+ : this.identityStorageRetentionSeconds
440
602
  if (userSigner) {
441
603
  const userPubkey = await userSigner.getPublicKey?.()
442
- if (!userPubkey) throw new Error('USER_SIGNER_REQUIRED')
443
- if (this.userPubkey && userPubkey !== this.userPubkey) throw new Error('USER_SIGNER_MISMATCH')
604
+ if (!userPubkey) throw new ValidationError('USER_SIGNER_REQUIRED')
605
+ if (this.userPubkey && userPubkey !== this.userPubkey) throw new ValidationError('USER_SIGNER_MISMATCH')
444
606
  this.userSigner = userSigner
445
607
  }
446
608
  this.contentKeySigner = contentKeySigner || null
@@ -449,19 +611,48 @@ export class PrivateMessenger {
449
611
  const nextChannels = await this.normalizeChannels(channels, { relays, mode })
450
612
  this.assertOpen()
451
613
  const nextPubkeys = new Set(nextChannels.map(channel => channel.pubkey))
452
-
453
- for (const pubkey of [...this.channels.keys()]) {
454
- if (!nextPubkeys.has(pubkey)) {
455
- await this.unwatch(pubkey)
456
- this.channels.delete(pubkey)
614
+ const removedPubkeys = [...this.channels.keys()].filter(pubkey => !nextPubkeys.has(pubkey))
615
+ const updatesStoragePolicy = updatesStalePolicy || updatesIdentityPolicy
616
+
617
+ if (updatesStoragePolicy) {
618
+ const currentPolicy = await this.readStoragePolicySnapshot()
619
+ if (!updatesStalePolicy) nextStaleChannelSeconds = currentPolicy?.staleChannelSeconds ?? this.staleChannelSeconds
620
+ if (!updatesIdentityPolicy) {
621
+ nextIdentityStorageRetentionSeconds = currentPolicy?.identityStorageRetentionSeconds ?? this.identityStorageRetentionSeconds
457
622
  }
458
623
  }
624
+
625
+ if (removedPubkeys.length) {
626
+ await this.stampChannelActivity(removedPubkeys)
627
+ }
628
+
629
+ const storageSnapshot = await activatePrivateMessengerStorage({
630
+ userPubkey: this.userPubkey,
631
+ leaseId: this.storageLeaseId,
632
+ activeChannelPubkeys: [...nextPubkeys],
633
+ storagePolicy: updatesStoragePolicy
634
+ ? {
635
+ staleChannelSeconds: nextStaleChannelSeconds,
636
+ identityStorageRetentionSeconds: nextIdentityStorageRetentionSeconds
637
+ }
638
+ : undefined,
639
+ indexedDB: this._indexedDB
640
+ })
641
+ this.applyStoragePolicySnapshot(storageSnapshot)
642
+ this.lastStorageTouch = Date.now()
643
+ if (updatesStoragePolicy) this.broadcastStoragePolicyChange()
644
+
645
+ await this.unwatch([...this.channels.keys()])
646
+ this.channels.clear()
459
647
  for (const channel of nextChannels) this.channels.set(channel.pubkey, channel)
460
648
 
461
- await this.cleanupStaleChannels()
649
+ await this.cleanupStaleChannels({ storageSnapshot })
462
650
  await this.applyRecoveryPolicies(nextChannels)
463
651
  await this.watch()
464
652
  await this.reconcilePresencePublishers()
653
+ if (this.storagePolicyRevision === storageSnapshot.policyRevision) {
654
+ this.storagePolicyNeedsApply = false
655
+ }
465
656
  return this
466
657
  }
467
658
 
@@ -476,10 +667,10 @@ export class PrivateMessenger {
476
667
  const hasChannelSendRelays = Boolean(channel.sendRelays?.length)
477
668
  const hasDefaultRelays = Boolean(defaults.relays?.length)
478
669
  const pubkey = channel.pubkey || await signer?.getPublicKey?.()
479
- if (!pubkey) throw new Error('CHANNEL_PUBKEY_REQUIRED')
480
- if (!signer && !readerSigner) throw new Error('CHANNEL_SIGNER_REQUIRED')
670
+ if (!pubkey) throw new ValidationError('CHANNEL_PUBKEY_REQUIRED')
671
+ if (!signer && !readerSigner) throw new ValidationError('CHANNEL_SIGNER_REQUIRED')
481
672
  const mode = channel.mode || defaults.mode || 'leecher'
482
- if (!signer && storesRecoverySeeds(mode)) throw new Error('PRIVATE_CHANNEL_WRITER_REQUIRED')
673
+ if (!signer && doesModeStoreRecoverySeeds(mode)) throw new ValidationError('PRIVATE_CHANNEL_WRITER_REQUIRED')
483
674
  const readerPubkey = channel.readerPubkey || channel.privateChannelReaderPubkey || await readerSigner?.getPublicKey?.() || pubkey
484
675
  const autoDeletionCapability = channel.autoDeletionCapability === undefined
485
676
  ? undefined
@@ -543,7 +734,7 @@ export class PrivateMessenger {
543
734
  if (channel.sendRelays.length) return { relays: channel.sendRelays, recoveryRelays }
544
735
  if (channel.relays.length) return { relays: channel.relays, recoveryRelays }
545
736
  const derived = await this.readRelayToReceivers(receiverPubkeys)
546
- if (!relayMapRelays(derived).length) throw new Error('NO_RELAYS')
737
+ if (!relayMapRelays(derived).length) throw new ValidationError('NO_RELAYS')
547
738
  return { relayToReceivers: derived, recoveryRelays }
548
739
  }
549
740
 
@@ -551,14 +742,19 @@ export class PrivateMessenger {
551
742
  return structuredClone(this.state)
552
743
  }
553
744
 
554
- writeState (state) {
555
- this.touchStorageActivity().catch(err => this.onError?.(err))
745
+ writeState (state, { touchStorage = true } = {}) {
746
+ if (touchStorage) this.touchStorageActivity().catch(err => this.onError?.(err))
747
+ const previous = this.state?.channels || {}
556
748
  const next = {
557
749
  channels: isPlainObject(state?.channels) ? structuredClone(state.channels) : {}
558
750
  }
751
+ const changed = Object.fromEntries(Object.entries(next.channels)
752
+ .filter(([pubkey, value]) => !areStateValuesEqual(previous[pubkey], value)))
753
+ const removed = Object.keys(previous).filter(pubkey => !Object.hasOwn(next.channels, pubkey))
559
754
  this.state = next
560
- const snapshot = structuredClone(next.channels)
561
- const write = this.stateWriteTail.then(() => this.stateStore.replace(snapshot))
755
+ if (!Object.keys(changed).length && !removed.length) return this.stateWriteTail
756
+ const snapshot = structuredClone(changed)
757
+ const write = this.stateWriteTail.then(() => this.stateStore.update(snapshot, removed))
562
758
  this.stateWriteTail = write.catch(err => {
563
759
  try { this.onError?.(err) } catch {}
564
760
  })
@@ -569,6 +765,29 @@ export class PrivateMessenger {
569
765
  await this.stateWriteTail
570
766
  }
571
767
 
768
+ async stampChannelActivity (pubkeys) {
769
+ if (!this.stateStore || !this.state) return false
770
+ pubkeys = [...new Set(pubkeys || [])].filter(pubkey => this.state.channels[pubkey])
771
+ if (!pubkeys.length) return false
772
+ const lastWatchedAt = nowSeconds()
773
+ for (const pubkey of pubkeys) {
774
+ this.state.channels[pubkey] = {
775
+ ...this.state.channels[pubkey],
776
+ lastWatchedAt
777
+ }
778
+ }
779
+ const write = this.stateWriteTail.then(() => this.stateStore.touch(pubkeys, lastWatchedAt))
780
+ this.stateWriteTail = write.catch(err => {
781
+ try { this.onError?.(err) } catch {}
782
+ })
783
+ await write
784
+ return true
785
+ }
786
+
787
+ stampActiveChannelActivity () {
788
+ return this.stampChannelActivity([...this.channels.keys()])
789
+ }
790
+
572
791
  updateChannelState (pubkey, patch) {
573
792
  const state = this.readState()
574
793
  const current = state.channels[pubkey] || {}
@@ -578,9 +797,19 @@ export class PrivateMessenger {
578
797
  }
579
798
 
580
799
  removeChannelState (pubkey) {
581
- const state = this.readState()
582
- delete state.channels[pubkey]
583
- this.writeState(state)
800
+ this.removeChannelStates([pubkey])
801
+ }
802
+
803
+ removeChannelStates (pubkeys) {
804
+ pubkeys = [...new Set(pubkeys || [])]
805
+ for (const pubkey of pubkeys) delete this.state.channels[pubkey]
806
+ if (!pubkeys.length) return this.stateWriteTail
807
+ this.touchStorageActivity().catch(err => this.onError?.(err))
808
+ const write = this.stateWriteTail.then(() => this.stateStore.update({}, pubkeys))
809
+ this.stateWriteTail = write.catch(err => {
810
+ try { this.onError?.(err) } catch {}
811
+ })
812
+ return write
584
813
  }
585
814
 
586
815
  markSeen (pubkey, createdAt = nowSeconds()) {
@@ -765,7 +994,7 @@ export class PrivateMessenger {
765
994
  const channelPubkeys = uniq(channels)
766
995
  for (const pubkey of channelPubkeys) {
767
996
  const channel = this.channels.get(pubkey)
768
- if (!channel) throw new Error('UNKNOWN_CHANNEL')
997
+ if (!channel) throw new ValidationError('UNKNOWN_CHANNEL')
769
998
  const watchRelays = await this.resolveWatchRelays(channel)
770
999
  this.assertOpen()
771
1000
  const stop = await this._privateMessage.watch({
@@ -879,7 +1108,7 @@ export class PrivateMessenger {
879
1108
 
880
1109
  async handleAsk (channelPubkey, message) {
881
1110
  this.trackSeederActivity(channelPubkey, message)
882
- if (storesRecoverySeeds(this.channels.get(channelPubkey)?.mode) && messageCode(message) === MISSING_MESSAGES_ASK_CODE) {
1111
+ if (doesModeStoreRecoverySeeds(this.channels.get(channelPubkey)?.mode) && messageCode(message) === MISSING_MESSAGES_ASK_CODE) {
883
1112
  await this.replyWithStoredSeeds(channelPubkey, message)
884
1113
  return
885
1114
  }
@@ -1233,10 +1462,10 @@ export class PrivateMessenger {
1233
1462
  async reconcilePresencePublishers () {
1234
1463
  const starts = []
1235
1464
  for (const pubkey of [...this.presenceTimers.keys()]) {
1236
- if (!storesRecoverySeeds(this.channels.get(pubkey)?.mode) || !this.offlineRecoverySecondsFor(pubkey)) this.stopPresencePublisher(pubkey)
1465
+ if (!doesModeStoreRecoverySeeds(this.channels.get(pubkey)?.mode) || !this.offlineRecoverySecondsFor(pubkey)) this.stopPresencePublisher(pubkey)
1237
1466
  }
1238
1467
  for (const [pubkey, channel] of this.channels) {
1239
- if (storesRecoverySeeds(channel.mode) && this.offlineRecoverySecondsFor(channel)) starts.push(this.startPresencePublisher(pubkey))
1468
+ if (doesModeStoreRecoverySeeds(channel.mode) && this.offlineRecoverySecondsFor(channel)) starts.push(this.startPresencePublisher(pubkey))
1240
1469
  else this.stopPresencePublisher(pubkey)
1241
1470
  }
1242
1471
  await Promise.all(starts)
@@ -1256,13 +1485,13 @@ export class PrivateMessenger {
1256
1485
 
1257
1486
  requireChannel (pubkey) {
1258
1487
  const channel = this.channels.get(pubkey)
1259
- if (!channel) throw new Error('UNKNOWN_CHANNEL')
1488
+ if (!channel) throw new ValidationError('UNKNOWN_CHANNEL')
1260
1489
  return channel
1261
1490
  }
1262
1491
 
1263
1492
  requireWritableChannel (pubkey) {
1264
1493
  const channel = this.requireChannel(pubkey)
1265
- if (!channel.signer) throw new Error('PRIVATE_CHANNEL_WRITER_REQUIRED')
1494
+ if (!channel.signer) throw new ValidationError('PRIVATE_CHANNEL_WRITER_REQUIRED')
1266
1495
  return channel
1267
1496
  }
1268
1497
 
@@ -1270,7 +1499,7 @@ export class PrivateMessenger {
1270
1499
  return channel.autoDeletionCapability ?? this.autoDeletionCapability
1271
1500
  }
1272
1501
 
1273
- offlineRecoverySecondsFor (channelOrPubkey) {
1502
+ requestedOfflineRecoverySecondsFor (channelOrPubkey) {
1274
1503
  const channel = typeof channelOrPubkey === 'string'
1275
1504
  ? this.channels.get(channelOrPubkey)
1276
1505
  : channelOrPubkey
@@ -1282,6 +1511,18 @@ export class PrivateMessenger {
1282
1511
  : normalizeOfflineRecoverySeconds(persisted)
1283
1512
  }
1284
1513
 
1514
+ offlineRecoverySecondsFor (channelOrPubkey) {
1515
+ return Math.min(
1516
+ this.requestedOfflineRecoverySecondsFor(channelOrPubkey),
1517
+ this.staleChannelSeconds,
1518
+ this.identityStorageRetentionSeconds
1519
+ )
1520
+ }
1521
+
1522
+ staleChannelSecondsForCleanup () {
1523
+ return Math.min(this.staleChannelSeconds, this.identityStorageRetentionSeconds)
1524
+ }
1525
+
1285
1526
  eventExpirationSecondsFor (channel) {
1286
1527
  return this.offlineRecoverySecondsFor(channel) || privateChannel.EXPIRATION_SECONDS
1287
1528
  }
@@ -1297,13 +1538,14 @@ export class PrivateMessenger {
1297
1538
  const now = nowSeconds()
1298
1539
  for (const channel of channels) {
1299
1540
  const current = state.channels[channel.pubkey] || {}
1300
- const seconds = channel.offlineRecoverySeconds
1301
- current.offlineRecoverySeconds = seconds
1302
- if (!seconds) {
1541
+ const requestedSeconds = channel.offlineRecoverySeconds
1542
+ const effectiveSeconds = this.offlineRecoverySecondsFor(channel)
1543
+ current.offlineRecoverySeconds = requestedSeconds
1544
+ if (!effectiveSeconds) {
1303
1545
  delete current.openOfflineStart
1304
1546
  current.offlineRanges = []
1305
1547
  } else {
1306
- const cutoff = now - seconds
1548
+ const cutoff = now - effectiveSeconds
1307
1549
  current.offlineRanges = mergeRanges((current.offlineRanges || [])
1308
1550
  .filter(range => range.end >= cutoff)
1309
1551
  .map(range => ({ ...range, start: Math.max(range.start, cutoff) })))
@@ -1319,7 +1561,7 @@ export class PrivateMessenger {
1319
1561
 
1320
1562
  requireNymSigner (channel, override) {
1321
1563
  const signer = override || channel?.nymSigner || this.nymSigner
1322
- if (!signer?.getPublicKey) throw new Error('NYM_SIGNER_REQUIRED')
1564
+ if (!signer?.getPublicKey) throw new ValidationError('NYM_SIGNER_REQUIRED')
1323
1565
  return signer
1324
1566
  }
1325
1567
 
@@ -1463,7 +1705,7 @@ export class PrivateMessenger {
1463
1705
 
1464
1706
  const routerRecord = record?.recordType === ROUTER_SEED_RECORD_TYPE ? record.router : null
1465
1707
  if (!isPrivateChannelRouter(routerRecord)) return null
1466
- if (!this._privateChannel.unwrapEvent) throw new Error('PRIVATE_CHANNEL_UNWRAP_UNSUPPORTED')
1708
+ if (!this._privateChannel.unwrapEvent) throw new ValidationError('PRIVATE_CHANNEL_UNWRAP_UNSUPPORTED')
1467
1709
 
1468
1710
  const channel = this.requireChannel(channelPubkey)
1469
1711
  const router = {
@@ -1568,6 +1810,7 @@ export class PrivateMessenger {
1568
1810
  await this.flushStateWrites()
1569
1811
  await this.queue.removeBy('byChannel', pubkey)
1570
1812
  await this.seedQueue.removeBy('byChannel', pubkey)
1813
+ await this.touchStorageActivity({ force: true })
1571
1814
  this.ensureRelayListWatcher()
1572
1815
  })
1573
1816
  }
@@ -1576,19 +1819,26 @@ export class PrivateMessenger {
1576
1819
  return this.runQueueOperation(() => this.queue.clear())
1577
1820
  }
1578
1821
 
1579
- async cleanupStaleChannels () {
1822
+ async cleanupStaleChannels ({ storageSnapshot } = {}) {
1580
1823
  if (!this.prefix) return
1824
+ storageSnapshot ||= await this.readStoragePolicySnapshot()
1825
+ if (!storageSnapshot) return
1826
+ const activeChannelPubkeys = new Set(storageSnapshot.activeChannelPubkeys || [])
1581
1827
  return this.runQueueOperation(async () => {
1582
- const state = this.readState()
1583
- const cutoff = nowSeconds() - this.staleChannelSeconds
1828
+ await this.flushStateWrites()
1829
+ const state = { channels: await this.stateStore.load() }
1830
+ const cutoff = nowSeconds() - this.staleChannelSecondsForCleanup()
1831
+ const stalePubkeys = []
1584
1832
  for (const [pubkey, channel] of Object.entries(state.channels)) {
1833
+ if (activeChannelPubkeys.has(pubkey)) continue
1585
1834
  if ((channel.lastWatchedAt || 0) >= cutoff) continue
1586
1835
  delete state.channels[pubkey]
1836
+ stalePubkeys.push(pubkey)
1587
1837
  await this.queue?.removeBy('byChannel', pubkey)
1588
1838
  await this.seedQueue?.removeBy('byChannel', pubkey)
1589
1839
  }
1590
- this.writeState(state)
1591
- await this.flushStateWrites()
1840
+ this.state = state
1841
+ if (stalePubkeys.length) await this.removeChannelStates(stalePubkeys)
1592
1842
  })
1593
1843
  }
1594
1844
 
@@ -1636,15 +1886,18 @@ export class PrivateMessenger {
1636
1886
  this.stopOffline = null
1637
1887
  this.stopOnline = null
1638
1888
  this.stopStorageMaintenance()
1889
+ this.stopStoragePolicyBroadcast()
1639
1890
 
1640
1891
  this.closePromise = (async () => {
1641
1892
  let unwatchError
1642
1893
  try { await unwatchPromise } catch (err) { unwatchError = err }
1643
1894
  await initSettledPromise
1895
+ await this.stampActiveChannelActivity()
1644
1896
  await this.queueOperationTail
1645
1897
  await this.stateWriteTail
1646
1898
  try { await this.storageTouchPromise } catch {}
1647
1899
  await this.storageMaintenancePromise
1900
+ try { await this.storagePolicyRefreshTail } catch {}
1648
1901
  await Promise.all([
1649
1902
  this.queue?.close?.(),
1650
1903
  this.seedQueue?.close?.(),
@@ -1658,6 +1911,12 @@ export class PrivateMessenger {
1658
1911
  })
1659
1912
  }
1660
1913
  this.storageActive = false
1914
+ if (this.identityStorageRetentionSeconds === 0) {
1915
+ await PrivateMessenger.maintainStorage({
1916
+ indexedDB: this._indexedDB,
1917
+ temporaryStorageArea: this.temporaryStorageArea
1918
+ })
1919
+ }
1661
1920
  if (unwatchError) throw unwatchError
1662
1921
  })()
1663
1922
  return this.closePromise