libp2r2p 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/AGENTS.md +20 -0
  2. package/README.md +129 -0
  3. package/base16/index.js +14 -0
  4. package/base64/index.js +29 -0
  5. package/content-key/event/index.js +85 -0
  6. package/content-key/index.js +1 -0
  7. package/content-key/services/iykc-proof.js +129 -0
  8. package/double-dh/index.js +166 -0
  9. package/ecdh/index.js +8 -0
  10. package/idb/index.js +64 -0
  11. package/idb-queue/index.js +793 -0
  12. package/index.js +21 -0
  13. package/key/index.js +139 -0
  14. package/network/index.js +68 -0
  15. package/nip44-v3/index.js +175 -0
  16. package/nip46/constants/index.js +5 -0
  17. package/nip46/helpers/frame.js +51 -0
  18. package/nip46/helpers/url.js +96 -0
  19. package/nip46/index.js +5 -0
  20. package/nip46/services/bunker-signer.js +49 -0
  21. package/nip46/services/client.js +252 -0
  22. package/nip46/services/server-session.js +225 -0
  23. package/nip46/services/transport.js +265 -0
  24. package/package.json +49 -0
  25. package/private-channel/constants/index.js +5 -0
  26. package/private-channel/helpers/chunk-size.js +61 -0
  27. package/private-channel/helpers/chunks.js +282 -0
  28. package/private-channel/helpers/event.js +68 -0
  29. package/private-channel/index.js +942 -0
  30. package/private-channel/services/received-chunks.js +398 -0
  31. package/private-message/index.js +672 -0
  32. package/private-messenger/constants/index.js +1 -0
  33. package/private-messenger/index.js +1431 -0
  34. package/private-messenger/recovery/index.js +316 -0
  35. package/relay/constants/index.js +18 -0
  36. package/relay/helpers/hll.js +62 -0
  37. package/relay/helpers/publish.js +100 -0
  38. package/relay/helpers/routing.js +36 -0
  39. package/relay/helpers/timer.js +4 -0
  40. package/relay/index.js +4 -0
  41. package/relay/services/query.js +224 -0
  42. package/relay/services/relay-connection.js +156 -0
  43. package/relay/services/relay-pool.js +908 -0
  44. package/temporary-storage/index.js +85 -0
  45. package/tests/base16.test.js +19 -0
  46. package/tests/base64.test.js +18 -0
  47. package/tests/content-key-event.test.js +48 -0
  48. package/tests/fixtures/nip44v3-vectors.json +1 -0
  49. package/tests/helpers/test-signer.js +185 -0
  50. package/tests/idb-queue.test.js +153 -0
  51. package/tests/idb.test.js +91 -0
  52. package/tests/nip44-v3.test.js +73 -0
  53. package/tests/nip46.test.js +668 -0
  54. package/tests/private-channel.test.js +1899 -0
  55. package/tests/private-message.test.js +460 -0
  56. package/tests/private-messenger.test.js +1715 -0
  57. package/tests/queries.test.js +101 -0
  58. package/tests/queue-parity.test.js +105 -0
  59. package/tests/relay-hll.test.js +32 -0
  60. package/tests/relay-pool.test.js +2067 -0
  61. package/tests/temporary-storage.test.js +89 -0
  62. package/tests/web-storage-queue.test.js +480 -0
  63. package/web-storage-queue/index.js +652 -0
@@ -0,0 +1,265 @@
1
+ import { getPublicKey } from 'nostr-tools'
2
+ import { relayPool as defaultRelayPool } from '../../relay/index.js'
3
+ import { DEFAULT_TIMEOUT, DEFAULT_TIMEOUT_AFTER_FIRST_EOSE } from '../constants/index.js'
4
+ import { createNip46Event, requestError } from '../helpers/frame.js'
5
+
6
+ const RETIRE_CONTEXT_AFTER_MS = 5000
7
+ const SEEN_EVENT_LIMIT = 500
8
+
9
+ function requestId () {
10
+ const bytes = new Uint8Array(8)
11
+ globalThis.crypto.getRandomValues(bytes)
12
+ return [...bytes].map(byte => byte.toString(16).padStart(2, '0')).join('')
13
+ }
14
+
15
+ function requestExtension (value) {
16
+ if (value === undefined || value === null) return null
17
+ if (typeof value !== 'object' || Array.isArray(value)) throw new Error('NIP46_REQUEST_EXTENSION_REQUIRED')
18
+ for (const key of ['id', 'method', 'params']) {
19
+ if (Object.hasOwn(value, key)) throw new Error(`NIP46_REQUEST_EXTENSION_CANNOT_SET_${key.toUpperCase()}`)
20
+ }
21
+ return value
22
+ }
23
+
24
+ export function waitForNip46 (promise, { timeout = null, signal, label = 'NIP46_TIMEOUT' } = {}) {
25
+ if (signal?.aborted) return Promise.reject(new Error('Aborted'))
26
+ if (timeout === null && !signal) return Promise.resolve(promise)
27
+
28
+ return new Promise((resolve, reject) => {
29
+ let timer = null
30
+ let settled = false
31
+ const finish = (fn, value) => {
32
+ if (settled) return
33
+ settled = true
34
+ clearTimeout(timer)
35
+ signal?.removeEventListener('abort', onAbort)
36
+ fn(value)
37
+ }
38
+ const onAbort = () => finish(reject, new Error('Aborted'))
39
+
40
+ if (timeout !== null) timer = setTimeout(() => finish(reject, new Error(label)), timeout)
41
+ signal?.addEventListener('abort', onAbort, { once: true })
42
+ Promise.resolve(promise).then(
43
+ value => finish(resolve, value),
44
+ error => finish(reject, error)
45
+ )
46
+ })
47
+ }
48
+
49
+ export function sameRelays (left, right) {
50
+ if (left.length !== right.length) return false
51
+ const values = new Set(left)
52
+ return right.every(value => values.has(value))
53
+ }
54
+
55
+ // Owns the shared encrypted RPC mechanics while client/server classes enforce
56
+ // their different connection and authorization rules.
57
+ export class Nip46Transport {
58
+ #secretKey
59
+ #pubkey
60
+ #relayPool
61
+ #networkTimeout
62
+ #timeoutAfterFirstEose
63
+ #onError
64
+ #contexts = new Set()
65
+ #activeContext = null
66
+ #retireTimers = new Set()
67
+ #pending = new Map()
68
+ #seenEventIds = new Set()
69
+ #closed = false
70
+
71
+ constructor (secretKey, {
72
+ relayPool = defaultRelayPool,
73
+ networkTimeout = DEFAULT_TIMEOUT,
74
+ timeoutAfterFirstEose = DEFAULT_TIMEOUT_AFTER_FIRST_EOSE,
75
+ onError
76
+ } = {}) {
77
+ if (!(secretKey instanceof Uint8Array)) throw new Error('NIP46_SECRET_KEY_REQUIRED')
78
+ if (!relayPool?.getLiveEventsGenerator || !relayPool?.sendEvent) throw new Error('RELAY_POOL_REQUIRED')
79
+ this.#secretKey = secretKey
80
+ this.#pubkey = getPublicKey(secretKey)
81
+ this.#relayPool = relayPool
82
+ this.#networkTimeout = networkTimeout
83
+ this.#timeoutAfterFirstEose = timeoutAfterFirstEose
84
+ this.#onError = onError
85
+ }
86
+
87
+ get pubkey () { return this.#pubkey }
88
+ get closed () { return this.#closed }
89
+ get activeContext () { return this.#activeContext }
90
+ get readyRelays () { return this.#activeContext?.stream.readyRelays || Object.freeze([]) }
91
+
92
+ openContext ({ filter, relays, onEvent }) {
93
+ if (this.#closed) throw new Error('NIP46_CLOSED')
94
+ const controller = new AbortController()
95
+ const stream = this.#relayPool.getLiveEventsGenerator(filter, relays, {
96
+ signal: controller.signal,
97
+ timeoutAfterFirstEose: this.#timeoutAfterFirstEose
98
+ })
99
+ const context = { controller, stream, relays: [...relays], consume: null }
100
+ this.#contexts.add(context)
101
+ context.consume = (async () => {
102
+ try {
103
+ for await (const event of stream) {
104
+ Promise.resolve(onEvent(event)).catch(error => this.#reportError(error))
105
+ }
106
+ } catch (error) {
107
+ if (!this.#closed && error?.message !== 'Aborted') this.#reportError(error)
108
+ }
109
+ })()
110
+ return context
111
+ }
112
+
113
+ async awaitContextReady (context, { timeout = this.#networkTimeout, signal } = {}) {
114
+ const report = await waitForNip46(context.stream.ready, {
115
+ timeout,
116
+ signal,
117
+ label: 'NIP46_LISTENER_TIMEOUT'
118
+ })
119
+ if (!context.stream.readyRelays.length) {
120
+ throw requestError(report.errors?.[0]?.reason || 'NIP46_NO_READY_RELAYS')
121
+ }
122
+ return context.stream.readyRelays
123
+ }
124
+
125
+ activateContext (context, { retirePrevious = true } = {}) {
126
+ const previous = this.#activeContext
127
+ this.#activeContext = context
128
+ if (retirePrevious && previous && previous !== context) this.retireContext(previous)
129
+ return previous
130
+ }
131
+
132
+ retireContext (context, delay = RETIRE_CONTEXT_AFTER_MS) {
133
+ if (!context || !this.#contexts.has(context)) return
134
+ const timer = setTimeout(() => {
135
+ this.#retireTimers.delete(timer)
136
+ this.closeContext(context)
137
+ }, delay)
138
+ this.#retireTimers.add(timer)
139
+ }
140
+
141
+ async closeContext (context) {
142
+ if (!context || !this.#contexts.delete(context)) return
143
+ context.controller.abort()
144
+ try {
145
+ await context.consume
146
+ } catch {
147
+ // Unexpected stream failures are reported by the consume loop.
148
+ }
149
+ }
150
+
151
+ isNewEvent (event) {
152
+ if (!event?.id) return true
153
+ if (this.#seenEventIds.has(event.id)) return false
154
+ if (this.#seenEventIds.size >= SEEN_EVENT_LIMIT) {
155
+ this.#seenEventIds.delete(this.#seenEventIds.values().next().value)
156
+ }
157
+ this.#seenEventIds.add(event.id)
158
+ return true
159
+ }
160
+
161
+ async sendRequest (peerPubkey, method, params = [], {
162
+ timeout = null,
163
+ signal,
164
+ extension
165
+ } = {}) {
166
+ if (this.#closed) throw new Error('NIP46_CLOSED')
167
+ if (typeof method !== 'string' || !method) throw new Error('NIP46_METHOD_REQUIRED')
168
+ if (!Array.isArray(params) || !params.every(param => typeof param === 'string')) {
169
+ throw new Error('NIP46_PARAMS_REQUIRED')
170
+ }
171
+ if (signal?.aborted) throw new Error('Aborted')
172
+ const context = this.#activeContext
173
+ if (!context) throw new Error('NIP46_LISTENER_UNAVAILABLE')
174
+ await this.awaitContextReady(context, { signal })
175
+
176
+ const id = requestId()
177
+ const response = Promise.withResolvers()
178
+ const extra = requestExtension(extension)
179
+ this.#pending.set(id, { ...response, peerPubkey })
180
+
181
+ try {
182
+ await this.publish(peerPubkey, { id, method, params, ...(extra || {}) }, { context })
183
+ return await waitForNip46(response.promise, {
184
+ timeout,
185
+ signal,
186
+ label: 'NIP46_REQUEST_TIMEOUT'
187
+ })
188
+ } finally {
189
+ const pending = this.#pending.get(id)
190
+ if (pending?.promise === response.promise) this.#pending.delete(id)
191
+ }
192
+ }
193
+
194
+ receiveResponse (peerPubkey, response, { onAuthUrl } = {}) {
195
+ if (!response || typeof response.id !== 'string') return false
196
+ const pending = this.#pending.get(response.id)
197
+ if (!pending || pending.peerPubkey !== peerPubkey) return false
198
+
199
+ if (response.result === 'auth_url') {
200
+ try {
201
+ onAuthUrl?.(typeof response.error === 'string' ? response.error : '')
202
+ } catch (error) {
203
+ this.#reportError(error)
204
+ }
205
+ return true
206
+ }
207
+
208
+ this.#pending.delete(response.id)
209
+ if (Object.hasOwn(response, 'error') && response.error !== null && response.error !== undefined) {
210
+ pending.reject(requestError(response.error))
211
+ } else if (Object.hasOwn(response, 'result')) {
212
+ pending.resolve(response.result)
213
+ } else {
214
+ pending.reject(new Error('NIP46_INVALID_RESPONSE'))
215
+ }
216
+ return true
217
+ }
218
+
219
+ reply (peerPubkey, id, result, error = null, options) {
220
+ return this.publish(peerPubkey, { id, result, error }, options)
221
+ }
222
+
223
+ async publish (peerPubkey, payload, { context = this.#activeContext } = {}) {
224
+ if (this.#closed) throw new Error('NIP46_CLOSED')
225
+ if (!context) throw new Error('NIP46_LISTENER_UNAVAILABLE')
226
+ await this.awaitContextReady(context)
227
+ const relays = context.stream.readyRelays
228
+ if (!relays.length) throw new Error('NIP46_NO_READY_RELAYS')
229
+ const event = createNip46Event({ secretKey: this.#secretKey, recipientPubkey: peerPubkey, payload })
230
+ const published = await this.#relayPool.sendEvent(event, relays, {
231
+ timeout: this.#networkTimeout,
232
+ timeoutUntilFirstFulfillment: null
233
+ })
234
+ if (!published.success) {
235
+ const report = await published.promise
236
+ throw requestError(report.errors?.[0]?.reason || 'NIP46_PUBLISH_FAILED')
237
+ }
238
+ return event
239
+ }
240
+
241
+ async close () {
242
+ if (this.#closed) return
243
+ this.#closed = true
244
+ for (const timer of this.#retireTimers) clearTimeout(timer)
245
+ this.#retireTimers.clear()
246
+ const contexts = [...this.#contexts]
247
+ for (const context of contexts) context.controller.abort()
248
+ await Promise.all(contexts.map(context => this.closeContext(context)))
249
+ this.#activeContext = null
250
+ for (const pending of this.#pending.values()) pending.reject(new Error('NIP46_CLOSED'))
251
+ this.#pending.clear()
252
+ }
253
+
254
+ #reportError (error) {
255
+ try {
256
+ if (this.#onError) {
257
+ Promise.resolve(this.#onError(error)).catch(callbackError => {
258
+ console.error('NIP46 onError failed:', callbackError)
259
+ })
260
+ } else console.error('NIP46 stream failed:', error)
261
+ } catch (callbackError) {
262
+ console.error('NIP46 onError failed:', callbackError)
263
+ }
264
+ }
265
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "libp2r2p",
3
+ "version": "0.0.1",
4
+ "description": "Peer-to-relay-to-peer",
5
+ "keywords": [
6
+ "p2r2p",
7
+ "p2p",
8
+ "nostr"
9
+ ],
10
+ "license": "MIT",
11
+ "author": "arthurfranca",
12
+ "type": "module",
13
+ "sideEffects": false,
14
+ "scripts": {
15
+ "test": "node --test --experimental-test-module-mocks 'tests/**/*.test.js'",
16
+ "test:only": "node --test --test-only --experimental-test-module-mocks 'tests/**/*.test.js'"
17
+ },
18
+ "exports": {
19
+ ".": "./index.js",
20
+ "./base16": "./base16/index.js",
21
+ "./base64": "./base64/index.js",
22
+ "./content-key": "./content-key/index.js",
23
+ "./content-key/event": "./content-key/event/index.js",
24
+ "./double-dh": "./double-dh/index.js",
25
+ "./ecdh": "./ecdh/index.js",
26
+ "./idb": "./idb/index.js",
27
+ "./idb-queue": "./idb-queue/index.js",
28
+ "./key": "./key/index.js",
29
+ "./network": "./network/index.js",
30
+ "./nip44-v3": "./nip44-v3/index.js",
31
+ "./nip46": "./nip46/index.js",
32
+ "./private-channel": "./private-channel/index.js",
33
+ "./private-message": "./private-message/index.js",
34
+ "./private-messenger": "./private-messenger/index.js",
35
+ "./private-messenger/recovery": "./private-messenger/recovery/index.js",
36
+ "./relay": "./relay/index.js",
37
+ "./temporary-storage": "./temporary-storage/index.js",
38
+ "./web-storage-queue": "./web-storage-queue/index.js"
39
+ },
40
+ "dependencies": {
41
+ "@noble/ciphers": "2.2.0",
42
+ "@noble/curves": "2.2.0",
43
+ "@noble/hashes": "2.2.0",
44
+ "nostr-tools": "2.23.5"
45
+ },
46
+ "devDependencies": {
47
+ "fake-indexeddb": "6.2.5"
48
+ }
49
+ }
@@ -0,0 +1,5 @@
1
+ export const PRIVATE_BROADCAST_KIND = 3560
2
+ export const ROUTER_KIND = 26300
3
+ export const NYM_CARRIER_KIND = 26400
4
+ export const MAX_EVENT_BYTES = 65536
5
+ export const EXPIRATION_SECONDS = 2 * 24 * 60 * 60
@@ -0,0 +1,61 @@
1
+ import { MAX_EVENT_BYTES, PRIVATE_BROADCAST_KIND, ROUTER_KIND } from '../constants/index.js'
2
+ import { eventByteLength } from './event.js'
3
+ import { payloadByteLength as nip44v3PayloadByteLength } from '../../nip44-v3/index.js'
4
+
5
+ const MAX_TIME_SECONDS = 9999999999
6
+ const MAX_CHUNK_TAG_VALUE = '9999999999'
7
+ const SAMPLE_PUBKEY = 'f'.repeat(64)
8
+ const SAMPLE_SIGNATURE = 'f'.repeat(128)
9
+
10
+ function base64EncodedByteLength (byteLength) {
11
+ return Math.ceil(byteLength / 3) * 4
12
+ }
13
+
14
+ function routerPlaintextByteLengthForChunk (jsonlByteLength) {
15
+ return eventByteLength({
16
+ kind: ROUTER_KIND,
17
+ pubkey: SAMPLE_PUBKEY,
18
+ created_at: MAX_TIME_SECONDS,
19
+ tags: [['f', SAMPLE_PUBKEY], ['imkc', SAMPLE_PUBKEY, `${MAX_TIME_SECONDS}:${SAMPLE_SIGNATURE}`], ['c', MAX_CHUNK_TAG_VALUE, MAX_CHUNK_TAG_VALUE], ['r', SAMPLE_PUBKEY]],
20
+ content: 'A'.repeat(base64EncodedByteLength(jsonlByteLength)),
21
+ id: SAMPLE_PUBKEY,
22
+ sig: SAMPLE_SIGNATURE
23
+ })
24
+ }
25
+
26
+ function outerEventByteLengthForChunk (jsonlByteLength) {
27
+ const contentByteLength = nip44v3PayloadByteLength(routerPlaintextByteLengthForChunk(jsonlByteLength), 0)
28
+ if (!Number.isFinite(contentByteLength)) return Infinity
29
+
30
+ return eventByteLength({
31
+ kind: PRIVATE_BROADCAST_KIND,
32
+ created_at: MAX_TIME_SECONDS,
33
+ // Reserve the deletion capability tag used by high-level private sends.
34
+ tags: [['s', SAMPLE_PUBKEY], ['expiration', String(MAX_TIME_SECONDS)]],
35
+ content: 'A'.repeat(contentByteLength),
36
+ pubkey: SAMPLE_PUBKEY,
37
+ id: SAMPLE_PUBKEY,
38
+ sig: SAMPLE_SIGNATURE
39
+ })
40
+ }
41
+
42
+ function maxJsonlChunkByteSize () {
43
+ let min = 1
44
+ let max = MAX_EVENT_BYTES
45
+
46
+ while (min < max) {
47
+ const mid = Math.ceil((min + max) / 2)
48
+ if (outerEventByteLengthForChunk(mid) <= MAX_EVENT_BYTES) min = mid
49
+ else max = mid - 1
50
+ }
51
+
52
+ return min
53
+ }
54
+
55
+ // NIP-44 v3 padding has large size jumps, so derive this from the actual signed
56
+ // event shapes instead of estimating a flat overhead.
57
+ export const JSONL_CHUNK_BYTES = maxJsonlChunkByteSize()
58
+ // Nym carrier content is a base64 slice of the inner event JSON. The router
59
+ // budget is more conservative because routers carry extra tags, so it is safe
60
+ // to reuse it as the maximum carrier content length.
61
+ export const NYM_CARRIER_CHUNK_CHARS = JSONL_CHUNK_BYTES
@@ -0,0 +1,282 @@
1
+ import { generateSecretKey, getPublicKey } from 'nostr-tools'
2
+ import { bytesToBase64, base64ToBytes } from '../../base64/index.js'
3
+ import { bytesToHex } from '../../base16/index.js'
4
+ import { verifyIykcProof } from '../../content-key/event/index.js'
5
+ import * as nip44v3 from '../../nip44-v3/index.js'
6
+ import { JSONL_CHUNK_BYTES } from './chunk-size.js'
7
+ import { ROUTER_KIND } from '../constants/index.js'
8
+ import { createTemporaryStorage } from '../../temporary-storage/index.js'
9
+
10
+ const encoder = new TextEncoder()
11
+ const decoder = new TextDecoder()
12
+ const STORAGE_PREFIX = 'libp2r2p:private-channel:'
13
+
14
+ function appendBytes (left, right) {
15
+ const out = new Uint8Array(left.length + right.length)
16
+ out.set(left)
17
+ out.set(right, left.length)
18
+ return out
19
+ }
20
+
21
+ function tempKey (id, index) {
22
+ return `${STORAGE_PREFIX}${id}:${index}`
23
+ }
24
+
25
+ function rowTempKey (id, index) {
26
+ return `${STORAGE_PREFIX}${id}:row:${index}`
27
+ }
28
+
29
+ function normalizeContentKey ({ receiverPubkey, iykcPubkey = '', iykcProof = '' } = {}) {
30
+ if (!iykcPubkey) return { iykcPubkey: '', iykcProof: '' }
31
+ if (!verifyIykcProof({ receiverPubkey, iykcPubkey, iykcProof })) throw new Error('INVALID_IYKC_PROOF')
32
+ return { iykcPubkey, iykcProof }
33
+ }
34
+
35
+ function receiverRecord (receiver, receiverContentKeys) {
36
+ if (typeof receiver === 'string') {
37
+ const fetchedContentKey = normalizeContentKey({ receiverPubkey: receiver, ...receiverContentKeys[receiver] })
38
+ return {
39
+ receiverPubkey: receiver,
40
+ ...fetchedContentKey
41
+ }
42
+ }
43
+
44
+ if (Array.isArray(receiver)) {
45
+ const [receiverPubkey, iykcPubkey = '', iykcProof = ''] = receiver
46
+ const explicitContentKey = normalizeContentKey({ receiverPubkey, iykcPubkey, iykcProof })
47
+ const fetchedContentKey = normalizeContentKey({ receiverPubkey, ...receiverContentKeys[receiverPubkey] })
48
+ const contentKey = explicitContentKey.iykcPubkey ? explicitContentKey : fetchedContentKey
49
+ return {
50
+ receiverPubkey,
51
+ ...contentKey
52
+ }
53
+ }
54
+
55
+ const receiverPubkey = receiver?.receiverPubkey || receiver?.pubkey || ''
56
+ const explicitContentKey = normalizeContentKey({ receiverPubkey, ...receiver })
57
+ const fetchedContentKey = normalizeContentKey({ receiverPubkey, ...receiverContentKeys[receiverPubkey] })
58
+ const resolvedContentKey = explicitContentKey.iykcPubkey ? explicitContentKey : fetchedContentKey
59
+ return {
60
+ receiverPubkey,
61
+ ...resolvedContentKey
62
+ }
63
+ }
64
+
65
+ function buildRecipientRow ({ receiverPubkey, iykcPubkey, iykcProof }, ciphertext) {
66
+ const line = [receiverPubkey, ciphertext]
67
+ if (iykcPubkey) line.push(iykcPubkey, iykcProof)
68
+ return JSON.stringify(line)
69
+ }
70
+
71
+ function buildPayloadRow (ciphertext) {
72
+ return JSON.stringify([ciphertext])
73
+ }
74
+
75
+ function encryptedPayload ({ messageSecretKey, event }) {
76
+ const messagePubkey = getPublicKey(messageSecretKey)
77
+ return nip44v3.encrypt(messageSecretKey, messagePubkey, ROUTER_KIND, '', JSON.stringify(event))
78
+ }
79
+
80
+ function appendLine (chunk, line, id, chunkIndex, temporaryStorage) {
81
+ while (line.length) {
82
+ const available = JSONL_CHUNK_BYTES - chunk.length
83
+ chunk = appendBytes(chunk, line.slice(0, available))
84
+ line = line.slice(available)
85
+ if (chunk.length === JSONL_CHUNK_BYTES) {
86
+ temporaryStorage.setItem(tempKey(id, chunkIndex++), bytesToBase64(chunk))
87
+ chunk = new Uint8Array()
88
+ }
89
+ }
90
+ return { chunk, chunkIndex }
91
+ }
92
+
93
+ function appendRow (chunk, row, id, chunkIndex, temporaryStorage) {
94
+ return appendLine(chunk, encoder.encode(`${row}\n`), id, chunkIndex, temporaryStorage)
95
+ }
96
+
97
+ function joinByteChunks (parts) {
98
+ let length = 0
99
+ const decoded = parts.map(part => {
100
+ const bytes = base64ToBytes(part)
101
+ length += bytes.length
102
+ return bytes
103
+ })
104
+ const out = new Uint8Array(length)
105
+ let offset = 0
106
+ for (const bytes of decoded) {
107
+ out.set(bytes, offset)
108
+ offset += bytes.length
109
+ }
110
+ return out
111
+ }
112
+
113
+ function storageFor (temporaryStorage) {
114
+ return temporaryStorage || createTemporaryStorage()
115
+ }
116
+
117
+ export function readChunkContent (id, index, temporaryStorage) {
118
+ return storageFor(temporaryStorage).getItem(tempKey(id, index))
119
+ }
120
+
121
+ export function decodeChunkLines (content) {
122
+ return decodeChunkText(content).split('\n').filter(Boolean)
123
+ }
124
+
125
+ export function decodeChunkText (content) {
126
+ return decoder.decode(base64ToBytes(content))
127
+ }
128
+
129
+ export function joinChunksAsBase64 (parts) {
130
+ return bytesToBase64(joinByteChunks(parts))
131
+ }
132
+
133
+ export function receiverPubkeys (receivers) {
134
+ return receivers.map(receiver => receiverRecord(receiver, {}).receiverPubkey).filter(Boolean)
135
+ }
136
+
137
+ export function receiverPubkeysWithoutContentKeys (receivers) {
138
+ return receivers
139
+ .map(receiver => receiverRecord(receiver, {}))
140
+ .filter(receiver => receiver.receiverPubkey && !receiver.iykcPubkey)
141
+ .map(receiver => receiver.receiverPubkey)
142
+ }
143
+
144
+ function temporaryId () {
145
+ return `${Date.now()}:${Math.random().toString(16).slice(2)}`
146
+ }
147
+
148
+ function cleanupPreparedRows (id, totalRows, temporaryStorage) {
149
+ const keys = []
150
+ for (let i = 0; i < totalRows; i++) keys.push(rowTempKey(id, i))
151
+ storageFor(temporaryStorage).removeItems(keys)
152
+ }
153
+
154
+ function setPreparedRow (id, index, row, temporaryStorage) {
155
+ storageFor(temporaryStorage).setItem(rowTempKey(id, index), row)
156
+ }
157
+
158
+ function readPreparedRow (preparedRows, index) {
159
+ const row = storageFor(preparedRows.temporaryStorage).getItem(rowTempKey(preparedRows.id, index))
160
+ if (typeof row !== 'string') throw new Error('MISSING_PREPARED_ROW')
161
+ return row
162
+ }
163
+
164
+ async function prepareEnvelopeRowsOnce ({ id, senderSigner, receivers, receiverContentKeys = {}, event, rowScope = '', temporaryStorage }) {
165
+ const useDoubleDh = typeof senderSigner?.nip44EncryptDoubleDH === 'function'
166
+ let foundOwnContentPubkey = false
167
+ let usedOwnContentPubkey = ''
168
+ const messageSecretKey = generateSecretKey()
169
+ const messageSeckey = bytesToHex(messageSecretKey)
170
+ const rowIndexes = []
171
+ const receiverPubkeys = []
172
+ const receiverRowIndexesByPubkey = {}
173
+
174
+ setPreparedRow(id, 0, buildPayloadRow(encryptedPayload({ messageSecretKey, event })), temporaryStorage)
175
+
176
+ for (const receiver of receivers) {
177
+ const row = receiverRecord(receiver, useDoubleDh ? receiverContentKeys : {})
178
+ let ciphertext
179
+ if (useDoubleDh) {
180
+ const encrypted = await senderSigner.nip44EncryptDoubleDH(
181
+ row.receiverPubkey,
182
+ ROUTER_KIND,
183
+ rowScope,
184
+ bytesToBase64(encoder.encode(messageSeckey)),
185
+ row.iykcPubkey
186
+ )
187
+ ciphertext = encrypted[0]
188
+ const nextContentPubkey = encrypted[1] || ''
189
+ if (foundOwnContentPubkey && nextContentPubkey !== usedOwnContentPubkey) {
190
+ throw new Error('INCONSISTENT_IMKC_PUBKEY')
191
+ }
192
+ foundOwnContentPubkey = true
193
+ if (nextContentPubkey) usedOwnContentPubkey = nextContentPubkey
194
+ } else {
195
+ ciphertext = await senderSigner.nip44v3Encrypt(row.receiverPubkey, ROUTER_KIND, rowScope, bytesToBase64(encoder.encode(messageSeckey)))
196
+ }
197
+ const rowIndex = rowIndexes.length + 1
198
+ setPreparedRow(id, rowIndex, buildRecipientRow(row, ciphertext), temporaryStorage)
199
+ rowIndexes.push(rowIndex)
200
+ receiverPubkeys.push(row.receiverPubkey)
201
+ if (row.receiverPubkey && receiverRowIndexesByPubkey[row.receiverPubkey] === undefined) {
202
+ receiverRowIndexesByPubkey[row.receiverPubkey] = rowIndex
203
+ }
204
+ }
205
+
206
+ return {
207
+ id,
208
+ totalRows: rowIndexes.length + 1,
209
+ rowIndexes,
210
+ receiverPubkeys,
211
+ receiverRowIndexesByPubkey,
212
+ ownContentPubkey: usedOwnContentPubkey,
213
+ temporaryStorage
214
+ }
215
+ }
216
+
217
+ export async function prepareEnvelopeRows ({ temporaryStorageArea, ...options } = {}) {
218
+ const temporaryStorage = createTemporaryStorage({ storageArea: temporaryStorageArea })
219
+ const maxRows = (options.receivers?.length || 0) + 1
220
+ for (let attempt = 0; attempt < 2; attempt++) {
221
+ const id = temporaryId()
222
+ try {
223
+ return await prepareEnvelopeRowsOnce({ ...options, id, temporaryStorage })
224
+ } catch (err) {
225
+ cleanupPreparedRows(id, maxRows, temporaryStorage)
226
+ if (err?.message === 'INCONSISTENT_IMKC_PUBKEY' && attempt === 0) continue
227
+ throw err
228
+ }
229
+ }
230
+ }
231
+
232
+ export function cleanupEnvelopeRows (preparedRows) {
233
+ if (!preparedRows?.id || !Number.isSafeInteger(preparedRows.totalRows)) return
234
+ cleanupPreparedRows(preparedRows.id, preparedRows.totalRows, preparedRows.temporaryStorage)
235
+ }
236
+
237
+ export function preparedRowIndexesForReceivers (preparedRows, receivers) {
238
+ const indexes = []
239
+ const seen = new Set()
240
+ for (const receiver of receivers || []) {
241
+ const pubkey = receiverRecord(receiver, {}).receiverPubkey
242
+ const index = preparedRows?.receiverRowIndexesByPubkey?.[pubkey]
243
+ if (!pubkey || index === undefined) throw new Error('MISSING_PREPARED_RECEIVER')
244
+ if (seen.has(index)) continue
245
+ seen.add(index)
246
+ indexes.push(index)
247
+ }
248
+ return indexes
249
+ }
250
+
251
+ export function writeChunksFromPreparedRows (preparedRows, rowIndexes = preparedRows?.rowIndexes || []) {
252
+ const temporaryStorage = storageFor(preparedRows?.temporaryStorage)
253
+ const id = temporaryId()
254
+ let chunk = new Uint8Array()
255
+ let chunkIndex = 0
256
+ try {
257
+ ;({ chunk, chunkIndex } = appendRow(chunk, readPreparedRow(preparedRows, 0), id, chunkIndex, temporaryStorage))
258
+ for (const rowIndex of rowIndexes) {
259
+ ;({ chunk, chunkIndex } = appendRow(chunk, readPreparedRow(preparedRows, rowIndex), id, chunkIndex, temporaryStorage))
260
+ }
261
+ if (chunk.length || chunkIndex === 0) temporaryStorage.setItem(tempKey(id, chunkIndex++), bytesToBase64(chunk))
262
+ return { id, total: chunkIndex, ownContentPubkey: preparedRows.ownContentPubkey || '' }
263
+ } catch (err) {
264
+ cleanupChunks(id, chunkIndex + 1, temporaryStorage)
265
+ throw err
266
+ }
267
+ }
268
+
269
+ export async function writeChunks (options) {
270
+ const preparedRows = await prepareEnvelopeRows(options)
271
+ try {
272
+ return writeChunksFromPreparedRows(preparedRows)
273
+ } finally {
274
+ cleanupEnvelopeRows(preparedRows)
275
+ }
276
+ }
277
+
278
+ export function cleanupChunks (id, total, temporaryStorage) {
279
+ const keys = []
280
+ for (let i = 0; i < total; i++) keys.push(tempKey(id, i))
281
+ storageFor(temporaryStorage).removeItems(keys)
282
+ }