nappup 2.0.1 → 2.2.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.
@@ -0,0 +1,356 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { randomUUID } from 'node:crypto'
4
+ import * as dotenv from 'dotenv'
5
+ import { decrypt, derive, encrypt } from '@dotenvx/primitives'
6
+ import { base64ToBytes, bytesToBase64 } from 'libp2r2p/base64'
7
+ import { decodeCliSession } from '#services/bunker-session-codec.js'
8
+ import { validateNostrSecretKey } from '#services/nostr-secret-key.js'
9
+
10
+ export const DOTENV_PRIVATE_KEY_NAME = 'DOTENV_PRIVATE_KEY_NAPPUP'
11
+ export const DOTENV_PUBLIC_KEY_NAME = 'DOTENV_PUBLIC_KEY_NAPPUP'
12
+ export const LAST_CLI_BUNKER_SESSION = 'LAST_CLI_BUNKER_SESSION'
13
+ export const DEFAULT_DOTENV_PRIVATE_KEY = '0000000000000000000000000000000000000000000000000000000000000001'
14
+ export const dotenvPath = path.resolve(process.env.DOTENV_CONFIG_PATH ?? path.join(process.cwd(), '.env'))
15
+
16
+ const MANAGED_NAMES = new Set(['NOSTR_SECRET_KEY', LAST_CLI_BUNKER_SESSION])
17
+ const ENCRYPTED_PREFIX = 'encrypted:'
18
+ const MIN_ECIES_PAYLOAD_BYTES = 97
19
+
20
+ let configuration = {
21
+ privateKey: DEFAULT_DOTENV_PRIVATE_KEY,
22
+ privateKeyExplicit: false,
23
+ externalPublicKey: null,
24
+ processEnv: process.env,
25
+ initialized: false
26
+ }
27
+
28
+ export class DotenvDecryptionError extends Error {
29
+ constructor (name, cause) {
30
+ super(`Unable to decrypt ${name} with the selected dotenv private key`, { cause })
31
+ this.name = 'DotenvDecryptionError'
32
+ this.code = 'DOTENV_DECRYPTION_FAILED'
33
+ }
34
+ }
35
+
36
+ function normalizePrivateKey (value) {
37
+ const normalized = typeof value === 'string' ? value.toLowerCase() : ''
38
+ if (!/^[0-9a-f]{64}$/.test(normalized)) throw new Error('Invalid dotenv private key')
39
+ try {
40
+ derive(normalized)
41
+ } catch {
42
+ throw new Error('Invalid dotenv private key')
43
+ }
44
+ return normalized
45
+ }
46
+
47
+ function normalizePublicKey (value) {
48
+ const normalized = typeof value === 'string' ? value.toLowerCase() : ''
49
+ if (!/^(02|03)[0-9a-f]{64}$/.test(normalized)) throw new Error('Invalid dotenv public key')
50
+ try {
51
+ encrypt(normalized, '')
52
+ } catch {
53
+ throw new Error('Invalid dotenv public key')
54
+ }
55
+ return normalized
56
+ }
57
+
58
+ function isEncrypted (value) {
59
+ return typeof value === 'string' && value.startsWith(ENCRYPTED_PREFIX)
60
+ }
61
+
62
+ function validateEncryptedValue (value) {
63
+ if (!isEncrypted(value)) throw new Error('Invalid encrypted dotenv value')
64
+ const encoded = value.slice(ENCRYPTED_PREFIX.length)
65
+ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(encoded)) throw new Error('Invalid encrypted dotenv value')
66
+ let bytes
67
+ try {
68
+ bytes = base64ToBytes(encoded)
69
+ } catch {
70
+ throw new Error('Invalid encrypted dotenv value')
71
+ }
72
+ if (bytes.length < MIN_ECIES_PAYLOAD_BYTES || bytesToBase64(bytes) !== encoded) {
73
+ throw new Error('Invalid encrypted dotenv value')
74
+ }
75
+ }
76
+
77
+ function validateManagedValue (name, value) {
78
+ if (name === 'NOSTR_SECRET_KEY') return validateNostrSecretKey(value)
79
+ if (name === LAST_CLI_BUNKER_SESSION) {
80
+ decodeCliSession(value)
81
+ return value
82
+ }
83
+ throw new Error(`Unsupported managed dotenv variable: ${name}`)
84
+ }
85
+
86
+ export function readDotenvValues (filePath = dotenvPath) {
87
+ try {
88
+ return dotenv.parse(fs.readFileSync(filePath))
89
+ } catch (error) {
90
+ if (error?.code === 'ENOENT') return {}
91
+ throw error
92
+ }
93
+ }
94
+
95
+ function readDotenvFile (filePath) {
96
+ try {
97
+ return {
98
+ contents: fs.readFileSync(filePath, 'utf8'),
99
+ mode: fs.statSync(filePath).mode & 0o777
100
+ }
101
+ } catch (error) {
102
+ if (error?.code === 'ENOENT') return { contents: '', mode: 0o600 }
103
+ throw error
104
+ }
105
+ }
106
+
107
+ function writeDotenvChanges (changes, { filePath = dotenvPath } = {}) {
108
+ const { contents, mode } = readDotenvFile(filePath)
109
+ const eol = contents.includes('\r\n') ? '\r\n' : '\n'
110
+ const lines = contents ? contents.split(/\r?\n/) : []
111
+ if (lines.at(-1) === '') lines.pop()
112
+
113
+ const names = [...changes.keys()]
114
+ const assignments = new Map(names.map(name => [
115
+ name,
116
+ new RegExp(`^\\s*(?:export\\s+)?${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*=`)
117
+ ]))
118
+ const written = new Set()
119
+ const nextLines = []
120
+
121
+ for (const line of lines) {
122
+ const name = names.find(candidate => assignments.get(candidate).test(line))
123
+ if (!name) {
124
+ nextLines.push(line)
125
+ continue
126
+ }
127
+ if (written.has(name)) continue
128
+ written.add(name)
129
+ const value = changes.get(name)
130
+ if (value !== null) nextLines.push(`${name}=${JSON.stringify(value)}`)
131
+ }
132
+ for (const [name, value] of changes) {
133
+ if (!written.has(name) && value !== null) nextLines.push(`${name}=${JSON.stringify(value)}`)
134
+ }
135
+
136
+ const nextContents = `${nextLines.join(eol)}${eol}`
137
+ const directory = path.dirname(filePath)
138
+ const temporaryPath = path.join(directory, `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`)
139
+ try {
140
+ fs.writeFileSync(temporaryPath, nextContents, { encoding: 'utf8', flag: 'wx', mode })
141
+ fs.chmodSync(temporaryPath, mode)
142
+ fs.renameSync(temporaryPath, filePath)
143
+ } finally {
144
+ try {
145
+ fs.unlinkSync(temporaryPath)
146
+ } catch {}
147
+ }
148
+ }
149
+
150
+ function decryptManagedValue (name, value, privateKey) {
151
+ try {
152
+ validateEncryptedValue(value)
153
+ return decrypt(privateKey, value)
154
+ } catch (error) {
155
+ throw new DotenvDecryptionError(name, error)
156
+ }
157
+ }
158
+
159
+ function warnKeyMismatch (names, onWarning, replacingName) {
160
+ const details = []
161
+ if (replacingName) details.push(`${replacingName} will be replaced by the supplied value`)
162
+ if (names.includes('NOSTR_SECRET_KEY')) {
163
+ details.push('a new publisher identity will be generated unless -s or an environment NOSTR_SECRET_KEY is provided')
164
+ }
165
+ if (names.includes(LAST_CLI_BUNKER_SESSION)) {
166
+ details.push('the next CLI bunker connection will generate a new client key and may require authorization')
167
+ }
168
+ const discarded = names.length ? ` Inaccessible values reset: ${names.join(', ')}.` : ''
169
+ const consequences = details.length ? ` ${details.join('; ')}.` : ''
170
+ onWarning?.(`The explicit dotenv private key does not match the stored public key; adopting the explicit key.${discarded}${consequences}`)
171
+ }
172
+
173
+ function reconcileExplicitPrivateKey (filePath, onWarning, replacement) {
174
+ if (!configuration.privateKeyExplicit) return false
175
+ const values = readDotenvValues(filePath)
176
+ const derivedPublicKey = derive(configuration.privateKey)
177
+ const existingPublicValue = values[DOTENV_PUBLIC_KEY_NAME]
178
+ if (!existingPublicValue) return false
179
+
180
+ let existingPublicKey
181
+ try {
182
+ existingPublicKey = normalizePublicKey(existingPublicValue)
183
+ } catch {
184
+ existingPublicKey = null
185
+ }
186
+ if (existingPublicKey === derivedPublicKey) {
187
+ if (existingPublicValue !== derivedPublicKey) {
188
+ writeDotenvChanges(new Map([[DOTENV_PUBLIC_KEY_NAME, derivedPublicKey]]), { filePath })
189
+ }
190
+ return false
191
+ }
192
+
193
+ const fallbackPublicKey = derive(DEFAULT_DOTENV_PRIVATE_KEY)
194
+ const fallbackUpgrade = existingPublicKey === fallbackPublicKey
195
+ const changes = new Map([[DOTENV_PUBLIC_KEY_NAME, derivedPublicKey]])
196
+ const discarded = []
197
+
198
+ for (const name of MANAGED_NAMES) {
199
+ const stored = values[name]
200
+ if (stored === undefined) continue
201
+ if (replacement?.name === name) continue
202
+ let plaintext
203
+ try {
204
+ if (isEncrypted(stored)) {
205
+ try {
206
+ plaintext = decryptManagedValue(name, stored, configuration.privateKey)
207
+ } catch (error) {
208
+ if (!fallbackUpgrade) throw error
209
+ plaintext = decryptManagedValue(name, stored, DEFAULT_DOTENV_PRIVATE_KEY)
210
+ }
211
+ } else {
212
+ plaintext = stored
213
+ }
214
+ validateManagedValue(name, plaintext)
215
+ changes.set(name, encrypt(derivedPublicKey, plaintext))
216
+ } catch (error) {
217
+ if (fallbackUpgrade) throw error
218
+ changes.set(name, null)
219
+ discarded.push(name)
220
+ }
221
+ }
222
+
223
+ if (replacement) changes.set(replacement.name, encrypt(derivedPublicKey, replacement.value))
224
+ if (!fallbackUpgrade) warnKeyMismatch(discarded, onWarning, replacement?.name)
225
+ writeDotenvChanges(changes, { filePath })
226
+ return true
227
+ }
228
+
229
+ function getPublicKeyForFile (filePath) {
230
+ const values = readDotenvValues(filePath)
231
+ const stored = values[DOTENV_PUBLIC_KEY_NAME]
232
+ if (stored) return normalizePublicKey(stored)
233
+ if (configuration.externalPublicKey) return configuration.externalPublicKey
234
+ return derive(configuration.privateKey)
235
+ }
236
+
237
+ export function setDotenvValue (name, value, {
238
+ filePath = dotenvPath,
239
+ updateProcessEnv = true
240
+ } = {}) {
241
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new Error('Invalid dotenv variable name')
242
+ if (MANAGED_NAMES.has(name)) throw new Error(`Use setEncryptedDotenvValue for ${name}`)
243
+ if (typeof value !== 'string') throw new TypeError('Dotenv value should be a string')
244
+ writeDotenvChanges(new Map([[name, value]]), { filePath })
245
+ if (updateProcessEnv) configuration.processEnv[name] = value
246
+ }
247
+
248
+ export function setEncryptedDotenvValue (name, value, {
249
+ filePath = dotenvPath,
250
+ updateProcessEnv = true,
251
+ onWarning = console.warn
252
+ } = {}) {
253
+ if (!MANAGED_NAMES.has(name)) throw new Error(`Unsupported managed dotenv variable: ${name}`)
254
+ if (typeof value !== 'string') throw new TypeError('Dotenv value should be a string')
255
+ validateManagedValue(name, value)
256
+ const reconciled = reconcileExplicitPrivateKey(filePath, onWarning, { name, value })
257
+ if (reconciled) {
258
+ if (updateProcessEnv) configuration.processEnv[name] = value
259
+ return
260
+ }
261
+ const publicKey = getPublicKeyForFile(filePath)
262
+ writeDotenvChanges(new Map([
263
+ [DOTENV_PUBLIC_KEY_NAME, publicKey],
264
+ [name, encrypt(publicKey, value)]
265
+ ]), { filePath })
266
+ if (updateProcessEnv) configuration.processEnv[name] = value
267
+ }
268
+
269
+ export function readEncryptedDotenvValue (name, {
270
+ filePath = dotenvPath,
271
+ migratePlaintext = true,
272
+ onWarning = console.warn
273
+ } = {}) {
274
+ if (!MANAGED_NAMES.has(name)) throw new Error(`Unsupported managed dotenv variable: ${name}`)
275
+ reconcileExplicitPrivateKey(filePath, onWarning)
276
+ const values = readDotenvValues(filePath)
277
+ const stored = values[name]
278
+ if (stored === undefined) return null
279
+
280
+ if (!isEncrypted(stored)) {
281
+ validateManagedValue(name, stored)
282
+ if (migratePlaintext) {
283
+ setEncryptedDotenvValue(name, stored, { filePath, updateProcessEnv: false, onWarning })
284
+ }
285
+ return stored
286
+ }
287
+
288
+ const publicKey = values[DOTENV_PUBLIC_KEY_NAME]
289
+ ? normalizePublicKey(values[DOTENV_PUBLIC_KEY_NAME])
290
+ : derive(configuration.privateKey)
291
+ if (derive(configuration.privateKey) !== publicKey) {
292
+ throw new DotenvDecryptionError(name, new Error('The selected private key does not match DOTENV_PUBLIC_KEY_NAPPUP'))
293
+ }
294
+ const plaintext = decryptManagedValue(name, stored, configuration.privateKey)
295
+ validateManagedValue(name, plaintext)
296
+ return plaintext
297
+ }
298
+
299
+ export function initializeDotenv ({
300
+ privateKey,
301
+ filePath = dotenvPath,
302
+ processEnv = process.env,
303
+ loadNostrSecretKey = true,
304
+ reconcilePrivateKey = true,
305
+ onWarning = console.warn
306
+ } = {}) {
307
+ const hadNostrSecretKey = Object.hasOwn(processEnv, 'NOSTR_SECRET_KEY')
308
+ const environmentPrivateKey = Object.hasOwn(processEnv, DOTENV_PRIVATE_KEY_NAME)
309
+ ? processEnv[DOTENV_PRIVATE_KEY_NAME]
310
+ : null
311
+ const environmentPublicKey = Object.hasOwn(processEnv, DOTENV_PUBLIC_KEY_NAME)
312
+ ? processEnv[DOTENV_PUBLIC_KEY_NAME]
313
+ : null
314
+ const selectedPrivateKey = normalizePrivateKey(privateKey ?? environmentPrivateKey ?? DEFAULT_DOTENV_PRIVATE_KEY)
315
+
316
+ const initialValues = readDotenvValues(filePath)
317
+ if (Object.hasOwn(initialValues, DOTENV_PRIVATE_KEY_NAME)) {
318
+ throw new Error(`${DOTENV_PRIVATE_KEY_NAME} must not be stored in ${filePath}`)
319
+ }
320
+
321
+ configuration = {
322
+ privateKey: selectedPrivateKey,
323
+ privateKeyExplicit: (privateKey !== undefined && privateKey !== null) || environmentPrivateKey !== null,
324
+ externalPublicKey: environmentPublicKey ? normalizePublicKey(environmentPublicKey) : null,
325
+ processEnv,
326
+ initialized: true
327
+ }
328
+
329
+ if (reconcilePrivateKey) reconcileExplicitPrivateKey(filePath, onWarning)
330
+ const values = readDotenvValues(filePath)
331
+ for (const [name, value] of Object.entries(values)) {
332
+ if (name === DOTENV_PRIVATE_KEY_NAME || MANAGED_NAMES.has(name)) continue
333
+ if (!Object.hasOwn(processEnv, name)) processEnv[name] = value
334
+ }
335
+
336
+ let nostrSecretKeySource = hadNostrSecretKey ? 'process' : null
337
+ if (!hadNostrSecretKey && loadNostrSecretKey) {
338
+ const value = readEncryptedDotenvValue('NOSTR_SECRET_KEY', { filePath, onWarning })
339
+ if (value !== null) {
340
+ processEnv.NOSTR_SECRET_KEY = value
341
+ nostrSecretKeySource = 'dotenv'
342
+ }
343
+ }
344
+
345
+ return {
346
+ filePath,
347
+ nostrSecretKeySource,
348
+ privateKeyExplicit: configuration.privateKeyExplicit,
349
+ publicKey: getPublicKeyForFile(filePath)
350
+ }
351
+ }
352
+
353
+ export function ensureDotenvInitialized (options) {
354
+ if (!configuration.initialized) return initializeDotenv(options)
355
+ return null
356
+ }
@@ -1,4 +1,4 @@
1
- import nostrRelays, { nappRelays } from '#services/nostr-relays.js'
1
+ import nostrRelays, { nappRelays, sendEventReport } from '#services/nostr-relays.js'
2
2
  import NMMR from 'nmmr'
3
3
  import { decode as base93Decode, encode as base93Encode } from 'libp2r2p/base93'
4
4
  import { stringifyEvent } from '#helpers/event.js'
@@ -137,7 +137,7 @@ export async function throttledSendEvent (event, relays, {
137
137
  if (pause && leadingPause) await new Promise(resolve => setTimeout(resolve, pause))
138
138
  if (retries > 0) log(`Retrying upload to ${relays.length} relays: ${relays.join(', ')}`)
139
139
 
140
- const { errors } = (await nostrRelays.sendEvent(event, relays, 15000))
140
+ const { errors } = await sendEventReport(event, relays, { timeout: 15000, timeoutUntilFirstFulfillment: null })
141
141
  if (errors.length === 0) {
142
142
  if (pause && trailingPause) await new Promise(resolve => setTimeout(resolve, pause))
143
143
  return { pause }
@@ -147,8 +147,7 @@ export async function throttledSendEvent (event, relays, {
147
147
  errors.reduce((r, v) => {
148
148
  const message = v.reason?.message ?? ''
149
149
  if (message.startsWith('rate-limited:')) r[0].push(v)
150
- // https://github.com/nbd-wtf/nostr-tools/blob/28f7553187d201088c8a1009365db4ecbe03e568/abstract-relay.ts#L311
151
- else if (message === 'publish timed out') r[1].push(v)
150
+ else if (message === 'PUBLISH_TIMEOUT') r[1].push(v)
152
151
  else r[2].push(v)
153
152
  return r
154
153
  }, [[], [], []])
@@ -158,7 +157,7 @@ export async function throttledSendEvent (event, relays, {
158
157
  const timedOutRelays = maybeUnretryableErrors.map(v => v.relay)
159
158
  log(`${maybeUnretryableErrors.length} timeout errors, retrying once after ${pause}ms:\n${maybeUnretryableErrors.map(v => `${v.relay}: ${v.reason.message}`).join('; ')}`)
160
159
  if (pause) await new Promise(resolve => setTimeout(resolve, pause))
161
- const { errors: timeoutRetryErrors } = await nostrRelays.sendEvent(event, timedOutRelays, 15000)
160
+ const { errors: timeoutRetryErrors } = await sendEventReport(event, timedOutRelays, { timeout: 15000, timeoutUntilFirstFulfillment: null })
162
161
  unretryableErrors.push(...timeoutRetryErrors)
163
162
  }
164
163
 
@@ -204,7 +203,7 @@ export async function getPreviousChunks (dTagValues, relays, signer) {
204
203
  authors: [pubkey],
205
204
  '#d': batch,
206
205
  limit: batch.length
207
- }, targetRelays)).result
206
+ }, targetRelays, { timeoutAfterFirstEose: null })).result
208
207
 
209
208
  for (const event of storedEvents) {
210
209
  const dTag = event.tags?.find(tag => tag[0] === 'd')?.[1]
@@ -1,5 +1,4 @@
1
- import { Relay } from 'nostr-tools/relay'
2
- import { maybeUnref } from '#helpers/timer.js'
1
+ import { relayPool } from 'libp2r2p/relay'
3
2
 
4
3
  export const seedRelays = [
5
4
  'wss://relay.44billion.net',
@@ -14,145 +13,13 @@ export const freeRelays = [
14
13
  'wss://nos.lol',
15
14
  'wss://relay.damus.io'
16
15
  ]
17
- export const nappRelays = [
18
- 'wss://relay.44billion.net'
19
- ]
20
-
21
- // Interacts with Nostr relays
22
- export class NostrRelays {
23
- #relays = new Map()
24
- #relayTimeouts = new Map()
25
- #timeout = 30000 // 30 seconds
26
-
27
- // Get a relay connection, creating one if it doesn't exist
28
- async #getRelay (url) {
29
- if (this.#relays.has(url)) {
30
- clearTimeout(this.#relayTimeouts.get(url))
31
- this.#relayTimeouts.set(url, maybeUnref(setTimeout(() => this.disconnect(url), this.#timeout)))
32
- const relay = this.#relays.get(url)
33
- // Reconnect if needed to avoid SendingOnClosedConnection errors
34
- await relay.connect()
35
- return relay
36
- }
37
-
38
- const relay = new Relay(url)
39
- this.#relays.set(url, relay)
40
-
41
- await relay.connect()
42
-
43
- this.#relayTimeouts.set(url, maybeUnref(setTimeout(() => this.disconnect(url), this.#timeout)))
44
-
45
- return relay
46
- }
47
-
48
- // Disconnect from a relay
49
- async disconnect (url) {
50
- if (this.#relays.has(url)) {
51
- const relay = this.#relays.get(url)
52
- if (relay.ws.readyState < 2) await relay.close()?.catch(console.log)
53
- this.#relays.delete(url)
54
- clearTimeout(this.#relayTimeouts.get(url))
55
- this.#relayTimeouts.delete(url)
56
- }
57
- }
58
-
59
- // Disconnect from all relays
60
- async disconnectAll () {
61
- for (const url of this.#relays.keys()) {
62
- await this.disconnect(url)
63
- }
64
- }
65
-
66
- // Get events from a list of relays
67
- async getEvents (filter, relays, timeout = 5000) {
68
- const events = []
69
- const promises = relays.map(async (url) => {
70
- let sub
71
- const p = Promise.withResolvers()
72
- const t = Promise.withResolvers()
73
- const timer = maybeUnref(setTimeout(() => {
74
- sub?.close()
75
- t.reject(new Error(`timeout: ${url}`))
76
- }, timeout))
77
-
78
- ;(async () => {
79
- try {
80
- const relay = await this.#getRelay(url)
81
- sub = relay.subscribe([filter], {
82
- onevent: (event) => {
83
- event.meta = { relay: url }
84
- events.push(event)
85
- },
86
- onclose: err => err ? p.reject(err) : p.resolve(),
87
- oneose: () => { sub.close(); p.resolve() }
88
- })
89
- } catch (err) {
90
- p.reject(err)
91
- }
92
- })()
93
-
94
- try {
95
- await Promise.race([p.promise, t.promise])
96
- } finally {
97
- clearTimeout(timer)
98
- }
99
- })
100
-
101
- const results = await Promise.allSettled(promises)
102
- const rejectedResults = results.filter(v => v.status === 'rejected')
103
-
104
- return {
105
- result: events,
106
- errors: rejectedResults.map(v => ({ reason: v.reason, relay: relays[results.indexOf(v)] })),
107
- success: events.length > 0 || results.length !== rejectedResults.length
108
- }
109
- }
110
-
111
- // Send an event to a list of relays
112
- async sendEvent (event, relays, timeout = 3000) {
113
- const eventToSend = event.meta ? { ...event } : event
114
- if (eventToSend.meta) delete eventToSend.meta
115
-
116
- const promises = relays.map(async (url) => {
117
- const p = Promise.withResolvers()
118
- const t = Promise.withResolvers()
119
- const timer = maybeUnref(setTimeout(() => {
120
- t.reject(new Error(`timeout: ${url}`))
121
- }, timeout))
122
-
123
- ;(async () => {
124
- try {
125
- const relay = await this.#getRelay(url)
126
- await relay.publish(eventToSend)
127
- p.resolve()
128
- } catch (err) {
129
- if (err.message?.startsWith('duplicate:')) return p.resolve()
130
- if (err.message?.startsWith('mute:')) {
131
- console.info(`${url} - ${err.message}`)
132
- return p.resolve()
133
- }
134
- p.reject(err)
135
- }
136
- })()
137
-
138
- try {
139
- await Promise.race([p.promise, t.promise])
140
- } finally {
141
- clearTimeout(timer)
142
- }
143
- })
144
-
145
- const results = await Promise.allSettled(promises)
146
- const rejectedResults = results.filter(v => v.status === 'rejected')
16
+ export const nappRelays = ['wss://relay.44billion.net']
147
17
 
148
- return {
149
- result: null,
150
- errors: rejectedResults.map(v => ({ reason: v.reason, relay: relays[results.indexOf(v)] })),
151
- success: results.length !== rejectedResults.length
152
- }
153
- }
18
+ // sendEvent returns quickly after the first successful publish. Upload flows
19
+ // need the terminal per-relay report so retries and replication stay correct.
20
+ export async function sendEventReport (event, relays, options) {
21
+ const result = await relayPool.sendEvent(event, relays, options)
22
+ return await (result?.promise ?? result)
154
23
  }
155
24
 
156
- // Share same connection
157
- // Connections aren't authenticated, thus no need to split by authed user
158
- export default new NostrRelays()
25
+ export default relayPool
@@ -0,0 +1,24 @@
1
+ import { getPublicKey } from 'libp2r2p/key'
2
+ import { base16ToBytes } from 'libp2r2p/base16'
3
+ import { nsecDecode } from 'libp2r2p/nip19'
4
+ import { parseBunkerSessionUrl } from '#services/bunker-url.js'
5
+
6
+ const HEX_32 = /^[0-9a-f]{64}$/i
7
+
8
+ export function validateNostrSecretKey (value) {
9
+ if (typeof value !== 'string' || !value) throw new Error('Invalid NOSTR_SECRET_KEY')
10
+ if (value.startsWith('bunker://')) {
11
+ parseBunkerSessionUrl(value)
12
+ return value
13
+ }
14
+
15
+ let hex = value
16
+ try {
17
+ if (value.startsWith('nsec1')) hex = nsecDecode(value)
18
+ if (!HEX_32.test(hex)) throw new Error()
19
+ getPublicKey(base16ToBytes(hex))
20
+ } catch {
21
+ throw new Error('Invalid NOSTR_SECRET_KEY')
22
+ }
23
+ return value
24
+ }
@@ -1,27 +1,11 @@
1
- import fs from 'node:fs'
2
- import path from 'node:path'
3
- import { fileURLToPath } from 'node:url'
4
- import * as dotenv from 'dotenv'
5
- import { getPublicKey } from 'nostr-tools/pure'
6
- import { getConversationKey, encrypt, decrypt } from 'nostr-tools/nip44'
7
- import nostrRelays, { seedRelays, freeRelays } from '#services/nostr-relays.js'
1
+ import { finalizeEvent } from 'libp2r2p/event'
2
+ import { generateSecretKey, getPublicKey } from 'libp2r2p/key'
3
+ import * as nip44 from 'libp2r2p/nip44'
4
+ import { seedRelays, freeRelays, sendEventReport } from '#services/nostr-relays.js'
8
5
  import { getRelays } from '#helpers/signer.js'
9
6
  import { bytesToBase16, base16ToBytes } from '#helpers/base16.js'
10
- import { finalizeEvent } from '#helpers/nip01.js'
11
7
  import { nsecDecode, nsecEncode } from 'libp2r2p/nip19'
12
- const __dirname = fileURLToPath(new URL('.', import.meta.url))
13
-
14
- const dotenvPath = process.env.DOTENV_CONFIG_PATH ?? `${__dirname}/../../.env`
15
- dotenv.config({
16
- path: dotenvPath,
17
- quiet: true
18
- })
19
-
20
- const nip44 = {
21
- getConversationKey,
22
- encrypt,
23
- decrypt
24
- }
8
+ import { ensureDotenvInitialized, setEncryptedDotenvValue } from '#services/dotenv.js'
25
9
 
26
10
  const createToken = Symbol('createToken')
27
11
 
@@ -42,6 +26,7 @@ export default class NostrSigner {
42
26
  return new this(createToken, base16ToBytes(sk))
43
27
  }
44
28
 
29
+ ensureDotenvInitialized()
45
30
  let skBytes
46
31
  let isNewSk = false
47
32
  if (process.env.NOSTR_SECRET_KEY) {
@@ -51,9 +36,8 @@ export default class NostrSigner {
51
36
  skBytes = base16ToBytes(envSk)
52
37
  } else {
53
38
  isNewSk = true
54
- sk = generateSecretKey()
55
- fs.appendFileSync(path.resolve(dotenvPath), `NOSTR_SECRET_KEY=${nsecEncode(sk)}\n`)
56
- skBytes = base16ToBytes(sk)
39
+ skBytes = generateSecretKey()
40
+ setEncryptedDotenvValue('NOSTR_SECRET_KEY', nsecEncode(bytesToBase16(skBytes)))
57
41
  }
58
42
  const ret = new this(createToken, skBytes)
59
43
  if (isNewSk) await ret.#initSk(sk)
@@ -74,7 +58,7 @@ export default class NostrSigner {
74
58
  content: '',
75
59
  created_at: Math.floor(Date.now() / 1000)
76
60
  })
77
- await nostrRelays.sendEvent(relayList, [...new Set([...seedRelays, ...relays].map(r => r.trim().replace(/\/$/, '')))])
61
+ await sendEventReport(relayList, [...new Set([...seedRelays, ...relays].map(r => r.trim().replace(/\/$/, '')))])
78
62
 
79
63
  const profile = await this.signEvent({
80
64
  kind: 0,
@@ -86,7 +70,7 @@ export default class NostrSigner {
86
70
  }),
87
71
  created_at: Math.floor(Date.now() / 1000)
88
72
  })
89
- await nostrRelays.sendEvent(profile, relays)
73
+ await sendEventReport(profile, relays)
90
74
  }
91
75
 
92
76
  // hex
@@ -109,21 +93,11 @@ export default class NostrSigner {
109
93
 
110
94
  nip44Encrypt (pubkey, plaintext) {
111
95
  const conversationKey = nip44.getConversationKey(this.#secretKey, pubkey)
112
- return nip44.encrypt(conversationKey, plaintext)
96
+ return nip44.encrypt(plaintext, conversationKey)
113
97
  }
114
98
 
115
99
  nip44Decrypt (pubkey, ciphertext) {
116
100
  const conversationKey = nip44.getConversationKey(this.#secretKey, pubkey)
117
- return nip44.decrypt(conversationKey, ciphertext)
101
+ return nip44.decrypt(ciphertext, conversationKey)
118
102
  }
119
103
  }
120
-
121
- function generateSecretKey () {
122
- const randomBytes = crypto.getRandomValues(new Uint8Array(40))
123
- const B256 = 2n ** 256n // secp256k1 is short weierstrass curve
124
- const N = B256 - 0x14551231950b75fc4402da1732fc9bebfn // curve (group) order
125
- const bytesToNumber = b => BigInt('0x' + (bytesToBase16(b) || '0'))
126
- const mod = (a, b) => { const r = a % b; return r >= 0n ? r : b + r } // mod division
127
- const num = mod(bytesToNumber(randomBytes), N - 1n) + 1n // takes at least n+8 bytes
128
- return num.toString(16).padStart(64, '0')
129
- }
@@ -10,6 +10,7 @@ const MANAGED_MANIFEST_TAGS = new Set([
10
10
  export function normalizeManifestPath (value) {
11
11
  if (typeof value !== 'string') throw new TypeError('Manifest path must be a string')
12
12
  const path = value.startsWith('/') ? value.slice(1) : value
13
+ // eslint-disable-next-line no-control-regex
13
14
  if (!path || path.includes('\\') || /[\u0000-\u001f\u007f]/.test(path)) {
14
15
  throw new Error(`Unsafe manifest path: ${JSON.stringify(value)}`)
15
16
  }
@@ -186,7 +187,7 @@ export async function uploadSiteManifest ({
186
187
  authors: [await signer.getPublicKey()],
187
188
  '#d': [dTag],
188
189
  limit: 1
189
- }, relays)).result
190
+ }, relays, { timeoutAfterFirstEose: null })).result
190
191
  events.sort(newestFirst)
191
192
  const previous = events[0]
192
193
  const tags = buildManifestTags({