nappup 2.3.10 → 2.3.11

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
@@ -76,7 +76,10 @@ export DOTENV_PRIVATE_KEY_NAPPUP="$(nappup env keygen)"
76
76
 
77
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
78
 
79
- Because encryption itself requires only the public key, the following command can safely replace the Nostr secret without receiving the private key:
79
+ Encrypting a replacement requires only `DOTENV_PUBLIC_KEY_NAPPUP`. The command
80
+ below receives the **new Nostr credential** (a secret key or bunker URL), but does
81
+ not require `DOTENV_PRIVATE_KEY_NAPPUP`, the separate **dotenv decryption key**.
82
+ It does not need to decrypt or know the previous Nostr credential:
80
83
 
81
84
  ```bash
82
85
  nappup env set NOSTR_SECRET_KEY
@@ -89,6 +92,39 @@ Existing plaintext values are encrypted automatically when used. If an explicit
89
92
 
90
93
  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
94
 
95
+ ### Local development and shared credentials
96
+
97
+ To use a checkout instead of the registry package:
98
+
99
+ ```bash
100
+ cd /path/to/nappup
101
+ npm link
102
+ cd /path/to/app
103
+ npm link nappup --no-save --package-lock=false
104
+ ```
105
+
106
+ The second command links the app dependency and its local CLI to the global link.
107
+ Projects that use nappup at runtime can keep a registry dependency for reproducible
108
+ installs; local CLI-only projects may rely entirely on the link. `npm ci` removes
109
+ local links, so repeat the second command afterward. When switching Node/npm
110
+ installations, repeat both commands to register the checkout under the new global
111
+ prefix as well.
112
+
113
+ A link shares code, not credentials: `.env` still defaults to the current working
114
+ directory. To use one existing encrypted identity across projects, export an
115
+ absolute path to its dotenv file in the shells running nappup:
116
+
117
+ ```bash
118
+ export DOTENV_CONFIG_PATH="/absolute/path/to/shared/nappup.env"
119
+ nappup env set NOSTR_SECRET_KEY
120
+ ```
121
+
122
+ Choose an existing file to retain its identity, or set the desired credential once
123
+ in a new file. Supply the matching `DOTENV_PRIVATE_KEY_NAPPUP` if that file uses a
124
+ custom encryption key. Changing credentials in a shared file affects every project
125
+ using it; existing project `.env` files are not automatically merged or migrated.
126
+ A process-level `NOSTR_SECRET_KEY` still takes precedence over the file.
127
+
92
128
  ### Examples
93
129
 
94
130
  Upload the current directory to the main channel:
@@ -142,7 +178,22 @@ await publishApp(fileList, signer, {
142
178
  Rejected uploads use `NappupError`, with a stable code from
143
179
  `NAPPUP_ERROR_CODES`. The original error is retained as `cause`, and some
144
180
  errors include structured `details`, so applications can show their own
145
- recovery instructions without matching CLI-oriented message text:
181
+ recovery instructions without matching CLI-oriented message text.
182
+
183
+ For terminal destination failures, `error.details.failures` contains
184
+ `{ destination, filename?, reason }` entries. `reason` retains the original error,
185
+ including HTTP `status`, `retryable`, `retryAfterMs`, or relay `category` when
186
+ available. Native causes and aggregated errors remain available for diagnostics.
187
+ Signer failures are normalized to `NAPPUP_SIGNER_LOCKED` or `NAPPUP_SIGNER_DENIED`,
188
+ including when nested inside an aggregate.
189
+
190
+ A file succeeds when at least one destination confirms a copy (each chunk for
191
+ IRFS). Failures of extra replicas are logged without emitting a terminal error.
192
+ Publishing the app manifest still requires a confirmed copy. Display recovery
193
+ instructions for rejected operations; use specific destination guidance only
194
+ when it applies to all blocking failures. Different files may succeed on
195
+ different servers.
196
+
146
197
 
147
198
  ```js
148
199
  import publishApp, { NAPPUP_ERROR_CODES } from 'nappup'
@@ -90,13 +90,14 @@ function parseEnvArgs (args) {
90
90
  function hiddenQuestion (query, { input, output }) {
91
91
  return new Promise((resolve, reject) => {
92
92
  const wasRaw = Boolean(input.isRaw)
93
- const wasPaused = input.isPaused?.() ?? false
93
+ const wasFlowing = input.readableFlowing === true
94
94
  let value = ''
95
95
 
96
96
  function cleanup () {
97
97
  input.off('keypress', onKeypress)
98
98
  input.setRawMode(wasRaw)
99
- if (wasPaused) input.pause()
99
+ // An untouched TTY has readableFlowing === null, not isPaused() === true.
100
+ if (!wasFlowing) input.pause()
100
101
  }
101
102
 
102
103
  function onKeypress (text, key = {}) {
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.3.10",
9
+ "version": "2.3.11",
10
10
  "description": "Nostr App Uploader",
11
11
  "type": "module",
12
12
  "scripts": {
package/src/errors.js CHANGED
@@ -75,14 +75,27 @@ const SIGNER_DENIED_PATTERNS = [
75
75
  /sign(?:ing)? request (?:rejected|denied)/i
76
76
  ]
77
77
 
78
- // Walks the cause chain looking for signer-level failures such as a locked
79
- // vault or a rejected signing prompt. Returns a NAPPUP_* code or null.
78
+ // Traverses native error trees without looping through cycles or unbounded causes.
79
+ function * errorTree (error, seen = new Set(), depth = 0) {
80
+ if (!error || typeof error !== 'object' || seen.has(error) || depth >= 12) return
81
+ seen.add(error)
82
+ yield error
83
+ yield * errorTree(error.cause, seen, depth + 1)
84
+ if (Array.isArray(error.errors)) {
85
+ for (const child of error.errors) yield * errorTree(child, seen, depth + 1)
86
+ }
87
+ }
88
+
89
+ // Classifies original signer failures, not diagnostic text from servers or aggregates.
80
90
  export function classifySignerError (error) {
81
- let current = error
82
- for (let depth = 0; current && depth < 6; depth++) {
91
+ for (const current of errorTree(error)) {
83
92
  if (current.name === 'NotAllowedError' || current.code === 'DENIED_BY_USER') {
84
93
  return NAPPUP_ERROR_CODES.SIGNER_DENIED
85
94
  }
95
+ if (current.code === NAPPUP_ERROR_CODES.SIGNER_LOCKED || current.code === NAPPUP_ERROR_CODES.SIGNER_DENIED) {
96
+ return current.code
97
+ }
98
+ if (Array.isArray(current.errors) || current.category || current.code === 'BLOSSOM_HTTP_ERROR') continue
86
99
  const message = typeof current.message === 'string' ? current.message : ''
87
100
  if (SIGNER_LOCKED_PATTERNS.some(pattern => pattern.test(message))) {
88
101
  return NAPPUP_ERROR_CODES.SIGNER_LOCKED
@@ -90,20 +103,46 @@ export function classifySignerError (error) {
90
103
  if (SIGNER_DENIED_PATTERNS.some(pattern => pattern.test(message))) {
91
104
  return NAPPUP_ERROR_CODES.SIGNER_DENIED
92
105
  }
93
- current = current.cause
94
106
  }
95
107
  return null
96
108
  }
97
109
 
110
+ // Retains only failures of files with no confirmed Blossom copy.
111
+ export function blossomUploadError (failedFiles) {
112
+ const failures = failedFiles.flatMap(file => (file.errors ?? []).map(({ server, error }) => ({
113
+ filename: file.filename, destination: server, reason: error
114
+ })))
115
+ return new NappupError(NAPPUP_ERROR_CODES.BLOSSOM_UPLOAD_FAILED,
116
+ `${failedFiles.length} file(s) failed to upload to Blossom`, {
117
+ cause: new AggregateError(failures.map(failure => failure.reason), 'Blossom destinations failed'),
118
+ details: {
119
+ failedFileCount: failedFiles.length,
120
+ filenames: failedFiles.map(file => file.filename).filter(Boolean),
121
+ failures
122
+ }
123
+ })
124
+ }
125
+
126
+ // Exposes destination failures consistently across Blossom and relay publication.
127
+ function failureDetails (error) {
128
+ if (error?.details?.failures) return error.details
129
+ const failures = []
130
+ for (const current of errorTree(error)) {
131
+ if (!Array.isArray(current.failures)) continue
132
+ for (const { relay, reason } of current.failures) {
133
+ failures.push({ destination: relay, reason, ...(error?.details?.filename ? { filename: error.details.filename } : {}) })
134
+ }
135
+ }
136
+ return failures.length ? { ...error.details, failures } : error?.details
137
+ }
138
+
98
139
  // Ensures every error crossing nappup's public API has a documented code.
99
140
  export function normalizeNappupError (error) {
141
+ const details = failureDetails(error)
100
142
  if (typeof error?.code === 'string' && error.code.startsWith('NAPPUP_')) {
101
- const signerCode = classifySignerError(error)
102
- if (signerCode && signerCode !== error.code) {
103
- return new NappupError(signerCode, error.message, {
104
- cause: error.cause,
105
- details: error.details
106
- })
143
+ const code = classifySignerError(error) ?? error.code
144
+ if (code !== error.code || details !== error.details) {
145
+ return new NappupError(code, error.message, { cause: error.cause, details })
107
146
  }
108
147
  return error
109
148
  }
@@ -111,12 +150,12 @@ export function normalizeNappupError (error) {
111
150
  return new NappupError(
112
151
  NAPPUP_ERROR_CODES.UPLOAD_CANCELLED,
113
152
  error?.message || 'Upload cancelled',
114
- { cause: error }
153
+ { cause: error, details }
115
154
  )
116
155
  }
117
156
  return new NappupError(
118
157
  classifySignerError(error) ?? NAPPUP_ERROR_CODES.UPLOAD_FAILED,
119
158
  error?.message || 'Upload failed',
120
- { cause: error }
159
+ { cause: error, details }
121
160
  )
122
161
  }
package/src/index.js CHANGED
@@ -8,7 +8,7 @@ import { extractHtmlMetadata, findAppIcon, findIndexFile } from '#helpers/app-me
8
8
  import { getBlossomServers, healthCheckServers, uploadFilesToBlossom } from '#services/blossom-upload.js'
9
9
  import { uploadBinaryDataChunks } from '#services/irfs-upload.js'
10
10
  import { uploadSiteManifest } from '#services/site-manifest.js'
11
- import { NappupError, NAPPUP_ERROR_CODES, normalizeNappupError, classifySignerError } from '#errors.js'
11
+ import { NappupError, NAPPUP_ERROR_CODES, normalizeNappupError, blossomUploadError } from '#errors.js'
12
12
 
13
13
  export { NappupError, NAPPUP_ERROR_CODES } from '#errors.js'
14
14
 
@@ -215,7 +215,7 @@ async function publishApp (fileList, nostrSigner, {
215
215
  shouldReupload,
216
216
  log
217
217
  })
218
- if (failedFiles.length) throw new Error(`Blossom upload failed for ${mediaName}`)
218
+ if (failedFiles.length) throw blossomUploadError(failedFiles)
219
219
  return { rootHash: uploadedFiles[0].sha256, mimeType, size: blob.size }
220
220
  }
221
221
 
@@ -300,21 +300,7 @@ async function publishApp (fileList, nostrSigner, {
300
300
  shouldReupload,
301
301
  log
302
302
  })
303
- if (failedFiles.length) {
304
- const signerError = failedFiles
305
- .flatMap(failed => failed.errors ?? [])
306
- .map(failed => failed.error)
307
- .find(error => classifySignerError(error))
308
- const details = {
309
- failedFileCount: failedFiles.length,
310
- filenames: failedFiles.map(failed => failed.filename).filter(Boolean)
311
- }
312
- throw new NappupError(
313
- NAPPUP_ERROR_CODES.BLOSSOM_UPLOAD_FAILED,
314
- `${failedFiles.length} file(s) failed to upload to Blossom`,
315
- signerError ? { cause: signerError, details } : { details }
316
- )
317
- }
303
+ if (failedFiles.length) throw blossomUploadError(failedFiles)
318
304
 
319
305
  for (const uploaded of uploadedFiles) {
320
306
  const metadata = {
@@ -6,9 +6,11 @@ import { classifySignerError } from '#errors.js'
6
6
 
7
7
  const DEFAULT_HEALTH_CHECK_TIMEOUT_MS = 5000
8
8
  const DEFAULT_EXISTENCE_CHECK_TIMEOUT_MS = 5000
9
+ const DEFAULT_UPLOAD_TIMEOUT_MS = 60000
10
+ const MAX_RETRY_WAIT_MS = 60000
9
11
 
10
12
  // Bounds browser fetches whose native network timeout can take minutes.
11
- async function fetchWithTimeout (url, options, timeoutMs) {
13
+ async function fetchWithTimeout (url, options, timeoutMs, consume = response => response) {
12
14
  const controller = new AbortController()
13
15
  let timedOut = false
14
16
  const timeoutId = setTimeout(() => {
@@ -16,9 +18,12 @@ async function fetchWithTimeout (url, options, timeoutMs) {
16
18
  controller.abort()
17
19
  }, timeoutMs)
18
20
  try {
19
- return await fetch(url, { ...options, signal: controller.signal })
21
+ const response = await fetch(url, { ...options, signal: controller.signal })
22
+ return await consume(response)
20
23
  } catch (error) {
21
- if (timedOut) throw new Error(`request timed out after ${timeoutMs}ms`)
24
+ if (timedOut) {
25
+ throw Object.assign(new Error(`request timed out after ${timeoutMs}ms`, { cause: error }), { category: 'timeout' })
26
+ }
22
27
  throw error
23
28
  } finally {
24
29
  clearTimeout(timeoutId)
@@ -104,12 +109,42 @@ export async function computeFileHash (file) {
104
109
  return bytesToBase16(hash.digest())
105
110
  }
106
111
 
112
+ // Retry-After is advisory timing; X-Reason is diagnostic text, never policy.
113
+ function retryAfterMs (value) {
114
+ if (!value) return 0
115
+ const text = value.trim()
116
+ if (/^\d+$/.test(text)) return Number(text) * 1000
117
+ const date = Date.parse(text)
118
+ return Number.isFinite(date) ? Math.max(0, date - Date.now()) : 0
119
+ }
120
+
121
+ async function readUploadResponse (response) {
122
+ if (response.status !== 200 && response.status !== 201) {
123
+ const status = response.status
124
+ const reason = response.headers.get('X-Reason') || response.statusText || 'No error message provided'
125
+ const error = new Error(`upload returned an error (${status}): ${reason}`)
126
+ error.code = 'BLOSSOM_HTTP_ERROR'
127
+ error.status = status
128
+ error.retryable = [408, 425, 429].includes(status) || (status >= 500 && status <= 599 && ![501, 505].includes(status))
129
+ error.retryAfterMs = retryAfterMs(response.headers.get('Retry-After'))
130
+ await response.body?.cancel().catch(() => {})
131
+ throw error
132
+ }
133
+ try {
134
+ return await response.json()
135
+ } catch (cause) {
136
+ if (!(cause instanceof SyntaxError)) throw cause
137
+ const error = new Error('upload returned an invalid JSON blob descriptor', { cause })
138
+ error.retryable = false
139
+ throw error
140
+ }
141
+ }
142
+
107
143
  /**
108
- * Uploads a single file to a single blossom server with retry+backoff.
144
+ * Uploads a single file to a single blossom server with bounded retry+backoff.
109
145
  * Returns { success: true, descriptor } or { success: false, error }.
110
146
  */
111
- async function uploadFileToServer (serverUrl, signer, file, fileHash, mimeType, { shouldReupload, log, maxRetries = 5 }) {
112
- // Check if already uploaded
147
+ async function uploadFileToServer (serverUrl, signer, file, fileHash, mimeType, { shouldReupload, log, maxRetries = 5, uploadTimeoutMs }) {
113
148
  if (!shouldReupload) {
114
149
  try {
115
150
  const checkResponse = await fetchWithTimeout(
@@ -117,9 +152,7 @@ async function uploadFileToServer (serverUrl, signer, file, fileHash, mimeType,
117
152
  { method: 'HEAD' },
118
153
  DEFAULT_EXISTENCE_CHECK_TIMEOUT_MS
119
154
  )
120
- if (checkResponse.ok) {
121
- return { success: true, alreadyExists: true }
122
- }
155
+ if (checkResponse.ok) return { success: true, alreadyExists: true }
123
156
  } catch (error) {
124
157
  log(`Could not check whether ${fileHash} exists on ${serverUrl}; uploading it anyway: ${error?.message ?? error}`)
125
158
  }
@@ -129,36 +162,36 @@ async function uploadFileToServer (serverUrl, signer, file, fileHash, mimeType,
129
162
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
130
163
  try {
131
164
  if (attempt > 0) {
132
- log(`Retrying upload to ${serverUrl} (attempt ${attempt + 1}/${maxRetries + 1})`)
165
+ log(`Retrying upload to ${serverUrl} in ${pause}ms (attempt ${attempt + 1}/${maxRetries + 1})`)
133
166
  await new Promise(resolve => setTimeout(resolve, pause))
134
- pause += 2000
135
167
  }
136
168
  const authorization = await createAuthHeader(signer, (evt) => {
137
169
  evt.tags.push(['t', 'upload'])
138
170
  evt.tags.push(['x', fileHash])
139
171
  })
140
- const response = await fetch(`${serverUrl}/upload`, {
141
- method: 'PUT',
142
- headers: { 'Content-Type': mimeType, Authorization: authorization },
143
- body: file.stream(),
144
- duplex: 'half'
145
- })
146
- if (response.status >= 300) {
147
- const reason = response.headers.get('X-Reason') || response.statusText
148
- throw new Error(`upload returned an error (${response.status}): ${reason}`)
172
+ const headers = { 'Content-Type': mimeType, 'X-SHA-256': fileHash, Authorization: authorization }
173
+ // Browsers own Content-Length. A native Blob/File gives fetch its size;
174
+ // Node's file-like streaming adapter needs the known length explicitly.
175
+ if (globalThis.process?.versions?.node && Number.isSafeInteger(file.size) && file.size >= 0) {
176
+ headers['Content-Length'] = String(file.size)
149
177
  }
150
- const descriptor = await response.json()
178
+ const isBlob = typeof Blob !== 'undefined' && file instanceof Blob
179
+ const descriptor = await fetchWithTimeout(`${serverUrl}/upload`, {
180
+ method: 'PUT', headers,
181
+ body: isBlob ? file : file.stream(),
182
+ ...(isBlob ? {} : { duplex: 'half' }),
183
+ redirect: 'manual'
184
+ }, uploadTimeoutMs, readUploadResponse)
151
185
  return { success: true, descriptor }
152
- } catch (err) {
153
- if (classifySignerError(err)) {
154
- // Signer-level failures (locked vault, rejected prompt) are not
155
- // transient upload errors: retrying would just re-prompt or fail
156
- // again, so surface them immediately.
157
- return { success: false, error: err }
186
+ } catch (error) {
187
+ if (classifySignerError(error) || error.name === 'AbortError' || error.retryable === false || attempt === maxRetries) {
188
+ return { success: false, error }
158
189
  }
159
- if (attempt === maxRetries) {
160
- return { success: false, error: err }
190
+ if (error.retryAfterMs > MAX_RETRY_WAIT_MS) {
191
+ log(`${serverUrl}: Retry-After exceeds the ${MAX_RETRY_WAIT_MS}ms automatic wait budget; retry in a later operation`)
192
+ return { success: false, error }
161
193
  }
194
+ pause = Math.max(1000 + attempt * 2000, error.retryAfterMs || 0)
162
195
  }
163
196
  }
164
197
  return { success: false, error: new Error('Max retries exceeded') }
@@ -180,6 +213,7 @@ export async function uploadFilesToBlossom ({
180
213
  signer,
181
214
  shouldReupload = false,
182
215
  maxRetries = 5,
216
+ uploadTimeoutMs = DEFAULT_UPLOAD_TIMEOUT_MS,
183
217
  log = () => {}
184
218
  }) {
185
219
  const normalizedServers = [...new Set(servers.flatMap(server => {
@@ -207,7 +241,7 @@ export async function uploadFilesToBlossom ({
207
241
  for (let i = 0; i < fileInfos.length; i++) {
208
242
  const info = fileInfos[i]
209
243
  log(`Uploading ${info.filename} to ${serverUrl}`)
210
- const result = await uploadFileToServer(serverUrl, signer, info.file, info.sha256, info.mimeType, { shouldReupload, log, maxRetries })
244
+ const result = await uploadFileToServer(serverUrl, signer, info.file, info.sha256, info.mimeType, { shouldReupload, log, maxRetries, uploadTimeoutMs })
211
245
 
212
246
  if (result.success) {
213
247
  fileServerResults[i].successCount++
@@ -218,7 +252,7 @@ export async function uploadFilesToBlossom ({
218
252
  }
219
253
  } else {
220
254
  fileServerResults[i].errors.push({ server: serverUrl, error: result.error })
221
- log(`${info.filename}: Failed to upload to ${serverUrl}: ${result.error?.message ?? result.error}`)
255
+ log(`${info.filename}: Failed to upload to ${serverUrl} (${info.mimeType}, ${info.file.size} bytes): ${result.error?.message ?? result.error}`)
222
256
  }
223
257
  }
224
258
  })
@@ -168,6 +168,12 @@ export async function throttledSendEvent (event, relays, {
168
168
  }
169
169
  const maybeSuccessfulRelays = relays.length - noRetryErrors.length
170
170
  const hasReachedMaxRetries = retries > maxRetries
171
+ // Exhausted replication attempts cannot invalidate an already confirmed copy.
172
+ const confirmedRelays = relays.length - noRetryErrors.length - rateLimitErrors.length
173
+ if (hasReachedMaxRetries && confirmedRelays >= minSuccessfulRelays) {
174
+ log(`Replication retries exhausted; the required confirmations are already satisfied:\n${rateLimitErrors.map(formatRelayFailure).join('\n')}`)
175
+ return { pause }
176
+ }
171
177
  if (
172
178
  hasReachedMaxRetries ||
173
179
  maybeSuccessfulRelays < minSuccessfulRelays