nappup 2.2.1 → 2.3.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
@@ -29,7 +29,7 @@ nappup [directory] [options]
29
29
  | `-s <secret_key>` | Your Nostr secret key (hex, nsec, or `bunker://` URL) used to sign the application event. See [Authentication](#authentication) for alternatives. |
30
30
  | `-d <d_tag>` | The identifier (`d` tag) for your application. Any UTF-8 text up to 260 characters. If omitted, defaults to the directory name. Avoid generic names like `dist` or `build` - use something unique among your other apps like `mycoolapp`. |
31
31
  | `-y` | Skip confirmation prompt. Useful for CI/CD pipelines or automated scripts. |
32
- | `-r` | Force re-upload. By default, Napp Up! might skip files that haven't changed. Use this flag to ensure everything is pushed fresh. |
32
+ | `-r` | Force re-upload. By default, Napp Up! might skip files that haven't changed. Use this flag to ensure everything is pushed fresh; it does not create a new app version unless a file path or hash changes. |
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. |
@@ -113,6 +113,13 @@ nappup ~/my-repos/projectx/build/projectx --draft -r
113
113
 
114
114
  ## Programmatic Usage
115
115
 
116
+ Each published manifest includes an aggregate `x` tag that identifies the app
117
+ version from its file path/hash mappings. Updating only manifest metadata or
118
+ forcing a re-upload preserves that aggregate. The `published_at` tag records
119
+ the first publication time of that aggregate and is preserved across later
120
+ metadata revisions, while the event's `created_at` records the latest manifest
121
+ revision.
122
+
116
123
  Napp Up! also exports a function that works in both Node.js and the browser, so you can integrate app uploads directly into your own tooling:
117
124
 
118
125
  ```js
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.2.1",
9
+ "version": "2.3.0",
10
10
  "description": "Nostr App Uploader",
11
11
  "type": "module",
12
12
  "scripts": {
@@ -1,10 +1,12 @@
1
1
  import { NAPP_CATEGORIES } from '#config/napp-categories.js'
2
2
  import nostrRelays, { nappRelays } from '#services/nostr-relays.js'
3
3
  import { throttledSendEvent } from '#services/irfs-upload.js'
4
+ import { sha256 } from '@noble/hashes/sha2.js'
5
+ import { bytesToBase16 } from '#helpers/base16.js'
4
6
 
5
7
  const MANAGED_MANIFEST_TAGS = new Set([
6
8
  'd', 'service', 'path', 'r', 'name', 'summary', 'description', 'self',
7
- 'c', 'l', 't', 'auto', 'icon', 'key_art', 'screenshot'
9
+ 'c', 'l', 't', 'auto', 'icon', 'key_art', 'screenshot', 'x', 'published_at'
8
10
  ])
9
11
 
10
12
  export function normalizeManifestPath (value) {
@@ -25,6 +27,37 @@ function validRoot (root) {
25
27
  return typeof root === 'string' && /^[0-9a-f]{64}$/.test(root)
26
28
  }
27
29
 
30
+ function manifestAggregateLines (manifest) {
31
+ const tags = Array.isArray(manifest?.tags) ? manifest.tags : []
32
+ const service = tags.find(tag => Array.isArray(tag) && tag[0] === 'service')?.[1]
33
+ const lines = []
34
+
35
+ if (service === 'irfs') {
36
+ for (const tag of tags) {
37
+ if (!Array.isArray(tag) || tag[0] !== 'r' || !validRoot(tag[1])) continue
38
+ for (const field of tag.slice(2)) {
39
+ if (typeof field !== 'string' || !field.startsWith('path ')) continue
40
+ const path = normalizeManifestPath(field.slice(5))
41
+ lines.push(`${tag[1]} /${path}\n`)
42
+ }
43
+ }
44
+ } else {
45
+ for (const tag of tags) {
46
+ if (!Array.isArray(tag) || tag[0] !== 'path' || !validRoot(tag[2])) continue
47
+ const path = normalizeManifestPath(tag[1])
48
+ lines.push(`${tag[2]} /${path}\n`)
49
+ }
50
+ }
51
+
52
+ return lines
53
+ }
54
+
55
+ export function getManifestAggregateHash (manifest) {
56
+ const lines = manifestAggregateLines(manifest)
57
+ if (!lines.length) throw new Error('Site manifest must reference at least one file')
58
+ return bytesToBase16(sha256(new TextEncoder().encode(lines.sort().join(''))))
59
+ }
60
+
28
61
  function decimalSize (size) {
29
62
  if (!Number.isSafeInteger(size) || size < 0) {
30
63
  throw new Error('Asset size must be a non-negative safe integer')
@@ -138,7 +171,7 @@ function buildMetadataTags ({
138
171
 
139
172
  export function buildManifestTags ({
140
173
  dTag, uploadService, fileMetadata = [], icon, keyArt = [], screenshots = [],
141
- previousTags = [], ...metadata
174
+ previousTags = [], publishedAt, ...metadata
142
175
  }) {
143
176
  if (uploadService !== 'irfs' && uploadService !== 'blossom') {
144
177
  throw new Error('Unknown upload service')
@@ -153,10 +186,21 @@ export function buildManifestTags ({
153
186
  .slice(0, 10)
154
187
  .map(tag => [...tag])
155
188
 
189
+ if (!Number.isSafeInteger(publishedAt) || publishedAt < 0) {
190
+ throw new Error('published_at must be a non-negative safe integer')
191
+ }
192
+
193
+ const referenceTags = buildReferenceTags(uploadService, fileMetadata, media)
194
+ const aggregateHash = getManifestAggregateHash({
195
+ tags: [...referenceTags, ['service', uploadService]]
196
+ })
197
+
156
198
  return [
157
199
  ['d', dTag],
158
- ...buildReferenceTags(uploadService, fileMetadata, media),
200
+ ...referenceTags,
159
201
  ['service', uploadService],
202
+ ['x', aggregateHash, 'aggregate'],
203
+ ['published_at', String(publishedAt)],
160
204
  ...buildMetadataTags({ ...metadata, hasIcon: Boolean(icon) }),
161
205
  ...unknownTags
162
206
  ]
@@ -190,8 +234,34 @@ export async function uploadSiteManifest ({
190
234
  }, relays, { timeoutAfterFirstEose: null })).result
191
235
  events.sort(newestFirst)
192
236
  const previous = events[0]
237
+
238
+ const now = Math.floor(Date.now() / 1000)
239
+ const createdAt = Math.max(now, (previous?.created_at ?? -1) + 1)
240
+ if (createdAt > now + 172800) throw new Error('Existing manifest timestamp is too far in the future to replace safely')
241
+
242
+ const prospectiveTags = buildManifestTags({
243
+ dTag, uploadService, fileMetadata, previousTags: previous?.tags,
244
+ publishedAt: createdAt, ...metadata
245
+ })
246
+ const aggregateHash = getManifestAggregateHash({ tags: prospectiveTags })
247
+ let previousAggregateHash = null
248
+ try {
249
+ if (previous) previousAggregateHash = getManifestAggregateHash(previous)
250
+ } catch (_) {}
251
+ const isSameVersion = previousAggregateHash === aggregateHash
252
+ const previousPublishedAt = previous?.tags?.find(tag => tag[0] === 'published_at')?.[1]
253
+ const parsedPreviousPublishedAt = typeof previousPublishedAt === 'string' && /^(0|[1-9][0-9]*)$/.test(previousPublishedAt)
254
+ ? Number(previousPublishedAt)
255
+ : null
256
+ const fallbackPublishedAt = Number.isSafeInteger(previous?.created_at) && previous.created_at >= 0
257
+ ? previous.created_at
258
+ : createdAt
259
+ const publishedAt = isSameVersion && Number.isSafeInteger(parsedPreviousPublishedAt)
260
+ ? parsedPreviousPublishedAt
261
+ : (isSameVersion ? fallbackPublishedAt : createdAt)
193
262
  const tags = buildManifestTags({
194
- dTag, uploadService, fileMetadata, previousTags: previous?.tags, ...metadata
263
+ dTag, uploadService, fileMetadata, previousTags: previous?.tags,
264
+ publishedAt, ...metadata
195
265
  })
196
266
 
197
267
  if (!shouldReupload && previous && previous.content === '' && JSON.stringify(previous.tags) === JSON.stringify(tags)) {
@@ -208,9 +278,6 @@ export async function uploadSiteManifest ({
208
278
  return previous
209
279
  }
210
280
 
211
- const now = Math.floor(Date.now() / 1000)
212
- const createdAt = Math.max(now, (previous?.created_at ?? -1) + 1)
213
- if (createdAt > now + 172800) throw new Error('Existing manifest timestamp is too far in the future to replace safely')
214
281
  const event = await signer.signEvent({ kind, tags, content: '', created_at: createdAt })
215
282
  await throttledSendEvent(event, relays, { pause, trailingPause: true, log })
216
283
  return event