libp2r2p 0.10.16 → 0.10.18

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
@@ -377,6 +377,9 @@ plus NIP-05 in its standard, root and custom compact spellings.
377
377
  `decodeReference()` parses a single reference, and `decodeMediaMetadata()`
378
378
  reads the file/media metadata carried in a URL fragment
379
379
  (`#m=image/png&dim=640x480&...`).
380
+ URLs can contain literal `+` in their paths, including launcher links such as
381
+ `https://44billion.net/+apps?by=fiatjaf.com`. They produce a single URL item
382
+ with the full address, rather than an app item for the embedded reference.
380
383
 
381
384
  App references are a library extension to NIP-27. `extractMedia()` recognizes
382
385
  encoded `+…` app entities and named references such as `+hallway@fiatjaf.com`,
@@ -552,3 +555,14 @@ bytes, while integer mode supports fixed-width identifiers.
552
555
  In NIP-5A, "no padding" means that no separate padding character such as `=`
553
556
  is used. Leading `0` digits are nevertheless required to make every Nsite
554
557
  Base36 value exactly 50 characters long.
558
+
559
+ ## Files
560
+
561
+ [`libp2r2p/irfs`](irfs/README.md) prepares retryable chunk templates from files,
562
+ with explicit cancellation and resource release. [`libp2r2p/nip94`](nip94/README.md)
563
+ builds/interprets file metadata, including the local IRFS profile. NIP-27 extracts
564
+ `https://nostr.alt/nfile1…?localOnly=1` up to the NIP-19 codec's 5,000-character
565
+ limit, retaining the full URL and exposing decoded `url.nfile` and MIME `url.m`.
566
+
567
+ The NIP-94 extension also carries optional `download` intent; see
568
+ [nip94/README.md](nip94/README.md#download-intent) for event and inline URL forms.
package/irfs/README.md ADDED
@@ -0,0 +1,35 @@
1
+ # IRFS preparation
2
+
3
+ `libp2r2p/irfs` prepares an immutable browser `File`/`Blob` or `Uint8Array`.
4
+ It has no signer, publisher, relay pool or launcher dependency.
5
+
6
+ ```js
7
+ import { prepareIrfsFile, decodeIrfsChunk } from 'libp2r2p/irfs'
8
+ const prepared = await prepareIrfsFile(file, { signal })
9
+ try {
10
+ const created_at = Math.floor(Date.now() / 1000)
11
+ for await (const template of prepared.chunks({ created_at, signal })) {
12
+ // Sign/store/publish according to the consuming application's policy.
13
+ const verified = decodeIrfsChunk(template)
14
+ }
15
+ // prepared.chunks({ created_at }) may be iterated again for retry.
16
+ } finally {
17
+ prepared.close()
18
+ }
19
+ ```
20
+
21
+ The result exposes `root` (hex MMR root), `size`, `total`, `chunks()` and
22
+ idempotent `close()`. Blocks contain at most 51,000 bytes; only the final block
23
+ may be smaller. Templates use kind 34601, deterministic `d` identifiers, an
24
+ `mmr` tag containing decimal index/total and Base93 proof, and Base93 content.
25
+ Use a stable timestamp when identical templates are required across retries.
26
+ `decodeIrfsChunk()` validates the proof, identifier, root and block size.
27
+
28
+ Preparation retains the immutable input and an in-memory NMMR hash tree, not
29
+ a second copy of file bytes. It creates **no temporary IndexedDB database**.
30
+ `close()` drops its input/tree references and abort-listener registration;
31
+ abort also closes preparation. In-flight Blob reads finish but cannot yield
32
+ another chunk after cancellation. Consumers own any object URLs they create.
33
+ Hash memory scales with chunk count. `onProgress({ completed, total })` reports
34
+ bytes hashed; preparation yields regularly for input/cancellation.
35
+ Empty input raises `ValidationError('EMPTY_IRFS_FILE')` in this version.
package/irfs/index.js ADDED
@@ -0,0 +1,74 @@
1
+ import NMMR, { InMemoryMMR } from 'nmmr'
2
+ import { encode, decode } from '../base93/index.js'
3
+ import { bytesToBase16 } from '../base16/index.js'
4
+ import { ValidationError } from '../error/index.js'
5
+
6
+ export const IRFS_CHUNK_BYTES = 51000
7
+ export const IRFS_CHUNK_KIND = 34601
8
+
9
+ // Blob/File input is immutable and seekable: retain it instead of copying its
10
+ // bytes to temporary IndexedDB. Only tree hashes and leaf positions stay in RAM.
11
+ export async function prepareIrfsFile (input, { signal, onProgress } = {}) {
12
+ let blob = input instanceof Uint8Array ? new Blob([input]) : input
13
+ if (!(blob instanceof Blob)) throw new ValidationError('INVALID_IRFS_FILE')
14
+ if (!blob.size) throw new ValidationError('EMPTY_IRFS_FILE')
15
+ let tree = new InMemoryMMR()
16
+ let positions = []
17
+ const size = blob.size
18
+ const total = Math.ceil(size / IRFS_CHUNK_BYTES)
19
+ let closed = false
20
+ const check = currentSignal => {
21
+ signal?.throwIfAborted()
22
+ currentSignal?.throwIfAborted()
23
+ if (closed) throw new Error('IRFS preparation closed')
24
+ }
25
+ const close = () => {
26
+ closed = true
27
+ blob = tree = positions = null
28
+ signal?.removeEventListener('abort', close)
29
+ }
30
+ signal?.addEventListener('abort', close, { once: true })
31
+ try {
32
+ for (let index = 0; index < total; index++) {
33
+ check()
34
+ const bytes = new Uint8Array(await blob.slice(index * IRFS_CHUNK_BYTES, (index + 1) * IRFS_CHUNK_BYTES).arrayBuffer())
35
+ check()
36
+ positions.push(Number(tree.append(bytes).leafIdx))
37
+ onProgress?.({ completed: Math.min(size, (index + 1) * IRFS_CHUNK_BYTES), total: size })
38
+ // Yield to input and cancellation even when a Blob read resolves immediately.
39
+ if (index % 32 === 31) await new Promise(resolve => setTimeout(resolve, 0))
40
+ }
41
+ const root = bytesToBase16(tree.bagThePeaks())
42
+ return {
43
+ root, size, total, close,
44
+ async * chunks ({ created_at: createdAt = Math.floor(Date.now() / 1000), signal: readSignal } = {}) {
45
+ if (!Number.isSafeInteger(createdAt) || createdAt < 0) throw new ValidationError('INVALID_IRFS_TIMESTAMP')
46
+ for (let index = 0; index < total; index++) {
47
+ check(readSignal)
48
+ const bytes = new Uint8Array(await blob.slice(index * IRFS_CHUNK_BYTES, (index + 1) * IRFS_CHUNK_BYTES).arrayBuffer())
49
+ check(readSignal)
50
+ const hashes = tree.getProofArray(positions[index])
51
+ const proof = new Uint8Array(hashes.length * 32)
52
+ hashes.forEach((hash, offset) => proof.set(hash, offset * 32))
53
+ yield { kind: IRFS_CHUNK_KIND, created_at: createdAt, tags: [['d', NMMR.deriveChunkId(root, index)], ['mmr', String(index), String(total), encode(proof)]], content: encode(bytes) }
54
+ }
55
+ }
56
+ }
57
+ } catch (error) { close(); throw error }
58
+ }
59
+
60
+ export function decodeIrfsChunk (event) {
61
+ try {
62
+ if (event?.kind !== IRFS_CHUNK_KIND || !Array.isArray(event.tags)) throw new Error('Invalid kind or tags')
63
+ const d = event.tags.filter(tag => tag[0] === 'd')
64
+ const mmr = event.tags.filter(tag => tag[0] === 'mmr')
65
+ if (d.length !== 1 || d[0].length !== 2 || mmr.length !== 1 || mmr[0].length !== 4) throw new Error('Invalid chunk tags')
66
+ const [, index, total, encodedProof] = mmr[0]
67
+ const contentBytes = decode(event.content)
68
+ const proof = decode(encodedProof)
69
+ const root = NMMR.calculateRoot({ contentBytes, index, total, proof })
70
+ if (!contentBytes.length || contentBytes.length > IRFS_CHUNK_BYTES || (Number(index) < Number(total) - 1 && contentBytes.length !== IRFS_CHUNK_BYTES)) throw new Error('Invalid chunk length')
71
+ if (NMMR.deriveChunkId(root, index) !== d[0][1]) throw new Error('Invalid chunk ID')
72
+ return { root, index: Number(index), total: Number(total), contentBytes, proof }
73
+ } catch (cause) { throw new ValidationError('INVALID_IRFS_CHUNK', { cause }) }
74
+ }
package/nip27/index.js CHANGED
@@ -2,6 +2,7 @@ import { ValidationError } from '../error/index.js'
2
2
  import {
3
3
  NAPP_ENTITY_REGEX,
4
4
  naddrDecode,
5
+ nfileDecode,
5
6
  neventDecode,
6
7
  noteDecode,
7
8
  nrelayDecode
@@ -24,7 +25,7 @@ const BOUNDARY_SUFFIX = /(?=\.?$|[.,]?\s|\.(?=\.)|\.?["»”」'!?;\])}])/.sourc
24
25
  const URL_SOURCE =
25
26
  '(?<url>' +
26
27
  /(?<protocol>https:\/\/)?(?:[-_A-Za-z0-9]{1,30}\.){1,4}[a-z]{2,63}/.source +
27
- /(?:(?:\/[-._A-Za-z0-9%@#]{1,300}){0,11}(?<ext>\.[a-z-0-9]{3,4})|(?:\/[-._A-Za-z0-9%@#]{1,300}){0,11})\/?/.source +
28
+ /(?:(?:\/[-._+A-Za-z0-9%@#]{1,300}){0,11}(?<ext>\.[a-z-0-9]{3,4})|(?:\/[-._+A-Za-z0-9%@#]{1,300}){0,11})\/?/.source +
28
29
  /(?:\??&?(?:[-_+.A-Za-z0-9%*]{1,30}=[-_+.A-Za-z0-9%*]{1,300}&?){1,40}|\?)?/.source +
29
30
  /(?:(?:#|(?<=#))[-_+.A-Za-z0-9%=&*:~,]{1,4000})?/.source +
30
31
  /(?:\/|(?<![.,]))/.source +
@@ -75,6 +76,7 @@ function getReferencesRegex (bareNip05) {
75
76
  ...(bareNip05 ? [NIP05_BARE_ROOT, NIP05_BARE_CUSTOM] : [])
76
77
  ]
77
78
  const alternatives = [
79
+ '(?<nfileUrl>https://nostr[.]alt/(?<nfileEntity>nfile1[ac-hj-np-z02-9]{1,4994})(?:[?]localOnly=1)?(?:#[-_+.A-Za-z0-9%=&*]{1,4000})?)',
78
80
  URL_SOURCE,
79
81
  APP_SOURCE,
80
82
  NIP05_STANDARD,
@@ -203,7 +205,8 @@ const NIP94_TAGS = {
203
205
  image: ['image'],
204
206
  summary: ['summary'],
205
207
  alt: ['alt'],
206
- caption: ['caption']
208
+ caption: ['caption'],
209
+ download: ['download']
207
210
  }
208
211
 
209
212
  function validateTagConfigs (extraTags) {
@@ -236,11 +239,20 @@ export function decodeMediaMetadata (url, { extraTags } = {}) {
236
239
 
237
240
  const tags = extraTags ? { ...NIP94_TAGS, ...extraTags } : NIP94_TAGS
238
241
  const tagIndexes = {}
242
+ // Validate against the complete fragment before the generic bounded parser:
243
+ // punctuation must not turn an invalid value such as 1/foo into a valid 1.
244
+ const fragment = url.includes('#') ? url.slice(url.indexOf('#') + 1) : ''
245
+ const downloads = fragment.split('&').filter(item => decodeFragmentValue(item.split('=')[0]) === 'download')
246
+ if (downloads.length > 1 || downloads.some(item => {
247
+ const parts = item.split('=')
248
+ return parts.length !== 2 || !['0', '1'].includes(decodeFragmentValue(parts[1]))
249
+ })) throw new ValidationError('INVALID_MEDIA_METADATA_DOWNLOAD')
239
250
  const obj = (url.match(/(?<=#)[-_+.A-Za-z0-9%=&*]{1,4000}/)?.[0] || '')
240
251
  .split('&')
241
252
  .filter(Boolean)
242
253
  .reduce((memo, item) => {
243
- let [key, value = ''] = item.split('=')
254
+ const parts = item.split('=')
255
+ let [key, value = ''] = parts
244
256
  key = decodeFragmentValue(key)
245
257
  value = decodeFragmentValue(value)
246
258
  const config = tags[key]
@@ -299,6 +311,14 @@ function getReferenceItem (original, groups, { getMimeType, defaultAppUser }) {
299
311
  : { key: 'text', text: { value: original } }
300
312
  }
301
313
 
314
+ if (groups.nfileUrl) {
315
+ try {
316
+ const file = nfileDecode(groups.nfileEntity)
317
+ const metadata = tryDecodeMediaMetadata(groups.nfileUrl) || {}
318
+ return { key: 'url', url: { ...metadata, value: groups.nfileUrl, nfile: file, ...(file.mime ? { m: file.mime } : {}) } }
319
+ } catch { return { key: 'text', text: { value: original } } }
320
+ }
321
+
302
322
  if (groups.url) {
303
323
  const url = `${groups.protocol ? '' : 'https://'}${groups.url}`
304
324
  let mediaMetadata = {}
@@ -0,0 +1,40 @@
1
+ # File metadata
2
+
3
+ `libp2r2p/nip94` exports `createFileMetadata(options)` and
4
+ `decodeFileMetadata(event)`. The former creates an unsigned kind-1063 template;
5
+ the latter reads and validates its tags without parsing the caption as a URL.
6
+
7
+ This is an **adaptation/extension of NIP-94**, not a claim that every emitted
8
+ event meets its SHA-256 requirements. `url` and `mime` are required. Options
9
+ include `caption`, `created_at`, `size`, `width`/`height`, `alt`, `root`, `service`,
10
+ `thumbhash`, `sha256`, `originalSha256`, and additional `tags` (e.g. replies).
11
+ The caption becomes `.content`. `root` maps to `r`, dimensions to `dim`, hashes
12
+ to `x`/`ox`; hashes are optional and never synthesized. ThumbHash is transported
13
+ unchanged as Base64, independently of image decoding. No `blurhash` is generated.
14
+
15
+ For local IRFS files use `service: 'irfs'` and
16
+ `https://nostr.alt/nfile1…?localOnly=1`. Encode the MMR root, MIME and filename
17
+ with `nfileEncode`; relay/author hints are unnecessary. The decoder verifies
18
+ agreement between `r`/`m` and nfile metadata and exposes its `filename`.
19
+ The `r` reference allows storage owners to retain the associated local chunks.
20
+ The module does not encrypt, sign, store or download data.
21
+
22
+ ## Download intent
23
+
24
+ `download` is an optional extension expressing the author's intended action:
25
+ `'1'` asks clients to download on activation rather than open/play the media.
26
+ It does not prevent thumbnails or enforce server behavior.
27
+
28
+ For kind 1063, no tag and `['download', '0']` both decode to `download: '0'`;
29
+ `['download']` and `['download', '1']` both decode to `download: '1'`.
30
+ Empty/other values, extra fields and duplicate download tags are invalid.
31
+ `createFileMetadata({ ..., download: '0' | '1' })` emits an explicit value;
32
+ omitting the option emits no download tag. Boolean/numeric options are invalid.
33
+
34
+ For inline URLs, `nip27.decodeMediaMetadata()` requires an explicit
35
+ `#download=0` or `#download=1` (or `&download=...` after other fragment fields).
36
+ A bare/empty/duplicate/invalid value throws `INVALID_MEDIA_METADATA_DOWNLOAD`;
37
+ `tryDecodeMediaMetadata()` returns null. No fragment means no download property.
38
+ `extractMedia()` carries the string flag for ordinary URLs and nfile URLs,
39
+ including long nfile entities and `?localOnly=1`. Metadata fragments do not
40
+ change the bytes or root of a file; clients strip them from download routes.
package/nip94/index.js ADDED
@@ -0,0 +1,55 @@
1
+ import { FILE_METADATA } from '../kind/index.js'
2
+ import { nfileDecode } from '../nip19/index.js'
3
+ import { ValidationError } from '../error/index.js'
4
+
5
+ // NIP-94 fields plus the IRFS r/service and ThumbHash extensions. SHA-256
6
+ // x/ox are optional in this profile; an MMR root is never substituted for x.
7
+ export function decodeFileMetadata (event) {
8
+ if (event?.kind !== FILE_METADATA || typeof event.content !== 'string' || !Array.isArray(event.tags)) throw new ValidationError('INVALID_FILE_METADATA')
9
+ const field = name => {
10
+ const tags = event.tags.filter(tag => Array.isArray(tag) && tag[0] === name)
11
+ if (tags.length > 1 || (tags.length && (tags[0].length < 2 || typeof tags[0][1] !== 'string'))) throw new ValidationError('INVALID_FILE_METADATA_TAG')
12
+ return tags[0]?.[1]
13
+ }
14
+ const downloadTags = event.tags.filter(tag => Array.isArray(tag) && tag[0] === 'download')
15
+ const download = downloadTags[0]
16
+ if (downloadTags.length > 1 || (download && (download.length > 2 || (download.length === 2 && !['0', '1'].includes(download[1]))))) throw new ValidationError('INVALID_FILE_METADATA_DOWNLOAD')
17
+ const result = { url: field('url'), mime: field('m'), caption: event.content, download: download ? download.length === 1 ? '1' : download[1] : '0' }
18
+ let url
19
+ try { url = new URL(result.url) } catch { throw new ValidationError('INVALID_FILE_METADATA_URL') }
20
+ if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password) throw new ValidationError('INVALID_FILE_METADATA_URL')
21
+ if (typeof result.mime !== 'string' || !/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/.test(result.mime)) throw new ValidationError('INVALID_FILE_METADATA_MIME')
22
+ for (const [tag, key] of [['r', 'root'], ['service', 'service'], ['thumbhash', 'thumbhash'], ['x', 'sha256'], ['ox', 'originalSha256'], ['alt', 'alt']]) {
23
+ const value = field(tag)
24
+ if (value !== undefined) result[key] = value
25
+ }
26
+ for (const key of ['root', 'sha256', 'originalSha256']) if (result[key] !== undefined && !/^[0-9a-f]{64}$/.test(result[key])) throw new ValidationError('INVALID_FILE_METADATA_HASH')
27
+ const size = field('size')
28
+ if (size !== undefined) {
29
+ if (!/^(0|[1-9][0-9]*)$/.test(size) || !Number.isSafeInteger(Number(size))) throw new ValidationError('INVALID_FILE_METADATA_SIZE')
30
+ result.size = Number(size)
31
+ }
32
+ const dim = field('dim')
33
+ if (dim !== undefined) {
34
+ if (!/^[1-9][0-9]*x[1-9][0-9]*$/.test(dim)) throw new ValidationError('INVALID_FILE_METADATA_DIMENSIONS')
35
+ ;[result.width, result.height] = dim.split('x').map(Number)
36
+ if (![result.width, result.height].every(Number.isSafeInteger)) throw new ValidationError('INVALID_FILE_METADATA_DIMENSIONS')
37
+ }
38
+ if (result.thumbhash !== undefined && !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(result.thumbhash)) throw new ValidationError('INVALID_FILE_METADATA_THUMBHASH')
39
+ if (url.origin === 'https://nostr.alt') {
40
+ const file = nfileDecode(url.pathname.slice(1))
41
+ if ((result.root && result.root !== file.root) || (file.mime && file.mime !== result.mime)) throw new ValidationError('FILE_METADATA_NFILE_MISMATCH')
42
+ result.filename = file.filename
43
+ }
44
+ return result
45
+ }
46
+
47
+ export function createFileMetadata ({ caption = '', created_at: createdAt = Math.floor(Date.now() / 1000), tags = [], ...metadata }) {
48
+ if (!Number.isSafeInteger(createdAt) || createdAt < 0 || !Array.isArray(tags)) throw new ValidationError('INVALID_FILE_METADATA')
49
+ if (metadata.download !== undefined && !['0', '1'].includes(metadata.download)) throw new ValidationError('INVALID_FILE_METADATA_DOWNLOAD')
50
+ const fields = [['download', metadata.download], ['url', metadata.url], ['m', metadata.mime], ['r', metadata.root], ['size', metadata.size], ['service', metadata.service], ['thumbhash', metadata.thumbhash], ['x', metadata.sha256], ['ox', metadata.originalSha256], ['alt', metadata.alt]]
51
+ if (metadata.width !== undefined || metadata.height !== undefined) fields.push(['dim', `${metadata.width}x${metadata.height}`])
52
+ const event = { kind: FILE_METADATA, created_at: createdAt, content: caption, tags: [...fields.filter(([, value]) => value !== undefined).map(([key, value]) => [key, String(value)]), ...tags.map(tag => [...tag])] }
53
+ decodeFileMetadata(event)
54
+ return event
55
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.10.16",
3
+ "version": "0.10.18",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -25,6 +25,7 @@
25
25
  "idb",
26
26
  "idb-queue",
27
27
  "index.js",
28
+ "irfs",
28
29
  "key",
29
30
  "kind",
30
31
  "network",
@@ -35,6 +36,7 @@
35
36
  "nip44",
36
37
  "nip44-v3",
37
38
  "nip46",
39
+ "nip94",
38
40
  "nip96",
39
41
  "nip98",
40
42
  "nwt",
@@ -66,6 +68,7 @@
66
68
  "./event": "./event/index.js",
67
69
  "./idb": "./idb/index.js",
68
70
  "./idb-queue": "./idb-queue/index.js",
71
+ "./irfs": "./irfs/index.js",
69
72
  "./key": "./key/index.js",
70
73
  "./kind": "./kind/index.js",
71
74
  "./network": "./network/index.js",
@@ -76,6 +79,7 @@
76
79
  "./nip44": "./nip44/index.js",
77
80
  "./nip44-v3": "./nip44-v3/index.js",
78
81
  "./nip46": "./nip46/index.js",
82
+ "./nip94": "./nip94/index.js",
79
83
  "./nip96": "./nip96/index.js",
80
84
  "./nip98": "./nip98/index.js",
81
85
  "./nwt": "./nwt/index.js",
@@ -92,7 +96,8 @@
92
96
  "@noble/ciphers": "2.2.0",
93
97
  "@noble/curves": "2.2.0",
94
98
  "@noble/hashes": "2.2.0",
95
- "@scure/base": "2.0.0"
99
+ "@scure/base": "2.0.0",
100
+ "nmmr": "2.0.0"
96
101
  },
97
102
  "devDependencies": {
98
103
  "eslint": "^9.39.5",