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 CHANGED
@@ -33,6 +33,7 @@ nappup [directory] [options]
33
33
  | `--main` | Publish to the **main** release channel. This is the default behavior. |
34
34
  | `--next` | Publish to the **next** release channel. Ideal for beta testing or staging builds. |
35
35
  | `--draft` | Publish to the **draft** release channel. Use this for internal testing or work-in-progress builds. |
36
+ | `--dotenv-private-key <hex>` | Use a 32-byte hex private key to decrypt and manage encrypted credentials in `.env`. Prefer `DOTENV_PRIVATE_KEY_NAPPUP` because command-line arguments can be visible in shell history and process listings. |
36
37
 
37
38
  ## Authentication
38
39
 
@@ -47,6 +48,7 @@ Napp Up! supports multiple ways to provide your Nostr secret key:
47
48
  ```bash
48
49
  nappup -s 'bunker://<pubkey>?relay=wss://relay.example.com&secret=<token>'
49
50
  ```
51
+ Napp Up! creates a persistent NIP-46 client key before connecting. For URLs passed with `-s`, the latest session is stored as `LAST_CLI_BUNKER_SESSION` in the project's `.env`. A URL without `secret` is also valid when the remote signer uses its public key as the access capability.
50
52
 
51
53
  3. **Environment variable**: Set `NOSTR_SECRET_KEY` in your environment or a `.env` file (also supports `bunker://` URLs):
52
54
  ```bash
@@ -56,6 +58,37 @@ Napp Up! supports multiple ways to provide your Nostr secret key:
56
58
 
57
59
  4. **Auto-generated key**: If no key is provided, Napp Up! will generate a new keypair automatically and store it (as nsec) in your project's `.env` file for future use.
58
60
 
61
+ When `NOSTR_SECRET_KEY` in `.env` is a bunker URL, Napp Up! adds the local client key as a `#client_key=...` URI fragment while retaining the query-string `secret` for compatibility fallback. Reconnects first use the same client key without the token and retry with the token only if necessary.
62
+
63
+ Napp Up! stores `NOSTR_SECRET_KEY` and `LAST_CLI_BUNKER_SESSION` in the official dotenvx `encrypted:<base64>` format, using the compressed public key in `DOTENV_PUBLIC_KEY_NAPPUP`. The private key is selected from `--dotenv-private-key`, then `DOTENV_PRIVATE_KEY_NAPPUP` in the process environment, and finally a built-in fallback. The public key is selected from the `.env`, then the process environment, and finally derived from the selected private key.
64
+
65
+ The built-in fallback makes migration automatic, but is public knowledge and therefore only hides plaintext; use an explicit private key for confidentiality. Generate one locally for storage in your secret manager with:
66
+
67
+ ```bash
68
+ nappup env keygen
69
+ ```
70
+
71
+ Alternatively, generate and export one directly into the current shell:
72
+
73
+ ```bash
74
+ export DOTENV_PRIVATE_KEY_NAPPUP="$(nappup env keygen)"
75
+ ```
76
+
77
+ `env keygen` writes only the generated 64-character lowercase hex key to standard output, writes a reminder to standard error, and never reads or modifies `.env`. Store the result in a secret manager; nappup cannot recover it.
78
+
79
+ Because encryption itself requires only the public key, the following command can safely replace the Nostr secret without receiving the private key:
80
+
81
+ ```bash
82
+ nappup env set NOSTR_SECRET_KEY
83
+ printf '%s\n' 'nsec1...' | nappup env set NOSTR_SECRET_KEY
84
+ ```
85
+
86
+ The interactive form hides and confirms the value. Redirected stdin and a positional value are also accepted; the positional form emits a warning because it may be exposed through shell history or process listings. With only `DOTENV_PUBLIC_KEY_NAPPUP`, `env set` can encrypt a replacement, while commands that need an existing value fail clearly without the matching private key.
87
+
88
+ Existing plaintext values are encrypted automatically when used. If an explicit private key does not match the stored public key, it becomes authoritative: recoverable values are re-encrypted and inaccessible credentials are reset with a warning naming only the affected variables. This can result in a new publisher identity or bunker client key.
89
+
90
+ Use `DOTENV_CONFIG_PATH` to select a different dotenv file. The private key must come from the process environment or CLI and is rejected if stored inside that file.
91
+
59
92
  ### Examples
60
93
 
61
94
  Upload the current directory to the main channel:
@@ -6,12 +6,15 @@ import mime from 'mime-types'
6
6
  import { fileTypeFromFile } from 'file-type'
7
7
 
8
8
  export function parseArgs (args) {
9
+ if (args[0] === 'env') return parseEnvArgs(args)
10
+
9
11
  let dir = null
10
12
  let sk = null
11
13
  let dTag = null
12
14
  let channel = null
13
15
  let shouldReupload = false
14
16
  let yes = false
17
+ let dotenvPrivateKey = null
15
18
 
16
19
  for (let i = 0; i < args.length; i++) {
17
20
  if (args[i] === '-s' && args[i + 1]) {
@@ -30,19 +33,123 @@ export function parseArgs (args) {
30
33
  shouldReupload = true
31
34
  } else if (args[i] === '-y') {
32
35
  yes = true
36
+ } else if (args[i] === '--dotenv-private-key' && args[i + 1]) {
37
+ dotenvPrivateKey = args[i + 1]
38
+ i++
39
+ } else if (args[i] === '--dotenv-private-key') {
40
+ throw new Error('--dotenv-private-key requires a value')
33
41
  } else if (!args[i].startsWith('-') && dir === null) {
34
42
  dir = args[i]
35
43
  }
36
44
  }
37
45
 
38
46
  return {
47
+ command: 'upload',
39
48
  dir: path.resolve(dir ?? '.'),
40
49
  sk,
41
50
  dTag,
42
51
  channel: channel || 'main',
43
52
  shouldReupload,
44
- yes
53
+ yes,
54
+ dotenvPrivateKey
55
+ }
56
+ }
57
+
58
+ function parseEnvArgs (args) {
59
+ if (args[1] === 'keygen') {
60
+ if (args.length !== 2) throw new Error('Expected: nappup env keygen')
61
+ return { command: 'env-keygen' }
62
+ }
63
+ if (args[1] !== 'set') throw new Error('Expected: nappup env set NOSTR_SECRET_KEY [value]')
64
+ if (args[2] !== 'NOSTR_SECRET_KEY') throw new Error('Only NOSTR_SECRET_KEY can be set')
65
+
66
+ let value = null
67
+ let dotenvPrivateKey = null
68
+ for (let i = 3; i < args.length; i++) {
69
+ if (args[i] === '--dotenv-private-key') {
70
+ if (!args[i + 1]) throw new Error('--dotenv-private-key requires a value')
71
+ dotenvPrivateKey = args[++i]
72
+ } else if (args[i] === '--') {
73
+ if (value !== null || !args[i + 1] || i + 2 !== args.length) throw new Error('Expected one NOSTR_SECRET_KEY value')
74
+ value = args[++i]
75
+ } else if (value === null) {
76
+ value = args[i]
77
+ } else {
78
+ throw new Error('Expected one NOSTR_SECRET_KEY value')
79
+ }
45
80
  }
81
+
82
+ return {
83
+ command: 'env-set',
84
+ name: 'NOSTR_SECRET_KEY',
85
+ value,
86
+ dotenvPrivateKey
87
+ }
88
+ }
89
+
90
+ function hiddenQuestion (query, { input, output }) {
91
+ return new Promise((resolve, reject) => {
92
+ const wasRaw = Boolean(input.isRaw)
93
+ const wasPaused = input.isPaused?.() ?? false
94
+ let value = ''
95
+
96
+ function cleanup () {
97
+ input.off('keypress', onKeypress)
98
+ input.setRawMode(wasRaw)
99
+ if (wasPaused) input.pause()
100
+ }
101
+
102
+ function onKeypress (text, key = {}) {
103
+ if (key.ctrl && key.name === 'c') {
104
+ cleanup()
105
+ output.write('\n')
106
+ const error = new Error('Operation cancelled by user')
107
+ error.code = 'ABORT_ERR'
108
+ reject(error)
109
+ } else if (key.name === 'return' || key.name === 'enter') {
110
+ cleanup()
111
+ output.write('\n')
112
+ resolve(value)
113
+ } else if (key.name === 'backspace') {
114
+ value = value.slice(0, -1)
115
+ } else if (text && !key.ctrl && !key.meta) {
116
+ value += text
117
+ }
118
+ }
119
+
120
+ readline.emitKeypressEvents(input)
121
+ input.setRawMode(true)
122
+ input.resume()
123
+ input.on('keypress', onKeypress)
124
+ output.write(query)
125
+ })
126
+ }
127
+
128
+ async function readPipedValue (input) {
129
+ let value = ''
130
+ for await (const chunk of input) {
131
+ value += chunk
132
+ if (value.length > 65536) throw new Error('NOSTR_SECRET_KEY input is too large')
133
+ }
134
+ return value.replace(/(?:\r\n|\n)$/, '')
135
+ }
136
+
137
+ export async function readEnvSetValue (args, {
138
+ input = process.stdin,
139
+ output = process.stdout,
140
+ errorOutput = process.stderr
141
+ } = {}) {
142
+ if (args.value !== null) {
143
+ errorOutput.write('Warning: a plaintext value passed as an argument may be visible in shell history and process listings.\n')
144
+ return args.value
145
+ }
146
+ if (!input.isTTY) return readPipedValue(input)
147
+ if (typeof input.setRawMode !== 'function') throw new Error('Cannot hide input on this terminal')
148
+
149
+ const first = await hiddenQuestion('NOSTR_SECRET_KEY: ', { input, output })
150
+ const second = await hiddenQuestion('Confirm NOSTR_SECRET_KEY: ', { input, output })
151
+ if (first !== second) throw new Error('NOSTR_SECRET_KEY values do not match')
152
+ return first
46
153
  }
47
154
 
48
155
  export async function confirmArgs (args) {
@@ -1,51 +1,85 @@
1
1
  #!/usr/bin/env node
2
2
  import path from 'node:path'
3
- import NostrSigner from '#services/nostr-signer.js'
4
3
  import { GENERIC_BUILD_FOLDER_NAMES } from '#helpers/app.js'
5
4
  import {
6
5
  parseArgs,
7
6
  confirmArgs,
7
+ readEnvSetValue,
8
8
  toFileList,
9
9
  getFiles
10
10
  } from './helpers.js'
11
- import toApp from '#index.js'
12
11
 
13
12
  const args = parseArgs(process.argv.slice(2))
14
13
 
15
- let { dTag } = args
16
- const { dir, sk, channel, shouldReupload } = args
17
-
18
- if (!dTag) {
19
- let folderName = path.basename(dir)
20
- if (GENERIC_BUILD_FOLDER_NAMES.has(folderName.toLowerCase())) {
21
- const parentName = path.basename(path.dirname(dir))
22
- if (parentName && parentName !== '.' && parentName !== '/' && !GENERIC_BUILD_FOLDER_NAMES.has(parentName.toLowerCase())) {
23
- folderName = parentName
24
- } else {
25
- console.error(`Directory name "${folderName}" is a generic build folder. Please provide a d tag with -d.`)
26
- process.exit(1)
27
- }
14
+ if (args.command === 'env-keygen') {
15
+ const { keypair } = await import('@dotenvx/primitives')
16
+ const { privateKey } = keypair()
17
+ console.error('Keep this dotenv private key secret; nappup has not stored it.')
18
+ process.stdout.write(`${privateKey}\n`)
19
+ } else {
20
+ const dotenv = await import('#services/dotenv.js')
21
+ const dotenvState = dotenv.initializeDotenv({
22
+ privateKey: args.dotenvPrivateKey,
23
+ loadNostrSecretKey: args.command === 'upload' && !args.sk,
24
+ reconcilePrivateKey: args.command === 'upload'
25
+ })
26
+
27
+ if (args.command === 'env-set') {
28
+ const { validateNostrSecretKey } = await import('#services/nostr-secret-key.js')
29
+ const value = await readEnvSetValue(args)
30
+ validateNostrSecretKey(value)
31
+ dotenv.setEncryptedDotenvValue(args.name, value, {
32
+ filePath: dotenvState.filePath,
33
+ updateProcessEnv: false
34
+ })
35
+ console.log(`Set encrypted ${args.name} in ${dotenvState.filePath}`)
36
+ } else {
37
+ await upload(args, dotenvState)
28
38
  }
29
- dTag = folderName
30
39
  }
31
- args.dTag = dTag
32
40
 
33
- await confirmArgs(args)
41
+ async function upload (args, dotenvState) {
42
+ let { dTag } = args
43
+ const { dir, sk, channel, shouldReupload } = args
34
44
 
35
- const fileList = await toFileList(getFiles(dir), dir)
45
+ if (!dTag) {
46
+ let folderName = path.basename(dir)
47
+ if (GENERIC_BUILD_FOLDER_NAMES.has(folderName.toLowerCase())) {
48
+ const parentName = path.basename(path.dirname(dir))
49
+ if (parentName && parentName !== '.' && parentName !== '/' && !GENERIC_BUILD_FOLDER_NAMES.has(parentName.toLowerCase())) {
50
+ folderName = parentName
51
+ } else {
52
+ console.error(`Directory name "${folderName}" is a generic build folder. Please provide a d tag with -d.`)
53
+ process.exit(1)
54
+ }
55
+ }
56
+ dTag = folderName
57
+ }
58
+ args.dTag = dTag
36
59
 
37
- const bunkerUrl = sk?.startsWith('bunker://') ? sk : (!sk && process.env.NOSTR_SECRET_KEY?.startsWith('bunker://')) ? process.env.NOSTR_SECRET_KEY : null
60
+ await confirmArgs(args)
61
+ const fileList = await toFileList(getFiles(dir), dir)
62
+ const bunkerUrl = sk?.startsWith('bunker://')
63
+ ? sk
64
+ : !sk && process.env.NOSTR_SECRET_KEY?.startsWith('bunker://')
65
+ ? process.env.NOSTR_SECRET_KEY
66
+ : null
38
67
 
39
- let signer
40
- if (bunkerUrl) {
41
- const { default: NostrBunkerSigner } = await import('#services/bunker-signer.js')
42
- signer = await NostrBunkerSigner.create(bunkerUrl)
43
- } else {
44
- signer = await NostrSigner.create(sk)
45
- }
68
+ let signer
69
+ if (bunkerUrl) {
70
+ const { default: NostrBunkerSigner } = await import('#services/bunker-signer.js')
71
+ signer = await NostrBunkerSigner.create(bunkerUrl, {
72
+ source: sk ? 'cli' : dotenvState.nostrSecretKeySource === 'dotenv' ? 'dotenv' : 'cli'
73
+ })
74
+ } else {
75
+ const { default: NostrSigner } = await import('#services/nostr-signer.js')
76
+ signer = await NostrSigner.create(sk)
77
+ }
46
78
 
47
- try {
48
- await toApp(fileList, signer, { log: console.log.bind(console), dTag, channel, shouldReupload })
49
- } finally {
50
- await signer.close?.()
79
+ try {
80
+ const { default: toApp } = await import('#index.js')
81
+ await toApp(fileList, signer, { log: console.log.bind(console), dTag, channel, shouldReupload })
82
+ } finally {
83
+ await signer.close?.()
84
+ }
51
85
  }
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "url": "git+https://github.com/44billion/nappup.git"
7
7
  },
8
8
  "license": "MIT",
9
- "version": "2.0.1",
9
+ "version": "2.2.0",
10
10
  "description": "Nostr App Uploader",
11
11
  "type": "module",
12
12
  "scripts": {
@@ -17,14 +17,14 @@
17
17
  "nappup": "bin/nappup/index.js"
18
18
  },
19
19
  "dependencies": {
20
+ "@dotenvx/primitives": "^2.1.0",
20
21
  "@noble/curves": "^2.0.0",
21
22
  "@noble/hashes": "^2.0.0",
22
23
  "dotenv": "^17.2.0",
23
24
  "file-type": "^21.0.0",
24
- "libp2r2p": "^0.3.0",
25
+ "libp2r2p": "^0.9.0",
25
26
  "mime-types": "^3.0.1",
26
- "nmmr": "^2.0.0",
27
- "nostr-tools": "^2.23.3"
27
+ "nmmr": "^2.0.0"
28
28
  },
29
29
  "devDependencies": {
30
30
  "eslint": "^9.22.0",
@@ -0,0 +1,64 @@
1
+ import { getPublicKey } from 'libp2r2p/key'
2
+ import { base16ToBytes } from 'libp2r2p/base16'
3
+ import { base64UrlToBytes, bytesToBase64Url } from 'libp2r2p/base64'
4
+
5
+ const HEX_32 = /^[0-9a-f]{64}$/
6
+ const BASE64_URL = /^[A-Za-z0-9_-]+$/
7
+
8
+ export function normalizeClientKey (value) {
9
+ if (typeof value !== 'string') throw new Error('Invalid bunker client key')
10
+ const normalized = value.toLowerCase()
11
+ if (!HEX_32.test(normalized)) throw new Error('Invalid bunker client key')
12
+ try {
13
+ getPublicKey(base16ToBytes(normalized))
14
+ } catch {
15
+ throw new Error('Invalid bunker client key')
16
+ }
17
+ return normalized
18
+ }
19
+
20
+ function normalizePubkey (value) {
21
+ const normalized = typeof value === 'string' ? value.toLowerCase() : ''
22
+ if (!HEX_32.test(normalized)) throw new Error('Invalid CLI bunker session')
23
+ return normalized
24
+ }
25
+
26
+ function uniqueStrings (values) {
27
+ return [...new Set(values.filter(value => typeof value === 'string' && value))]
28
+ }
29
+
30
+ export function encodeCliSession (session) {
31
+ const value = JSON.stringify({
32
+ remoteSignerPubkey: session.remoteSignerPubkey,
33
+ relays: session.relays,
34
+ secret: session.secret,
35
+ clientKey: session.clientKey
36
+ })
37
+ return bytesToBase64Url(new TextEncoder().encode(value))
38
+ }
39
+
40
+ export function decodeCliSession (encoded) {
41
+ if (typeof encoded !== 'string' || !BASE64_URL.test(encoded)) throw new Error('Invalid CLI bunker session')
42
+ const bytes = base64UrlToBytes(encoded)
43
+ if (bytesToBase64Url(bytes) !== encoded) throw new Error('Invalid CLI bunker session')
44
+
45
+ let value
46
+ try {
47
+ value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes))
48
+ } catch {
49
+ throw new Error('Invalid CLI bunker session')
50
+ }
51
+
52
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid CLI bunker session')
53
+ const remoteSignerPubkey = normalizePubkey(value.remoteSignerPubkey)
54
+ const relays = uniqueStrings(Array.isArray(value.relays) ? value.relays : [])
55
+ if (!relays.length) throw new Error('Invalid CLI bunker session')
56
+ let secret = null
57
+ if (value.secret !== null) {
58
+ if (typeof value.secret !== 'string' || !value.secret) throw new Error('Invalid CLI bunker session')
59
+ secret = value.secret
60
+ }
61
+ const clientKey = normalizeClientKey(value.clientKey)
62
+
63
+ return { remoteSignerPubkey, relays, secret, clientKey }
64
+ }
@@ -0,0 +1,96 @@
1
+ import { generateSecretKey } from 'libp2r2p/key'
2
+ import { bytesToBase16 } from 'libp2r2p/base16'
3
+ import { toBunkerUrl } from 'libp2r2p/nip46'
4
+ import {
5
+ dotenvPath,
6
+ DotenvDecryptionError,
7
+ readEncryptedDotenvValue,
8
+ setEncryptedDotenvValue
9
+ } from '#services/dotenv.js'
10
+ import {
11
+ decodeCliSession,
12
+ encodeCliSession,
13
+ normalizeClientKey
14
+ } from '#services/bunker-session-codec.js'
15
+ import { parseBunkerSessionUrl } from '#services/bunker-url.js'
16
+
17
+ export const LAST_CLI_BUNKER_SESSION = 'LAST_CLI_BUNKER_SESSION'
18
+
19
+ function uniqueStrings (values) {
20
+ return [...new Set(values.filter(value => typeof value === 'string' && value))]
21
+ }
22
+
23
+ function readCliSession (filePath, onWarning) {
24
+ let encoded
25
+ try {
26
+ encoded = readEncryptedDotenvValue(LAST_CLI_BUNKER_SESSION, { filePath, onWarning })
27
+ } catch (error) {
28
+ if (error instanceof DotenvDecryptionError) throw error
29
+ onWarning?.(`Ignoring invalid ${LAST_CLI_BUNKER_SESSION}: ${error.message}`)
30
+ return null
31
+ }
32
+ if (!encoded) return null
33
+ try {
34
+ return decodeCliSession(encoded)
35
+ } catch (error) {
36
+ onWarning?.(`Ignoring invalid ${LAST_CLI_BUNKER_SESSION}: ${error.message}`)
37
+ return null
38
+ }
39
+ }
40
+
41
+ function cacheMatchesInput (cache, parsed) {
42
+ if (!cache || cache.remoteSignerPubkey !== parsed.pointer.remoteSignerPubkey) return false
43
+ if (parsed.clientKey) return cache.clientKey === parsed.clientKey
44
+ return parsed.pointer.secret ? cache.secret === parsed.pointer.secret : true
45
+ }
46
+
47
+ export function persistBunkerSession (session) {
48
+ if (session.source === 'dotenv') {
49
+ const url = new URL(toBunkerUrl({
50
+ remoteSignerPubkey: session.remoteSignerPubkey,
51
+ relays: session.relays,
52
+ secret: session.secret
53
+ }))
54
+ url.hash = new URLSearchParams({ client_key: session.clientKey }).toString()
55
+ setEncryptedDotenvValue('NOSTR_SECRET_KEY', url.toString(), {
56
+ filePath: session.filePath,
57
+ updateProcessEnv: true
58
+ })
59
+ return
60
+ }
61
+
62
+ setEncryptedDotenvValue(LAST_CLI_BUNKER_SESSION, encodeCliSession(session), {
63
+ filePath: session.filePath,
64
+ updateProcessEnv: true
65
+ })
66
+ }
67
+
68
+ export { parseBunkerSessionUrl } from '#services/bunker-url.js'
69
+
70
+ export function prepareBunkerSession (bunkerUrl, {
71
+ source = 'cli',
72
+ filePath = dotenvPath,
73
+ onWarning = console.warn,
74
+ generateClientKey = generateSecretKey
75
+ } = {}) {
76
+ if (source !== 'cli' && source !== 'dotenv') throw new Error('Invalid bunker session source')
77
+ const parsed = parseBunkerSessionUrl(bunkerUrl)
78
+ const cache = source === 'cli' ? readCliSession(filePath, onWarning) : null
79
+ const matchingCache = cacheMatchesInput(cache, parsed) ? cache : null
80
+
81
+ let clientKey = parsed.clientKey || matchingCache?.clientKey || null
82
+ const reusedClientKey = Boolean(clientKey)
83
+ if (!clientKey) clientKey = normalizeClientKey(bytesToBase16(generateClientKey()))
84
+
85
+ const session = {
86
+ source,
87
+ filePath,
88
+ remoteSignerPubkey: parsed.pointer.remoteSignerPubkey,
89
+ relays: uniqueStrings([...parsed.pointer.relays, ...(matchingCache?.relays || [])]),
90
+ secret: parsed.pointer.secret || matchingCache?.secret || null,
91
+ clientKey,
92
+ reusedClientKey
93
+ }
94
+ persistBunkerSession(session)
95
+ return session
96
+ }
@@ -1,8 +1,69 @@
1
- import { generateSecretKey } from 'nostr-tools/pure'
2
- import { BunkerSigner, parseBunkerInput } from 'nostr-tools/nip46'
1
+ import { BunkerSigner } from 'libp2r2p/nip46'
2
+ import { base16ToBytes } from 'libp2r2p/base16'
3
3
  import { getRelays } from '#helpers/signer.js'
4
+ import {
5
+ persistBunkerSession,
6
+ prepareBunkerSession
7
+ } from '#services/bunker-session.js'
4
8
 
5
9
  const createToken = Symbol('createToken')
10
+ const DEFAULT_CONNECT_TIMEOUT = 5_000
11
+ const DEFAULT_REQUEST_TIMEOUT = 30_000
12
+
13
+ function alreadyConnected (error) {
14
+ return /already connected/i.test(typeof error === 'string' ? error : error?.message || '')
15
+ }
16
+
17
+ async function closeQuietly (bunker) {
18
+ try {
19
+ await bunker?.close()
20
+ } catch {}
21
+ }
22
+
23
+ async function runWithDeadline (operation, timeout, timeoutMessage, onFailure) {
24
+ const controller = new AbortController()
25
+ let timer
26
+ const deadline = new Promise((resolve, reject) => {
27
+ timer = setTimeout(() => reject(new Error(timeoutMessage)), timeout)
28
+ })
29
+ const pending = Promise.resolve().then(() => operation(controller.signal))
30
+
31
+ try {
32
+ return await Promise.race([pending, deadline])
33
+ } catch (error) {
34
+ controller.abort()
35
+ await onFailure?.()
36
+ await pending.catch(() => {})
37
+ throw error
38
+ } finally {
39
+ clearTimeout(timer)
40
+ }
41
+ }
42
+
43
+ async function openBunker (session, secret, {
44
+ bunkerFactory,
45
+ connectTimeout
46
+ }) {
47
+ const bunker = bunkerFactory(base16ToBytes(session.clientKey), {
48
+ remoteSignerPubkey: session.remoteSignerPubkey,
49
+ relays: session.relays,
50
+ secret
51
+ })
52
+
53
+ await runWithDeadline(async signal => {
54
+ try {
55
+ await bunker.connect({
56
+ clientMetadata: { name: 'nappup' },
57
+ signal
58
+ })
59
+ } catch (error) {
60
+ if (!alreadyConnected(error)) throw error
61
+ await bunker.getPublicKey({ signal })
62
+ }
63
+ }, connectTimeout, 'Bunker connection timed out', () => closeQuietly(bunker))
64
+
65
+ return bunker
66
+ }
6
67
 
7
68
  export default class NostrBunkerSigner {
8
69
  #bunker
@@ -14,28 +75,53 @@ export default class NostrBunkerSigner {
14
75
  this.#publicKey = publicKey
15
76
  }
16
77
 
17
- static async create (bunkerUrl, { connectTimeout = 30_000 } = {}) {
18
- const bp = await parseBunkerInput(bunkerUrl)
19
- if (!bp) throw new Error('Invalid bunker URL')
20
- if (bp.relays.length === 0) throw new Error('Bunker URL must include at least one relay (?relay=wss://...)')
78
+ static async create (bunkerUrl, {
79
+ source = 'cli',
80
+ dotenvFilePath,
81
+ connectTimeout = DEFAULT_CONNECT_TIMEOUT,
82
+ requestTimeout = DEFAULT_REQUEST_TIMEOUT,
83
+ onWarning = console.warn,
84
+ generateClientKey,
85
+ bunkerFactory = (clientKey, pointer) => BunkerSigner.fromBunker(clientKey, pointer)
86
+ } = {}) {
87
+ const session = prepareBunkerSession(bunkerUrl, {
88
+ source,
89
+ ...(dotenvFilePath ? { filePath: dotenvFilePath } : {}),
90
+ onWarning,
91
+ ...(generateClientKey ? { generateClientKey } : {})
92
+ })
21
93
 
22
- const clientSk = generateSecretKey()
23
- const bunker = BunkerSigner.fromBunker(clientSk, bp)
24
-
25
- let timeoutHandle
94
+ let bunker
26
95
  try {
27
- await Promise.race([
28
- bunker.connect(),
29
- new Promise((resolve, reject) => {
30
- timeoutHandle = setTimeout(() => reject(new Error('Bunker connection timed out')), connectTimeout)
31
- })
32
- ])
33
- } finally {
34
- clearTimeout(timeoutHandle)
35
- }
96
+ if (session.reusedClientKey) {
97
+ try {
98
+ bunker = await openBunker(session, null, { bunkerFactory, connectTimeout })
99
+ } catch (error) {
100
+ if (!session.secret) throw error
101
+ bunker = await openBunker(session, session.secret, { bunkerFactory, connectTimeout })
102
+ }
103
+ } else {
104
+ bunker = await openBunker(session, session.secret, { bunkerFactory, connectTimeout })
105
+ }
36
106
 
37
- const publicKey = await bunker.getPublicKey()
38
- return new this(createToken, bunker, publicKey)
107
+ const publicKey = await runWithDeadline(
108
+ signal => bunker.getPublicKey({ timeout: requestTimeout, signal }),
109
+ requestTimeout,
110
+ 'Bunker public key request timed out',
111
+ () => closeQuietly(bunker)
112
+ )
113
+
114
+ session.relays = [...bunker.pointer.relays]
115
+ try {
116
+ persistBunkerSession(session)
117
+ } catch (error) {
118
+ onWarning?.(`Could not persist updated bunker session: ${error.message}`)
119
+ }
120
+ return new this(createToken, bunker, publicKey)
121
+ } catch (error) {
122
+ await closeQuietly(bunker)
123
+ throw error
124
+ }
39
125
  }
40
126
 
41
127
  getPublicKey () {
@@ -0,0 +1,32 @@
1
+ import { parseBunkerUrl } from 'libp2r2p/nip46'
2
+ import { normalizeClientKey } from '#services/bunker-session-codec.js'
3
+
4
+ export function parseBunkerSessionUrl (input) {
5
+ let url
6
+ try {
7
+ url = new URL(input)
8
+ } catch {
9
+ throw new Error('Invalid bunker URL')
10
+ }
11
+ if (url.protocol !== 'bunker:') throw new Error('Invalid bunker URL')
12
+ if (url.searchParams.getAll('secret').length > 1) throw new Error('Bunker URL must not contain multiple secrets')
13
+ if (url.searchParams.getAll('relay').length === 0) {
14
+ throw new Error('Bunker URL must include at least one relay (?relay=wss://...)')
15
+ }
16
+
17
+ let clientKey = null
18
+ if (url.hash) {
19
+ const fragment = new URLSearchParams(url.hash.slice(1))
20
+ const keys = [...fragment.keys()]
21
+ const values = fragment.getAll('client_key')
22
+ if (keys.some(key => key !== 'client_key') || values.length !== 1) {
23
+ throw new Error('Invalid bunker URL fragment')
24
+ }
25
+ clientKey = normalizeClientKey(values[0])
26
+ url.hash = ''
27
+ }
28
+
29
+ const pointer = parseBunkerUrl(url.toString())
30
+ if (!pointer) throw new Error('Invalid bunker URL')
31
+ return { pointer, clientKey }
32
+ }