libp2r2p 0.10.6 → 0.10.8

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
@@ -329,6 +329,21 @@ the Schnorr signature on every call; it never adds a cache marker to the
329
329
  event. Their `assert…` counterparts return the original event or throw a
330
330
  `ValidationError` with a stable code.
331
331
 
332
+ NIP-27 text references live in `libp2r2p/nip27`. `extractMedia()` splits
333
+ content into text, URL, profile, event, relay, NIP-05 and hashtag items in
334
+ occurrence order, accepting the optional `@` and `nostr:` mention prefixes
335
+ plus NIP-05 in its standard, root and custom compact spellings.
336
+ `decodeReference()` parses a single reference, and `decodeMediaMetadata()`
337
+ reads the file/media metadata carried in a URL fragment
338
+ (`#m=image/png&dim=640x480&...`).
339
+
340
+ User references (`npub`, `nprofile`, hex pubkeys and every NIP-05 spelling)
341
+ are handled by `libp2r2p/nip27`: `decodeUserReference()` returns the decoded
342
+ form with its canonical compact spelling, `encodeUserReference()` returns
343
+ that canonical spelling, and `resolveUserReference()` resolves it to a
344
+ pubkey. `libp2r2p/nip05` keeps only `queryProfile()`, the NIP-05 lookup, which
345
+ accepts the compact custom forms directly.
346
+
332
347
  Public validity checks consistently use a non-throwing `is…` predicate plus an
333
348
  `assert…` counterpart when callers need the exact reason. Strict codecs,
334
349
  decoders, token validation, and malformed public arguments also throw
package/index.js CHANGED
@@ -18,6 +18,7 @@ export * as network from './network/index.js'
18
18
  export * as nip04 from './nip04/index.js'
19
19
  export * as nip05 from './nip05/index.js'
20
20
  export * as nip19 from './nip19/index.js'
21
+ export * as nip27 from './nip27/index.js'
21
22
  export * as nip44 from './nip44/index.js'
22
23
  export * as nip44v3 from './nip44-v3/index.js'
23
24
  export * as nip46 from './nip46/index.js'
package/kind/index.js CHANGED
@@ -88,6 +88,7 @@ export const USER_STATUSES = 30315
88
88
  export const I_TAG_TRUSTED_ASSERTION = 30385
89
89
  export const CLASSIFIED_LISTING = 30402
90
90
  export const DRAFT_CLASSIFIED_LISTING = 30403
91
+ export const SITE_CURATION_SET = 30499
91
92
  export const DATE_BASED_CALENDAR_EVENT = 31922
92
93
  export const TIME_BASED_CALENDAR_EVENT = 31923
93
94
  export const CALENDAR = 31924
@@ -226,6 +227,7 @@ export const eventKinds = /* @__PURE__ */ Object.freeze({
226
227
  I_TAG_TRUSTED_ASSERTION,
227
228
  CLASSIFIED_LISTING,
228
229
  DRAFT_CLASSIFIED_LISTING,
230
+ SITE_CURATION_SET,
229
231
  DATE_BASED_CALENDAR_EVENT,
230
232
  TIME_BASED_CALENDAR_EVENT,
231
233
  CALENDAR,
@@ -0,0 +1,57 @@
1
+ const NIP05_LOCAL = /^[a-z0-9._-]+$/
2
+ const NIP05_DOMAIN = /^[a-z0-9.-]+$/
3
+
4
+ export function isValidNip05Local (local) {
5
+ return typeof local === 'string' &&
6
+ (local === '_' || (local.length <= 64 && NIP05_LOCAL.test(local)))
7
+ }
8
+
9
+ export function isValidNip05Domain (domain) {
10
+ return typeof domain === 'string' &&
11
+ domain.length > 0 &&
12
+ domain.length <= 253 &&
13
+ domain.includes('.') &&
14
+ !domain.startsWith('.') &&
15
+ !domain.endsWith('.') &&
16
+ !domain.includes('..') &&
17
+ NIP05_DOMAIN.test(domain)
18
+ }
19
+
20
+ export function nip05FromLocalDomain (local, domain) {
21
+ if (!isValidNip05Local(local) || !isValidNip05Domain(domain)) return null
22
+ return { local, domain }
23
+ }
24
+
25
+ // Accepts:
26
+ // - `local@domain` (standard NIP-05)
27
+ // - `domain` with exactly one dot -> root `_@domain`
28
+ // - `local.domain...` with more than one dot -> local part + domain (custom extension)
29
+ export function decodeNip05Identifier (value) {
30
+ if (typeof value !== 'string') return null
31
+ const text = value.trim().toLowerCase()
32
+ if (!text) return null
33
+
34
+ const at = text.lastIndexOf('@')
35
+ 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))
38
+ }
39
+
40
+ if (!text.includes('.')) return null
41
+ const firstDot = text.indexOf('.')
42
+ if (text.slice(firstDot + 1).includes('.')) {
43
+ return nip05FromLocalDomain(text.slice(0, firstDot), text.slice(firstDot + 1))
44
+ }
45
+ return nip05FromLocalDomain('_', text)
46
+ }
47
+
48
+ // Returns the most compact unambiguous NIP-05 spelling:
49
+ // - root (`_@domain`) becomes `domain` only when the domain has one dot;
50
+ // - a non-root local part becomes `local.domain` unless the local part
51
+ // itself contains dots, which would make the compact form ambiguous.
52
+ export function compactNip05Raw (local, domain) {
53
+ if (local === '_') {
54
+ return domain.split('.').length === 2 ? domain : `_@${domain}`
55
+ }
56
+ return local.includes('.') ? `${local}@${domain}` : `${local}.${domain}`
57
+ }
package/nip05/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { normalizeRelayUrl } from '../url/index.js'
2
+ import { decodeNip05Identifier } from './helpers/nip05-identifier.js'
2
3
 
3
4
  const PUBKEY = /^[0-9a-f]{64}$/
4
- const LOCAL_PART = /^[a-z0-9._-]+$/
5
5
 
6
6
  export async function queryProfile (identifier, {
7
7
  fetch: fetchImpl = globalThis.fetch,
@@ -9,17 +9,15 @@ export async function queryProfile (identifier, {
9
9
  timeoutMs = 5000
10
10
  } = {}) {
11
11
  if (typeof identifier !== 'string' || typeof fetchImpl !== 'function') return null
12
- const normalized = identifier.trim().toLowerCase()
13
- const separator = normalized.lastIndexOf('@')
14
- if (separator <= 0 || separator === normalized.length - 1) return null
15
- const name = normalized.slice(0, separator)
16
- const domain = normalized.slice(separator + 1)
17
- if (!LOCAL_PART.test(name) || /[/?#@]/.test(domain)) return null
12
+ const nip05 = decodeNip05Identifier(identifier)
13
+ if (!nip05) return null
14
+ const name = nip05.local
15
+ const domain = nip05.domain
18
16
 
19
17
  let url
20
18
  try {
21
19
  url = new URL(`https://${domain}/.well-known/nostr.json`)
22
- if (url.hostname !== domain.split(':')[0] || url.username || url.password) return null
20
+ if (url.hostname !== domain || url.username || url.password) return null
23
21
  url.searchParams.set('name', name)
24
22
  } catch {
25
23
  return null
@@ -0,0 +1,79 @@
1
+ import { ValidationError } from '../../error/index.js'
2
+ import { npubDecode, nprofileDecode } from '../../nip19/index.js'
3
+ import { compactNip05Raw, decodeNip05Identifier } from '../../nip05/helpers/nip05-identifier.js'
4
+
5
+ const HEX_PUBKEY = /^[0-9a-f]{64}$/
6
+
7
+ // Strips the optional mention prefixes accepted by NIP-21 (`nostr:`) and the
8
+ // social `@` handle marker. Either prefix may appear first.
9
+ function stripReferencePrefix (value) {
10
+ let text = value.trim()
11
+ let changed = true
12
+ while (changed && text) {
13
+ changed = false
14
+ if (/^nostr:/i.test(text)) {
15
+ text = text.slice(6)
16
+ changed = true
17
+ }
18
+ if (text.startsWith('@')) {
19
+ text = text.slice(1)
20
+ changed = true
21
+ }
22
+ }
23
+ return text
24
+ }
25
+
26
+ // Decodes a user reference without performing any network lookup.
27
+ // Returns `{ kind: 'pubkey', pubkey, relays, raw }` for npub/nprofile/hex or
28
+ // `{ kind: 'nip05', local, domain, raw }` for NIP-05 (standard or extended),
29
+ // where `raw` is always the most compact canonical spelling.
30
+ export function decodeUserReference (value) {
31
+ if (typeof value !== 'string') return null
32
+ const text = stripReferencePrefix(value)
33
+ if (!text) return null
34
+
35
+ if (HEX_PUBKEY.test(text)) {
36
+ const raw = text.toLowerCase()
37
+ return { kind: 'pubkey', pubkey: raw, relays: [], raw }
38
+ }
39
+
40
+ if (text.toLowerCase().startsWith('npub1')) {
41
+ try {
42
+ const raw = text.toLowerCase()
43
+ return { kind: 'pubkey', pubkey: npubDecode(raw), relays: [], raw }
44
+ } catch {
45
+ return null
46
+ }
47
+ }
48
+
49
+ if (text.toLowerCase().startsWith('nprofile1')) {
50
+ try {
51
+ const raw = text.toLowerCase()
52
+ const { pubkey, relays } = nprofileDecode(raw)
53
+ return { kind: 'pubkey', pubkey, relays, raw }
54
+ } catch {
55
+ return null
56
+ }
57
+ }
58
+
59
+ const nip05 = decodeNip05Identifier(text)
60
+ if (!nip05) return null
61
+ const raw = compactNip05Raw(nip05.local, nip05.domain)
62
+ return { kind: 'nip05', ...nip05, raw }
63
+ }
64
+
65
+ // Returns the canonical compact spelling for a user reference, either as a
66
+ // string or as a decoded reference object.
67
+ export function encodeUserReference (value) {
68
+ const ref = typeof value === 'string'
69
+ ? decodeUserReference(value)
70
+ : value && typeof value === 'object' &&
71
+ (value.kind === 'pubkey' || value.kind === 'nip05')
72
+ ? value
73
+ : null
74
+ if (!ref) {
75
+ throw new ValidationError('INVALID_USER_REFERENCE', { message: 'Invalid user reference' })
76
+ }
77
+ if (ref.kind === 'pubkey') return ref.raw ?? ref.pubkey
78
+ return ref.raw ?? compactNip05Raw(ref.local, ref.domain)
79
+ }
package/nip27/index.js ADDED
@@ -0,0 +1,360 @@
1
+ import {
2
+ naddrDecode,
3
+ neventDecode,
4
+ noteDecode,
5
+ nrelayDecode
6
+ } from '../nip19/index.js'
7
+ import { queryProfile } from '../nip05/index.js'
8
+ import { normalizeRelayUrl } from '../url/index.js'
9
+ import {
10
+ decodeUserReference,
11
+ encodeUserReference
12
+ } from './helpers/user-reference.js'
13
+
14
+ export { decodeUserReference, encodeUserReference }
15
+
16
+ const BECH32_BODY = '[ac-hj-np-z02-9]'
17
+ const BOUNDARY_PREFIX = /(?<=^|[\s"«„「¡¿:{([])/.source
18
+ const BOUNDARY_SUFFIX = /(?=\.?$|[.,]?\s|\.(?=\.)|\.?["»”」'!?;\])}])/.source
19
+
20
+ const URL_SOURCE =
21
+ '(?<url>' +
22
+ /(?<protocol>https:\/\/)?(?:[-_A-Za-z0-9]{1,30}\.){1,4}[a-z]{2,63}/.source +
23
+ /(?:(?:\/[-._A-Za-z0-9%@#]{1,300}){0,11}(?<ext>\.[a-z-0-9]{3,4})|(?:\/[-._A-Za-z0-9%@#]{1,300}){0,11})\/?/.source +
24
+ /(?:\??&?(?:[-_+.A-Za-z0-9%*]{1,30}=[-_+.A-Za-z0-9%*]{1,300}&?){1,40}|\?)?/.source +
25
+ /(?:(?:#|(?<=#))[-_+.A-Za-z0-9%=&*:~,]{1,4000})?/.source +
26
+ /(?:\/|(?<![.,]))/.source +
27
+ ')'
28
+
29
+ const NIP05_LOCAL = '[a-z0-9._-]{1,64}'
30
+ const NIP05_DOMAIN = '(?:[a-z0-9-]+\\.)+[a-z]{2,63}'
31
+ const NIP05_STANDARD = `(?:@)?(?<nip05>${NIP05_LOCAL}@${NIP05_DOMAIN})`
32
+ const NIP05_AT_ROOT = '@(?<nip05AtRoot>[a-z0-9-]+\\.[a-z]{2,63})'
33
+ const NIP05_AT_CUSTOM = `@(?<nip05AtCustom>${NIP05_LOCAL}\\.${NIP05_DOMAIN})`
34
+ const NIP05_BARE_ROOT = '(?<nip05BareRoot>[a-z0-9-]+\\.[a-z]{2,63})'
35
+ const NIP05_BARE_CUSTOM = `(?<nip05BareCustom>${NIP05_LOCAL}\\.${NIP05_DOMAIN})`
36
+
37
+ function entitySource (name) {
38
+ const bodyLength = name === 'npub' ? '58' : (name === 'nrelay' ? '10,5000' : '58,5000')
39
+ return `(?:@|nostr:)?(?<${name}>${name}1${BECH32_BODY}{${bodyLength}})`
40
+ }
41
+
42
+ const ENTITY_SOURCES = [
43
+ entitySource('nrelay'),
44
+ entitySource('npub'),
45
+ entitySource('nprofile'),
46
+ entitySource('note'),
47
+ entitySource('nevent'),
48
+ entitySource('naddr')
49
+ ]
50
+
51
+ const HASHTAG_SOURCE = /(?:#(?<hashtag>[^\s.!¡?¿@#$%^&*()=+/,[{\]};:'"><]+))/.source
52
+
53
+ let regexCache
54
+ function getReferencesRegex (bareNip05) {
55
+ const key = bareNip05 ? 'bare' : 'handle'
56
+ regexCache ??= {}
57
+ if (regexCache[key]) return regexCache[key]
58
+
59
+ const nip05Compact = [
60
+ NIP05_AT_ROOT,
61
+ NIP05_AT_CUSTOM,
62
+ ...(bareNip05 ? [NIP05_BARE_ROOT, NIP05_BARE_CUSTOM] : [])
63
+ ]
64
+ const alternatives = [
65
+ URL_SOURCE,
66
+ NIP05_STANDARD,
67
+ ...nip05Compact,
68
+ ...ENTITY_SOURCES,
69
+ HASHTAG_SOURCE
70
+ ]
71
+ // Bare compact NIP-05 forms are ambiguous with plain hostnames/URLs, so
72
+ // they are placed first only when explicitly requested.
73
+ if (bareNip05) {
74
+ alternatives.splice(0, 0, NIP05_BARE_ROOT, NIP05_BARE_CUSTOM)
75
+ }
76
+ const source = BOUNDARY_PREFIX + '(?:' + alternatives.join('|') + ')' + BOUNDARY_SUFFIX
77
+ return (regexCache[key] = new RegExp(source, 'gu'))
78
+ }
79
+
80
+ // Strips the optional NIP-21 (`nostr:`) and `@` mention prefixes.
81
+ function stripReferencePrefix (value) {
82
+ let text = value.trim()
83
+ let changed = true
84
+ while (changed && text) {
85
+ changed = false
86
+ if (/^nostr:/i.test(text)) {
87
+ text = text.slice(6)
88
+ changed = true
89
+ }
90
+ if (text.startsWith('@')) {
91
+ text = text.slice(1)
92
+ changed = true
93
+ }
94
+ }
95
+ return text
96
+ }
97
+
98
+ function normalizeRelays (relays) {
99
+ return (relays || [])
100
+ .map(relay => {
101
+ try {
102
+ return normalizeRelayUrl(relay)
103
+ } catch {
104
+ return null
105
+ }
106
+ })
107
+ .filter(Boolean)
108
+ }
109
+
110
+ // Parses a single NIP-27-style reference (with optional `@`/`nostr:`
111
+ // prefixes): NIP-05 (including the custom compact forms), npub/nprofile/hex
112
+ // accounts, note/nevent/naddr events and nrelay relays.
113
+ export function decodeReference (value) {
114
+ if (typeof value !== 'string') return null
115
+ const original = value.trim()
116
+ if (!original) return null
117
+ const text = stripReferencePrefix(original)
118
+ if (!text) return null
119
+
120
+ const account = decodeUserReference(text)
121
+ if (account) {
122
+ return account.kind === 'pubkey'
123
+ ? {
124
+ type: 'pubkey',
125
+ original,
126
+ value: account.raw,
127
+ pubkey: account.pubkey,
128
+ relays: account.relays
129
+ }
130
+ : {
131
+ type: 'nip05',
132
+ original,
133
+ value: account.raw,
134
+ local: account.local,
135
+ domain: account.domain
136
+ }
137
+ }
138
+
139
+ if (text.startsWith('note1')) {
140
+ try {
141
+ return { type: 'note', original, value: text, id: noteDecode(text) }
142
+ } catch {
143
+ return null
144
+ }
145
+ }
146
+ if (text.startsWith('nevent1')) {
147
+ try {
148
+ return { type: 'nevent', original, value: text, ...neventDecode(text) }
149
+ } catch {
150
+ return null
151
+ }
152
+ }
153
+ if (text.startsWith('naddr1')) {
154
+ try {
155
+ return { type: 'naddr', original, value: text, ...naddrDecode(text) }
156
+ } catch {
157
+ return null
158
+ }
159
+ }
160
+ if (text.startsWith('nrelay1')) {
161
+ try {
162
+ return { type: 'nrelay', original, value: text, relay: nrelayDecode(text) }
163
+ } catch {
164
+ return null
165
+ }
166
+ }
167
+ return null
168
+ }
169
+
170
+ const NIP94_TAGS = {
171
+ url: [{ key: 'url', type: 'array' }],
172
+ 'aes-256-gcm': ['key', 'iv'],
173
+ m: ['m'],
174
+ x: [{ key: 'x', type: 'array' }],
175
+ ox: ['ox'],
176
+ size: ['size'],
177
+ dim: ['dim'],
178
+ magnet: ['magnet'],
179
+ i: ['i'],
180
+ blurhash: ['blurhash'],
181
+ thumb: ['thumb'],
182
+ image: ['image'],
183
+ summary: ['summary'],
184
+ alt: ['alt'],
185
+ caption: ['caption']
186
+ }
187
+
188
+ // Decodes the file/media metadata carried in a URL fragment
189
+ // (`#m=image/png&dim=640x480&alt=...`). Kept generic on purpose: the old
190
+ // draft number (54) was taken by an unrelated NIP.
191
+ export function decodeMediaMetadata (url, { extraTags } = {}) {
192
+ if (typeof url !== 'string' || !url) return {}
193
+
194
+ const tags = extraTags ? { ...NIP94_TAGS, ...extraTags } : NIP94_TAGS
195
+ const tagIndexes = {}
196
+ const obj = (url.match(/(?<=#)[-_+.A-Za-z0-9%=&*]{1,4000}/)?.[0] || '')
197
+ .split('&')
198
+ .filter(Boolean)
199
+ .reduce((memo, item) => {
200
+ let [key, value = ''] = item.split('=')
201
+ key = decodeFragmentValue(key)
202
+ value = decodeFragmentValue(value)
203
+ const config = tags[key]
204
+ if (!config || (tagIndexes[key] ??= 0) === config.length) return memo
205
+
206
+ const name = config[tagIndexes[key]]?.key ?? config[tagIndexes[key]]
207
+ const type = config[tagIndexes[key]]?.type ?? 'string'
208
+ switch (type) {
209
+ case 'array': memo[name] ??= []; memo[name].push(value); break
210
+ case 'string': memo[name] = value; tagIndexes[key]++; break
211
+ default: break
212
+ }
213
+ return memo
214
+ }, {})
215
+
216
+ if ('dim' in obj) {
217
+ const { width, height } = obj.dim.match(
218
+ /(?<width>[1-9]{1}[0-9]{0,10})(?:\s*[xX]\s*)(?<height>[1-9]{1}[0-9]{0,10})/
219
+ )?.groups ?? {}
220
+ if (width !== undefined) obj.width = width
221
+ if (height !== undefined) obj.height = height
222
+ }
223
+ return obj
224
+ }
225
+
226
+ function decodeFragmentValue (value) {
227
+ try {
228
+ return decodeURIComponent(value.replace(/\+/g, '%20'))
229
+ } catch {
230
+ return value
231
+ }
232
+ }
233
+
234
+ function getReferenceItem (original, groups, { getMimeType }) {
235
+ if (groups.url) {
236
+ const url = `${groups.protocol ? '' : 'https://'}${groups.url}`
237
+ const urlItem = { value: url, ...(groups.ext && { ext: groups.ext }), ...decodeMediaMetadata(url) }
238
+ if (!urlItem.m && typeof getMimeType === 'function') {
239
+ const mime = getMimeType({ url, ext: groups.ext })
240
+ if (mime) urlItem.m = mime
241
+ }
242
+ return { key: 'url', url: urlItem }
243
+ }
244
+
245
+ if (
246
+ groups.nip05 ||
247
+ groups.nip05AtRoot ||
248
+ groups.nip05AtCustom ||
249
+ groups.nip05BareRoot ||
250
+ groups.nip05BareCustom
251
+ ) {
252
+ const account = decodeUserReference(original)
253
+ if (!account) return null
254
+ return {
255
+ key: 'nip05',
256
+ nip05: {
257
+ original,
258
+ value: account.raw,
259
+ local: account.local,
260
+ domain: account.domain
261
+ }
262
+ }
263
+ }
264
+
265
+ if (groups.hashtag) {
266
+ return { key: 'hashtag', hashtag: { value: groups.hashtag } }
267
+ }
268
+
269
+ const ref = decodeReference(original)
270
+ if (!ref) return null
271
+ switch (ref.type) {
272
+ case 'pubkey': {
273
+ const isNprofile = ref.value.startsWith('nprofile1')
274
+ const profile = {
275
+ pubkey: ref.pubkey,
276
+ relays: normalizeRelays(ref.relays),
277
+ original,
278
+ nip19Type: isNprofile ? 'nprofile' : 'npub'
279
+ }
280
+ if (!isNprofile) profile.npub = ref.value
281
+ return { key: 'profile', profile }
282
+ }
283
+ case 'note':
284
+ return { key: 'event', event: { id: ref.id, relays: [], original, nip19Type: 'note' } }
285
+ case 'nevent':
286
+ return {
287
+ key: 'event',
288
+ event: {
289
+ id: ref.id,
290
+ relays: normalizeRelays(ref.relays),
291
+ original,
292
+ nip19Type: 'nevent',
293
+ ...(ref.author !== undefined && { author: ref.author }),
294
+ ...(ref.kind !== undefined && { kind: ref.kind })
295
+ }
296
+ }
297
+ case 'naddr':
298
+ return {
299
+ key: 'event',
300
+ event: {
301
+ identifier: ref.identifier,
302
+ pubkey: ref.pubkey,
303
+ kind: ref.kind,
304
+ relays: normalizeRelays(ref.relays),
305
+ original,
306
+ nip19Type: 'naddr'
307
+ }
308
+ }
309
+ case 'nrelay':
310
+ return { key: 'relay', relay: { relay: ref.relay, original, value: ref.value } }
311
+ default:
312
+ return null
313
+ }
314
+ }
315
+
316
+ // Modernized NIP-27 text-reference extractor. Accepts optional `@` and
317
+ // `nostr:` mention prefixes, NIP-05 (standard, root and custom compact) and
318
+ // keeps the same item shape as the previous text extractor for URLs/entities.
319
+ // Bare compact NIP-05 spellings (`bob.example.com`) are only recognized when
320
+ // `{ bareNip05: true }` is passed, since they are otherwise indistinguishable
321
+ // from plain hostnames; prefixed forms (`@bob.example.com`) always work.
322
+ export function extractMedia (content, { bareNip05 = false, getMimeType } = {}) {
323
+ if (typeof content !== 'string') return []
324
+ const regex = getReferencesRegex(bareNip05)
325
+ const items = []
326
+ let end = 0
327
+
328
+ for (const match of content.matchAll(regex)) {
329
+ const start = match.index
330
+ if (start > end) {
331
+ items.push({ key: 'text', text: { value: content.slice(end, start) } })
332
+ }
333
+ const original = match[0]
334
+ end = start + original.length
335
+ const item = getReferenceItem(original, match.groups, { getMimeType })
336
+ if (item) items.push(item)
337
+ }
338
+
339
+ if (items.length === 0) {
340
+ items.push({ key: 'text', text: { value: content } })
341
+ } else if (end < content.length) {
342
+ items.push({ key: 'text', text: { value: content.slice(end) } })
343
+ }
344
+ return items
345
+ }
346
+
347
+ // Resolves a user reference to a pubkey and relay hints. NIP-05 lookups go
348
+ // through `queryProfile` (accepting standard, root and compact custom
349
+ // spellings); npub/nprofile/hex are resolved locally.
350
+ export async function resolveUserReference (value, options = {}) {
351
+ const account = decodeUserReference(value)
352
+ if (!account) return null
353
+ if (account.kind === 'pubkey') {
354
+ return { pubkey: account.pubkey, relays: account.relays, label: account.raw }
355
+ }
356
+
357
+ const result = await queryProfile(`${account.local}@${account.domain}`, options)
358
+ if (!result) return null
359
+ return { pubkey: result.pubkey, relays: result.relays, label: account.raw }
360
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.10.6",
3
+ "version": "0.10.8",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -31,6 +31,7 @@
31
31
  "nip04",
32
32
  "nip05",
33
33
  "nip19",
34
+ "nip27",
34
35
  "nip44",
35
36
  "nip44-v3",
36
37
  "nip46",
@@ -71,6 +72,7 @@
71
72
  "./nip04": "./nip04/index.js",
72
73
  "./nip05": "./nip05/index.js",
73
74
  "./nip19": "./nip19/index.js",
75
+ "./nip27": "./nip27/index.js",
74
76
  "./nip44": "./nip44/index.js",
75
77
  "./nip44-v3": "./nip44-v3/index.js",
76
78
  "./nip46": "./nip46/index.js",
package/url/app-url.js ADDED
@@ -0,0 +1,192 @@
1
+ import { ValidationError } from '../error/index.js'
2
+ import {
3
+ compactNip05Raw,
4
+ nip05FromLocalDomain
5
+ } from '../nip05/helpers/nip05-identifier.js'
6
+ import {
7
+ decodeUserReference,
8
+ encodeUserReference
9
+ } from '../nip27/helpers/user-reference.js'
10
+ import {
11
+ NAPP_ENTITY_REGEX,
12
+ appDecode,
13
+ appEncode,
14
+ naddrDecode
15
+ } from '../nip19/index.js'
16
+ import {
17
+ DRAFT_SITE_MANIFEST,
18
+ MAIN_SITE_MANIFEST,
19
+ NEXT_SITE_MANIFEST
20
+ } from '../kind/index.js'
21
+
22
+ export const APP_URL_MIN_ENTITY_BODY_LENGTH = 48
23
+
24
+ const SITE_MANIFEST_KINDS = new Set([
25
+ MAIN_SITE_MANIFEST,
26
+ NEXT_SITE_MANIFEST,
27
+ DRAFT_SITE_MANIFEST
28
+ ])
29
+ const NADDR_PREFIX = 'naddr1'
30
+
31
+ const APP_NAME_MAX_LENGTH = 260
32
+ const CHANNEL_BY_PREFIX = { '+': 'main', '++': 'next', '+++': 'draft' }
33
+ const PREFIX_BY_CHANNEL = { main: '+', next: '++', draft: '+++' }
34
+ const KIND_BY_CHANNEL = { main: 35128, next: 35129, draft: 35130 }
35
+ // eslint-disable-next-line no-control-regex
36
+ const CONTROL_CHARS = /[\u0000-\u001f\u007f]/
37
+
38
+ function safeDecode (value) {
39
+ try {
40
+ return decodeURIComponent(value)
41
+ } catch {
42
+ return null
43
+ }
44
+ }
45
+
46
+ function isValidAppName (appName) {
47
+ return typeof appName === 'string' &&
48
+ appName.length > 0 &&
49
+ appName.length <= APP_NAME_MAX_LENGTH &&
50
+ !appName.startsWith('+') &&
51
+ !appName.includes('/') &&
52
+ !CONTROL_CHARS.test(appName)
53
+ }
54
+
55
+ // `naddr` already carries the event kind, so it does not need the `+`/`++`/
56
+ // `+++` channel prefix (a leading prefix is still tolerated). Only site
57
+ // manifests are app URLs; the result is canonicalized to the `appEncode`
58
+ // entity so downstream code keeps working unchanged.
59
+ function tryDecodeNaddr (value) {
60
+ if (typeof value !== 'string' || !value) return null
61
+ const body = value.replace(/^\+{1,3}/, '')
62
+ if (!body.startsWith(NADDR_PREFIX)) return null
63
+
64
+ let decoded
65
+ try {
66
+ decoded = naddrDecode(body)
67
+ } catch {
68
+ return null
69
+ }
70
+ if (!SITE_MANIFEST_KINDS.has(decoded.kind)) return null
71
+
72
+ try {
73
+ return {
74
+ type: 'entity',
75
+ entity: appEncode({
76
+ dTag: decoded.identifier,
77
+ pubkey: decoded.pubkey,
78
+ kind: decoded.kind,
79
+ relays: decoded.relays
80
+ })
81
+ }
82
+ } catch {
83
+ return null
84
+ }
85
+ }
86
+
87
+ // Decodes a raw (still percent-encoded) first path segment such as
88
+ // `+apps` or `+caf%C3%A9@bob@example.com` or `+3swFhu...`.
89
+ // Returns:
90
+ // - `{ type: 'entity', entity }` for NIP-19 app entities;
91
+ // - `{ type: 'named', prefix, channel, appName, user }` for named URLs
92
+ // (`user` is null when no user part is present or it is invalid);
93
+ // - `null` when the segment is not a valid app URL.
94
+ export function decodeAppUrl (segment) {
95
+ if (typeof segment !== 'string' || !segment) return null
96
+ const decodedNaddr = tryDecodeNaddr(segment)
97
+ if (decodedNaddr) return decodedNaddr
98
+
99
+ const prefixMatch = segment.match(/^\+{1,3}/)
100
+ if (!prefixMatch) return null
101
+ const prefix = prefixMatch[0]
102
+
103
+ if (NAPP_ENTITY_REGEX.test(segment)) {
104
+ try {
105
+ appDecode(segment)
106
+ } catch {
107
+ return null
108
+ }
109
+ return { type: 'entity', entity: segment }
110
+ }
111
+
112
+ const remainder = segment.slice(prefix.length)
113
+ if (!remainder) return null
114
+ const parts = remainder.split('@')
115
+ let appName
116
+ let user = null
117
+
118
+ if (parts.length === 1) {
119
+ appName = safeDecode(parts[0])
120
+ } else if (parts.length === 2) {
121
+ const tail = safeDecode(parts[1])
122
+ appName = safeDecode(parts[0])
123
+ user = tail === null ? null : decodeUserReference(tail)
124
+ } else {
125
+ const local = safeDecode(parts[parts.length - 2])
126
+ const domain = safeDecode(parts[parts.length - 1])
127
+ const nip05 = nip05FromLocalDomain(local, domain)
128
+ if (nip05) {
129
+ user = { kind: 'nip05', ...nip05, raw: compactNip05Raw(local, domain) }
130
+ appName = parts.slice(0, -2).map(safeDecode).join('@')
131
+ } else {
132
+ appName = safeDecode(remainder)
133
+ }
134
+ }
135
+
136
+ if (appName === null || !isValidAppName(appName)) return null
137
+ if (!user && appName.length >= APP_URL_MIN_ENTITY_BODY_LENGTH) return null
138
+
139
+ return {
140
+ type: 'named',
141
+ prefix,
142
+ channel: CHANNEL_BY_PREFIX[prefix],
143
+ appName,
144
+ user
145
+ }
146
+ }
147
+
148
+ // Encodes a named app URL segment. `user` is a decoded user reference:
149
+ // NIP-05 (`bob@example.com`, `_@example.com`, `example.com` or the custom
150
+ // `bob.xyz.example.com` form), npub, nprofile or hex pubkey.
151
+ export function encodeAppUrl ({ appName, channel = 'main', user }) {
152
+ if (!isValidAppName(appName)) {
153
+ throw new ValidationError('INVALID_APP_URL_NAME', { message: 'Invalid app URL name' })
154
+ }
155
+ const prefix = PREFIX_BY_CHANNEL[channel]
156
+ if (!prefix) {
157
+ throw new ValidationError('INVALID_APP_URL_CHANNEL', { message: 'Invalid app URL channel' })
158
+ }
159
+ const userRef = decodeUserReference(user)
160
+ if (!userRef) {
161
+ throw new ValidationError('INVALID_APP_URL_USER', { message: 'Invalid app URL user' })
162
+ }
163
+
164
+ let encodedAppName = encodeURIComponent(appName)
165
+ let userText
166
+ if (userRef.kind === 'nip05') {
167
+ // Keep `@` raw inside the app name so the verbose NIP-05 form stays
168
+ // readable (`+my@app@bob@example.com`).
169
+ encodedAppName = encodedAppName.replace(/%40/g, '@')
170
+ const appNameHasAt = appName.includes('@')
171
+ if (userRef.local === '_') {
172
+ userText = userRef.domain.split('.').length === 2 && !appNameHasAt
173
+ ? userRef.domain
174
+ : `_@${userRef.domain}`
175
+ } else {
176
+ // Prefer the shorter single-`@` custom NIP-05 form (`bob.example.com`)
177
+ // unless the app name itself contains `@`, which forces the verbose form.
178
+ userText = appNameHasAt
179
+ ? `${userRef.local}@${userRef.domain}`
180
+ : `${userRef.local}.${userRef.domain}`
181
+ }
182
+ } else {
183
+ userText = encodeUserReference(userRef)
184
+ }
185
+
186
+ return `${prefix}${encodedAppName}@${userText}`
187
+ }
188
+
189
+ // Convenience for callers that need the manifest kind for a decoded channel.
190
+ export function appUrlKindByChannel (channel) {
191
+ return KIND_BY_CHANNEL[channel] || null
192
+ }
package/url/index.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { ValidationError } from '../error/index.js'
2
2
 
3
+ export * from './app-url.js'
4
+
3
5
  function isIpv4Address (hostname) {
4
6
  const parts = hostname.split('.')
5
7
  return parts.length === 4 && parts.every(part =>