libp2r2p 0.10.9 → 0.10.10

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
@@ -342,13 +342,17 @@ are handled by `libp2r2p/nip27`: `decodeUserReference()` returns the decoded
342
342
  form with its canonical compact spelling, `encodeUserReference()` returns
343
343
  that canonical spelling, and `resolveUserReference()` resolves it to a
344
344
  pubkey. `libp2r2p/nip05` keeps only `queryProfile()`, the NIP-05 lookup, which
345
- accepts the compact custom forms directly.
345
+ accepts the compact custom forms directly. The decoders (`decodeReference`,
346
+ `decodeMediaMetadata`, `decodeUserReference`, `decodeAppUrl`) throw
347
+ `ValidationError` with a stable code; each has a `tryDecode…` counterpart
348
+ that returns `null` when the value cannot be decoded.
346
349
 
347
350
  Public validity checks consistently use a non-throwing `is…` predicate plus an
348
351
  `assert…` counterpart when callers need the exact reason. Strict codecs,
349
352
  decoders, token validation, and malformed public arguments also throw
350
- `ValidationError` from `libp2r2p/error`. Network, timeout, abort, quota, and
351
- closed-state failures remain ordinary operational errors.
353
+ `ValidationError` from `libp2r2p/error`; probing code can use the
354
+ non-throwing `tryDecode…` variants instead of catching. Network, timeout,
355
+ abort, quota, and closed-state failures remain ordinary operational errors.
352
356
 
353
357
  NIP-04 remains available at
354
358
  `libp2r2p/nip04` only for compatibility with older Nostr applications.
@@ -1,3 +1,5 @@
1
+ import { ValidationError } from '../../error/index.js'
2
+
1
3
  const NIP05_LOCAL = /^[a-z0-9._-]+$/
2
4
  const NIP05_DOMAIN = /^[a-z0-9.-]+$/
3
5
 
@@ -26,23 +28,36 @@ export function nip05FromLocalDomain (local, domain) {
26
28
  // - `local@domain` (standard NIP-05)
27
29
  // - `domain` with exactly one dot -> root `_@domain`
28
30
  // - `local.domain...` with more than one dot -> local part + domain (custom extension)
31
+ // Throws `ValidationError('INVALID_NIP05_IDENTIFIER')` for malformed input.
29
32
  export function decodeNip05Identifier (value) {
30
- if (typeof value !== 'string') return null
33
+ if (typeof value !== 'string' || !value.trim()) {
34
+ throw new ValidationError('INVALID_NIP05_IDENTIFIER', { message: 'IDENTIFIER_SHOULD_BE_A_NON_EMPTY_STRING' })
35
+ }
31
36
  const text = value.trim().toLowerCase()
32
- if (!text) return null
33
37
 
34
38
  const at = text.lastIndexOf('@')
35
39
  if (at !== -1) {
36
- if (at === 0 || at === text.length - 1 || text.includes('@', at + 1)) return null
37
- return nip05FromLocalDomain(text.slice(0, at), text.slice(at + 1))
40
+ if (at === 0 || at === text.length - 1 || text.includes('@', at + 1)) {
41
+ throw new ValidationError('INVALID_NIP05_IDENTIFIER', { message: 'INVALID_NIP05_FORMAT' })
42
+ }
43
+ const result = nip05FromLocalDomain(text.slice(0, at), text.slice(at + 1))
44
+ if (!result) {
45
+ throw new ValidationError('INVALID_NIP05_IDENTIFIER', { message: 'INVALID_NIP05_LOCAL_OR_DOMAIN' })
46
+ }
47
+ return result
38
48
  }
39
49
 
40
- if (!text.includes('.')) return null
50
+ if (!text.includes('.')) {
51
+ throw new ValidationError('INVALID_NIP05_IDENTIFIER', { message: 'INVALID_NIP05_FORMAT' })
52
+ }
41
53
  const firstDot = text.indexOf('.')
42
- if (text.slice(firstDot + 1).includes('.')) {
43
- return nip05FromLocalDomain(text.slice(0, firstDot), text.slice(firstDot + 1))
54
+ const result = text.slice(firstDot + 1).includes('.')
55
+ ? nip05FromLocalDomain(text.slice(0, firstDot), text.slice(firstDot + 1))
56
+ : nip05FromLocalDomain('_', text)
57
+ if (!result) {
58
+ throw new ValidationError('INVALID_NIP05_IDENTIFIER', { message: 'INVALID_NIP05_LOCAL_OR_DOMAIN' })
44
59
  }
45
- return nip05FromLocalDomain('_', text)
60
+ return result
46
61
  }
47
62
 
48
63
  // Returns the most compact unambiguous NIP-05 spelling:
package/nip05/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { ValidationError } from '../error/index.js'
1
2
  import { normalizeRelayUrl } from '../url/index.js'
2
3
  import { decodeNip05Identifier } from './helpers/nip05-identifier.js'
3
4
 
@@ -8,9 +9,11 @@ export async function queryProfile (identifier, {
8
9
  signal,
9
10
  timeoutMs = 5000
10
11
  } = {}) {
11
- if (typeof identifier !== 'string' || typeof fetchImpl !== 'function') return null
12
+ if (typeof identifier !== 'string' || !identifier.trim()) {
13
+ throw new ValidationError('INVALID_NIP05_IDENTIFIER', { message: 'IDENTIFIER_SHOULD_BE_A_NON_EMPTY_STRING' })
14
+ }
15
+ if (typeof fetchImpl !== 'function') return null
12
16
  const nip05 = decodeNip05Identifier(identifier)
13
- if (!nip05) return null
14
17
  const name = nip05.local
15
18
  const domain = nip05.domain
16
19
 
@@ -26,11 +26,17 @@ function stripReferencePrefix (value) {
26
26
  // Decodes a user reference without performing any network lookup.
27
27
  // Returns `{ type: 'pubkey', pubkey, relays, raw }` for npub/nprofile/hex or
28
28
  // `{ type: 'nip05', local, domain, raw }` for NIP-05 (standard or extended),
29
- // where `raw` is always the most compact canonical spelling.
29
+ // where `raw` is always the most compact canonical spelling. Throws
30
+ // `ValidationError('INVALID_USER_REFERENCE')` when the value cannot be
31
+ // decoded; use `tryDecodeUserReference` when a null result is preferred.
30
32
  export function decodeUserReference (value) {
31
- if (typeof value !== 'string') return null
33
+ if (typeof value !== 'string' || !value.trim()) {
34
+ throw new ValidationError('INVALID_USER_REFERENCE', { message: 'USER_REFERENCE_SHOULD_BE_A_NON_EMPTY_STRING' })
35
+ }
32
36
  const text = stripReferencePrefix(value)
33
- if (!text) return null
37
+ if (!text) {
38
+ throw new ValidationError('INVALID_USER_REFERENCE', { message: 'EMPTY_USER_REFERENCE' })
39
+ }
34
40
 
35
41
  if (HEX_PUBKEY.test(text)) {
36
42
  const raw = text.toLowerCase()
@@ -41,8 +47,8 @@ export function decodeUserReference (value) {
41
47
  try {
42
48
  const raw = text.toLowerCase()
43
49
  return { type: 'pubkey', pubkey: npubDecode(raw), relays: [], raw }
44
- } catch {
45
- return null
50
+ } catch (cause) {
51
+ throw new ValidationError('INVALID_USER_REFERENCE', { message: 'INVALID_NPUB', cause })
46
52
  }
47
53
  }
48
54
 
@@ -51,17 +57,32 @@ export function decodeUserReference (value) {
51
57
  const raw = text.toLowerCase()
52
58
  const { pubkey, relays } = nprofileDecode(raw)
53
59
  return { type: 'pubkey', pubkey, relays, raw }
54
- } catch {
55
- return null
60
+ } catch (cause) {
61
+ throw new ValidationError('INVALID_USER_REFERENCE', { message: 'INVALID_NPROFILE', cause })
56
62
  }
57
63
  }
58
64
 
59
- const nip05 = decodeNip05Identifier(text)
60
- if (!nip05) return null
65
+ let nip05
66
+ try {
67
+ nip05 = decodeNip05Identifier(text)
68
+ } catch (cause) {
69
+ throw new ValidationError('INVALID_USER_REFERENCE', { message: 'INVALID_NIP05', cause })
70
+ }
61
71
  const raw = compactNip05Raw(nip05.local, nip05.domain)
62
72
  return { type: 'nip05', ...nip05, raw }
63
73
  }
64
74
 
75
+ // Non-throwing variant of `decodeUserReference`: returns the decoded
76
+ // reference or `null` when the value is not a valid user reference.
77
+ export function tryDecodeUserReference (value) {
78
+ try {
79
+ return decodeUserReference(value)
80
+ } catch (error) {
81
+ if (error instanceof ValidationError) return null
82
+ throw error
83
+ }
84
+ }
85
+
65
86
  // Returns the canonical compact spelling for a user reference, either as a
66
87
  // string or as a decoded reference object.
67
88
  export function encodeUserReference (value) {
package/nip27/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { ValidationError } from '../error/index.js'
1
2
  import {
2
3
  naddrDecode,
3
4
  neventDecode,
@@ -8,10 +9,11 @@ import { queryProfile } from '../nip05/index.js'
8
9
  import { normalizeRelayUrl } from '../url/index.js'
9
10
  import {
10
11
  decodeUserReference,
11
- encodeUserReference
12
+ encodeUserReference,
13
+ tryDecodeUserReference
12
14
  } from './helpers/user-reference.js'
13
15
 
14
- export { decodeUserReference, encodeUserReference }
16
+ export { decodeUserReference, encodeUserReference, tryDecodeUserReference }
15
17
 
16
18
  const BECH32_BODY = '[ac-hj-np-z02-9]'
17
19
  const BOUNDARY_PREFIX = /(?<=^|[\s"«„「¡¿:{([])/.source
@@ -107,18 +109,30 @@ function normalizeRelays (relays) {
107
109
  .filter(Boolean)
108
110
  }
109
111
 
112
+ function looksLikeUserReference (text) {
113
+ return /^[0-9a-f]{64}$/i.test(text) ||
114
+ /^(?:npub1|nprofile1)/i.test(text) ||
115
+ text.includes('@') ||
116
+ /^[a-z0-9._-]+(?:\.[a-z0-9-]+)+$/i.test(text)
117
+ }
118
+
110
119
  // Parses a single NIP-27-style reference (with optional `@`/`nostr:`
111
120
  // prefixes): NIP-05 (including the custom compact forms), npub/nprofile/hex
112
- // accounts, note/nevent/naddr events and nrelay relays.
121
+ // accounts, note/nevent/naddr events and nrelay relays. Throws
122
+ // `ValidationError` when the value cannot be decoded; use
123
+ // `tryDecodeReference` when a null result is preferred.
113
124
  export function decodeReference (value) {
114
- if (typeof value !== 'string') return null
125
+ if (typeof value !== 'string' || !value.trim()) {
126
+ throw new ValidationError('INVALID_REFERENCE', { message: 'REFERENCE_SHOULD_BE_A_NON_EMPTY_STRING' })
127
+ }
115
128
  const original = value.trim()
116
- if (!original) return null
117
129
  const text = stripReferencePrefix(original)
118
- if (!text) return null
130
+ if (!text) {
131
+ throw new ValidationError('INVALID_REFERENCE', { message: 'EMPTY_REFERENCE' })
132
+ }
119
133
 
120
- const account = decodeUserReference(text)
121
- if (account) {
134
+ if (looksLikeUserReference(text)) {
135
+ const account = decodeUserReference(text)
122
136
  return account.type === 'pubkey'
123
137
  ? {
124
138
  type: 'pubkey',
@@ -137,34 +151,29 @@ export function decodeReference (value) {
137
151
  }
138
152
 
139
153
  if (text.startsWith('note1')) {
140
- try {
141
- return { type: 'note', original, value: text, id: noteDecode(text) }
142
- } catch {
143
- return null
144
- }
154
+ return { type: 'note', original, value: text, id: noteDecode(text) }
145
155
  }
146
156
  if (text.startsWith('nevent1')) {
147
- try {
148
- return { type: 'nevent', original, value: text, ...neventDecode(text) }
149
- } catch {
150
- return null
151
- }
157
+ return { type: 'nevent', original, value: text, ...neventDecode(text) }
152
158
  }
153
159
  if (text.startsWith('naddr1')) {
154
- try {
155
- return { type: 'naddr', original, value: text, ...naddrDecode(text) }
156
- } catch {
157
- return null
158
- }
160
+ return { type: 'naddr', original, value: text, ...naddrDecode(text) }
159
161
  }
160
162
  if (text.startsWith('nrelay1')) {
161
- try {
162
- return { type: 'nrelay', original, value: text, relay: nrelayDecode(text) }
163
- } catch {
164
- return null
165
- }
163
+ return { type: 'nrelay', original, value: text, relay: nrelayDecode(text) }
164
+ }
165
+ throw new ValidationError('INVALID_REFERENCE', { message: 'UNRECOGNIZED_REFERENCE' })
166
+ }
167
+
168
+ // Non-throwing variant of `decodeReference`: returns the decoded reference
169
+ // or `null` when the value is not a valid reference.
170
+ export function tryDecodeReference (value) {
171
+ try {
172
+ return decodeReference(value)
173
+ } catch (error) {
174
+ if (error instanceof ValidationError) return null
175
+ throw error
166
176
  }
167
- return null
168
177
  }
169
178
 
170
179
  const NIP94_TAGS = {
@@ -185,11 +194,33 @@ const NIP94_TAGS = {
185
194
  caption: ['caption']
186
195
  }
187
196
 
197
+ function validateTagConfigs (extraTags) {
198
+ if (!extraTags || typeof extraTags !== 'object' || Array.isArray(extraTags)) {
199
+ throw new ValidationError('INVALID_MEDIA_METADATA_TAGS', { message: 'EXTRA_TAGS_SHOULD_BE_AN_OBJECT' })
200
+ }
201
+ for (const [key, config] of Object.entries(extraTags)) {
202
+ const valid = Array.isArray(config) && config.length > 0 && config.every(entry =>
203
+ typeof entry === 'string' ||
204
+ (entry && typeof entry === 'object' && typeof entry.key === 'string' &&
205
+ (entry.type === undefined || entry.type === 'string' || entry.type === 'array'))
206
+ )
207
+ if (!valid) {
208
+ throw new ValidationError('INVALID_MEDIA_METADATA_TAGS', { message: `INVALID_TAG_CONFIG:${key}` })
209
+ }
210
+ }
211
+ }
212
+
188
213
  // Decodes the file/media metadata carried in a URL fragment
189
214
  // (`#m=image/png&dim=640x480&alt=...`). Kept generic on purpose: the old
190
- // draft number (54) was taken by an unrelated NIP.
215
+ // draft number (54) was taken by an unrelated NIP. Throws
216
+ // `ValidationError` for invalid URLs, tag configs or malformed `dim` values;
217
+ // use `tryDecodeMediaMetadata` when a null result is preferred. A URL
218
+ // without a metadata fragment still decodes to `{}`.
191
219
  export function decodeMediaMetadata (url, { extraTags } = {}) {
192
- if (typeof url !== 'string' || !url) return {}
220
+ if (typeof url !== 'string' || !url.trim()) {
221
+ throw new ValidationError('INVALID_MEDIA_METADATA_URL', { message: 'URL_SHOULD_BE_A_NON_EMPTY_STRING' })
222
+ }
223
+ if (extraTags !== undefined) validateTagConfigs(extraTags)
193
224
 
194
225
  const tags = extraTags ? { ...NIP94_TAGS, ...extraTags } : NIP94_TAGS
195
226
  const tagIndexes = {}
@@ -217,12 +248,26 @@ export function decodeMediaMetadata (url, { extraTags } = {}) {
217
248
  const { width, height } = obj.dim.match(
218
249
  /(?<width>[1-9]{1}[0-9]{0,10})(?:\s*[xX]\s*)(?<height>[1-9]{1}[0-9]{0,10})/
219
250
  )?.groups ?? {}
220
- if (width !== undefined) obj.width = width
221
- if (height !== undefined) obj.height = height
251
+ if (width === undefined || height === undefined) {
252
+ throw new ValidationError('INVALID_MEDIA_METADATA_DIM', { message: 'DIM_SHOULD_BE_WIDTHxHEIGHT' })
253
+ }
254
+ obj.width = width
255
+ obj.height = height
222
256
  }
223
257
  return obj
224
258
  }
225
259
 
260
+ // Non-throwing variant of `decodeMediaMetadata`: returns the decoded
261
+ // metadata or `null` when the URL/tags/dim cannot be decoded.
262
+ export function tryDecodeMediaMetadata (url, options) {
263
+ try {
264
+ return decodeMediaMetadata(url, options)
265
+ } catch (error) {
266
+ if (error instanceof ValidationError) return null
267
+ throw error
268
+ }
269
+ }
270
+
226
271
  function decodeFragmentValue (value) {
227
272
  try {
228
273
  return decodeURIComponent(value.replace(/\+/g, '%20'))
@@ -234,7 +279,13 @@ function decodeFragmentValue (value) {
234
279
  function getReferenceItem (original, groups, { getMimeType }) {
235
280
  if (groups.url) {
236
281
  const url = `${groups.protocol ? '' : 'https://'}${groups.url}`
237
- const urlItem = { value: url, ...(groups.ext && { ext: groups.ext }), ...decodeMediaMetadata(url) }
282
+ let mediaMetadata = {}
283
+ try {
284
+ mediaMetadata = decodeMediaMetadata(url)
285
+ } catch (error) {
286
+ if (!(error instanceof ValidationError)) throw error
287
+ }
288
+ const urlItem = { value: url, ...(groups.ext && { ext: groups.ext }), ...mediaMetadata }
238
289
  if (!urlItem.m && typeof getMimeType === 'function') {
239
290
  const mime = getMimeType({ url, ext: groups.ext })
240
291
  if (mime) urlItem.m = mime
@@ -249,7 +300,7 @@ function getReferenceItem (original, groups, { getMimeType }) {
249
300
  groups.nip05BareRoot ||
250
301
  groups.nip05BareCustom
251
302
  ) {
252
- const account = decodeUserReference(original)
303
+ const account = tryDecodeUserReference(original)
253
304
  if (!account) return null
254
305
  return {
255
306
  key: 'nip05',
@@ -266,7 +317,13 @@ function getReferenceItem (original, groups, { getMimeType }) {
266
317
  return { key: 'hashtag', hashtag: { value: groups.hashtag } }
267
318
  }
268
319
 
269
- const ref = decodeReference(original)
320
+ let ref
321
+ try {
322
+ ref = decodeReference(original)
323
+ } catch (error) {
324
+ if (!(error instanceof ValidationError)) throw error
325
+ return null
326
+ }
270
327
  if (!ref) return null
271
328
  switch (ref.type) {
272
329
  case 'pubkey': {
@@ -320,7 +377,9 @@ function getReferenceItem (original, groups, { getMimeType }) {
320
377
  // `{ bareNip05: true }` is passed, since they are otherwise indistinguishable
321
378
  // from plain hostnames; prefixed forms (`@bob.example.com`) always work.
322
379
  export function extractMedia (content, { bareNip05 = false, getMimeType } = {}) {
323
- if (typeof content !== 'string') return []
380
+ if (typeof content !== 'string') {
381
+ throw new ValidationError('INVALID_MEDIA_CONTENT', { message: 'CONTENT_SHOULD_BE_A_STRING' })
382
+ }
324
383
  const regex = getReferencesRegex(bareNip05)
325
384
  const items = []
326
385
  let end = 0
@@ -349,7 +408,6 @@ export function extractMedia (content, { bareNip05 = false, getMimeType } = {})
349
408
  // spellings); npub/nprofile/hex are resolved locally.
350
409
  export async function resolveUserReference (value, options = {}) {
351
410
  const account = decodeUserReference(value)
352
- if (!account) return null
353
411
  if (account.type === 'pubkey') {
354
412
  return { pubkey: account.pubkey, relays: account.relays, label: account.raw }
355
413
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.10.9",
3
+ "version": "0.10.10",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -1,3 +1,4 @@
1
+ import { ValidationError } from '../../error/index.js'
1
2
  import { freeRelays } from '../constants/index.js'
2
3
  import { pickRelaysForPubkeys } from '../helpers/routing.js'
3
4
  import { relayPool } from './relay-pool.js'
@@ -99,7 +100,9 @@ export async function getLatestEventsByPubkey (pubkeys, {
99
100
  } = {}) {
100
101
  const authors = [...new Set(pubkeys || [])].filter(Boolean)
101
102
  if (!authors.length) return { events: [], byPubkey: {}, relaysByPubkey: {} }
102
- if (!Array.isArray(kinds) || kinds.length === 0) throw new Error('Missing kinds')
103
+ if (!Array.isArray(kinds) || kinds.length === 0) {
104
+ throw new ValidationError('MISSING_EVENT_KINDS', { message: 'Missing kinds' })
105
+ }
103
106
  const type = relayType === 'read' ? 'read' : 'write'
104
107
 
105
108
  const relaysByAuthor = { ...(relaysByPubkey || {}) }
package/url/app-url.js CHANGED
@@ -5,7 +5,8 @@ import {
5
5
  } from '../nip05/helpers/nip05-identifier.js'
6
6
  import {
7
7
  decodeUserReference,
8
- encodeUserReference
8
+ encodeUserReference,
9
+ tryDecodeUserReference
9
10
  } from '../nip27/helpers/user-reference.js'
10
11
  import {
11
12
  NAPP_ENTITY_REGEX,
@@ -55,8 +56,10 @@ function isValidAppName (appName) {
55
56
  // `naddr` already carries the event kind, so it does not need the `+`/`++`/
56
57
  // `+++` channel prefix (a leading prefix is still tolerated). Only site
57
58
  // manifests are app URLs; the result is canonicalized to the `appEncode`
58
- // entity so downstream code keeps working unchanged.
59
- function tryDecodeNaddr (value) {
59
+ // entity so downstream code keeps working unchanged. Throws
60
+ // `ValidationError('INVALID_APP_URL_NADDR')` for malformed naddr or
61
+ // naddr that is not a site manifest.
62
+ function decodeNaddrSegment (value) {
60
63
  if (typeof value !== 'string' || !value) return null
61
64
  const body = value.replace(/^\+{1,3}/, '')
62
65
  if (!body.startsWith(NADDR_PREFIX)) return null
@@ -64,10 +67,12 @@ function tryDecodeNaddr (value) {
64
67
  let decoded
65
68
  try {
66
69
  decoded = naddrDecode(body)
67
- } catch {
68
- return null
70
+ } catch (cause) {
71
+ throw new ValidationError('INVALID_APP_URL_NADDR', { message: 'INVALID_NADDR', cause })
72
+ }
73
+ if (!SITE_MANIFEST_KINDS.has(decoded.kind)) {
74
+ throw new ValidationError('INVALID_APP_URL_NADDR', { message: 'NOT_SITE_MANIFEST' })
69
75
  }
70
- if (!SITE_MANIFEST_KINDS.has(decoded.kind)) return null
71
76
 
72
77
  try {
73
78
  return {
@@ -79,8 +84,8 @@ function tryDecodeNaddr (value) {
79
84
  relays: decoded.relays
80
85
  })
81
86
  }
82
- } catch {
83
- return null
87
+ } catch (cause) {
88
+ throw new ValidationError('INVALID_APP_URL_NADDR', { message: 'INVALID_APP_ENTITY', cause })
84
89
  }
85
90
  }
86
91
 
@@ -90,27 +95,34 @@ function tryDecodeNaddr (value) {
90
95
  // - `{ type: 'entity', entity }` for NIP-19 app entities;
91
96
  // - `{ type: 'named', prefix, channel, appName, user }` for named URLs
92
97
  // (`user` is null when no user part is present or it is invalid);
93
- // - `null` when the segment is not a valid app URL.
98
+ // Throws `ValidationError` when the segment is not a valid app URL; use
99
+ // `tryDecodeAppUrl` when a null result is preferred.
94
100
  export function decodeAppUrl (segment) {
95
- if (typeof segment !== 'string' || !segment) return null
96
- const decodedNaddr = tryDecodeNaddr(segment)
101
+ if (typeof segment !== 'string' || !segment) {
102
+ throw new ValidationError('INVALID_APP_URL', { message: 'URL_SEGMENT_SHOULD_BE_A_NON_EMPTY_STRING' })
103
+ }
104
+ const decodedNaddr = decodeNaddrSegment(segment)
97
105
  if (decodedNaddr) return decodedNaddr
98
106
 
99
107
  const prefixMatch = segment.match(/^\+{1,3}/)
100
- if (!prefixMatch) return null
108
+ if (!prefixMatch) {
109
+ throw new ValidationError('INVALID_APP_URL', { message: 'MISSING_APP_URL_PREFIX' })
110
+ }
101
111
  const prefix = prefixMatch[0]
102
112
 
103
113
  if (NAPP_ENTITY_REGEX.test(segment)) {
104
114
  try {
105
115
  appDecode(segment)
106
- } catch {
107
- return null
116
+ } catch (cause) {
117
+ throw new ValidationError('INVALID_APP_URL_ENTITY', { message: 'INVALID_APP_ENTITY', cause })
108
118
  }
109
119
  return { type: 'entity', entity: segment }
110
120
  }
111
121
 
112
122
  const remainder = segment.slice(prefix.length)
113
- if (!remainder) return null
123
+ if (!remainder) {
124
+ throw new ValidationError('INVALID_APP_URL', { message: 'MISSING_APP_NAME' })
125
+ }
114
126
  const parts = remainder.split('@')
115
127
  let appName
116
128
  let user = null
@@ -120,7 +132,7 @@ export function decodeAppUrl (segment) {
120
132
  } else if (parts.length === 2) {
121
133
  const tail = safeDecode(parts[1])
122
134
  appName = safeDecode(parts[0])
123
- user = tail === null ? null : decodeUserReference(tail)
135
+ user = tail === null ? null : tryDecodeUserReference(tail)
124
136
  } else {
125
137
  const local = safeDecode(parts[parts.length - 2])
126
138
  const domain = safeDecode(parts[parts.length - 1])
@@ -133,8 +145,12 @@ export function decodeAppUrl (segment) {
133
145
  }
134
146
  }
135
147
 
136
- if (appName === null || !isValidAppName(appName)) return null
137
- if (!user && appName.length >= APP_URL_MIN_ENTITY_BODY_LENGTH) return null
148
+ if (appName === null || !isValidAppName(appName)) {
149
+ throw new ValidationError('INVALID_APP_URL_NAME', { message: 'Invalid app URL name' })
150
+ }
151
+ if (!user && appName.length >= APP_URL_MIN_ENTITY_BODY_LENGTH) {
152
+ throw new ValidationError('INVALID_APP_URL_ENTITY', { message: 'ENTITY_LIKE_URL_WITHOUT_USER' })
153
+ }
138
154
 
139
155
  return {
140
156
  type: 'named',
@@ -145,6 +161,17 @@ export function decodeAppUrl (segment) {
145
161
  }
146
162
  }
147
163
 
164
+ // Non-throwing variant of `decodeAppUrl`: returns the decoded app URL or
165
+ // `null` when the segment is not a valid app URL.
166
+ export function tryDecodeAppUrl (segment) {
167
+ try {
168
+ return decodeAppUrl(segment)
169
+ } catch (error) {
170
+ if (error instanceof ValidationError) return null
171
+ throw error
172
+ }
173
+ }
174
+
148
175
  // Encodes a named app URL segment. `user` is a decoded user reference:
149
176
  // NIP-05 (`bob@example.com`, `_@example.com`, `example.com` or the custom
150
177
  // `bob.xyz.example.com` form), npub, nprofile or hex pubkey.
@@ -156,7 +183,15 @@ export function encodeAppUrl ({ appName, channel = 'main', user }) {
156
183
  if (!prefix) {
157
184
  throw new ValidationError('INVALID_APP_URL_CHANNEL', { message: 'Invalid app URL channel' })
158
185
  }
159
- const userRef = decodeUserReference(user)
186
+ let userRef
187
+ try {
188
+ userRef = decodeUserReference(user)
189
+ } catch (cause) {
190
+ if (cause instanceof ValidationError) {
191
+ throw new ValidationError('INVALID_APP_URL_USER', { message: cause.message ?? 'Invalid app URL user', cause })
192
+ }
193
+ throw cause
194
+ }
160
195
  if (!userRef) {
161
196
  throw new ValidationError('INVALID_APP_URL_USER', { message: 'Invalid app URL user' })
162
197
  }