libp2r2p 0.8.0 → 0.9.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.
Files changed (49) hide show
  1. package/README.md +83 -2
  2. package/base16/index.js +6 -3
  3. package/base36/index.js +12 -8
  4. package/base62/index.js +15 -11
  5. package/base64/index.js +18 -2
  6. package/base93/index.js +19 -7
  7. package/content-key/event/index.js +40 -12
  8. package/double-dh/index.js +21 -6
  9. package/ecdh/index.js +12 -1
  10. package/error/index.js +32 -0
  11. package/event/helpers/serialize.js +31 -0
  12. package/event/index.js +116 -0
  13. package/i18n/index.js +15 -13
  14. package/idb/index.js +5 -3
  15. package/idb-queue/index.js +21 -20
  16. package/index.js +10 -0
  17. package/key/index.js +31 -18
  18. package/kind/index.js +244 -0
  19. package/nip04/index.js +47 -0
  20. package/nip05/index.js +61 -0
  21. package/nip19/index.js +176 -43
  22. package/nip44/helpers.js +95 -0
  23. package/nip44/index.js +14 -0
  24. package/nip44-v3/index.js +35 -16
  25. package/nip46/helpers/frame.js +6 -6
  26. package/nip46/helpers/url.js +8 -6
  27. package/nip46/services/bunker-signer.js +7 -6
  28. package/nip46/services/client.js +8 -7
  29. package/nip46/services/server-session.js +8 -7
  30. package/nip46/services/transport.js +9 -8
  31. package/nip96/index.js +285 -0
  32. package/nip98/index.js +56 -0
  33. package/nwt/index.js +241 -0
  34. package/package.json +22 -3
  35. package/private-channel/helpers/chunks.js +7 -6
  36. package/private-channel/helpers/event.js +5 -4
  37. package/private-channel/index.js +63 -61
  38. package/private-channel/services/received-chunks.js +4 -3
  39. package/private-message/index.js +32 -31
  40. package/private-messenger/index.js +23 -22
  41. package/private-messenger/recovery/index.js +15 -14
  42. package/private-messenger/services/channel-state.js +2 -1
  43. package/relay/helpers/hll.js +3 -1
  44. package/relay/services/query.js +3 -3
  45. package/relay/services/relay-connection.js +222 -121
  46. package/relay/services/relay-pool.js +13 -10
  47. package/temporary-storage/index.js +3 -1
  48. package/url/index.js +131 -0
  49. package/web-storage-queue/index.js +8 -6
@@ -1,3 +1,5 @@
1
+ import { ValidationError } from '../../error/index.js'
2
+
1
3
  const PUBKEY = /^[0-9a-f]{64}$/
2
4
 
3
5
  function uniqueStrings (values) {
@@ -5,7 +7,7 @@ function uniqueStrings (values) {
5
7
  return [...new Set(values.filter(value => typeof value === 'string' && value))]
6
8
  }
7
9
 
8
- function validPubkey (value) {
10
+ function isValidPubkey (value) {
9
11
  return typeof value === 'string' && PUBKEY.test(value)
10
12
  }
11
13
 
@@ -13,7 +15,7 @@ export function normalizeBunkerPointer (pointer) {
13
15
  if (!pointer || typeof pointer !== 'object') return null
14
16
  const remoteSignerPubkey = String(pointer.remoteSignerPubkey || '').toLowerCase()
15
17
  const relays = uniqueStrings(pointer.relays)
16
- if (!validPubkey(remoteSignerPubkey) || !relays.length) return null
18
+ if (!isValidPubkey(remoteSignerPubkey) || !relays.length) return null
17
19
  return {
18
20
  remoteSignerPubkey,
19
21
  relays,
@@ -39,7 +41,7 @@ export function parseBunkerUrl (input) {
39
41
  // Serializes a direct NIP-46 bunker pointer without any network lookup.
40
42
  export function toBunkerUrl (pointer) {
41
43
  const normalized = normalizeBunkerPointer(pointer)
42
- if (!normalized) throw new Error('INVALID_BUNKER_POINTER')
44
+ if (!normalized) throw new ValidationError('INVALID_BUNKER_POINTER')
43
45
  const url = new URL(`bunker://${normalized.remoteSignerPubkey}`)
44
46
  for (const relay of normalized.relays) url.searchParams.append('relay', relay)
45
47
  if (normalized.secret) url.searchParams.set('secret', normalized.secret)
@@ -58,8 +60,8 @@ export function createNostrConnectURI ({
58
60
  } = {}) {
59
61
  const normalizedPubkey = String(clientPubkey || '').toLowerCase()
60
62
  const normalizedRelays = uniqueStrings(relays)
61
- if (!validPubkey(normalizedPubkey) || !normalizedRelays.length || typeof secret !== 'string' || !secret) {
62
- throw new Error('INVALID_NOSTRCONNECT_URI')
63
+ if (!isValidPubkey(normalizedPubkey) || !normalizedRelays.length || typeof secret !== 'string' || !secret) {
64
+ throw new ValidationError('INVALID_NOSTRCONNECT_URI')
63
65
  }
64
66
 
65
67
  const uri = new URL(`nostrconnect://${normalizedPubkey}`)
@@ -78,7 +80,7 @@ export function parseNostrConnectURI (input) {
78
80
  const clientPubkey = url.hostname.toLowerCase()
79
81
  const relays = uniqueStrings(url.searchParams.getAll('relay'))
80
82
  const secret = url.searchParams.get('secret') || ''
81
- if (url.protocol !== 'nostrconnect:' || !validPubkey(clientPubkey) || !relays.length || !secret) return null
83
+ if (url.protocol !== 'nostrconnect:' || !isValidPubkey(clientPubkey) || !relays.length || !secret) return null
82
84
  return {
83
85
  clientPubkey,
84
86
  relays,
@@ -1,5 +1,6 @@
1
- import { verifyEvent } from 'nostr-tools'
2
- import { validPubkey } from '../helpers/frame.js'
1
+ import { ValidationError } from '../../error/index.js'
2
+ import { isValidEvent } from '../../event/index.js'
3
+ import { isValidPubkey } from '../helpers/frame.js'
3
4
  import { Nip46Client } from './client.js'
4
5
 
5
6
  // A NIP-46 remote signer with the standard Nostr signing commands.
@@ -14,7 +15,7 @@ export class BunkerSigner extends Nip46Client {
14
15
  async getPublicKey (options) {
15
16
  if (!options?.extension && this.#cachedPubkey) return this.#cachedPubkey
16
17
  const pubkey = await this.sendRequest('get_public_key', [], options)
17
- if (!validPubkey(pubkey)) throw new Error('NIP46_INVALID_PUBLIC_KEY')
18
+ if (!isValidPubkey(pubkey)) throw new ValidationError('NIP46_INVALID_PUBLIC_KEY')
18
19
  if (!options?.extension) this.#cachedPubkey = pubkey
19
20
  return pubkey
20
21
  }
@@ -24,10 +25,10 @@ export class BunkerSigner extends Nip46Client {
24
25
  let signed
25
26
  try {
26
27
  signed = JSON.parse(response)
27
- } catch {
28
- throw new Error('NIP46_INVALID_SIGNED_EVENT')
28
+ } catch (cause) {
29
+ throw new ValidationError('NIP46_INVALID_SIGNED_EVENT', { cause })
29
30
  }
30
- if (!verifyEvent(signed)) throw new Error('NIP46_INVALID_SIGNED_EVENT')
31
+ if (!isValidEvent(signed)) throw new ValidationError('NIP46_INVALID_SIGNED_EVENT')
31
32
  return signed
32
33
  }
33
34
 
@@ -1,4 +1,5 @@
1
- import { getPublicKey } from 'nostr-tools'
1
+ import { ValidationError } from '../../error/index.js'
2
+ import { getPublicKey } from '../../key/index.js'
2
3
  import { relayPool as defaultRelayPool } from '../../relay/index.js'
3
4
  import {
4
5
  DEFAULT_TIMEOUT,
@@ -10,10 +11,10 @@ import {
10
11
  decodeNip46Frame,
11
12
  isNip46EventFor,
12
13
  requestError,
13
- validRequestFrame
14
+ isValidRequestFrame
14
15
  } from '../helpers/frame.js'
15
16
  import { normalizeBunkerPointer, parseNostrConnectURI } from '../helpers/url.js'
16
- import { Nip46Transport, sameRelays, waitForNip46 } from './transport.js'
17
+ import { areRelaySetsEqual, Nip46Transport, waitForNip46 } from './transport.js'
17
18
 
18
19
  function cleanClientMetadata (value) {
19
20
  if (!value || typeof value !== 'object') return null
@@ -41,7 +42,7 @@ export class Nip46Client {
41
42
  timeoutAfterFirstEose = DEFAULT_TIMEOUT_AFTER_FIRST_EOSE
42
43
  } = {}) {
43
44
  const normalized = normalizeBunkerPointer(pointer)
44
- if (!normalized) throw new Error('INVALID_BUNKER_POINTER')
45
+ if (!normalized) throw new ValidationError('INVALID_BUNKER_POINTER')
45
46
  this.#secretKey = clientSecretKey
46
47
  this.#pointer = normalized
47
48
  this.#onAuthUrl = onAuthUrl
@@ -64,7 +65,7 @@ export class Nip46Client {
64
65
  static async fromURI (clientSecretKey, uri, options = {}) {
65
66
  const parsed = parseNostrConnectURI(uri)
66
67
  const clientPubkey = getPublicKey(clientSecretKey)
67
- if (!parsed || clientPubkey !== parsed.clientPubkey) throw new Error('INVALID_NOSTRCONNECT_URI')
68
+ if (!parsed || clientPubkey !== parsed.clientPubkey) throw new ValidationError('INVALID_NOSTRCONNECT_URI')
68
69
 
69
70
  const relayPool = options.relayPool || defaultRelayPool
70
71
  const controller = new AbortController()
@@ -169,7 +170,7 @@ export class Nip46Client {
169
170
  if (relays === null) return false
170
171
 
171
172
  const nextPointer = normalizeBunkerPointer({ ...this.#pointer, relays })
172
- if (!nextPointer || sameRelays(nextPointer.relays, this.#pointer.relays)) return false
173
+ if (!nextPointer || areRelaySetsEqual(nextPointer.relays, this.#pointer.relays)) return false
173
174
 
174
175
  const nextContext = this.#openResponseContext(nextPointer)
175
176
  try {
@@ -229,7 +230,7 @@ export class Nip46Client {
229
230
  }
230
231
 
231
232
  async #handleRequest (peerPubkey, request, context) {
232
- if (!validRequestFrame(request)) {
233
+ if (!isValidRequestFrame(request)) {
233
234
  if (typeof request?.id === 'string') {
234
235
  await this.#transport.reply(peerPubkey, request.id, null, 'NIP46_INVALID_REQUEST', { context })
235
236
  }
@@ -1,11 +1,12 @@
1
+ import { ValidationError } from '../../error/index.js'
1
2
  import { relayPool as defaultRelayPool } from '../../relay/index.js'
2
3
  import { DEFAULT_TIMEOUT, DEFAULT_TIMEOUT_AFTER_FIRST_EOSE, NIP46_KIND } from '../constants/index.js'
3
4
  import {
4
5
  decodeNip46Frame,
5
6
  isNip46EventFor,
6
- validRequestFrame
7
+ isValidRequestFrame
7
8
  } from '../helpers/frame.js'
8
- import { Nip46Transport, sameRelays } from './transport.js'
9
+ import { areRelaySetsEqual, Nip46Transport } from './transport.js'
9
10
 
10
11
  function cleanRelays (relays) {
11
12
  return [...new Set((Array.isArray(relays) ? relays : [])
@@ -48,8 +49,8 @@ export class Nip46ServerSession {
48
49
  timeoutAfterFirstEose = DEFAULT_TIMEOUT_AFTER_FIRST_EOSE
49
50
  } = {}) {
50
51
  this.#relays = cleanRelays(relays)
51
- if (!this.#relays.length) throw new Error('NIP46_RELAYS_REQUIRED')
52
- if (typeof secret !== 'string') throw new Error('NIP46_SECRET_REQUIRED')
52
+ if (!this.#relays.length) throw new ValidationError('NIP46_RELAYS_REQUIRED')
53
+ if (typeof secret !== 'string') throw new ValidationError('NIP46_SECRET_REQUIRED')
53
54
  this.#secretKey = serverSecretKey
54
55
  this.#secret = secret
55
56
  this.#onConnect = onConnect
@@ -85,8 +86,8 @@ export class Nip46ServerSession {
85
86
  // Opens replacements now and switches only after the client requests them.
86
87
  async updateRelays (relays) {
87
88
  const nextRelays = cleanRelays(relays)
88
- if (!nextRelays.length) throw new Error('NIP46_RELAYS_REQUIRED')
89
- if (sameRelays(nextRelays, this.#relays)) return false
89
+ if (!nextRelays.length) throw new ValidationError('NIP46_RELAYS_REQUIRED')
90
+ if (areRelaySetsEqual(nextRelays, this.#relays)) return false
90
91
  if (!this.#transport.activeContext) {
91
92
  this.#relays = nextRelays
92
93
  return true
@@ -137,7 +138,7 @@ export class Nip46ServerSession {
137
138
  }
138
139
 
139
140
  async #handleRequest (peerPubkey, request, context) {
140
- if (!validRequestFrame(request)) {
141
+ if (!isValidRequestFrame(request)) {
141
142
  if (typeof request?.id === 'string') {
142
143
  await this.#transport.reply(peerPubkey, request.id, null, 'NIP46_INVALID_REQUEST', { context })
143
144
  }
@@ -1,4 +1,5 @@
1
- import { getPublicKey } from 'nostr-tools'
1
+ import { ValidationError } from '../../error/index.js'
2
+ import { getPublicKey } from '../../key/index.js'
2
3
  import { relayPool as defaultRelayPool } from '../../relay/index.js'
3
4
  import { DEFAULT_TIMEOUT, DEFAULT_TIMEOUT_AFTER_FIRST_EOSE } from '../constants/index.js'
4
5
  import { createNip46Event, requestError } from '../helpers/frame.js'
@@ -14,9 +15,9 @@ function requestId () {
14
15
 
15
16
  function requestExtension (value) {
16
17
  if (value === undefined || value === null) return null
17
- if (typeof value !== 'object' || Array.isArray(value)) throw new Error('NIP46_REQUEST_EXTENSION_REQUIRED')
18
+ if (typeof value !== 'object' || Array.isArray(value)) throw new ValidationError('NIP46_REQUEST_EXTENSION_REQUIRED')
18
19
  for (const key of ['id', 'method', 'params']) {
19
- if (Object.hasOwn(value, key)) throw new Error(`NIP46_REQUEST_EXTENSION_CANNOT_SET_${key.toUpperCase()}`)
20
+ if (Object.hasOwn(value, key)) throw new ValidationError(`NIP46_REQUEST_EXTENSION_CANNOT_SET_${key.toUpperCase()}`)
20
21
  }
21
22
  return value
22
23
  }
@@ -46,7 +47,7 @@ export function waitForNip46 (promise, { timeout = null, signal, label = 'NIP46_
46
47
  })
47
48
  }
48
49
 
49
- export function sameRelays (left, right) {
50
+ export function areRelaySetsEqual (left, right) {
50
51
  if (left.length !== right.length) return false
51
52
  const values = new Set(left)
52
53
  return right.every(value => values.has(value))
@@ -74,8 +75,8 @@ export class Nip46Transport {
74
75
  timeoutAfterFirstEose = DEFAULT_TIMEOUT_AFTER_FIRST_EOSE,
75
76
  onError
76
77
  } = {}) {
77
- if (!(secretKey instanceof Uint8Array)) throw new Error('NIP46_SECRET_KEY_REQUIRED')
78
- if (!relayPool?.getLiveEventsGenerator || !relayPool?.sendEvent) throw new Error('RELAY_POOL_REQUIRED')
78
+ if (!(secretKey instanceof Uint8Array)) throw new ValidationError('NIP46_SECRET_KEY_REQUIRED')
79
+ if (!relayPool?.getLiveEventsGenerator || !relayPool?.sendEvent) throw new ValidationError('RELAY_POOL_REQUIRED')
79
80
  this.#secretKey = secretKey
80
81
  this.#pubkey = getPublicKey(secretKey)
81
82
  this.#relayPool = relayPool
@@ -164,9 +165,9 @@ export class Nip46Transport {
164
165
  extension
165
166
  } = {}) {
166
167
  if (this.#closed) throw new Error('NIP46_CLOSED')
167
- if (typeof method !== 'string' || !method) throw new Error('NIP46_METHOD_REQUIRED')
168
+ if (typeof method !== 'string' || !method) throw new ValidationError('NIP46_METHOD_REQUIRED')
168
169
  if (!Array.isArray(params) || !params.every(param => typeof param === 'string')) {
169
- throw new Error('NIP46_PARAMS_REQUIRED')
170
+ throw new ValidationError('NIP46_PARAMS_REQUIRED')
170
171
  }
171
172
  if (signal?.aborted) throw new Error('Aborted')
172
173
  const context = this.#activeContext
package/nip96/index.js ADDED
@@ -0,0 +1,285 @@
1
+ import { sha256 } from '@noble/hashes/sha2.js'
2
+
3
+ import { bytesToBase16 } from '../base16/index.js'
4
+ import { ValidationError } from '../error/index.js'
5
+ import { FILE_SERVER_PREFERENCE } from '../kind/index.js'
6
+
7
+ const WELL_KNOWN_PATH = '/.well-known/nostr/nip96.json'
8
+
9
+ function isValidHttpUrl (value) {
10
+ try {
11
+ const url = new URL(value)
12
+ return url.protocol === 'http:' || url.protocol === 'https:'
13
+ } catch {
14
+ return false
15
+ }
16
+ }
17
+
18
+ function combineSignal (signal, timeoutMs) {
19
+ if (signal?.aborted) throw signal.reason ?? new DOMException('This operation was aborted', 'AbortError')
20
+ const controller = new AbortController()
21
+ const onAbort = () => controller.abort(signal.reason)
22
+ signal?.addEventListener('abort', onAbort, { once: true })
23
+ const timeout = Number.isFinite(timeoutMs) && timeoutMs >= 0
24
+ ? setTimeout(() => controller.abort(new DOMException('NIP-96 request timed out', 'TimeoutError')), timeoutMs)
25
+ : null
26
+ return {
27
+ signal: controller.signal,
28
+ cleanup () {
29
+ if (timeout !== null) clearTimeout(timeout)
30
+ signal?.removeEventListener('abort', onAbort)
31
+ }
32
+ }
33
+ }
34
+
35
+ async function fetchJson (url, options, { fetch: fetchImpl = globalThis.fetch, signal, timeoutMs = 10000 } = {}) {
36
+ if (typeof fetchImpl !== 'function') throw new Error('FETCH_IS_NOT_AVAILABLE')
37
+ const combined = combineSignal(signal, timeoutMs)
38
+ try {
39
+ const response = await fetchImpl(url, { ...options, signal: combined.signal })
40
+ if (!response.ok) {
41
+ const error = new Error(`NIP-96 request failed with status ${response.status}`)
42
+ error.status = response.status
43
+ throw error
44
+ }
45
+ try {
46
+ return { response, data: await response.json() }
47
+ } catch (cause) {
48
+ throw new ValidationError('INVALID_NIP96_JSON_RESPONSE', { cause })
49
+ }
50
+ } finally {
51
+ combined.cleanup()
52
+ }
53
+ }
54
+
55
+ function serverConfigurationError (config) {
56
+ if (!config || typeof config !== 'object' || Array.isArray(config)) return 'INVALID_NIP96_SERVER_CONFIGURATION'
57
+ const hasApi = typeof config.api_url === 'string' && config.api_url.length > 0
58
+ const hasDelegation = typeof config.delegated_to_url === 'string' && config.delegated_to_url.length > 0
59
+ if (!hasApi && !hasDelegation) return 'NIP96_SERVER_CONFIGURATION_SOURCE_REQUIRED'
60
+ if (hasApi && hasDelegation) return 'NIP96_SERVER_CONFIGURATION_SOURCE_CONFLICT'
61
+ if (hasApi && !isValidHttpUrl(config.api_url)) return 'INVALID_NIP96_API_URL'
62
+ if (hasDelegation && !isValidHttpUrl(config.delegated_to_url)) return 'INVALID_NIP96_DELEGATION_URL'
63
+ if (config.download_url !== undefined && !isValidHttpUrl(config.download_url)) return 'INVALID_NIP96_DOWNLOAD_URL'
64
+ return null
65
+ }
66
+
67
+ export function isValidServerConfiguration (config) {
68
+ return serverConfigurationError(config) === null
69
+ }
70
+
71
+ export function assertValidServerConfiguration (config) {
72
+ const code = serverConfigurationError(config)
73
+ if (code) throw new ValidationError(code)
74
+ return config
75
+ }
76
+
77
+ export async function readServerConfig ({ serverUrl, fetch, signal, timeoutMs = 10000 }) {
78
+ if (!isValidHttpUrl(serverUrl)) throw new ValidationError('INVALID_SERVER_URL')
79
+ const firstUrl = new URL(WELL_KNOWN_PATH, new URL(serverUrl).origin)
80
+ const first = await fetchJson(firstUrl, { headers: { Accept: 'application/json' } }, { fetch, signal, timeoutMs })
81
+ assertValidServerConfiguration(first.data)
82
+ if (!first.data.delegated_to_url) return first.data
83
+
84
+ const delegated = await fetchJson(first.data.delegated_to_url, { headers: { Accept: 'application/json' } }, { fetch, signal, timeoutMs })
85
+ try {
86
+ assertValidServerConfiguration(delegated.data)
87
+ } catch (cause) {
88
+ throw new ValidationError('INVALID_DELEGATED_SERVER_CONFIGURATION', { cause })
89
+ }
90
+ if (delegated.data.delegated_to_url) throw new ValidationError('INVALID_DELEGATED_SERVER_CONFIGURATION')
91
+ return delegated.data
92
+ }
93
+
94
+ function fileUploadResponseError (response) {
95
+ if (!response || typeof response !== 'object' || Array.isArray(response)) return 'INVALID_NIP96_FILE_UPLOAD_RESPONSE'
96
+ if (!['success', 'error', 'processing'].includes(response.status)) return 'INVALID_NIP96_UPLOAD_STATUS'
97
+ if (typeof response.message !== 'string') return 'INVALID_NIP96_UPLOAD_MESSAGE'
98
+ if (response.status === 'processing' && !isValidHttpUrl(response.processing_url)) return 'INVALID_NIP96_PROCESSING_URL'
99
+ if (response.processing_url !== undefined && !isValidHttpUrl(response.processing_url)) return 'INVALID_NIP96_PROCESSING_URL'
100
+ if (response.status === 'success' && !response.nip94_event) return 'NIP96_FILE_METADATA_REQUIRED'
101
+ if (response.nip94_event !== undefined) {
102
+ const { tags } = response.nip94_event
103
+ if (!Array.isArray(tags) || tags.some(tag => !Array.isArray(tag) || tag.length < 2 || tag.some(value => typeof value !== 'string'))) return 'INVALID_NIP94_TAGS'
104
+ if (!tags.some(tag => tag[0] === 'url' && isValidHttpUrl(tag[1]))) return 'NIP94_URL_TAG_REQUIRED'
105
+ if (!tags.some(tag => tag[0] === 'ox' && /^[0-9a-f]{64}$/.test(tag[1]))) return 'NIP94_ORIGINAL_HASH_TAG_REQUIRED'
106
+ }
107
+ return null
108
+ }
109
+
110
+ export function isValidFileUploadResponse (response) {
111
+ return fileUploadResponseError(response) === null
112
+ }
113
+
114
+ export function assertValidFileUploadResponse (response) {
115
+ const code = fileUploadResponseError(response)
116
+ if (code) throw new ValidationError(code)
117
+ return response
118
+ }
119
+
120
+ function uploadError (status) {
121
+ const messages = {
122
+ 400: 'Bad request! Some fields are missing or invalid!',
123
+ 402: 'Payment required!',
124
+ 403: 'Forbidden! Payload tag does not match the requested file!',
125
+ 413: 'File too large!'
126
+ }
127
+ const error = new Error(messages[status] ?? 'Unknown error in uploading file!')
128
+ error.status = status
129
+ return error
130
+ }
131
+
132
+ function makeFormData (file, optionalFormDataFields) {
133
+ const formData = new FormData()
134
+ for (const [key, value] of Object.entries(optionalFormDataFields ?? {})) {
135
+ if (value !== undefined) formData.append(key, value)
136
+ }
137
+ formData.append('file', file)
138
+ return formData
139
+ }
140
+
141
+ function emitProgress (onProgress, event) {
142
+ if (typeof onProgress !== 'function') return
143
+ try { onProgress(event) } catch (error) { console.error('NIP-96 progress callback failed:', error) }
144
+ }
145
+
146
+ function uploadWithXhr ({ file, serverApiUrl, nip98AuthorizationHeader, optionalFormDataFields, onProgress, signal, timeoutMs, xhrFactory }) {
147
+ return new Promise((resolve, reject) => {
148
+ const xhr = xhrFactory ? xhrFactory() : new XMLHttpRequest()
149
+ const onAbort = () => xhr.abort()
150
+ const settle = callback => value => {
151
+ signal?.removeEventListener('abort', onAbort)
152
+ callback(value)
153
+ }
154
+ const resolveUpload = settle(resolve)
155
+ const rejectUpload = settle(reject)
156
+ xhr.open('POST', serverApiUrl, true)
157
+ if (nip98AuthorizationHeader) xhr.setRequestHeader('Authorization', nip98AuthorizationHeader)
158
+ if (Number.isFinite(timeoutMs) && timeoutMs >= 0) xhr.timeout = timeoutMs
159
+ xhr.upload.addEventListener('progress', event => emitProgress(onProgress, event))
160
+ xhr.addEventListener('abort', () => rejectUpload(signal?.reason ?? new DOMException('This operation was aborted', 'AbortError')))
161
+ xhr.addEventListener('timeout', () => rejectUpload(new DOMException('NIP-96 request timed out', 'TimeoutError')))
162
+ xhr.addEventListener('error', () => rejectUpload(new Error('NIP-96 upload failed')))
163
+ xhr.addEventListener('load', () => {
164
+ if (xhr.status < 200 || xhr.status >= 300) return rejectUpload(uploadError(xhr.status))
165
+ let data
166
+ try { data = JSON.parse(xhr.responseText) } catch (cause) {
167
+ return rejectUpload(new ValidationError('INVALID_UPLOAD_RESPONSE_JSON', { cause }))
168
+ }
169
+ try {
170
+ resolveUpload(assertValidFileUploadResponse(data))
171
+ } catch (error) {
172
+ rejectUpload(error)
173
+ }
174
+ })
175
+ if (signal?.aborted) return rejectUpload(signal.reason ?? new DOMException('This operation was aborted', 'AbortError'))
176
+ signal?.addEventListener('abort', onAbort, { once: true })
177
+ xhr.send(makeFormData(file, optionalFormDataFields))
178
+ })
179
+ }
180
+
181
+ export async function uploadFile ({
182
+ file,
183
+ serverApiUrl,
184
+ nip98AuthorizationHeader,
185
+ optionalFormDataFields = {},
186
+ onProgress,
187
+ signal,
188
+ timeoutMs = 30000,
189
+ fetch: fetchImpl,
190
+ xhrFactory
191
+ }) {
192
+ if (!file || !isValidHttpUrl(serverApiUrl)) throw new ValidationError('INVALID_UPLOAD_ARGUMENTS')
193
+ if (!fetchImpl && (xhrFactory || typeof XMLHttpRequest === 'function') && typeof onProgress === 'function') {
194
+ return uploadWithXhr({ file, serverApiUrl, nip98AuthorizationHeader, optionalFormDataFields, onProgress, signal, timeoutMs, xhrFactory })
195
+ }
196
+
197
+ const total = Number.isFinite(file.size) ? file.size : 0
198
+ emitProgress(onProgress, { lengthComputable: Number.isFinite(file.size), loaded: 0, total })
199
+ let result
200
+ try {
201
+ result = await fetchJson(serverApiUrl, {
202
+ method: 'POST',
203
+ headers: nip98AuthorizationHeader ? { Authorization: nip98AuthorizationHeader } : {},
204
+ body: makeFormData(file, optionalFormDataFields)
205
+ }, { fetch: fetchImpl, signal, timeoutMs })
206
+ } catch (error) {
207
+ if (error.status) throw uploadError(error.status)
208
+ throw error
209
+ }
210
+ assertValidFileUploadResponse(result.data)
211
+ emitProgress(onProgress, { lengthComputable: Number.isFinite(file.size), loaded: total, total })
212
+ return result.data
213
+ }
214
+
215
+ export function generateDownloadUrl ({ fileHash, serverDownloadUrl, fileExtension = '' }) {
216
+ if (typeof fileHash !== 'string' || !isValidHttpUrl(serverDownloadUrl) || typeof fileExtension !== 'string') {
217
+ throw new ValidationError('INVALID_DOWNLOAD_ARGUMENTS')
218
+ }
219
+ return `${serverDownloadUrl.replace(/\/$/, '')}/${fileHash}${fileExtension}`
220
+ }
221
+
222
+ export async function deleteFile ({ fileHash, serverApiUrl, nip98AuthorizationHeader, fetch, signal, timeoutMs = 10000 }) {
223
+ const url = generateDownloadUrl({ fileHash, serverDownloadUrl: serverApiUrl })
224
+ const result = await fetchJson(url, {
225
+ method: 'DELETE',
226
+ headers: nip98AuthorizationHeader ? { Authorization: nip98AuthorizationHeader } : {}
227
+ }, { fetch, signal, timeoutMs })
228
+ return result.data
229
+ }
230
+
231
+ function delayedProcessingResponseError (response) {
232
+ if (!response || typeof response !== 'object' || Array.isArray(response)) return 'INVALID_NIP96_DELAYED_RESPONSE'
233
+ if (!['processing', 'error'].includes(response.status)) return 'INVALID_NIP96_DELAYED_STATUS'
234
+ if (typeof response.message !== 'string') return 'INVALID_NIP96_DELAYED_MESSAGE'
235
+ if (typeof response.percentage !== 'number' || !Number.isFinite(response.percentage) ||
236
+ response.percentage < 0 || response.percentage > 100) return 'INVALID_NIP96_DELAYED_PERCENTAGE'
237
+ return null
238
+ }
239
+
240
+ export function isValidDelayedProcessingResponse (response) {
241
+ return delayedProcessingResponseError(response) === null
242
+ }
243
+
244
+ export function assertValidDelayedProcessingResponse (response) {
245
+ const code = delayedProcessingResponseError(response)
246
+ if (code) throw new ValidationError(code)
247
+ return response
248
+ }
249
+
250
+ export async function checkFileProcessingStatus ({ processingUrl, fetch, signal, timeoutMs = 10000 }) {
251
+ if (!isValidHttpUrl(processingUrl)) throw new ValidationError('INVALID_PROCESSING_URL')
252
+ const { response, data } = await fetchJson(processingUrl, {}, { fetch, signal, timeoutMs })
253
+ if (response.status === 201) return assertValidFileUploadResponse(data)
254
+ if (response.status === 200) return assertValidDelayedProcessingResponse(data)
255
+ throw new ValidationError('INVALID_PROCESSING_RESPONSE')
256
+ }
257
+
258
+ export function generateFSPEventTemplate ({ serverUrls, createdAt = Math.floor(Date.now() / 1000) }) {
259
+ if (!Array.isArray(serverUrls)) throw new ValidationError('SERVER_URLS_SHOULD_BE_AN_ARRAY')
260
+ return {
261
+ kind: FILE_SERVER_PREFERENCE,
262
+ content: '',
263
+ tags: serverUrls.filter(isValidHttpUrl).map(serverUrl => ['server', serverUrl]),
264
+ created_at: createdAt
265
+ }
266
+ }
267
+
268
+ export async function calculateFileHash (file) {
269
+ if (!file || typeof file.stream !== 'function') {
270
+ if (!file || typeof file.arrayBuffer !== 'function') throw new ValidationError('FILE_SHOULD_BE_A_BLOB')
271
+ return bytesToBase16(sha256(new Uint8Array(await file.arrayBuffer())))
272
+ }
273
+ const hash = sha256.create()
274
+ const reader = file.stream().getReader()
275
+ try {
276
+ while (true) {
277
+ const { done, value } = await reader.read()
278
+ if (done) break
279
+ hash.update(value)
280
+ }
281
+ return bytesToBase16(hash.digest())
282
+ } finally {
283
+ reader.releaseLock()
284
+ }
285
+ }
package/nip98/index.js ADDED
@@ -0,0 +1,56 @@
1
+ import { sha256 } from '@noble/hashes/sha2.js'
2
+
3
+ import { bytesToBase16 } from '../base16/index.js'
4
+ import { bytesToBase64 } from '../base64/index.js'
5
+ import { ValidationError } from '../error/index.js'
6
+ import { isValidEvent } from '../event/index.js'
7
+ import { HTTP_AUTH } from '../kind/index.js'
8
+
9
+ const encoder = new TextEncoder()
10
+ const PAYLOAD_HASH = /^[0-9a-f]{64}$/
11
+
12
+ async function payloadBytes (payload) {
13
+ if (typeof payload === 'string') return encoder.encode(payload)
14
+ if (payload instanceof Uint8Array) return payload
15
+ if (payload instanceof ArrayBuffer) return new Uint8Array(payload)
16
+ if (ArrayBuffer.isView(payload)) return new Uint8Array(payload.buffer, payload.byteOffset, payload.byteLength)
17
+ if (typeof Blob === 'function' && payload instanceof Blob) return new Uint8Array(await payload.arrayBuffer())
18
+ throw new ValidationError('INVALID_PAYLOAD')
19
+ }
20
+
21
+ export async function getToken ({
22
+ loginUrl,
23
+ httpMethod,
24
+ signEvent,
25
+ includeAuthorizationScheme = false,
26
+ payload,
27
+ payloadHash
28
+ }) {
29
+ if (typeof loginUrl !== 'string' || loginUrl.length === 0) throw new ValidationError('INVALID_LOGIN_URL')
30
+ try { new URL(loginUrl) } catch (cause) { throw new ValidationError('INVALID_LOGIN_URL', { cause }) }
31
+ if (typeof httpMethod !== 'string' || httpMethod.trim().length === 0) throw new ValidationError('INVALID_HTTP_METHOD')
32
+ if (typeof signEvent !== 'function') throw new ValidationError('SIGN_EVENT_SHOULD_BE_A_FUNCTION')
33
+ if (payload !== undefined && payloadHash !== undefined) throw new ValidationError('PAYLOAD_AND_HASH_ARE_MUTUALLY_EXCLUSIVE')
34
+
35
+ let hash = payloadHash
36
+ if (payload !== undefined) hash = bytesToBase16(sha256(await payloadBytes(payload)))
37
+ if (hash !== undefined && !PAYLOAD_HASH.test(hash)) throw new ValidationError('INVALID_PAYLOAD_HASH')
38
+
39
+ const method = httpMethod.trim().toUpperCase()
40
+ const tags = [['u', loginUrl], ['method', method]]
41
+ if (hash !== undefined) tags.push(['payload', hash])
42
+ const event = await signEvent({
43
+ kind: HTTP_AUTH,
44
+ created_at: Math.floor(Date.now() / 1000),
45
+ tags,
46
+ content: ''
47
+ })
48
+ if (!isValidEvent(event) || event.kind !== HTTP_AUTH) throw new ValidationError('INVALID_SIGNED_HTTP_AUTH_EVENT')
49
+ if (!event.tags.some(tag => tag[0] === 'u' && tag[1] === loginUrl) ||
50
+ !event.tags.some(tag => tag[0] === 'method' && tag[1] === method) ||
51
+ (hash !== undefined && !event.tags.some(tag => tag[0] === 'payload' && tag[1] === hash))) {
52
+ throw new ValidationError('SIGNED_HTTP_AUTH_EVENT_WAS_CHANGED')
53
+ }
54
+ const token = bytesToBase64(encoder.encode(JSON.stringify(event)))
55
+ return includeAuthorizationScheme ? `Nostr ${token}` : token
56
+ }