libp2r2p 0.7.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.
- package/README.md +112 -8
- package/base16/index.js +6 -3
- package/base36/index.js +12 -8
- package/base62/index.js +15 -11
- package/base64/index.js +18 -2
- package/base93/index.js +19 -7
- package/content-key/event/index.js +40 -12
- package/double-dh/index.js +21 -6
- package/ecdh/index.js +12 -1
- package/error/index.js +32 -0
- package/event/helpers/serialize.js +31 -0
- package/event/index.js +116 -0
- package/i18n/index.js +15 -13
- package/idb/index.js +5 -3
- package/idb-queue/index.js +21 -20
- package/index.js +10 -0
- package/key/index.js +31 -18
- package/kind/index.js +244 -0
- package/nip04/index.js +47 -0
- package/nip05/index.js +61 -0
- package/nip19/index.js +176 -43
- package/nip44/helpers.js +95 -0
- package/nip44/index.js +14 -0
- package/nip44-v3/index.js +35 -16
- package/nip46/helpers/frame.js +6 -6
- package/nip46/helpers/url.js +8 -6
- package/nip46/services/bunker-signer.js +7 -6
- package/nip46/services/client.js +8 -7
- package/nip46/services/server-session.js +8 -7
- package/nip46/services/transport.js +9 -8
- package/nip96/index.js +285 -0
- package/nip98/index.js +56 -0
- package/nwt/index.js +241 -0
- package/package.json +22 -3
- package/private-channel/helpers/chunks.js +7 -6
- package/private-channel/helpers/event.js +5 -4
- package/private-channel/index.js +63 -61
- package/private-channel/services/received-chunks.js +4 -3
- package/private-message/index.js +32 -31
- package/private-messenger/index.js +313 -54
- package/private-messenger/recovery/index.js +15 -14
- package/private-messenger/services/channel-state.js +28 -7
- package/private-messenger/services/storage-maintenance.js +142 -46
- package/relay/helpers/hll.js +3 -1
- package/relay/services/query.js +3 -3
- package/relay/services/relay-connection.js +222 -121
- package/relay/services/relay-pool.js +13 -10
- package/temporary-storage/index.js +3 -1
- package/url/index.js +131 -0
- package/web-storage-queue/index.js +8 -6
package/nip46/helpers/url.js
CHANGED
|
@@ -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
|
|
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 (!
|
|
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
|
|
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 (!
|
|
62
|
-
throw new
|
|
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:' || !
|
|
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 {
|
|
2
|
-
import {
|
|
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 (!
|
|
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
|
|
28
|
+
} catch (cause) {
|
|
29
|
+
throw new ValidationError('NIP46_INVALID_SIGNED_EVENT', { cause })
|
|
29
30
|
}
|
|
30
|
-
if (!
|
|
31
|
+
if (!isValidEvent(signed)) throw new ValidationError('NIP46_INVALID_SIGNED_EVENT')
|
|
31
32
|
return signed
|
|
32
33
|
}
|
|
33
34
|
|
package/nip46/services/client.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
14
|
+
isValidRequestFrame
|
|
14
15
|
} from '../helpers/frame.js'
|
|
15
16
|
import { normalizeBunkerPointer, parseNostrConnectURI } from '../helpers/url.js'
|
|
16
|
-
import {
|
|
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
|
|
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
|
|
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 ||
|
|
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 (!
|
|
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
|
-
|
|
7
|
+
isValidRequestFrame
|
|
7
8
|
} from '../helpers/frame.js'
|
|
8
|
-
import {
|
|
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
|
|
52
|
-
if (typeof secret !== 'string') throw new
|
|
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
|
|
89
|
-
if (
|
|
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 (!
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
78
|
-
if (!relayPool?.getLiveEventsGenerator || !relayPool?.sendEvent) throw new
|
|
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
|
|
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
|
|
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
|
+
}
|