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.
- package/README.md +33 -0
- package/bin/nappup/helpers.js +108 -1
- package/bin/nappup/index.js +65 -31
- package/package.json +4 -4
- package/src/services/bunker-session-codec.js +64 -0
- package/src/services/bunker-session.js +96 -0
- package/src/services/bunker-signer.js +107 -21
- package/src/services/bunker-url.js +32 -0
- package/src/services/dotenv.js +356 -0
- package/src/services/irfs-upload.js +5 -6
- package/src/services/nostr-relays.js +8 -141
- package/src/services/nostr-secret-key.js +24 -0
- package/src/services/nostr-signer.js +12 -38
- package/src/services/site-manifest.js +2 -1
- package/src/helpers/nip01.js +0 -32
|
@@ -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 } =
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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({
|