libp2r2p 0.10.3 → 0.10.5

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
@@ -264,9 +264,18 @@ import {
264
264
  import { generateSecretKey, getPublicKey } from 'libp2r2p/key'
265
265
  import { eventKinds, classifyKind } from 'libp2r2p/kind'
266
266
  import * as nip44 from 'libp2r2p/nip44'
267
- import { assertValidPublicRelayUrl, normalizeRelayUrl } from 'libp2r2p/url'
267
+ import {
268
+ assertValidPublicBlossomServerUrl,
269
+ assertValidPublicRelayUrl,
270
+ normalizeBlossomServerUrl,
271
+ normalizeRelayUrl
272
+ } from 'libp2r2p/url'
268
273
  ```
269
274
 
275
+ Blossom server normalization accepts root `http:` origins for local
276
+ development. Public Blossom validation additionally requires `https:` and a
277
+ public host, and rejects credentials, paths, query strings and fragments.
278
+
270
279
  `classifyEvent()` from `libp2r2p/event` combines the exact NIP-01 kind
271
280
  ranges with tag-defined behavior. The first `d` tag may add `replaceable` or
272
281
  `addressable`, while an `expiration` tag equal to `created_at` adds
@@ -278,6 +287,12 @@ NIP-44 v2 uses the interoperable `nip44-v2` salt by default. A custom UTF-8
278
287
  salt of at most 32 bytes may be passed to `getConversationKey()`, but messages
279
288
  derived with it are not interoperable with standard NIP-44 implementations.
280
289
 
290
+ NIP-46 clients and bunker signers use a 30-second operation timeout by
291
+ default. Set `timeout` in the `Nip46Client`/`BunkerSigner` constructor to
292
+ choose another default, override it for an individual `connect()` or RPC, or
293
+ pass `timeout: null` explicitly when an operation is intentionally allowed to
294
+ wait indefinitely.
295
+
281
296
  Nostr Web Tokens are available from `libp2r2p/nwt`. Creation returns a signed
282
297
  kind `27519` event, while transport encoding is kept separate:
283
298
 
@@ -129,7 +129,9 @@ export class Nip46Client {
129
129
  get pointer () { return { ...this.#pointer, relays: [...this.#pointer.relays] } }
130
130
 
131
131
  // Connects and immediately asks the remote signer for its preferred relays.
132
- async connect ({ requestedPermissions = [], clientMetadata, timeout = null, signal } = {}) {
132
+ // An omitted timeout inherits the configurable constructor timeout; null
133
+ // explicitly disables the response deadline.
134
+ async connect ({ requestedPermissions = [], clientMetadata, timeout, signal } = {}) {
133
135
  const permissions = Array.isArray(requestedPermissions)
134
136
  ? requestedPermissions.filter(permission => typeof permission === 'string' && permission).join(',')
135
137
  : ''
@@ -59,7 +59,7 @@ export class Nip46Transport {
59
59
  #secretKey
60
60
  #pubkey
61
61
  #relayPool
62
- #networkTimeout
62
+ #operationTimeout
63
63
  #timeoutAfterFirstEose
64
64
  #onError
65
65
  #contexts = new Set()
@@ -80,7 +80,7 @@ export class Nip46Transport {
80
80
  this.#secretKey = secretKey
81
81
  this.#pubkey = getPublicKey(secretKey)
82
82
  this.#relayPool = relayPool
83
- this.#networkTimeout = networkTimeout
83
+ this.#operationTimeout = networkTimeout
84
84
  this.#timeoutAfterFirstEose = timeoutAfterFirstEose
85
85
  this.#onError = onError
86
86
  }
@@ -111,7 +111,7 @@ export class Nip46Transport {
111
111
  return context
112
112
  }
113
113
 
114
- async awaitContextReady (context, { timeout = this.#networkTimeout, signal } = {}) {
114
+ async awaitContextReady (context, { timeout = this.#operationTimeout, signal } = {}) {
115
115
  const report = await waitForNip46(context.stream.ready, {
116
116
  timeout,
117
117
  signal,
@@ -159,11 +159,14 @@ export class Nip46Transport {
159
159
  return true
160
160
  }
161
161
 
162
- async sendRequest (peerPubkey, method, params = [], {
163
- timeout = null,
164
- signal,
165
- extension
166
- } = {}) {
162
+ async sendRequest (peerPubkey, method, params = [], options = {}) {
163
+ const {
164
+ // Undefined inherits the constructor-wide operation timeout. Passing
165
+ // null explicitly is the opt-out for RPCs that may wait indefinitely.
166
+ timeout = this.#operationTimeout,
167
+ signal,
168
+ extension
169
+ } = options
167
170
  if (this.#closed) throw new Error('NIP46_CLOSED')
168
171
  if (typeof method !== 'string' || !method) throw new ValidationError('NIP46_METHOD_REQUIRED')
169
172
  if (!Array.isArray(params) || !params.every(param => typeof param === 'string')) {
@@ -229,7 +232,7 @@ export class Nip46Transport {
229
232
  if (!relays.length) throw new Error('NIP46_NO_READY_RELAYS')
230
233
  const event = createNip46Event({ secretKey: this.#secretKey, recipientPubkey: peerPubkey, payload })
231
234
  const published = await this.#relayPool.sendEvent(event, relays, {
232
- timeout: this.#networkTimeout,
235
+ timeout: this.#operationTimeout,
233
236
  timeoutUntilFirstFulfillment: null
234
237
  })
235
238
  if (!published.success) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.10.3",
3
+ "version": "0.10.5",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
package/url/index.js CHANGED
@@ -53,6 +53,30 @@ function removeEmptyQuerySegments (url) {
53
53
  url.searchParams.sort()
54
54
  }
55
55
 
56
+ function publicHostError (url, {
57
+ invalidHostCode,
58
+ nonPublicHostCode,
59
+ nonPublicIpCode
60
+ }) {
61
+ const hostname = url.hostname.toLowerCase().replace(/\.+$/, '')
62
+ if (
63
+ hostname === 'localhost' ||
64
+ hostname.endsWith('.localhost') ||
65
+ hostname.endsWith('.local') ||
66
+ hostname.endsWith('.onion')
67
+ ) return nonPublicHostCode
68
+
69
+ const unwrappedHostname = hostname.replace(/^\[|\]$/g, '')
70
+ if (isIpv4Address(unwrappedHostname)) {
71
+ if (!isPublicIpv4Address(unwrappedHostname)) return nonPublicIpCode
72
+ } else if (unwrappedHostname.includes(':')) {
73
+ if (!isPublicIpv6Address(unwrappedHostname)) return nonPublicIpCode
74
+ } else if (!hostname.includes('.')) {
75
+ return invalidHostCode
76
+ }
77
+ return null
78
+ }
79
+
56
80
  export function normalizeRelayUrl (value) {
57
81
  if (typeof value !== 'string' || value.trim().length === 0) {
58
82
  throw new ValidationError('INVALID_RELAY_URL', { message: 'URL_SHOULD_BE_A_STRING' })
@@ -76,6 +100,36 @@ export function normalizeRelayUrl (value) {
76
100
  return url.toString().replace(/^(wss?:\/\/[^/?#]+)\/(?=[?#]|$)/, '$1')
77
101
  }
78
102
 
103
+ // Canonicalizes the origin used by the root-level Blossom HTTP endpoints.
104
+ export function normalizeBlossomServerUrl (value) {
105
+ if (typeof value !== 'string' || value.trim().length === 0) {
106
+ throw new ValidationError('INVALID_BLOSSOM_SERVER_URL', { message: 'URL_SHOULD_BE_A_STRING' })
107
+ }
108
+ const input = value.trim()
109
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(input)) {
110
+ throw new ValidationError('INVALID_BLOSSOM_SERVER_URL', { message: 'ABSOLUTE_URL_REQUIRED' })
111
+ }
112
+ let url
113
+ try {
114
+ url = new URL(input)
115
+ } catch (cause) {
116
+ throw new ValidationError('INVALID_BLOSSOM_SERVER_URL', { cause })
117
+ }
118
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
119
+ throw new ValidationError('INVALID_BLOSSOM_SERVER_PROTOCOL')
120
+ }
121
+ if (url.username || url.password) {
122
+ throw new ValidationError('BLOSSOM_SERVER_URL_CREDENTIALS_NOT_ALLOWED')
123
+ }
124
+ if (!/^\/*$/.test(url.pathname)) {
125
+ throw new ValidationError('BLOSSOM_SERVER_URL_PATH_NOT_ALLOWED')
126
+ }
127
+ if (url.search) throw new ValidationError('BLOSSOM_SERVER_URL_QUERY_NOT_ALLOWED')
128
+ if (url.hash) throw new ValidationError('BLOSSOM_SERVER_URL_FRAGMENT_NOT_ALLOWED')
129
+ url.hostname = url.hostname.replace(/\.+$/, '')
130
+ return url.origin
131
+ }
132
+
79
133
  function publicRelayUrlError (value) {
80
134
  if (typeof value !== 'string' || value.trim().length === 0) return 'INVALID_RELAY_URL'
81
135
  let input = value.trim()
@@ -97,22 +151,12 @@ function publicRelayUrlError (value) {
97
151
  if (url.protocol !== 'wss:') return 'INSECURE_RELAY_URL'
98
152
  if (url.username || url.password) return 'RELAY_URL_CREDENTIALS_NOT_ALLOWED'
99
153
 
100
- const hostname = url.hostname.toLowerCase()
101
- if (
102
- hostname === 'localhost' ||
103
- hostname.endsWith('.localhost') ||
104
- hostname.endsWith('.local') ||
105
- hostname.endsWith('.onion')
106
- ) return 'NON_PUBLIC_RELAY_HOST'
107
-
108
- const unwrappedHostname = hostname.replace(/^\[|\]$/g, '')
109
- if (isIpv4Address(unwrappedHostname)) {
110
- if (!isPublicIpv4Address(unwrappedHostname)) return 'NON_PUBLIC_RELAY_IP'
111
- } else if (unwrappedHostname.includes(':')) {
112
- if (!isPublicIpv6Address(unwrappedHostname)) return 'NON_PUBLIC_RELAY_IP'
113
- } else if (!hostname.includes('.')) {
114
- return 'INVALID_RELAY_HOST'
115
- }
154
+ const hostError = publicHostError(url, {
155
+ invalidHostCode: 'INVALID_RELAY_HOST',
156
+ nonPublicHostCode: 'NON_PUBLIC_RELAY_HOST',
157
+ nonPublicIpCode: 'NON_PUBLIC_RELAY_IP'
158
+ })
159
+ if (hostError) return hostError
116
160
 
117
161
  const lowerValue = normalized.toLowerCase()
118
162
  if (lowerValue.includes('npub1') || lowerValue.includes('nprofile1')) return 'RELAY_URL_NOSTR_ENTITY_NOT_ALLOWED'
@@ -129,3 +173,29 @@ export function assertValidPublicRelayUrl (value) {
129
173
  if (code) throw new ValidationError(code)
130
174
  return value
131
175
  }
176
+
177
+ function publicBlossomServerUrlError (value) {
178
+ let normalized
179
+ try {
180
+ normalized = normalizeBlossomServerUrl(value)
181
+ } catch (error) {
182
+ return error instanceof ValidationError ? error.code : 'INVALID_BLOSSOM_SERVER_URL'
183
+ }
184
+ const url = new URL(normalized)
185
+ if (url.protocol !== 'https:') return 'INSECURE_BLOSSOM_SERVER_URL'
186
+ return publicHostError(url, {
187
+ invalidHostCode: 'INVALID_BLOSSOM_SERVER_HOST',
188
+ nonPublicHostCode: 'NON_PUBLIC_BLOSSOM_SERVER_HOST',
189
+ nonPublicIpCode: 'NON_PUBLIC_BLOSSOM_SERVER_IP'
190
+ })
191
+ }
192
+
193
+ export function isValidPublicBlossomServerUrl (value) {
194
+ return publicBlossomServerUrlError(value) === null
195
+ }
196
+
197
+ export function assertValidPublicBlossomServerUrl (value) {
198
+ const code = publicBlossomServerUrlError(value)
199
+ if (code) throw new ValidationError(code)
200
+ return value
201
+ }