libp2r2p 0.6.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
@@ -20,10 +20,14 @@ import { createPrivateMessenger } from 'libp2r2p/private-messenger'
20
20
  const messenger = await createPrivateMessenger({
21
21
  userSigner,
22
22
  contentKeySigner,
23
+ offlineRecoverySeconds: 7 * 24 * 60 * 60,
24
+ staleChannelSeconds: 45 * 24 * 60 * 60,
25
+ identityStorageRetentionSeconds: 60 * 24 * 60 * 60,
23
26
  channels: [{
24
27
  signer: privateChannelSigner,
25
28
  relays: ['wss://relay.example'],
26
- mode: 'leecher'
29
+ mode: 'leecher',
30
+ offlineRecoverySeconds: 30 * 24 * 60 * 60
27
31
  }],
28
32
  onError: err => console.warn('private messenger failed', err)
29
33
  })
@@ -126,45 +130,101 @@ one `['k', '3560']` tag, explicit matching `e` targets, and no `a` tags. A
126
130
  regular NIP-09 kind `5` request signed by the outer event's private-channel key
127
131
  must not delete a kind `3560` event, whether or not that event has an `s` tag.
128
132
 
129
- ### Temporary Send Storage
133
+ ### Storage Maintenance
130
134
 
131
135
  While an outgoing private message is being assembled, the messenger keeps
132
136
  encrypted envelope rows and router chunks in `sessionStorage`. They are
133
137
  removed when the send finishes, but an interrupted browser operation can leave
134
138
  them behind until cleanup runs.
135
139
 
136
- `PrivateMessenger.init()` performs cleanup automatically. Call
137
- `PrivateMessenger.cleanupTemporaryStorage()` once during app startup when
138
- messenger initialization may be delayed, such as while an account is locked:
140
+ `PrivateMessenger.init()` awaits storage maintenance automatically. Call
141
+ `PrivateMessenger.maintainStorage()` during app startup when messenger
142
+ initialization may be delayed, such as while an account is locked:
139
143
 
140
144
  ```js
141
145
  import { PrivateMessenger } from 'libp2r2p/private-messenger'
142
146
 
143
- PrivateMessenger.cleanupTemporaryStorage()
147
+ PrivateMessenger.maintainStorage().catch(console.warn)
144
148
  ```
145
149
 
146
- Call it before any private-message send using that storage area starts. It
147
- does not clear persisted messages, recovery material, or channel state. Pass
148
- `temporaryStorageArea: localStorage` when constructing a messenger to opt into
149
- a different Storage area.
150
+ Maintenance removes interrupted-send staging, expired receive chunks, and
151
+ storage belonging to inactive principal identities. It also
152
+ resumes any interrupted database-set deletion. An application does not need to
153
+ know database names or enumerate IndexedDB. Pass `temporaryStorageArea` only
154
+ when the messenger was configured to use a Storage area other than the default
155
+ `sessionStorage`.
156
+
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.
162
+ Maintenance runs on every initialization and every six hours while a messenger
163
+ is active; failed deletions remain journaled and retry automatically.
164
+
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.
169
+ Set `offlineRecoverySeconds` on an individual channel to override the
170
+ messenger default for its recovery seeds, offline ranges, new outer-event
171
+ expiration, and new incomplete receive groups. Updating a channel applies a
172
+ shorter window immediately to its stored seeds and ranges; increasing it does
173
+ not recreate data already removed.
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
+
193
+ Set a channel's `offlineRecoverySeconds` to `0` to disable durable recovery for
194
+ that channel. The messenger then stores no recovery seeds, tracks no offline
195
+ ranges, contacts no seeders, publishes no seeder presence, and uses no recovery
196
+ mirror relays. Live delivery remains usable: new outer events retain the
197
+ private-channel two-day technical expiration and incomplete receive groups use
198
+ the one-hour technical TTL. Existing signed events and receive groups retain
199
+ the deadlines chosen when they were created.
150
200
 
151
201
  Recovery metadata is separate from that temporary send staging. Per-channel
152
202
  `lastSeenAt`, offline ranges, and related state are stored in IndexedDB. Raw
153
- incomplete receive chunks are also stored in IndexedDB, expire after one hour,
154
- and share a 16 MiB logical budget. Capacity eviction removes whole
203
+ incomplete receive chunks are also stored in IndexedDB and share a 16 MiB
204
+ logical budget. Direct private-channel calls give each new group a one-hour
205
+ TTL; PrivateMessenger groups use the effective recovery window of their
206
+ channel, or one hour when durable recovery is disabled. The TTL is persisted
207
+ per group, so another caller opening the shared database or a later channel
208
+ configuration update cannot change it. Capacity
209
+ eviction removes whole
155
210
  least-recently-used message groups so a partial group is never mistaken for a
156
211
  complete one. `receivedChunkTtlMs`, `receivedChunkMaxBytes`, and
157
212
  `receivedChunkIndexedDB` may be supplied to the private-channel APIs when an
158
213
  embedding environment needs different limits or an injected IDB factory.
159
214
  Legacy Web Storage recovery records are neither read nor migrated.
160
215
 
216
+ The recovery-seed queue has a shared 64 MiB logical budget by default and uses
217
+ FIFO eviction. A channel recovery duration is therefore a maximum retention
218
+ window, not a guarantee that every seed remains available until its deadline.
219
+
161
220
  Signers are expected to expose the Nostr-style methods used by the messenger,
162
221
  including `getPublicKey()`, `signEvent(event)`, and the NIP-44 v3 methods
163
222
  needed by private channels. For double-DH content-key use, pass a
164
223
  `contentKeySigner` or a signer implementation that handles content keys
165
224
  internally.
166
225
 
167
- Messages are stored in a bounded, durable IndexedDB queue until consumed:
226
+ Messages are stored in a bounded, durable IndexedDB queue until consumed or
227
+ until the principal identity has been inactive for 60 days:
168
228
 
169
229
  ```js
170
230
  async function handleMessages () {
@@ -274,6 +274,10 @@ export async function createQueue ({
274
274
  const waiters = new Set()
275
275
  let sessionMaxBytes = configuredMaxBytes
276
276
  let revision = 0
277
+ let closed = false
278
+ let activeTransactions = 0
279
+ let closePromise = null
280
+ const closeWaiters = new Set()
277
281
 
278
282
  function hasByteLimit () {
279
283
  return Number.isFinite(sessionMaxBytes)
@@ -317,17 +321,27 @@ export async function createQueue ({
317
321
  }
318
322
 
319
323
  async function transaction (mode, work) {
320
- const tx = db.transaction([ITEMS_STORE, STATE_STORE], mode)
321
- const done = transactionDone(tx)
324
+ if (closed) throw new Error('QUEUE_CLOSED')
325
+ activeTransactions++
322
326
  try {
323
- // Keep work limited to IndexedDB requests so the transaction stays active.
324
- const value = await work(tx)
325
- await done
326
- return value
327
- } catch (err) {
328
- try { tx.abort() } catch {}
329
- try { await done } catch {}
330
- throw err
327
+ const tx = db.transaction([ITEMS_STORE, STATE_STORE], mode)
328
+ const done = transactionDone(tx)
329
+ try {
330
+ // Keep work limited to IndexedDB requests so the transaction stays active.
331
+ const value = await work(tx)
332
+ await done
333
+ return value
334
+ } catch (err) {
335
+ try { tx.abort() } catch {}
336
+ try { await done } catch {}
337
+ throw err
338
+ }
339
+ } finally {
340
+ activeTransactions--
341
+ if (!activeTransactions) {
342
+ for (const resolve of closeWaiters) resolve()
343
+ closeWaiters.clear()
344
+ }
331
345
  }
332
346
  }
333
347
 
@@ -771,6 +785,17 @@ export async function createQueue ({
771
785
  for (const record of (await snapshotStoredItems()).reverse()) yield record.item
772
786
  }
773
787
 
788
+ function close () {
789
+ if (closePromise) return closePromise
790
+ closed = true
791
+ wake()
792
+ closePromise = (activeTransactions
793
+ ? new Promise(resolve => closeWaiters.add(resolve))
794
+ : Promise.resolve())
795
+ .then(() => { db.close() })
796
+ return closePromise
797
+ }
798
+
774
799
  async function * storedItemsBy (indexName, query, { direction = 'next' } = {}) {
775
800
  direction = validDirection(direction)
776
801
  const records = await snapshot(async tx => {
@@ -811,6 +836,7 @@ export async function createQueue ({
811
836
  getBy,
812
837
  someBy,
813
838
  removeBy,
814
- storedItemsBy
839
+ storedItemsBy,
840
+ close
815
841
  }
816
842
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -566,8 +566,8 @@ async function decryptRouter ({ content, channelPubkey, channelSigner, channelRe
566
566
 
567
567
  function readValueFromMap (map, key) {
568
568
  if (!map || !key) return null
569
- if (map instanceof Map) return map.get(key) || null
570
- return map[key] || null
569
+ if (map instanceof Map) return map.get(key) ?? null
570
+ return map[key] ?? null
571
571
  }
572
572
 
573
573
  function privateChannelPubkeyList ({ privateChannelPubkey, privateChannelPubkeys }) {
@@ -669,6 +669,7 @@ function createProcessor ({
669
669
  onContentKeyUsage,
670
670
  onError,
671
671
  receivedChunkTtlMs = DEFAULT_RECEIVED_CHUNK_TTL_MS,
672
+ receivedChunkTtlMsByPubkey,
672
673
  receivedChunkMaxBytes = DEFAULT_RECEIVED_CHUNK_MAX_BYTES,
673
674
  receivedChunkIndexedDB = globalThis.indexedDB,
674
675
  ignoredGroupTtlMs = DEFAULT_IGNORED_GROUP_TTL_MS,
@@ -683,8 +684,11 @@ function createProcessor ({
683
684
  ttlMs: ignoredGroupTtlMs,
684
685
  maxEntries: ignoredGroupMaxEntries
685
686
  })
687
+ const storeReady = receivedChunks.ready()
688
+ storeReady.catch(() => {})
686
689
 
687
- return async function processOuterEvent (outer) {
690
+ async function processOuterEvent (outer) {
691
+ await storeReady
688
692
  let groupKey = ''
689
693
  try {
690
694
  const channelPubkey = outer.pubkey || await privateChannelSigner?.getPublicKey?.()
@@ -692,6 +696,7 @@ function createProcessor ({
692
696
  const channelReaderSigner = readSignerFromMap(privateChannelReaderSignersByPubkey, channelPubkey) || privateChannelReaderSigner || channelSigner
693
697
  const channelReaderPubkey = readValueFromMap(privateChannelReaderPubkeysByPubkey, channelPubkey) || privateChannelReaderPubkey || channelPubkey
694
698
  const channelMode = readValueFromMap(modeByPubkey, channelPubkey) || mode
699
+ const channelReceivedChunkTtlMs = readValueFromMap(receivedChunkTtlMsByPubkey, channelPubkey) ?? receivedChunkTtlMs
695
700
  if (!channelPubkey) throw new Error('PRIVATE_CHANNEL_PUBKEY_REQUIRED')
696
701
 
697
702
  const decrypted = await decryptRouter({
@@ -712,7 +717,8 @@ function createProcessor ({
712
717
  routerPubkey: nymCarrierGroupId(carrier),
713
718
  index,
714
719
  total,
715
- contentBytes: encoder.encode(JSON.stringify(carrier))
720
+ contentBytes: encoder.encode(JSON.stringify(carrier)),
721
+ ttlMs: channelReceivedChunkTtlMs
716
722
  })
717
723
  const status = await receivedChunks.status(meta)
718
724
  onChunk?.({
@@ -762,7 +768,8 @@ function createProcessor ({
762
768
  routerPubkey: router.pubkey,
763
769
  index,
764
770
  total,
765
- contentBytes: base64ToBytes(router.content)
771
+ contentBytes: base64ToBytes(router.content),
772
+ ttlMs: channelReceivedChunkTtlMs
766
773
  })
767
774
  const status = await receivedChunks.status(meta)
768
775
  onChunk?.({
@@ -857,6 +864,9 @@ function createProcessor ({
857
864
  onError?.(err)
858
865
  }
859
866
  }
867
+
868
+ processOuterEvent.close = () => receivedChunks.close()
869
+ return processOuterEvent
860
870
  }
861
871
 
862
872
  function shouldIgnoreGroupError (err) {
@@ -882,7 +892,7 @@ function shouldIgnoreGroupError (err) {
882
892
  ].includes(err?.message)
883
893
  }
884
894
 
885
- export async function fetch ({ receiverSigner, iykcSigner, privateChannelSigner = receiverSigner, privateChannelSignersByPubkey, privateChannelReaderSigner = privateChannelSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, privateChannelPubkey, privateChannelPubkeys, receiverPubkey, relays, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onError, since, until, limit, mode = 'leecher', modeByPubkey, receivedChunkTtlMs = DEFAULT_RECEIVED_CHUNK_TTL_MS, receivedChunkMaxBytes = DEFAULT_RECEIVED_CHUNK_MAX_BYTES, receivedChunkIndexedDB = globalThis.indexedDB, ignoredGroupTtlMs = DEFAULT_IGNORED_GROUP_TTL_MS, ignoredGroupMaxEntries = DEFAULT_IGNORED_GROUP_MAX_ENTRIES, _getEvents = getEvents }) {
895
+ export async function fetch ({ receiverSigner, iykcSigner, privateChannelSigner = receiverSigner, privateChannelSignersByPubkey, privateChannelReaderSigner = privateChannelSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, privateChannelPubkey, privateChannelPubkeys, receiverPubkey, relays, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onError, since, until, limit, mode = 'leecher', modeByPubkey, receivedChunkTtlMs = DEFAULT_RECEIVED_CHUNK_TTL_MS, receivedChunkTtlMsByPubkey, receivedChunkMaxBytes = DEFAULT_RECEIVED_CHUNK_MAX_BYTES, receivedChunkIndexedDB = globalThis.indexedDB, ignoredGroupTtlMs = DEFAULT_IGNORED_GROUP_TTL_MS, ignoredGroupMaxEntries = DEFAULT_IGNORED_GROUP_MAX_ENTRIES, _getEvents = getEvents }) {
886
896
  if (!relays?.length) throw new Error('NO_RELAYS')
887
897
  const authors = privateChannelPubkeyList({ privateChannelPubkey, privateChannelPubkeys })
888
898
  const filter = { kinds: [PRIVATE_BROADCAST_KIND] }
@@ -896,12 +906,16 @@ export async function fetch ({ receiverSigner, iykcSigner, privateChannelSigner
896
906
  timeoutAfterFirstEose: null
897
907
  })
898
908
  events.sort((a, b) => a.created_at - b.created_at)
899
- const processOuterEvent = createProcessor({ receiverSigner, iykcSigner, privateChannelSigner, privateChannelSignersByPubkey, privateChannelReaderSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, receiverPubkey, mode, modeByPubkey, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onError, receivedChunkTtlMs, receivedChunkMaxBytes, receivedChunkIndexedDB, ignoredGroupTtlMs, ignoredGroupMaxEntries })
900
- for (const event of events) await processOuterEvent(event)
901
- return events
909
+ const processOuterEvent = createProcessor({ receiverSigner, iykcSigner, privateChannelSigner, privateChannelSignersByPubkey, privateChannelReaderSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, receiverPubkey, mode, modeByPubkey, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onError, receivedChunkTtlMs, receivedChunkTtlMsByPubkey, receivedChunkMaxBytes, receivedChunkIndexedDB, ignoredGroupTtlMs, ignoredGroupMaxEntries })
910
+ try {
911
+ for (const event of events) await processOuterEvent(event)
912
+ return events
913
+ } finally {
914
+ processOuterEvent.close()
915
+ }
902
916
  }
903
917
 
904
- export function subscribe ({ receiverSigner, iykcSigner, privateChannelSigner = receiverSigner, privateChannelSignersByPubkey, privateChannelReaderSigner = privateChannelSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, privateChannelPubkey, privateChannelPubkeys, receiverPubkey, relays, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onError, since = nowSeconds() - 5, limit, liveOnly = false, mode = 'leecher', modeByPubkey, receivedChunkTtlMs = DEFAULT_RECEIVED_CHUNK_TTL_MS, receivedChunkMaxBytes = DEFAULT_RECEIVED_CHUNK_MAX_BYTES, receivedChunkIndexedDB = globalThis.indexedDB, ignoredGroupTtlMs = DEFAULT_IGNORED_GROUP_TTL_MS, ignoredGroupMaxEntries = DEFAULT_IGNORED_GROUP_MAX_ENTRIES, _liveEventsGenerator = getLiveEventsGenerator, _eventsFeedGenerator = getEventsFeedGenerator }) {
918
+ export function subscribe ({ receiverSigner, iykcSigner, privateChannelSigner = receiverSigner, privateChannelSignersByPubkey, privateChannelReaderSigner = privateChannelSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, privateChannelPubkey, privateChannelPubkeys, receiverPubkey, relays, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onError, since = nowSeconds() - 5, limit, liveOnly = false, mode = 'leecher', modeByPubkey, receivedChunkTtlMs = DEFAULT_RECEIVED_CHUNK_TTL_MS, receivedChunkTtlMsByPubkey, receivedChunkMaxBytes = DEFAULT_RECEIVED_CHUNK_MAX_BYTES, receivedChunkIndexedDB = globalThis.indexedDB, ignoredGroupTtlMs = DEFAULT_IGNORED_GROUP_TTL_MS, ignoredGroupMaxEntries = DEFAULT_IGNORED_GROUP_MAX_ENTRIES, _liveEventsGenerator = getLiveEventsGenerator, _eventsFeedGenerator = getEventsFeedGenerator }) {
905
919
  if (!relays?.length) throw new Error('NO_RELAYS')
906
920
  if (receiverSigner && !receiverSigner?.nip44DecryptDoubleDH && !receiverSigner?.nip44v3Decrypt) throw new Error('RECEIVER_SIGNER_NIP44V3_DECRYPT_UNSUPPORTED')
907
921
  if (!privateChannelReaderSigner && !privateChannelReaderSignersByPubkey && !privateChannelSigner && !privateChannelSignersByPubkey) throw new Error('PRIVATE_CHANNEL_READER_REQUIRED')
@@ -910,17 +924,17 @@ export function subscribe ({ receiverSigner, iykcSigner, privateChannelSigner =
910
924
  const filter = { kinds: [PRIVATE_BROADCAST_KIND], since }
911
925
  if (authors.length) filter.authors = authors
912
926
  if (limit != null) filter.limit = limit
913
- const processOuterEvent = createProcessor({ receiverSigner, iykcSigner, privateChannelSigner, privateChannelSignersByPubkey, privateChannelReaderSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, receiverPubkey, mode, modeByPubkey, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onError, receivedChunkTtlMs, receivedChunkMaxBytes, receivedChunkIndexedDB, ignoredGroupTtlMs, ignoredGroupMaxEntries })
927
+ const processOuterEvent = createProcessor({ receiverSigner, iykcSigner, privateChannelSigner, privateChannelSignersByPubkey, privateChannelReaderSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, receiverPubkey, mode, modeByPubkey, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onError, receivedChunkTtlMs, receivedChunkTtlMsByPubkey, receivedChunkMaxBytes, receivedChunkIndexedDB, ignoredGroupTtlMs, ignoredGroupMaxEntries })
914
928
  const controller = new AbortController()
915
929
  const events = liveOnly
916
930
  ? _liveEventsGenerator(filter, relays, {
917
- signal: controller.signal
918
- })
931
+ signal: controller.signal
932
+ })
919
933
  : _eventsFeedGenerator(filter, relays, {
920
- signal: controller.signal,
921
- timeout: 5000,
922
- timeoutAfterFirstEose: null
923
- })
934
+ signal: controller.signal,
935
+ timeout: 5000,
936
+ timeoutAfterFirstEose: null
937
+ })
924
938
 
925
939
  async function consumeEvents () {
926
940
  try {
@@ -930,14 +944,17 @@ export function subscribe ({ receiverSigner, iykcSigner, privateChannelSigner =
930
944
  }
931
945
  } catch (error) {
932
946
  if (!controller.signal.aborted && error?.message !== 'Aborted') onError?.(error)
947
+ } finally {
948
+ processOuterEvent.close()
933
949
  }
934
950
  }
935
951
 
936
- consumeEvents()
952
+ const consumePromise = consumeEvents()
937
953
 
938
954
  return {
939
955
  close () {
940
956
  controller.abort()
957
+ return consumePromise
941
958
  }
942
959
  }
943
960
  }